This commit is contained in:
Sakurasan
2025-04-17 23:09:57 +08:00
parent 20c3cda4a7
commit fca67cae40
15 changed files with 579 additions and 82 deletions

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine AS frontend
WORKDIR /frontend-build
COPY ./frontend .
RUN npm config set registry https://registry.npmmirror.com && npm install -g pnpm --registry=https://registry.npmmirror.com && pnpm i && pnpm build
RUN npm install -g pnpm && pnpm i && pnpm build
FROM golang:1.23-alpine AS backend
LABEL anther="github.com/Sakurasan"

View File

@@ -0,0 +1,243 @@
<template>
<!-- 组件根元素相对定位设置最大宽度外边距宽高比背景渐变内边距圆角阴影和溢出隐藏 -->
<div
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] bg-gradient-to-br from-slate-50 to-orange-50 p-4 rounded-lg shadow-md overflow-hidden">
<!-- 中心图标容器 -->
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<!-- 中心图标本身设置宽高圆角Flex 布局居中背景模糊效果 -->
<div class="w-10 h-10 md:w-12 md:h-12 rounded-full flex items-center justify-center backdrop-blur-md">
<!-- 中心图标图片 -->
<img src="../assets/logo.svg" alt="Center Logo" class="w-full h-full object-contain">
</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 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 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="'line-' + icon.id">
<!-- 绘制静态连接线 (图标到中心) -->
<path :id="'path-visual-' + icon.id" :d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')"
stroke="#E5E7EB" stroke-width="1" fill="none" />
<!-- 创建一个用于动画的小圆点 (从图标到中心) -->
<circle cx="0" cy="0" r="2.5" :fill="icon.color || '#DB2777'">
<!-- 定义动画让圆点沿着指定路径移动 (图标到中心) -->
<animateMotion :dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze"
:path="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'left', 'toCenter')"
rotate="auto" />
</circle>
</template>
<!-- 绘制右侧图标的线条和动画 -->
<template v-for="icon in rightIcons" :key="'line-' + icon.id">
<!-- 绘制静态连接线 (图标到中心) -->
<path :id="'path-visual-' + icon.id" :d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'right')"
stroke="#E5E7EB" stroke-width="1" fill="none" />
<!-- 创建一个用于动画的小圆点 (从中心到图标) -->
<circle cx="0" cy="0" r="2.5" :fill="icon.color || '#1D4ED8'">
<!-- 定义动画让圆点沿着指定路径移动 (中心到图标) -->
<animateMotion :dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze"
:path="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right', 'fromCenter')"
rotate="auto" />
</circle>
</template>
</g>
</svg>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
// --- 图标数据 ---
// (保持你的图标数据不变)
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: '#00AB6C' },
{ 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' }, // Changed color slightly
{ id: 'bedrock', name: 'BedRock', img: 'https://img.icons8.com/?size=100&id=saSupsgVcmJe&format=png&color=000000', color: '#FF9900' }, // Changed color slightly
{ 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: '#3a7dd5' }, // Changed color slightly
{ id: 'github', name: 'GitHub', img: 'https://img.icons8.com/ios-filled/50/000000/github.png', color: '#111111' },
]);
// --- 结束图标数据 ---
const svgCanvas = ref(null);
const centerElement = ref(null);
const iconRefs = reactive({});
const centerCoords = ref(null);
const iconCoords = reactive({});
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("部分图标坐标未能成功计算。"); // 可以取消注释以进行调试
}
};
/**
* 计算静态视觉连接线的 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; // 控制点 Y 与起点(图标)对齐
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;
background: #3a7dd5;
}
.flex-col.justify-around {
justify-content: space-around;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div class="relative w-full h-[500px] flex items-center justify-center backdrop-blur-0">
<svg class="absolute w-full h-full" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<g>
<path :d="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment left-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="4s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="4s" begin="1s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="4s" begin="2s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
</g>
<path :d="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment left-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="1.2s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="2.4s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3s" begin="1s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3s" begin="2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="2.5s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="2.5s" begin="0.8s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="2.5s" begin="1.6s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.2s" begin="1.1s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.2s" begin="2.2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="1.2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="2.4s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)"
rotate="auto" />
</line>
</g>
</g>
<g transform="translate(250, 250)">
<rect x="-18" y="-18" width="36" height="36" rx="6" fill="#f97316" />
<path d="M-9,-5 A9,3 0 0,1 9,-5 M-9,0 A9,3 0 0,1 9,0 M-9,5 A9,3 0 0,1 9,5 M-9,-5 L-9,5 M9,-5 L9,5"
stroke="white" stroke-width="1.5" fill="none" />
</g>
<g :transform="'translate(80, ' + getLeftIconY(0) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4" />
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853" />
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05" />
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335" />
</svg>
</g>
<g :transform="'translate(80, ' + getLeftIconY(1) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
fill="#000" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(0) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0 1 12 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z"
fill="#000" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(1) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 2c2.717 0 3.056.01 4.122.06 1.065.05 1.79.217 2.428.465.66.254 1.216.598 1.772 1.153.509.5.902 1.105 1.153 1.772.247.637.415 1.363.465 2.428.047 1.066.06 1.405.06 4.122 0 2.717-.01 3.056-.06 4.122-.05 1.065-.218 1.79-.465 2.428a4.883 4.883 0 0 1-1.153 1.772c-.5.508-1.105.902-1.772 1.153-.637.247-1.363.415-2.428.465-1.066.047-1.405.06-4.122.06-2.717 0-3.056-.01-4.122-.06-1.065-.05-1.79-.218-2.428-.465a4.89 4.89 0 0 1-1.772-1.153 4.904 4.904 0 0 1-1.153-1.772c-.247-.637-.415-1.363-.465-2.428C2.013 15.056 2 14.717 2 12c0-2.717.01-3.056.06-4.122.05-1.066.217-1.79.465-2.428a4.88 4.88 0 0 1 1.153-1.772A4.897 4.897 0 0 1 5.45 2.525c.638-.248 1.362-.415 2.428-.465C8.944 2.013 9.283 2 12 2zm0 1.802c-2.67 0-2.987.01-4.04.059-.976.045-1.505.207-1.858.344-.466.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.048 1.053-.059 1.37-.059 4.04 0 2.67.01 2.987.059 4.04.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.684.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.059 4.04.059 2.67 0 2.987-.01 4.04-.059.975-.045 1.504-.207 1.857-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.054.059-1.37.059-4.04 0-2.67-.01-2.987-.059-4.04-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 0 0-.748-1.15 3.098 3.098 0 0 0-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.054-.048-1.37-.059-4.04-.059zm0 3.063a5.135 5.135 0 1 1 0 10.27 5.135 5.135 0 0 1 0-10.27zm0 8.468a3.333 3.333 0 1 0 0-6.666 3.333 3.333 0 0 0 0 6.666zm6.538-8.469a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 2.4 0z"
fill="#E1306C" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(2) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.96 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"
fill="#0088cc" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(3) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M10 15l5.19-3L10 9v6m11.56-7.83c.13.47.22 1.1.28 1.9.07.8.1 1.49.1 2.09L22 12c0 2.19-.16 3.8-.44 4.83-.25.9-.83 1.48-1.73 1.73-.47.13-1.33.22-2.65.28-1.3.07-2.49.1-3.59.1L12 19c-4.19 0-6.8-.16-7.83-.44-.9-.25-1.48-.83-1.73-1.73-.13-.47-.22-1.1-.28-1.9-.07-.8-.1-1.49-.1-2.09L2 12c0-2.19.16-3.8.44-4.83.25-.9.83-1.48 1.73-1.73.47-.13 1.33-.22 2.65-.28 1.3-.07 2.49-.1 3.59-.1L12 5c4.19 0 6.8.16 7.83.44.9.25 1.48.83 1.73 1.73z"
fill="#FF0000" />
</svg>
</g>
</svg>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const containerHeight = 500;
const centerOffsetY = containerHeight / 2;
const iconRadius = 18;
const spacing = 70; // Adjust as needed
const leftIconsCount = 2;
const rightIconsCount = 4;
const getLeftIconY = (index) => {
if (leftIconsCount % 2 === 1) {
const middleIndex = Math.floor(leftIconsCount / 2);
if (index === middleIndex) {
return centerOffsetY;
} else {
const offset = Math.abs(index - middleIndex) * spacing;
return index < middleIndex ? centerOffsetY - offset : centerOffsetY + offset;
}
} else {
const halfCount = leftIconsCount / 2;
const offsetBase = centerOffsetY - (spacing / 2) - ((halfCount - 1) * spacing);
return offsetBase + index * spacing;
}
};
const getRightIconY = (index) => {
if (rightIconsCount % 2 === 1) {
const middleIndex = Math.floor(rightIconsCount / 2);
if (index === middleIndex) {
return centerOffsetY;
} else {
const offset = Math.abs(index - middleIndex) * spacing;
return index < middleIndex ? centerOffsetY - offset : centerOffsetY + offset;
}
} else {
const halfCount = rightIconsCount / 2;
const offsetBase = centerOffsetY - (spacing / 2) - ((halfCount - 1) * spacing);
return offsetBase + index * spacing;
}
};
</script>
<style>
/* Animation styles */
.flow-segment line {
filter: drop-shadow(0 0 3px currentColor);
}
.left-flow line {
stroke: #6ca0f3;
/* Blue color for left to center flow */
}
.right-flow line {
stroke: #f09859;
/* Orange color for center to right flow */
}
</style>

View File

@@ -20,7 +20,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
try {
// 1. 从后端获取注册选项 (Creation Options)
const res = await request.get("/profile/passkey");
console.log("begin:", res.data.data.publicKey);
// console.log("begin:", res.data.data.publicKey);
const options = res.data.data.publicKey;
// 调用 Web Authentication API 进行注册
@@ -44,7 +44,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
const res2 = await request.post("/profile/passkey", attestation);
console.log("end:", res2);
// console.log("end:", res2);
return res2;
} catch (err) {
error.value =err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";

View File

@@ -73,7 +73,7 @@
</header>
<!-- Main Content Area -->
<main class="flex-1 overflow-y-auto p-6 bg-base-200">
<main class="flex-1 overflow-y-auto p-6 bg-base-200 mx-0 px-0">
<router-view></router-view>
</main>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader />
<div class="max-w-3xl mx-auto">
@@ -24,7 +24,7 @@
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createApiKey" class="card-body space-y-5">
<form @submit.prevent="createApiKey" class="card-body space-y-5 p-3 sm:p-8">
<div class="space-y-4">
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
Basic Information

View File

@@ -1,10 +1,10 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader title="Key Details" />
<div class="divider mt-1 mb-0"></div>
<div class="card border border-base-300/40 shadow-sm"v-if="key" >
<form @submit.prevent="updateKey" class="card-body space-y-5">
<form @submit.prevent="updateKey" class="card-body space-y-5 p-3 sm:p-8">
<div class="space-y-4">
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
Basic Information

View File

@@ -37,7 +37,7 @@
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>

View File

@@ -1,10 +1,10 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader title="User Details" />
<div class="max-w-3xl mx-auto space-y-4">
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body">
<div class="card-body px-2 sm:px-8">
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
<div class="avatar placeholder" v-if="!user?.avatar_url">
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
@@ -29,7 +29,7 @@
</div>
</div>
<div class="text-right md:text-left">
<div class="text-right sm:text-left">
<div class="flex items-center gap-2">
<span class="font-medium text-base-content/90">Status:</span>
<span class="badge badge-outline"
@@ -119,7 +119,7 @@
</div>
<div v-else class="max-w-3xl mx-auto">
<div class="card card-bordered bg-base-100 shadow-sm">
<div class="card-body">
<div class="card-body space-y-4 space-y-4 px-2 sm:px-8">
<div class="flex justify-center items-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
@@ -128,7 +128,7 @@
</div>
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-4">
<div class="card-body space-y-4 space-y-4 px-2 sm:px-8">
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
<Bookmark class="h-5 w-5 text-base-content/80" /> Password
</h3>
@@ -195,14 +195,14 @@
</div>
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-4">
<div class="card-body space-y-4 px-2 sm:px-8">
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
<Bookmark class="h-5 w-5 text-base-content/80" /> Passkeys
</h3>
<p class="text-sm text-base-content/80">Manage your passkeys for secure and passwordless login.</p>
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
<div class="card-body">
<div class="card-body px-2 sm:px-8">
<div v-if="passkeys" class="overflow-x-auto -mx-6">
<table class="table table-sm w-full">
<thead>
@@ -243,7 +243,7 @@
</div>
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-4">
<div class="card-body space-y-4 px-2 sm:px-8">
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
<Bookmark class="h-5 w-5 text-base-content/80" /> Linked Accounts (todo)
</h3>
@@ -421,7 +421,10 @@ const toggleTelegramConnection = () => {
const newpasskey = async () => {
try {
await webAuthStore.addPasskey();
let res = await webAuthStore.addPasskey();
if (res.data?.code == 200) {
await getPasskeys();
}
} catch (err) {
console.log('err', err);

View File

@@ -1,10 +1,10 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader title="User Details" />
<div class="max-w-3xl mx-auto">
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-5">
<div class="card-body space-y-5 p-3 sm:p-8">
<div class="flex flex-col md:flex-row items-center gap-6">
<div class="avatar placeholder" v-if="!user?.avatar_url">
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader />
<div class="max-w-3xl mx-auto">
@@ -24,7 +24,7 @@
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createToken" class="card-body space-y-5">
<form @submit.prevent="createToken" class="card-body space-y-5 p-3 sm:p-8">
<div class="space-y-4">
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
Basic Information
@@ -36,33 +36,11 @@
Name <span class="text-red-500">*</span>
</span>
</label>
<input id="username" type="text" v-model="newToken.name" placeholder="Select a username"
<input id="username" type="text" v-model="newToken.name" placeholder="New Token Name"
class="input input-sm input-bordered w-full" required />
</div>
<div class="form-control">
<label for="password" class="label">
<span class="label-text">
Key
</span>
</label>
<div class="relative">
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="newToken.key"
class="input input-sm input-bordered w-full" />
<button type="button" @click="togglePasswordVisibility"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="password-visibility-toggle">
<template v-if="!isPasswordVisible">
<EyeOff class="w-5 h-5" />
</template>
<template v-else>
<Eye class="w-5 h-5" />
</template>
</button>
</div>
</div>
</div>
</div>
@@ -74,18 +52,41 @@
</div>
<div class="collapse-content px-0">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
<div class="form-control">
<label for="password" class="label">
<span class="label-text">
Key
</span>
</label>
<div class="relative">
<input id="token" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
class="input input-sm input-bordered w-full" />
<button type="button" @click="toggleTokenVisibility"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="token-visibility-toggle">
<template v-if="!isPasswordVisible">
<EyeOff class="w-5 h-5" />
</template>
<template v-else>
<Eye class="w-5 h-5" />
</template>
</button>
</div>
</div>
<div class="form-control">
<label class="label label-text text-xs">Expired at</label>
<div class="flex items-center space-x-3">
<input type="date" v-model="newToken.format_expired_at" class="input input-bordered input-sm w-full"
placeholder="年/月/日" :disabled="newToken.never_expired"/>
placeholder="年/月/日" :disabled="newToken.never_expired" />
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
<input type="checkbox" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
<span class="text-sm text-base-content/90">Never</span>
</label>
</div>
</div>
<div class="form-control">
<label for="quota" class="label">
<span class="label-text">Quota</span>
@@ -112,7 +113,7 @@
</span>
</div>
</div>
</div>
</div>
</div>
@@ -133,7 +134,7 @@
</template>
<script setup>
import { ref, computed, inject ,watch } from 'vue'
import { ref, computed, inject, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
@@ -144,7 +145,7 @@ const router = useRouter()
const authStore = useAuthStore()
const { setToast } = inject('toast')
const error = ref(null)
const user = computed(() =>authStore.user);
const user = computed(() => authStore.user);
const showAdvancedOptions = ref(false)
@@ -153,12 +154,12 @@ const newToken = ref({
name: '',
key: '',
user_id: user.user_id,
active: true,
quota: 0,
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
expired_at: 0,
format_expired_at: '',
never_expired:true,
never_expired: true,
})
const resetnewToken = () => {
@@ -166,12 +167,12 @@ const resetnewToken = () => {
name: '',
key: '',
user_id: '',
active: true,
quota: 0,
unlimited_quota: true,
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired:true,
format_expired_at: '',
never_expired: true,
}
}
@@ -184,11 +185,11 @@ watch(
}
);
watch(
() =>newToken.value.format_expired_at,
(format_expired_at)=> {
if(!newToken.value.never_expired && format_expired_at) {
() => newToken.value.format_expired_at,
(format_expired_at) => {
if (!newToken.value.never_expired && format_expired_at) {
newToken.value.expired_at = dateToUnix(format_expired_at);
}else{
} else {
newToken.value.expired_at = 0;
}
}
@@ -231,10 +232,10 @@ const deleteToken = async (id) => {
}
// 显示密码
const isPasswordVisible = ref(false);
const isTokenVisible = ref(false);
function togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
function toggleTokenVisibility() {
isTokenVisible.value = !isTokenVisible.value;
}
const emit = defineEmits(['closeModal'])

View File

@@ -9,7 +9,7 @@
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>

View File

@@ -38,7 +38,7 @@
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader />
<div class="max-w-3xl mx-auto">
@@ -24,7 +24,7 @@
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createUser" class="card-body space-y-5">
<form @submit.prevent="createUser" class="card-body space-y-5 p-3 sm:p-8">
<div class="space-y-4">
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
Basic Information

View File

@@ -1,14 +1,13 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<div class="min-h-screen bg-base-100 p-2 md:p-6">
<BreadcrumbHeader title="User Details" />
<div class="max-w-3xl mx-auto">
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-5">
<div class="card-body space-y-5 p-3 sm:p-8">
<div class="flex flex-col md:flex-row items-center gap-6">
<div class="avatar placeholder" v-if="!user?.avatar_url">
<div
class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
@@ -118,7 +117,7 @@
<select id="role" v-model="user.role" class="select select-bordered select-sm w-full">
<option :value="0">User</option>
<option :value="10">Admin</option>
<option v-if="user.role>10" :value="20">Root</option>
<option v-if="user.role > 10" :value="20">Root</option>
</select>
</div>
@@ -139,7 +138,7 @@
</label>
<div class="flex items-center space-x-3">
<input id="quota" type="number" v-model="user.quota" placeholder="Enter quota amount"
class="input input-bordered input-sm flex-grow" :disabled="user.unlimited_quota" />
class="input input-bordered input-sm w-1/2" :disabled="user.unlimited_quota" />
<label class="label cursor-pointer space-x-2 p-0">
<input type="checkbox" v-model="user.unlimited_quota" class="checkbox checkbox-sm" />
<span class="label-text text-sm text-base-content/90">Unlimited</span>
@@ -198,8 +197,8 @@
</td>
<td class="px-2 py-3">{{ token.used_quota }}</td>
<td class="text-right px-2 py-3">
<button v-if="token.name!=='default'" class="btn btn-ghost btn-xs btn-square text-error" @click="revokeToken(token.id)"
aria-label="Revoke token">
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
@click="revokeToken(token.id)" aria-label="Revoke token">
<TrashIcon class="w-4 h-4" />
</button>
</td>
@@ -272,7 +271,7 @@ const updateUser = async () => {
payload.password = user.value.password;
}
const res = await userStore.editUser(userId.value, payload);
console.log('updateUser',res)
console.log('updateUser', res)
if (res.data?.code == 200) {
setToast(`User ${userId.value} updated`, 'success');
}
@@ -293,9 +292,9 @@ const togglePasswordVisibility = () => {
// 格式化角色
const formatRole = (role) => {
switch (true) {
case role>10:
case role > 10:
return 'Root';
case role>0:
case role > 0:
return 'Admin';
default:
return 'U';