Files
opencatd-open/frontend/src/components/common/LineSegmentFlow.vue
T
2026-09-01 15:38:32 +08:00

251 lines
8.7 KiB
Vue

<template>
<div ref="containerRef" class="relative mx-auto w-full max-w-3xl" style="height: 420px">
<!-- Left: Clients -->
<div class="absolute top-0 left-0 z-10 flex h-full flex-col justify-around py-2 pl-1 sm:pl-3">
<div
v-for="icon in leftIcons"
:key="icon.id"
:ref="el => { if (el) iconRefs[icon.id] = el as Element }"
class="flex items-center justify-center"
>
<div class="flex h-7 w-7 items-center justify-center rounded-lg border border-base-300/60 bg-base-100 shadow-sm p-0.5">
<div v-if="'img' in icon && icon.img" :style="{ color: icon.color }" v-html="getIconSvg(icon.img)" class="h-full w-full [&>svg]:h-full [&>svg]:w-full" />
<component v-else :is="icon.component" class="h-4 w-4 text-base-content/70" />
</div>
</div>
</div>
<!-- Right: Providers -->
<div class="absolute top-0 right-0 z-10 flex h-full flex-col justify-around py-2 pr-1 sm:pr-3">
<div
v-for="icon in rightIcons"
:key="icon.id"
:ref="el => { if (el) iconRefs[icon.id] = el as Element }"
class="flex items-center justify-center"
>
<div class="flex h-7 w-7 items-center justify-center rounded-lg border border-base-300/60 bg-base-100 shadow-sm p-0.5">
<div :style="{ color: icon.color }" v-html="getIconSvg(icon.img)" class="h-full w-full [&>svg]:h-full [&>svg]:w-full" />
</div>
</div>
</div>
<!-- Center logo -->
<div ref="centerElement" class="absolute left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2">
<div
class="flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border border-base-300/60 bg-base-100 shadow-lg transition-transform hover:scale-105"
@click="$router.push('/dashboard')"
>
<img src="@/assets/logo.svg" alt="OpenTeam" class="h-6 w-6" />
</div>
</div>
<!-- SVG canvas -->
<svg ref="svgCanvas" class="absolute inset-0 h-full w-full">
<g v-if="centerCoords && Object.keys(iconCoords).length >= totalIcons">
<!-- Left: icon → center, inner concave curve -->
<template v-for="icon in leftIcons" :key="'left-' + icon.id">
<template v-if="iconCoords[icon.id]">
<path
:d="getLeftPath(iconCoords[icon.id]!, centerCoords!)"
stroke="currentColor"
class="line-bg"
stroke-width="1"
fill="none"
/>
<path
:d="getLeftPath(iconCoords[icon.id]!, centerCoords!)"
:stroke="icon.color"
stroke-width="2"
fill="none"
stroke-linecap="round"
class="line-animated"
:stroke-dasharray="`${dashLen} ${largeGap}`"
:stroke-dashoffset="largeGap + dashLen"
>
<animate
attributeName="stroke-dashoffset"
:from="largeGap + dashLen"
:to="0"
:dur="`${3.5 + Math.random() * 3}s`"
:begin="`${Math.random() * -4}s`"
repeatCount="indefinite"
fill="freeze"
/>
</path>
</template>
</template>
<!-- Right: center → icon, outer convex curve -->
<template v-for="icon in rightIcons" :key="'right-' + icon.id">
<template v-if="iconCoords[icon.id]">
<path
:d="getRightPath(centerCoords!, iconCoords[icon.id]!)"
stroke="currentColor"
class="line-bg"
stroke-width="1"
fill="none"
/>
<path
:d="getRightPath(centerCoords!, iconCoords[icon.id]!)"
:stroke="icon.color"
stroke-width="2"
fill="none"
stroke-linecap="round"
class="line-animated"
:stroke-dasharray="`${dashLen} ${largeGap}`"
:stroke-dashoffset="0"
>
<animate
attributeName="stroke-dashoffset"
:from="0"
:to="-(largeGap + dashLen)"
:dur="`${3.5 + Math.random() * 3}s`"
:begin="`${Math.random() * -4}s`"
repeatCount="indefinite"
fill="freeze"
/>
</path>
</template>
</template>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
import {
GlobeIcon,
SmartphoneIcon,
MonitorIcon,
} from '@lucide/vue';
type Coords = { x: number; y: number };
const LobeIcon = (slug: string) =>
`https://unpkg.com/@lobehub/icons-static-svg@latest/icons/${slug}.svg`;
const leftIcons = [
{ id: 'web', component: markRaw(GlobeIcon), color: '#64748b' },
{ id: 'ios', component: markRaw(SmartphoneIcon), color: '#64748b' },
{ id: 'desktop', component: markRaw(MonitorIcon), color: '#64748b' },
{ id: 'claude-code', label: 'Claude Code', img: LobeIcon('claudecode'), color: '#d97757' },
{ id: 'codex', label: 'Codex', img: LobeIcon('codex'), color: '#10a37f' },
{ id: 'openclaw', label: 'OpenClaw', img: LobeIcon('openclaw'), color: '#ff4d4d' },
];
const rightIcons = [
{ id: 'openai', label: 'OpenAI', img: LobeIcon('openai'), color: '#10a37f' },
{ id: 'claude', label: 'Claude', img: LobeIcon('claude'), color: '#d97706' },
{ id: 'gemini', label: 'Gemini', img: LobeIcon('gemini'), color: '#4285f4' },
{ id: 'zhipu', label: 'Zhipu', img: LobeIcon('zhipu'), color: '#4268fa' },
{ id: 'qwen', label: 'Qwen', img: LobeIcon('qwen'), color: '#615ced' },
{ id: 'deepseek', label: 'DeepSeek', img: LobeIcon('deepseek'), color: '#4d6bfe' },
{ id: 'moonshot', label: 'Moonshot', img: LobeIcon('moonshot'), color: '#666' },
{ id: 'minimax', label: 'MiniMax', img: LobeIcon('minimax'), color: '#F23F5D' },
{ id: 'bedrock', label: 'Bedrock', img: LobeIcon('bedrock'), color: '#ff9900' },
{ id: 'azure', label: 'Azure', img: LobeIcon('azure'), color: '#0078d4' },
{ id: 'volcengine', label: 'Volcengine', img: LobeIcon('volcengine'), color: '#325ab4' },
];
const totalIcons = leftIcons.length + rightIcons.length;
const dashLen = 15;
const largeGap = 1000;
const containerRef = ref<HTMLElement | null>(null);
const svgCanvas = ref<SVGSVGElement | null>(null);
const centerElement = ref<HTMLElement | null>(null);
const iconRefs = reactive<Record<string, Element | null>>({});
const centerCoords = ref<Coords | null>(null);
const iconCoords = reactive<Record<string, Coords | null>>({});
const getCenter = (el: Element | null): Coords | null => {
if (!el || !svgCanvas.value) return null;
const svg = svgCanvas.value.getBoundingClientRect();
const rect = el.getBoundingClientRect();
return {
x: rect.left + rect.width / 2 - svg.left,
y: rect.top + rect.height / 2 - svg.top,
};
};
const updateCoords = () => {
if (!centerElement.value || !svgCanvas.value) return;
centerCoords.value = getCenter(centerElement.value);
[...leftIcons, ...rightIcons].forEach(icon => {
iconCoords[icon.id] = getCenter(iconRefs[icon.id]);
});
};
const getLeftPath = (from: Coords, to: Coords): string => {
const cx = from.x + (to.x - from.x) * 0.5;
const cy = to.y;
return `M ${from.x},${from.y} Q ${cx},${cy} ${to.x},${to.y}`;
};
const getRightPath = (from: Coords, to: Coords): string => {
const cx = from.x + (to.x - from.x) * 0.5;
const cy = from.y;
return `M ${from.x},${from.y} Q ${cx},${cy} ${to.x},${to.y}`;
};
let observer: ResizeObserver | undefined;
const iconCache = new Map<string, string>();
const getIconSvg = (url: string) => iconCache.get(url) || '';
const loadIcons = async () => {
const urls = [...leftIcons, ...rightIcons]
.filter((i): i is typeof i & { img: string } => 'img' in i)
.map(i => i.img);
await Promise.allSettled(
urls.map(async url => {
if (iconCache.has(url)) return;
try {
const res = await fetch(url);
if (res.ok) iconCache.set(url, await res.text());
} catch {}
})
);
};
onMounted(async () => {
await nextTick();
await loadIcons();
updateCoords();
observer = new ResizeObserver(updateCoords);
if (containerRef.value) {
observer.observe(containerRef.value);
}
});
onUnmounted(() => observer?.disconnect());
</script>
<style scoped>
.line-bg {
stroke: var(--color-base-300);
opacity: 0.6;
}
.line-animated {
filter: drop-shadow(0 0 3px currentColor);
}
html[data-theme="dark"] .line-bg {
stroke: var(--color-base-content);
opacity: 0.2;
}
html[data-theme="dark"] .line-animated {
stroke-width: 2.5;
filter: drop-shadow(0 0 6px currentColor);
}
@media (prefers-reduced-motion: reduce) {
svg animate {
duration: 0s !important;
}
}
</style>