frontend
This commit is contained in:
277
frontend/src/components/LineSegmentFlow.vue
Normal file
277
frontend/src/components/LineSegmentFlow.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
|
||||
<div
|
||||
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] backdrop-blur-0 px-4 py-0 my-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>
|
||||
|
||||
<!-- 左侧图标列 -->
|
||||
<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 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 }"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick, reactive, computed } from 'vue'; // 引入 computed
|
||||
|
||||
// --- 图标数据 (保持不变) ---
|
||||
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: '#eac50c' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
]);
|
||||
// --- 结束图标数据 ---
|
||||
|
||||
// --- Dash 动画参数 ---
|
||||
const dashLen = ref(15); // 移动线段的长度
|
||||
const largeGap = ref(1000); // 一个足够大的间隔,确保只有一个线段可见
|
||||
// --- 结束 Dash 动画参数 ---
|
||||
|
||||
|
||||
const svgCanvas = ref(null);
|
||||
const centerElement = ref(null);
|
||||
const iconRefs = reactive({});
|
||||
const centerCoords = ref(null);
|
||||
const iconCoords = reactive({});
|
||||
|
||||
// (getElementCenterCoords 和 updateCoordinates 函数保持不变)
|
||||
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("部分图标坐标未能成功计算。");
|
||||
// }
|
||||
};
|
||||
|
||||
// (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation)
|
||||
/**
|
||||
* 计算静态视觉连接线的 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;
|
||||
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;
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
|
||||
.flex-col.justify-around {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
/* 可选:给动画路径添加一点模糊效果? */
|
||||
#path-anim-left,
|
||||
#path-anim-right {
|
||||
filter: blur(2px);
|
||||
background-color: #eac50c;
|
||||
}
|
||||
</style>
|
||||
140
frontend/src/components/Pagination.vue
Normal file
140
frontend/src/components/Pagination.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-3 mt-4 text-sm">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
显示 {{ startItem }} 到 {{ endItem }},共 {{ totalItems }} 项
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-4 justify-center md:justify-end">
|
||||
<div v-if="showSelectPageSize" class="flex items-center gap-2">
|
||||
<span class="hidden sm:inline text-sm text-gray-500 dark:text-gray-400">每页显示</span>
|
||||
<select
|
||||
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-16 dark:text-white dark:border-neutral-700 dark:bg-neutral-900"
|
||||
:value="pageSize"
|
||||
@change="emitChangePageSize($event)"
|
||||
>
|
||||
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="px-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ currentPage }}/{{ totalPages }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||
:disabled="currentPage === 1"
|
||||
@click="emitChangePage(1, pageSize)"
|
||||
aria-label="第一页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||
:disabled="currentPage === 1"
|
||||
@click="emitChangePage(currentPage - 1, pageSize)"
|
||||
aria-label="上一页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="emitChangePage(currentPage + 1, pageSize)"
|
||||
aria-label="下一页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="emitChangePage(totalPages, pageSize)"
|
||||
aria-label="最后一页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref ,watch} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => [10, 25, 50, 100],
|
||||
},
|
||||
showSelectPageSize: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['changePage', 'changePageSize']);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
|
||||
});
|
||||
|
||||
// 使用一个 ref 来存储内部的 currentPage 状态
|
||||
const localCurrentPage = ref(props.currentPage);
|
||||
const localPageSize = ref(props.pageSize);
|
||||
|
||||
// 监听 props.currentPage 的变化,并更新 localCurrentPage
|
||||
watch(() => props.currentPage, (newCurrentPage) => {
|
||||
localCurrentPage.value = newCurrentPage;
|
||||
});
|
||||
|
||||
watch(() => props.pageSize, (newPageSize) => {
|
||||
localPageSize.value = newPageSize;
|
||||
});
|
||||
|
||||
const emitChangePage = (page, pageSize) => { // 添加了 pageSize 参数
|
||||
const validPage = Math.max(1, Math.min(page, totalPages.value));
|
||||
if (validPage !== localCurrentPage.value) {
|
||||
localCurrentPage.value = validPage;
|
||||
emit('changePage', validPage, pageSize); // 将 pageSize 传递给父组件
|
||||
}
|
||||
};
|
||||
|
||||
const emitChangePageSize = (event) => {
|
||||
const newPageSize = parseInt(event.target.value, 10);
|
||||
localPageSize.value = newPageSize;
|
||||
emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize
|
||||
};
|
||||
|
||||
const startItem = computed(() => {
|
||||
return (localCurrentPage.value - 1) * localPageSize.value + 1;
|
||||
});
|
||||
|
||||
const endItem = computed(() => {
|
||||
return Math.min(localCurrentPage.value * localPageSize.value, props.totalItems);
|
||||
});
|
||||
</script>
|
||||
60
frontend/src/components/Toast.vue
Normal file
60
frontend/src/components/Toast.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<!-- src/components/Toast.vue -->
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="mt-20 toast "
|
||||
:class="{ 'toast-error': currentMessage?.type === 'error', 'toast-success': currentMessage?.type === 'success' }"
|
||||
>
|
||||
<div
|
||||
class="alert"
|
||||
:class="{ 'alert-error': currentMessage?.type === 'error', 'alert-success': currentMessage?.type === 'success' }"
|
||||
>
|
||||
<span>{{ currentMessage?.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, watch, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
queue: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const show = ref(false);
|
||||
const currentMessage = ref(null);
|
||||
let timer = null;
|
||||
|
||||
const processQueue = () => {
|
||||
if (props.queue.length === 0) {
|
||||
show.value = false;
|
||||
currentMessage.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentMessage.value = props.queue.shift();
|
||||
show.value = true;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
processQueue();
|
||||
}, currentMessage.value.duration || 3000);
|
||||
|
||||
};
|
||||
|
||||
watch(() => props.queue, () => {
|
||||
if(!show.value) {
|
||||
processQueue()
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
81
frontend/src/components/dashboard/BreadcrumbHeader.vue
Normal file
81
frontend/src/components/dashboard/BreadcrumbHeader.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<!-- Breadcrumb and Title -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-lg font-medium">{{ displayTitle }}</h1>
|
||||
<div class="text-xs breadcrumbs">
|
||||
<ul>
|
||||
<li v-for="(item, index) in breadcrumbItems" :key="index">
|
||||
<template v-if="item.path">
|
||||
<a
|
||||
:href="item.path"
|
||||
class="text-gray-500"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-500">{{ item.name }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null // 默认为null,将从路由中获取
|
||||
},
|
||||
// Optional custom breadcrumb items
|
||||
customBreadcrumbs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Generate breadcrumb items based on current route
|
||||
// 生成面包屑项
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (props.customBreadcrumbs.length > 0) {
|
||||
return props.customBreadcrumbs;
|
||||
}
|
||||
|
||||
// 获取当前路径并分割成段
|
||||
const pathSegments = route.path.split('/').filter(segment => segment);
|
||||
|
||||
return pathSegments.map((segment, index) => {
|
||||
const name = segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
|
||||
// 对于最后一段,不设置链接
|
||||
if (index === pathSegments.length - 1) {
|
||||
return { name, path: '' };
|
||||
}
|
||||
|
||||
// 创建到此段的路径
|
||||
const path = '/' + pathSegments.slice(0, index + 1).join('/');
|
||||
return { name, path };
|
||||
});
|
||||
});
|
||||
|
||||
// 显示的标题:如果提供了自定义标题则使用,否则使用当前路径的最后一段
|
||||
const displayTitle = computed(() => {
|
||||
if (props.title) {
|
||||
return props.title;
|
||||
}
|
||||
|
||||
const pathSegments = route.path.split('/').filter(segment => segment);
|
||||
if (pathSegments.length > 0) {
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
|
||||
}
|
||||
|
||||
return 'Dashboard';
|
||||
});
|
||||
</script>
|
||||
98
frontend/src/components/dashboard/Sidebar.vue
Normal file
98
frontend/src/components/dashboard/Sidebar.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<aside class="w-60 bg-base-100 border-r border-base-200">
|
||||
<div class="flex items-center p-4 outline-none select-none">
|
||||
<div class="w-12 h-12 rounded-full">
|
||||
<img src='@/assets/logo.svg' class="">
|
||||
</div>
|
||||
<div class="p-0 text-2xl font-bold text-center">OpenTeam</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="menu p-4 w-60 min-h-full bg-base-100 text-base-content">
|
||||
<li v-for="item in menuItems" :key="item.label">
|
||||
<template v-if="item.type === 'link'">
|
||||
<router-link :to="item.to" :class="{ 'active': isActive(item.to) }">
|
||||
<component :is="item.icon" class="w-4" />
|
||||
{{ item.label }}
|
||||
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'title'">
|
||||
<span class="menu-title">
|
||||
<span>{{ item.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'submenu'">
|
||||
<details :open="item.open">
|
||||
<summary>
|
||||
<component :is="item.icon" class="w-4" />
|
||||
{{ item.label }}
|
||||
<div v-if="item.badge" class="badge badge-sm">{{ item.badge }}</div>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="subItem in item.children" :key="subItem.label">
|
||||
<router-link :to="subItem.to" :class="{ 'active': isActive(subItem.to) }">
|
||||
<component :is="subItem.icon" class="w-4" />
|
||||
{{ subItem.label }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
ShieldPlus,
|
||||
UsersRoundIcon,
|
||||
KeyRoundIcon,
|
||||
MessageSquareIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
CommandIcon,
|
||||
BracesIcon,
|
||||
} from 'lucide-vue-next'
|
||||
import { ref, reactive, onMounted ,computed} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {routes,generateMenuItemsFromRoutes}from '@/utils/router_menu.js'
|
||||
import { useAuthStore } from '@/stores/auth.js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const userrole = computed(() => {
|
||||
const user = authStore.user;
|
||||
return user ? user.role : 0;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
// 判断当前路由是否激活菜单项
|
||||
const isActive = (path) => {
|
||||
return route.path === path;
|
||||
// return router.currentRoute.value.fullPath.startsWith(path);
|
||||
};
|
||||
|
||||
let menuItems = reactive([
|
||||
{ type: 'link', label: 'Overview', to: '/dashboard/overview', icon: LayoutDashboardIcon },
|
||||
{ type: 'title', label: 'Apps' },
|
||||
{ type: 'link', label: 'Tokens', to: '/dashboard/tokens', icon: BracesIcon },
|
||||
{
|
||||
type: 'submenu', label: 'Manager', icon: CommandIcon, open: true, badge: 'Admin',
|
||||
children: [
|
||||
{ label: 'Users', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||
{ label: 'ApiKeys', to: '/dashboard/manager/keys', icon: KeyRoundIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'submenu', label: 'Settings', icon: SettingsIcon,open: false,
|
||||
children: [
|
||||
{ label: 'Profile', to: '/dashboard/settings/profile', icon: UserIcon },
|
||||
]
|
||||
},
|
||||
]);
|
||||
menuItems = computed(() => generateMenuItemsFromRoutes(routes, userrole.value));
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user