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:
Sakurasan
2026-08-30 17:05:41 +08:00
parent 8f6acb981d
commit 4b845e14fc
4 changed files with 255 additions and 245 deletions
+40
View File
@@ -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"]
}
}
+1 -1
View File
@@ -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:
+213 -243
View File
@@ -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>
+1 -1
View File
@@ -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 后端,免去跨域与重建