251 lines
8.7 KiB
Vue
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>
|