feat: redesign LineSegmentFlow + dev improvements
- LineSegmentFlow: LobeHub brand icons, concave left curves, glow effects - Dark mode: line colors adapt, animated lines glow in dark theme - Vite: enable LAN access (host: 0.0.0.0) - VSCode: workspace settings for performance optimization - Toast: redesign with icons, progress bar, better animations
This commit is contained in:
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
// Vue: 启用 Take Over 模式,禁用内置 TS 服务,减少内存占用
|
||||
"vue.server.hybridMode": true,
|
||||
|
||||
// Git: 降低自动拉取频率
|
||||
"git.autofetch": false,
|
||||
"git.maxVisibleCommits": 10,
|
||||
"git.decorations.enabled": false,
|
||||
|
||||
// 保存时只格式化,不运行 code action(慢)
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "never",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// 排除大目录,减少文件监听
|
||||
"files.watcherExclude": {
|
||||
"**/node_modules/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/tmp/**": true,
|
||||
"**/bin/**": true
|
||||
},
|
||||
|
||||
// 排除搜索目录
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/dist": true,
|
||||
"**/tmp": true,
|
||||
"**/bin": true,
|
||||
"**/*.sum": true
|
||||
},
|
||||
|
||||
// TypeScript: 降低语言服务开销
|
||||
"typescript.tsserver.maxTsServerMemory": 1024,
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeDirectories": ["node_modules", "dist"]
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ run: build
|
||||
./$(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
# Development: backend + frontend (requires air + pnpm)
|
||||
dev: dev-backend dev-frontend
|
||||
dev: dev-frontend dev-backend
|
||||
|
||||
# Go backend with hot reload (requires: go install github.com/air-verse/air@latest)
|
||||
dev-backend:
|
||||
|
||||
@@ -1,280 +1,250 @@
|
||||
<template>
|
||||
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
|
||||
<div
|
||||
class="relative w-full max-w-4xl mx-auto my-10 aspect-[16/10] sm:aspect-[4/3] backdrop-blur-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 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
|
||||
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 as Element }"
|
||||
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 as Element }"
|
||||
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>
|
||||
</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, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
|
||||
import {
|
||||
GlobeIcon,
|
||||
SmartphoneIcon,
|
||||
MonitorIcon,
|
||||
} from '@lucide/vue';
|
||||
|
||||
type Coords = { x: number; y: number };
|
||||
type FlowIcon = { id: string; name: string; img: string; color: string };
|
||||
|
||||
// --- 图标数据 (保持不变) ---
|
||||
const leftIcons = ref<FlowIcon[]>([
|
||||
{ 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<FlowIcon[]>([
|
||||
{ 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' },
|
||||
]);
|
||||
// --- 结束图标数据 ---
|
||||
const LobeIcon = (slug: string) =>
|
||||
`https://unpkg.com/@lobehub/icons-static-svg@latest/icons/${slug}.svg`;
|
||||
|
||||
// --- Dash 动画参数 ---
|
||||
const dashLen = ref(15); // 移动线段的长度
|
||||
const largeGap = ref(1000); // 一个足够大的间隔,确保只有一个线段可见
|
||||
// --- 结束 Dash 动画参数 ---
|
||||
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: '#000' },
|
||||
{ id: 'minimax', label: 'MiniMax', img: LobeIcon('minimax'), color: '#000' },
|
||||
{ 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>>({});
|
||||
|
||||
// (getElementCenterCoords 和 updateCoordinates 函数保持不变)
|
||||
const getElementCenterCoords = (element: Element | null): Coords | null => {
|
||||
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("部分图标坐标未能成功计算。");
|
||||
// }
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
// (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation)
|
||||
/**
|
||||
* 计算静态视觉连接线的 SVG 路径 (总是从图标到中心)
|
||||
* @param iconCoord 图标坐标 {x, y}
|
||||
* @param centerCoord 中心坐标 {x, y}
|
||||
* @param side 图标在哪一侧
|
||||
* @returns SVG path 'd' 属性字符串
|
||||
*/
|
||||
const calculatePathForVisual = (iconCoord: Coords | null | undefined, centerCoord: Coords | null, side: 'left' | 'right'): string => {
|
||||
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}`;
|
||||
const updateCoords = () => {
|
||||
if (!centerElement.value || !svgCanvas.value) return;
|
||||
centerCoords.value = getCenter(centerElement.value);
|
||||
[...leftIcons, ...rightIcons].forEach(icon => {
|
||||
iconCoords[icon.id] = getCenter(iconRefs[icon.id]);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算动画运动的 SVG 路径
|
||||
* @param iconCoord 图标坐标 {x, y}
|
||||
* @param centerCoord 中心坐标 {x, y}
|
||||
* @param side 图标在哪一侧
|
||||
* @param direction 动画方向
|
||||
* @returns SVG path 'd' 属性字符串
|
||||
*/
|
||||
const calculatePathForAnimation = (iconCoord: Coords | null | undefined, centerCoord: Coords | null, side: 'left' | 'right', direction: 'toCenter' | 'fromCenter' = 'toCenter'): string => {
|
||||
if (!iconCoord || !centerCoord) return '';
|
||||
|
||||
let startX: number, startY: number, endX: number, endY: number;
|
||||
let controlX: number, controlY: number;
|
||||
|
||||
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}`;
|
||||
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 resizeObserver: ResizeObserver | undefined;
|
||||
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();
|
||||
updateCoordinates();
|
||||
resizeObserver = new ResizeObserver(updateCoordinates);
|
||||
if (svgCanvas.value?.parentElement) {
|
||||
resizeObserver.observe(svgCanvas.value.parentElement);
|
||||
} else {
|
||||
console.warn("无法找到用于 ResizeObserver 的父元素。");
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
await nextTick();
|
||||
await loadIcons();
|
||||
updateCoords();
|
||||
observer = new ResizeObserver(updateCoords);
|
||||
if (containerRef.value) {
|
||||
observer.observe(containerRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => observer?.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 */
|
||||
.line-bg {
|
||||
stroke: var(--color-base-300);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.flex-col.justify-around {
|
||||
justify-content: space-around;
|
||||
.line-animated {
|
||||
filter: drop-shadow(0 0 3px currentColor);
|
||||
}
|
||||
|
||||
/* 可选:给动画路径添加一点模糊效果? */
|
||||
#path-anim-left,
|
||||
#path-anim-right {
|
||||
filter: blur(2px);
|
||||
background-color: #eac50c;
|
||||
html[data-theme="dark"] .line-bg {
|
||||
stroke: var(--color-base-content);
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
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>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), ...(useHttps ? [basicSsl()] : [])],
|
||||
server: {
|
||||
https: useHttps ? {} : undefined,
|
||||
host: 'localhost', // 确保 host 是 localhost
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 前端 axios baseURL 为 /api,开发时代理到本地 Go 后端,免去跨域与重建
|
||||
|
||||
Reference in New Issue
Block a user