This commit is contained in:
Sakurasan
2025-04-16 18:14:53 +08:00
parent ffb4496fd8
commit 15f17f4e8d
55 changed files with 7654 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

3
frontend/README.md Normal file
View File

@@ -0,0 +1,3 @@
# OpenTeam Frontend

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="emerald">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenTeam</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "my-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@vitejs/plugin-basic-ssl": "^2.0.0",
"axios": "^1.8.4",
"element-plus": "^2.9.7",
"lucide-vue-next": "^0.479.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@iconify-json/mingcute": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.32",
"@iconify/vue": "^4.3.0",
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.21",
"daisyui": "^4.12.24",
"pinia": "^2.3.1",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vite": "^6.2.6"
}
}

2102
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

24
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<RouterView />
<Toast :queue="toastQueue" />
</template>
<script setup>
import { onMounted, ref, provide } from 'vue';
import Toast from './components/Toast.vue';
const toastQueue = ref([]);
const setToast = (message, type = 'info', duration) => {
toastQueue.value.push({ message, type, duration });
};
onMounted(() => {
// authStore.checkLoginStatus();
});
provide('toast', { setToast });
</script>

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 581 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View 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>

View 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>

View 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>

View 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>

View 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>

18
frontend/src/main.js Normal file
View File

@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import request from '@/utils/request'
const pinia = createPinia()
const app = createApp(App)
app.provide('request', request)
app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.mount('#app')

View File

@@ -0,0 +1,53 @@
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import { routes } from '@/utils/router_menu.js'
let defaultroutes = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/404', name: '404', component: () => import('@/views/404.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/signup', name: 'Signup', component: () => import('@/views/Signup.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') }, // Catch all 404
{
path: '/dashboard', name: 'Dashboard', component: () => import('@/views/DashBoard.vue'), meta: { requiresAuth: true }, redirect: '/dashboard/overview', children: [
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: 'Overview' } },
{ path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'Tokens' } },
{
path: 'manager', name: 'Manager', meta: { title: 'Manager' }, redirect: '/dashboard/manager/users', children: [
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: 'Users' } },
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: 'UserNew' } },
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: 'UserView' } },
{ path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: 'Keys' } },
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: 'KeyView' } },
]
},
{
path: 'settings', name: 'Settings', meta: { title: 'Settings' }, redirect: '/dashboard/settings/profile', children: [
{ path: 'profile', name: 'Profile', component: () => import('@/views/dashboard/Profile.vue'), meta: { title: 'Profile' } },
]
},
]
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// const router = createRouter({
// history: createWebHistory(process.env.BASE_URL),
// routes
// })
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router

219
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,219 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import request from '@/utils/request'
import { useRouter } from 'vue-router';
// import { jwtDecode } from 'jwt-decode';
export const useAuthStore = defineStore('auth', () => {
const router = useRouter();
const user = ref(null);
const role = computed(() => {
if (!user.value) return 0;
return user.value.role;
})
const token = ref(localStorage.getItem('token') || '');
const isAdmin = computed(() => {
if (!user.value || user.value.role === 0) return false;
return user.value.role > 0;
});
const isLoggedIn = computed(() => !!token.value);
const loading = ref(false);
const error = ref(null);
const setToken = (newToken) => {
token.value = newToken;
localStorage.setItem('token', newToken);
}
const loadTokenFromStorage=()=> {
const storedToken = localStorage.getItem('token');
if (storedToken) {
token.value = storedToken;
}
}
const register = async (userInfo) => {
error.value = null;
try {
const res = await request.post('/auth/register', userInfo)
return res
} catch (err) {
error.value = err.response?.data?.error || '注册失败';
throw error // 或者您可以在这里处理错误,例如显示错误消息
}finally{
loading.value = false;
}
}
const login = async (userInfo) => {
error.value = null;
try {
const res = await request.post('/auth/login', userInfo)
if (res.status === 200 && !!res.data.data?.token) {
setToken(res.data.data.token)
}
await getProfile() // 登录成功后获取用户信息
return res
} catch (err) {
error.value = err.response?.data?.error || '登录失败';
throw error // 或者您可以在这里处理错误,例如显示错误消息
}finally{
loading.value = false;
}
}
const getProfile = async () => {
loading.value = true;
error.value = null;
if (token.value && user.value) {
return user.value
}
try {
const res = await request.get('/profile')
user.value = res.data.data
return res
} catch (err) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw error
}finally {
loading.value = false;
}
}
const refreshProfile = async () => {
loading.value = true;
error.value = null;
try {
const res = await request.get('/profile')
if (res.data.code == 200) {
user.value = res.data.data
}
return res
} catch (err) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw error
}finally{
loading.value = false;
}
}
const updateProfile = async (userInfo) => {
loading.value = true;
error.value = null;
try {
const res = await request.post('/profile/update', userInfo)
console.log('auth.js updateProfile', res.data);
return res
} catch (err) {
error.value = err.response?.data?.error || '更新用户信息失败';
throw error
}finally{
loading.value = false;
}
}
const updatePassword = async (payload) => {
loading.value = true;
error.value = null;
try {
const res = await request.post('/profile/update/password', payload)
console.log('auth.js updatePassword', res.data);
return res
} catch (err) {
error.value = err.response?.data?.error || '更新密码失败';
throw error
}finally{
loading.value = false;
}
}
const createToken = async (newToken) => {
loading.value = true;
error.value = null;
try {
const response = await request.post('/tokens', newToken)
console.log('createToken', response.data);
return response
} catch (err) {
error.value = err.response?.data?.error || '创建token失败';
throw error
}finally{
loading.value = false;
}
}
const resetToken = async (id) => {
loading.value = true;
error.value = null;
try {
const response = await request.post(`/tokens/reset/${id}`)
return response
} catch (err) {
error.value = err.response?.data?.error || '重置token失败';
throw error
}finally{
loading.value = false;
}
}
const updateToken = async (token) => {
loading.value = true;
error.value = null;
try {
const response = await request.put(`/tokens/${token.id}`, token)
return response
} catch (err) {
error.value = err.response?.data?.error || '更新token失败';
throw error
}finally {
loading.value = false;
}
}
const deleteToken = async (id) => {
loading.value = true;
error.value = null;
try {
const response = await request.delete(`/tokens/${id}`)
return response
} catch (err) {
error.value = err.response?.data?.error || '删除token失败';
throw err
}finally {
loading.value = false;
}
}
const clear = () => {
user.value = null
token.value = ''
localStorage.removeItem('token')
}
const logout = () => {
clear()
router.push('/login')
}
return {
loading,error,
user,role,token,
isLoggedIn,
setToken,loadTokenFromStorage,
login,register,
getProfile,updateProfile,updatePassword,refreshProfile,
createToken,deleteToken,resetToken,updateToken,
clear,
logout
}
});

140
frontend/src/stores/key.js Normal file
View File

@@ -0,0 +1,140 @@
// src/stores/key.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
import request from '@/utils/request';
export const useKeyStore = defineStore('key', () => {
const loading = ref(false);
const error = ref(null);
const totalKeys = ref(0);
const keys = ref([]);
const key = ref(null);
const fetchKeys = async (pageSize = 20, page = 1, active) => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/keys', {
params: {
pageSize,
page,
active,
},
});
keys.value = response.data.data?.keys;
totalKeys.value = response.data.data?.total;
} catch (err) {
error.value = err.response?.data?.error || '获取ApiKeys失败';
throw error
} finally {
loading.value = false;
}
};
const fetchKey = async (id) => {
if (keys.value.length > 0) {
const findkey = keys.value.find(item => item.id === id)
if (findkey) {
key.value = findkey;
return;
}
}
loading.value = true;
error.value = null;
// const findkey = keys.find(item=>item.id === id)
// console.log('findkey',findkey)
try {
const response = await request.get(`/keys/${id}`);
key.value = response.data.data;
if (key.value.support_models.length < 3) {
key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
}
if (!key.value.support_models_array) {
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
}
} catch (err) {
error.value = err.response?.data?.error || '获取ApiKey失败';
throw error
} finally {
loading.value = false;
}
};
const refreshKey = async (id) => {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/keys/${id}`);
key.value = response.data.data;
if (key.value.support_models.length < 3) {
key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
}
if (!key.value.support_models_array) {
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
}
} catch (err) {
error.value = err.response?.data?.error || '获取ApiKey失败';
throw error
} finally {
loading.value = false;
}
}
const createKey = async (data) => {
loading.value = true;
error.value = null;
try {
const response = await request.post('/keys', data);
return response;
} catch (err) {
error.value = err.response?.data?.error || '创建ApiKey失败';
throw error
} finally {
loading.value = false;
}
}
const updateKey = async (key) => {
loading.value = true;
error.value = null;
try {
const response = await request.put(`/keys/${key.id}`, key);
return response;
} catch (err) {
error.value = err.response?.data?.error || '更新ApiKey失败';
throw error
} finally {
loading.value = false;
}
}
const keyOption = async (option, ids) => {
loading.value = true;
error.value = null;
try {
const response = await request.post(`/keys/batch/${option}`, { ids });
return response
} catch (err) {
error.value = err.response?.data?.error || '操作失败';
throw err;
} finally {
loading.value = false;
}
}
//todo 更新 批量操作
return {
loading, error,
key, keys, totalKeys,
fetchKeys,
fetchKey,
refreshKey,
createKey,
updateKey,
keyOption,
}
});

138
frontend/src/stores/user.js Normal file
View File

@@ -0,0 +1,138 @@
// src/stores/user.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
import request from '@/utils/request';
export const useUserStore = defineStore('user', () => {
const users = ref([]);
const totalUsers = ref(0);
const user = ref(null);
const loading = ref(false);
const error = ref(null);
async function createUser(userData) {
loading.value = true;
error.value = null;
try {
const response = await request.post('/users', userData);
return response
} catch (err) {
error.value = err.response?.data?.error || '创建用户失败'
throw err;
} finally {
loading.value = false;
}
}
async function listUser(pageSize = 20, page = 1, active) {
loading.value = true;
error.value = null;
try {
const response = await request.get('/users', {
params: {
pageSize,
page,
active,
},
});
users.value = response.data.data?.users;
totalUsers.value = response.data.data?.total;
} catch (err) {
error.value = err.response?.data?.error || '获取用户列表失败';
throw err;
} finally {
loading.value = false;
}
}
async function getUser(id) {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/users/${id}`);
console.log('getUser response',response);
user.value = response.data.data;
return response
} catch (err) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw err;
} finally {
loading.value = false;
}
}
async function refreshUser(id) {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/users/${id}`);
console.log('getUser response',response);
user.value = response.data.data;
return response
} catch (err) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw err;
} finally {
loading.value = false;
}
}
async function editUser(id, userData) {
loading.value = true;
error.value = null;
try {
const response= await request.put(`/users/${id}`, userData);
console.log('editUser',response);
return response
} catch (err) {
error.value = err.response?.data?.error || '编辑用户失败';
throw err;
} finally {
loading.value = false;
}
}
async function deleteUser(id) {
loading.value = true;
error.value = null;
try {
const response = await request.delete(`/users/${id}`);
return response
} catch (err) {
error.value = err.response?.data?.error || '删除用户失败';
throw err;
} finally {
loading.value = false;
}
}
async function userOption(option, ids) {
loading.value = true;
error.value = null;
try {
const response = await request.post(`/users/batch/${option}`, { ids });
return response
} catch (err) {
error.value = err.response?.data?.error || '操作失败';
throw err;
} finally {
loading.value = false;
}
}
return {
users,
totalUsers,
user,
loading,
error,
createUser,
listUser,
getUser,
refreshUser,
editUser,
deleteUser,
userOption,
};
});

View File

@@ -0,0 +1,138 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import request from "@/utils/request";
import { useRouter } from "vue-router";
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
import { useAuthStore } from "./auth";
export const useWebAuthStore = defineStore("webauth", () => {
const router = useRouter();
// const token = ref(localStorage.getItem("token") || "");
const passkeys = ref(null);
const loading = ref(false);
const error = ref(null);
const addPasskey = async () => {
error.value = "";
loading.value = true;
try {
// 1. 从后端获取注册选项 (Creation Options)
const res = await request.get("/profile/passkey");
console.log("begin:", res.data.data.publicKey);
const options = res.data.data.publicKey;
// 调用 Web Authentication API 进行注册
// const credential = await navigator.credentials.create(options);
// console.log("credential:", credential);
let attestation;
try {
// Pass 'undefined' as the second argument if you are not using an AbortSignal
attestation = await startRegistration({optionsJSON: options});
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
error.value = null;
} catch (regError) {
// console.log("WebAuthn 注册失败或取消:", regError);
if (regError.name === "NotAllowedError") {
error.value = "Passkey 操作被取消或不允许。";
} else {
error.value = `Passkey 创建出错: ${regError.message}`;
}
return; // 终止流程
}
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
const res2 = await request.post("/profile/passkey", attestation);
console.log("end:", res2);
return res2;
} catch (err) {
error.value =err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
throw error
} finally {
loading.value = false;
}
};
const loginPasskey = async () => {
error.value = null;
loading.value = true;
try {
// 1. 从后端获取登录选项 (Assertion Options)
const res = await request.get("/auth/passkey/begin");
// console.log("login begin:", res.data);
const options = res.data.data.publicKey;
// 2. 调用 Web Authentication API 进行认证
let assertion;
try {
assertion = await startAuthentication({ optionsJSON: options });
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
} catch (loginError) {
if (loginError.name === "NotAllowedError") {
error.value = "Passkey 登录被取消或不允许。";
} else {
error.value = `Passkey 登录出错: ${loginError.message}`;
}
throw error;
}
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
const res2 = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
// 4. 处理登录成功的响应,通常包含 Token
if (res2.status === 200 && !!res2.data.data?.token) {
const token = res2.data.data.token
const authStore = useAuthStore()
authStore.setToken(token)
await authStore.getProfile()
return res2.data
}
} catch (err) {
error.value = err.response?.data?.error || err.value || "Passkey 登录失败,请稍后重试。";
throw error;
} finally {
loading.value = false;
}
};
const getPasskeys = async () => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/profile/passkeys')
// console.log('getPasskeys',response.data.data)
passkeys.value = response.data.data
} catch (err) {
error.value = err.response?.data?.error || '获取token列表失败';
throw error
}finally {
loading.value = false;
}
}
const deletePasskey = async (id) => {
loading.value = true;
error.value = null;
try {
const response = await request.delete(`/profile/passkeys/${id}`)
return response
} catch (err) {
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
throw error
}finally {
loading.value = false;
}
}
return {
passkeys,
loading,
error,
addPasskey,
loginPasskey,
getPasskeys,
deletePasskey,
};
});

11
frontend/src/style.css Normal file
View File

@@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
}

View File

@@ -0,0 +1,32 @@
// src/utils/format-date.js
export function dateToUnix(dateString) {
const date = new Date(dateString);
return Math.floor(date.getTime() / 1000);
}
export function unixToDate(timestamp) {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDateTime(unixTimestamp) {
// 如果时间戳不存在或为0返回'未知'
if (!unixTimestamp) return "未知";
// 将Unix时间戳转换为毫秒
const date = new Date(unixTimestamp * 1000);
// 获取日期和时间的各个部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
// 返回格式化的日期时间字符串
return `${year}-${month}-${day} ${hours}:${minutes}`;
}

View File

@@ -0,0 +1,50 @@
// src/utils/request.js
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
const service = axios.create({
baseURL: 'http://localhost:8080/api', // 设置基础 URL根据你的实际 API 地址进行修改
timeout: 5000, // 请求超时时间
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
service.interceptors.request.use(
config => {
const authStore = useAuthStore();
if (!authStore.token) {
authStore.loadTokenFromStorage();
}
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
error => {
console.error('Request error:', error);
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
response => {
return response; // 只返回响应数据,便于后续使用
},
error => {
// 可以在这里处理响应错误的情况,例如统一处理错误信息, 提示用户等
console.error('Response error:', error);
// 这里可以做一些统一的错误处理,例如根据状态码判断是否 token 失效,并跳转到登录页面
if (error.response && error.response.status === 401) {
const authStore = useAuthStore();
authStore.clear();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default service;

View File

@@ -0,0 +1,75 @@
import {
LayoutDashboardIcon,
ShieldPlus,
UsersRoundIcon,
KeyRoundIcon,
MessageSquareIcon,
SettingsIcon,
UserIcon,
CommandIcon,
BracesIcon,
} from 'lucide-vue-next'
export const routes = [
{ path: '/', name: 'Home',component: () => import('@/views/Home.vue') },
{ path: '/404', name: '404',component: () => import('@/views/404.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/signup', name: 'Signup', component: () => import('@/views/Signup.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue')}, // Catch all 404
{ path: '/dashboard', name: 'Dashboard', component: ()=>import('@/views/DashBoard.vue'), meta: { requiresAuth: true, title: 'Dashboard', showInSidebar: false },redirect: '/dashboard/overview', children:[
{ path: 'overview', name: 'Overview', component: ()=>import('@/views/dashboard/Overview.vue'),meta: { title: 'Overview', icon: LayoutDashboardIcon, showInSidebar: true } },
{ path: 'tokens', name: 'Tokens', component: ()=>import('@/views/dashboard/Tokens.vue'),meta: { title: 'Tokens', icon: BracesIcon, showInSidebar: true } },
{ path: 'manager', name: 'Manager',meta: { title: 'Manager', icon: CommandIcon, showInSidebar: true, open: true, badge: 'Admin' }, redirect: '/dashboard/manager/users',children:[
{ path: 'users', name: 'User', component: ()=>import('@/views/dashboard/User.vue'),meta: { title: 'Users', icon: UsersRoundIcon, showInSidebar: true } },
{ path: 'users/new', name: 'UserNew', component: ()=>import('@/views/dashboard/UserNew.vue'),meta: { title: 'UserNew', icon: UsersRoundIcon, showInSidebar: false } },
{ path: 'users/view', name: 'UserView', component: ()=>import('@/views/dashboard/UserView.vue'),meta: { title: 'UserView', icon: UsersRoundIcon, showInSidebar: false } },
{ path: 'keys', name: 'ApiKey', component: ()=>import('@/views/dashboard/Keys.vue') ,meta: { title: 'ApiKeys', icon: KeyRoundIcon, showInSidebar: true } },
{ path: 'keys/view', name: 'ApiKeyView', component: ()=>import('@/views/dashboard/KeyView.vue') ,meta: { title: 'ApiKeyView', icon: KeyRoundIcon, showInSidebar: false } },
] },
{ path: 'settings', name: 'Settings', meta: { title: 'Settings', icon: SettingsIcon, showInSidebar: true, open: false } , redirect: '/dashboard/settings/profile',children:[
{ path: 'profile', name: 'Profile', component: ()=>import('@/views/dashboard/Profile.vue'),meta: { title: 'Profile', icon: UserIcon, showInSidebar: true } },
]},
]},
];
export function generateMenuItemsFromRoutes(routes, userRole, parentPath = '') {
const menuItems = [];
for (const route of routes) {
if (route.meta && route.meta.title && route.meta.showInSidebar) {
const fullPath = parentPath + '/' + route.path.replace(/^\//, '');
const menuItem = {
label: route.meta.title,
to: fullPath,
icon: route.meta.icon,
};
if (route.children && route.children.length > 0) {
if (route.name === 'Manager' && userRole < 10) {
continue;
}
menuItem.type = 'submenu';
menuItem.open = route.meta.open !== undefined ? route.meta.open : false;
menuItem.badge = route.meta.badge;
menuItem.children = generateMenuItemsFromRoutes(route.children, userRole, fullPath);
} else {
menuItem.type = 'link';
}
if (route.name === 'Overview') {
menuItems.push(menuItem);
menuItems.push({ type: 'title', label: 'Apps' });
continue
}
menuItems.push(menuItem);
} else if (route.path === '/dashboard' && route.children) {
menuItems.push(...generateMenuItemsFromRoutes(route.children, userRole, '/dashboard'));
}
}
return menuItems;
}

View File

@@ -0,0 +1,65 @@
<template>
<div class="min-h-screen bg-base-100 text-base-content">
<header class="fixed w-full top-0 z-50 backdrop-blur-md bg-base-100/50">
<div class="container mx-auto flex justify-between items-center p-4">
<div class="flex items-center h-12 w-12 rounded-full text-l">
<img src="../assets/logo.svg" alt="Logo" class="select-none">
<span class="hidden sm:flex text-xl font-bold">
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
</span>
</div>
<div class="flex items-center">
</div>
</div>
</header>
<main class="flex-grow flex flex-col justify-center items-center pt-16">
<div class="text-center">
<div class="flex items-center justify-center my-8 outline-none select-none">
<!-- <img src="../assets/404.svg" alt="404 Not Found" class="h-48"> -->
</div>
<h1 class="text-5xl font-bold mb-4 text-rose-300">
404
</h1>
<p class="text-gray-400 text-lg max-w-lg mx-auto mb-8">
迷路了吗?<span class="line-through text-gray-200">卑鄙的</span>异乡客?
</p>
<div class="flex flex-col sm:flex-row gap-4">
<a href="/" class="btn btn-primary">Go Back Home</a>
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" class="btn btn-outline">Visit GitHub Repo</a>
</div>
</div>
</main>
<footer class="footer footer-center text-base-content rounded p-10 mt-16 absolute bottom-0 w-full">
<nav>
<div class="grid grid-flow-col gap-4">
<a href="https://github.com/mirrors2/opencatd-open" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="fill-current">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</nav>
<aside>
<p>Copyright © {{ currentYear }} - All right reserved by <a href="https://github.com/mirrors2" target="_blank"
class="text-gray-600 hover:text-gray-800 transition duration-300">Mirrors2.</a> </p>
</aside>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const currentYear = ref('');
onMounted(() => {
currentYear.value = new Date().getFullYear().toString();
});
</script>
<style scoped>
/* You can add specific styles for the 404 page here if needed */
</style>

View File

@@ -0,0 +1,160 @@
<!-- src/layouts/MainLayout.vue -->
<template>
<div class="min-h-screen bg-base-100">
<div class="drawer drawer-overlay" :class="{ 'md:drawer-open': isLargeSidebarOpen }">
<!-- Sidebar Toggle for Mobile -->
<input id="drawer" type="checkbox" class="drawer-toggle" />
<!-- Main Content -->
<div class="drawer-content flex flex-col">
<!-- Top Navigation -->
<header class="w-full navbar bg-base-100 border-b border-base-200 h-14 min-h-[3rem]">
<!-- Mobile menu button -->
<div class="flex-none">
<div class="md:hidden">
<label for="drawer" class="btn btn-square btn-ghost h-10 min-h-[2.5rem]">
<MenuIcon class="w-5 h-5" />
</label>
</div>
<!-- Desktop menu button -->
<div class="hidden md:flex">
<label @click="toggleSidebarLarge" class="btn btn-square btn-ghost h-10 min-h-[2.5rem]">
<MenuIcon class="w-5 h-5" />
</label>
</div>
</div>
<div class="flex-auto space-x-1 justify-end">
<button class="btn btn-ghost btn-circle h-10 min-h-[2.5rem]" @click="toggleTheme">
<SunIcon v-if="isDark" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
<button class="btn btn-ghost btn-circle h-10 min-h-[2.5rem] hidden">
<div class="indicator">
<BellIcon class="w-5 h-5" />
<span class="badge badge-xs badge-primary indicator-item"></span>
</div>
</button>
<div class="dropdown dropdown-bottom dropdown-end">
<label tabindex="0" class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20 h-10 min-h-[2.5rem]">
<div class="flex items-center gap-2">
<!-- 有头像时显示头像 -->
<div v-if="userInfo.avatar" class="avatar">
<div class="mask mask-squircle w-8 h-8">
<img :src="userInfo.avatar" :alt="userInfo.name">
<!-- <img src='../assets/logo.svg' :alt="userInfo.name"> -->
</div>
</div>
<!-- 没有头像时显示首字母 -->
<div v-else class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-8 mask mask-squircle">
<span class="text-sm">{{ userInitials }}</span>
</div>
</div>
<div class="flex flex-col items-start">
<p class="text-sm/none">{{ userInfo.username }}</p>
<p class="mt-1 text-xs/none text-primary">Edit</p>
</div>
</div>
</label>
<ul class="menu menu-sm dropdown-content mt-2 z-[1] p-1.5 shadow bg-base-100 rounded-box w-32">
<template v-for="item in userNavigation" :key="item.name || 'divider'">
<hr v-if="item.type === 'divider'" class="-mx-2 my-1 border-base-content/10">
<li v-else :class="item.class">
<a href="#" class="py-1.5" @click.prevent="handleNavigation(item)">
<component :is="item.icon" class="w-4 h-4" />
{{ item.name }}
</a>
</li>
</template>
</ul>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="flex-1 overflow-y-auto p-6 bg-base-200">
<router-view></router-view>
</main>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<label for="drawer" class="drawer-overlay"></label>
<Sidebar />
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { MenuIcon, SunIcon, MoonIcon, BellIcon, User, Settings, LogOut } from 'lucide-vue-next'
import Sidebar from '@/components/dashboard/Sidebar.vue'
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
const router = useRouter()
const authStore = useAuthStore()
const isDark = ref(false)
const isLargeSidebarOpen = ref(true)
const userInfo = computed(() => {
return authStore.user || {}
})
onMounted(async () => {
if (!userInfo) {
await authStore.getProfile()
}
})
const userInitials = computed(() => {
// If name exists, use it first
if (userInfo.value.name) {
return userInfo.value.name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2); // Max 2 letters
}
if (userInfo.value.username) {
return userInfo.value.username
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2); // Max 2 letters
}
return 'U';
});
const userNavigation = reactive([
{ name: 'Profile', icon: User },
{ type: 'divider' },
{ name: 'Logout', icon: LogOut, class: 'text-error' }
]);
const handleNavigation = (item) => {
if (item.name === 'Profile') {
router.push('/dashboard/settings/profile')
} else if (item.name === 'Logout'){
authStore.logout()
}
}
const toggleSidebarLarge = () => {
isLargeSidebarOpen.value = !isLargeSidebarOpen.value
}
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'emerald')
}
</script>

154
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,154 @@
<template>
<div class="min-h-screen bg-base-100 text-base-content">
<div class="navbar fixed w-full top-0 z-50 backdrop-blur-sm bg-base-100/50">
<div class="container mx-auto flex justify-between items-center p-1 rounded-box">
<div class="flex items-center h-12 w-12 rounded-full text-l">
<img src="../assets/logo.svg" alt="Logo" class="select-none">
<span class="hidden sm:flex text-xl font-bold">
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
</span>
</div>
<div class="flex items-center">
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer"
class="btn btn-md h-10 min-h-10 px-3 md:flex bg-black text-white items-center justify-center whitespace-nowrap rounded-xl text-sm font-extralight ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-black/90 flex gap-2">
<Icon icon="simple-icons:github" class="size-5" />
<span>Star on GitHub</span>
<Icon icon="mingcute:star-fill" class="size-5 text-yellow-500 mb-0.5" />
<div class="font-extralight">{{ star }}</div>
</a>
<a class="hidden btn btn-outline font-extralight btn-md h-10 min-h-10 hover:bg-black px-1 ml-2"
@click="$router.push('/dashboard')">Dashboard</a>
</div>
</div>
</div>
<main class="flex-grow flex flex-col justify-center items-center pt-16">
<div class="text-center">
<div class="flex items-center justify-center my-4 outline-none select-none">
<img src="../assets/openteam.png" alt="Project Logo" class="h-40">
</div>
<h1 class="text-4xl font-bold mb-4">
<a class="text-gray-600" href="https://github.com/mirrors2/opencatd-open">OpenTeam</a>
</h1>
<p class="text-lg max-w-2xl mx-auto mb-8">
OpenTeam is an open-source, team-shared service. that is compatible with OpenAI API and can
be safely shared with others for API usage.
</p>
</div>
<div class="mb-8 md:tooltip md:tooltip-left md:tooltip-open" data-tip="指向 OpenTeam base_url">
<div class="join">
<input type="text" class="input input-bordered join-item grow focus:outline-none rounded-l-full"
v-model="url" />
<button class="hover:bg-black hover:text-white btn join-item rounded-r-full" @click="copyUrl">复制</button>
</div>
</div>
<div class="w-full max-w-4xl mx-auto px-4 text-center">
<p class="text-sm mb-4">
👉Api-Keys: <a href="https://platform.openai.com/account/api-keys"
class="link">https://platform.openai.com/account/api-keys</a>
</p>
<div class="card mb-8">
<div class="card-body px-1 flex flex-col items-center">
<h2 class="card-title text-xl font-extralight justify-center flex mb-4 p-2 rounded-lg border-2 border-dotted hover:cursor-alias"
@click="$router.push('/dashboard')">开始使用</h2>
<p class="mb-0">使用OpenTeam 管理你的LLM API,仅需一个地址即可接入不同的大模型</p>
<LineSegmentFlow />
</div>
</div>
<div class="card backdrop-blur-sm shadow-xl mb-8">
<div class="card-body">
<p class="mb-2">欢迎加入我们的Telegram频道获取最新动态和帮助</p>
<div class="flex justify-center mb-4">
<a href="https://t.me/OpenTeamLLM" target="_blank" class="tooltip tooltip-bottom backdrop-blur-0" data-tip="Telegram Channel">
<img src="../assets/openteam_channel.jpg" alt="Telegram Group QR Code"
class="w-40 fill-current backdrop-blur-0 select-none">
</a>
</div>
</div>
</div>
</div>
</main>
<footer class="footer footer-center text-base-content rounded p-10">
<nav>
<div class="grid grid-flow-col gap-4">
<a href="https://github.com/mirrors2/opencatd-open" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="fill-current">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</nav>
<aside>
<p>Copyright © {{ currentYear }} - All right reserved by <a href="https://github.com/mirrors2" target="_blank"
class="text-gray-600 hover:text-gray-800 transition duration-300">Mirrors2.</a> </p>
</aside>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted, inject } from 'vue';
import LineSegmentFlow from '@/components/LineSegmentFlow.vue';
import { Icon } from '@iconify/vue';
const currentYear = ref('');
const url = ref('');
const { setToast } = inject('toast');
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(url.value);
setToast('复制成功!', 'info');
} catch (err) {
setToast('复制失败,请手动复制。', 'error');
}
};
const star = ref(0);
const getGithubStars = async () => {
const res = await fetch('https://ungh.cc/repos/mirrors2/opencatd-open', { next: { revalidate: 3600 } });
const data = await res.json();
return data.repo.stars;
};
onMounted(() => {
url.value = window.location.origin;
currentYear.value = new Date().getFullYear().toString();
getGithubStars().then((stars) => {
star.value = stars;
});
});
</script>
<style scoped>
.toast {
transition: opacity 0.3s, visibility 0.3s;
opacity: 0;
visibility: hidden;
position: fixed;
top: 64px;
/* 调整这个值以匹配你的 header 高度 */
right: 1rem;
}
.toast.visible {
opacity: 1;
visibility: visible;
}
blockquote {
padding: 0 1em;
border-left: 0.25em solid #838989aa;
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body p-4 sm:p-6">
<img src="../assets/openteam.webp" alt="Company Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer"
@click="$router.push('/')" />
<h2 class="card-title text-md sm:text-xl mb-6 justify-center flex">
Log in to Dashboard
</h2>
<form @submit.prevent="handleLogin">
<div class="form-control mb-4">
<label class="label" for="username">
<span class="label-text">Account</span>
</label>
<input type="text" id="username" placeholder="username/email" class="input input-bordered w-full input-sm"
v-model="user.username" required />
</div>
<div class="form-control mb-4">
<label class="label" for="password">
<span class="label-text">Password</span>
<a href="#" class="label-text-alt link link-hover link-primary text-sm">
Forgot password?
</a>
</label>
<input type="password" id="password" placeholder="••••••••" class="input input-bordered w-full input-sm"
v-model="user.password" minlength="4" required />
</div>
<div class="form-control mb-6">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" v-model="user.rember" class="checkbox checkbox-primary checkbox-sm" />
<span class="label-text text-sm">Remember me</span>
</label>
</div>
<div class="form-control mb-4">
<button type="submit" class="btn btn-outline w-full btn-sm">
Log In
</button>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>{{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
</form>
<div class="divider my-2 text-sm">OR</div>
<div class="form-control mb-6">
<button @click="handlePasskeyLogin" class="btn btn-outline w-full btn-sm" :disabled="!supportWebAuth">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"
viewBox="0 0 24 24">
<path fill="currentColor"
d="M7 13.23q-.517 0-.874-.356T5.769 12t.357-.874t.874-.357t.874.357t.357.874t-.357.874t-.874.357M7 17q-2.077 0-3.538-1.461T2 12t1.462-3.538T7 7q1.54 0 2.778.835q1.238.834 1.807 2.165h9.204l2 2l-3.193 3.154l-1.712-1.288l-1.807 1.326L14.298 14h-2.713q-.57 1.312-1.807 2.156T7 17m0-1q1.477 0 2.52-.889T10.856 13h3.76l1.43.967l1.858-1.333l1.621 1.222L21.381 12l-1-1h-9.525q-.292-1.223-1.336-2.111T7 8Q5.35 8 4.175 9.175T3 12t1.175 2.825T7 16" />
</svg>
Sign in with Passkey
</button>
</div>
<div class="hidden flex flex-col sm:flex-row gap-3 w-full">
<button @click="handleGithubLogin" class="btn btn-outline flex-1 btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-github mr-1"
viewBox="0 0 16 16">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
</svg>
</button>
<button @click="handleGoogleLogin" class="btn btn-outline flex-1 btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-google mr-1"
viewBox="0 0 16 16">
<path
d="M15.545 6.558a9.4 9.4 0 0 1 .139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 1 1 8 0a7.7 7.7 0 0 1 5.352 2.082l-2.284 2.284A4.35 4.35 0 0 0 8 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.8 4.8 0 0 0 0 3.063h.003c.635 1.896 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.7 3.7 0 0 0 1.599-2.431H8v-3.08z" />
</svg>
</button>
<button @click="handleTelegramLogin" class="btn btn-outline flex-1 btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-telegram mr-1" viewBox="0 0 16 16">
<path
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8.287 5.906q-1.168.486-4.666 2.01-.567.225-.595.442c-.03.243.275.339.69.47l.175.055c.408.133.958.288 1.243.294q.39.01.868-.32 3.269-2.206 3.374-2.23c.05-.012.12-.026.166.016s.042.12.037.141c-.03.129-1.227 1.241-1.846 1.817-.193.18-.33.307-.358.336a8 8 0 0 1-.188.186c-.38.366-.664.64.015 1.088.327.216.589.393.85.571.284.194.568.387.936.629q.14.092.27.187c.331.236.63.448.997.414.214-.02.435-.22.547-.82.265-1.417.786-4.486.906-5.751a1.4 1.4 0 0 0-.013-.315.34.34 0 0 0-.114-.217.53.53 0 0 0-.31-.093c-.3.005-.763.166-2.984 1.09" />
</svg>
</button>
</div>
<div class="text-center mt-6 text-sm">
Don't have an account?
<router-link to="/signup" class="link link-primary link-hover">
Sign up
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth';
import { useWebAuthStore } from '@/stores/webauth';
// import request from '@/utils/request';
const router = useRouter()
const authStore = useAuthStore();
const webauthStore = useWebAuthStore();
const { setToast } = inject('toast');
const error = ref(null)
const user = reactive({
username: localStorage.getItem('account') || '',
password: localStorage.getItem('password') || '',
rember: localStorage.getItem('rember') || false,
})
const supportWebAuth = ref(false);
onMounted(() => {
if (localStorage.getItem('token')) {
router.push('/dashboard')
}
supportWebAuth.value = !!window.PublicKeyCredential;
})
const handleLogin = async () => {
error.value = null;
try {
const response = await authStore.login({ username: user.username, password: user.password });
if (response.status === 200) {
if (user.rember) {
localStorage.setItem('account', user.username);
localStorage.setItem('password', user.password);
localStorage.setItem('rember', user.rember);
} else {
localStorage.removeItem('account');
localStorage.removeItem('password');
localStorage.removeItem('rember');
}
setToast('登录成功', 'success');
router.push('/dashboard');
}
} catch (err) {
console.error('Login error:', err);
error.value = err
}
}
const handlePasskeyLogin = async () => {
error.value = null;
try {
const res = await webauthStore.loginPasskey();
if (!!res.code && res.code === 200) {
setToast('登录成功', 'success');
router.push('/dashboard');
}
} catch (err) {
console.error('Passkey login error:', err);
error.value = err
}
};
const handleGithubLogin = () => {
alert('GitHub login is not yet implemented.');
};
const handleGoogleLogin = () => {
alert('Google login is not yet implemented.');
};
const handleTelegramLogin = () => {
alert('Telegram login is not yet implemented.');
};
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body p-4 sm:p-6">
<img src="../assets/openteam.webp" alt="Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer" @click="$router.push('/')"/>
<h2 class="card-title text-md sm:text-xl mb-2 justify-center flex">
Create Your Account
</h2>
<form @submit.prevent="handleRegister">
<div class="form-control mb-4">
<label class="label" for="account">
<span class="label-text">Account</span>
</label>
<input type="text" id="account" placeholder="username"
class="input input-bordered w-full input-sm" v-model="username" required />
</div>
<div class="form-control mb-4">
<label class="label" for="password">
<span class="label-text">Password</span>
</label>
<input id="password" type="password" placeholder="password"
class="input input-bordered w-full input-sm" v-model="password" required minlength="4" />
</div>
<div class="form-control mb-6">
<label class="label" for="confirm-password">
<span class="label-text">Confirm Password</span>
</label>
<input type="password" id="confirm-password" placeholder="Retype your password"
class="input input-bordered w-full input-sm" v-model="confirmPassword" required />
</div>
<div class="form-control mb-4">
<button type="submit" class="btn btn-outline w-full btn-sm">
Sign Up
</button>
</div>
<div v-if="error" class="text-error text-sm mt-2 text-center">{{ error }}</div>
</form>
<div class="text-center mt-4 text-sm">
Already have an account?
<router-link to="/login" class="link link-primary link-hover">
Login
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, inject } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const router = useRouter()
const authStore = useAuthStore()
const { setToast } = inject('toast');
const error = ref('');
const username = ref('');
const password = ref('');
const confirmPassword = ref('');
const handleRegister = async () => {
if (password.value !== confirmPassword.value) {
alert("密码不一致");
return
}
try {
let res = await authStore.register({
username: username.value,
password: password.value
})
if (res.status === 200) {
setToast(res.data?.msg || '注册成功', 'success');
setTimeout(() => {
router.push('/sign_in');
}, 100);
error.value = ''
}
} catch (e) {
error.value = 'Registration failed. Please try again.'
}
}
</script>

View File

@@ -0,0 +1,309 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<BreadcrumbHeader />
<div class="max-w-3xl mx-auto">
<div class="mb-6 text-center md:text-left">
<h1 class="text-2xl font-semibold text-base-content">
Create New API Key
</h1>
<p class="text-sm text-base-content/70 mt-1">
Enter the API key's details below.
</p>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>Error! {{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createApiKey" class="card-body space-y-5">
<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
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
<div class="form-control">
<label for="name" class="label">
<span class="label-text">
Name <span class="text-red-500">*</span>
</span>
</label>
<input id="name" type="text" v-model="newApiKey.name" placeholder="API Key Name"
class="input input-sm input-bordered w-full" required />
</div>
<div class="form-control">
<label for="apitype" class="label">
<span class="label-text">
Type <span class="text-red-500">*</span>
</span>
</label>
<div class="relative">
<select id="apitype" v-model="newApiKey.type" class="select select-sm select-bordered w-full pl-10"
required>
<option class="disabled" value="" selected>Select API Type</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 left-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="newApiKey.type === 'openai'">
<img src="../../assets/openai.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="newApiKey.type === 'claude'">
<img src="../../assets/claude.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="newApiKey.type === 'gemini'">
<img src="../../assets/gemini.svg" class="w-5 h-5" alt="">
</template>
<template v-else="newApiKey.type">
<img src="../../assets/logo.svg" class="w-5 h-5" alt="">
</template>
</button>
</div>
</div>
<div class="form-control">
<label for="apikey" class="label">
<span class="label-text">
API Key <span class="text-red-500">*</span>
</span>
</label>
<input id="apikey" type="text" v-model="newApiKey.apikey" placeholder="Your API Key"
class="input input-sm input-bordered w-full" required />
</div>
<div class="form-control">
<label for="endpoint" class="label">
<span class="label-text">Endpoint</span>
</label>
<input id="endpoint" type="text" v-model="newApiKey.endpoint" placeholder="API Endpoint URL"
class="input input-sm input-bordered w-full" />
</div>
</div>
</div>
<div class="collapse collapse-arrow">
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
Advanced Options
</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="resource_name" class="label">
<span class="label-text">Resource Name</span>
</label>
<input id="resource_name" type="text" v-model="newApiKey.resource_name" placeholder="Resource Name"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="deployment_name" class="label">
<span class="label-text">Deployment Name</span>
</label>
<input id="deployment_name" type="text" v-model="newApiKey.deployment_name"
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="api_secret" class="label">
<span class="label-text">API Secret</span>
</label>
<input id="api_secret" type="text" v-model="newApiKey.api_secret" placeholder="API Secret"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="model_prefix" class="label">
<span class="label-text">Model Prefix</span>
</label>
<input id="model_prefix" type="text" v-model="newApiKey.model_prefix" placeholder="Model Prefix"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="model_alias" class="label">
<span class="label-text">Model Alias</span>
</label>
<textarea id="model_alias" v-model="newApiKey.model_alias" placeholder="{}"
class="textarea textarea-sm textarea-bordered w-full"></textarea>
</div>
<div class="form-control">
<label for="parameters" class="label">
<span class="label-text">Parameters (JSON)</span>
</label>
<textarea id="parameters" v-model="newApiKey.parameters" placeholder="{}"
class="textarea textarea-sm textarea-bordered w-full"></textarea>
</div>
<div class="md:col-span-2 form-control">
<label for="support_models" class="label">
<span class="label-text">Support Models</span>
</label>
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable
placeholder="Please input" @change="onchange_supportmodel"/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<div class="flex items-center space-x-3 h-9">
<input type="checkbox" v-model="newApiKey.active"
:class="`toggle toggle-sm ${newApiKey.active ? 'toggle-success' : 'toggle-error'}`" />
<span class="text-sm text-base-content/90">
{{ newApiKey.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-4 items-center gap-2">
<button @click="cancel" class="btn btn-sm btn-outline">Cancel</button>
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success" :disabled="!isFormValid">
Create
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useKeyStore } from '@/stores/key';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
const router = useRouter()
const keyStore = useKeyStore()
const { setToast } = inject('toast')
const error = ref(null)
// Control advanced options visibility
const showAdvancedOptions = ref(false)
// Initialize API key object
const newApiKey = ref({
name: '',
type: '',
apikey: '',
active: true,
endpoint: '',
resource_name: '',
deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '',
support_models_array: [],
})
const resetNewApiKey = () => {
newApiKey.value = {
name: '',
type: '',
apikey: '',
active: true,
endpoint: '',
resource_name: '',
deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '',
support_models_array: [],
}
}
const onchange_supportmodel = () => {
newApiKey.value.support_models = JSON.stringify(newApiKey.value.support_models_array)
}
// Form validation
const isFormValid = computed(() => {
return newApiKey.value.name &&
newApiKey.value.type &&
newApiKey.value.apikey
})
const cancel = () => {
resetNewApiKey()
emit('closeModal', true)
}
const createApiKey = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
return
}
try {
try {
if (!Array.isArray(newApiKey.value.support_models_array)) {
setToast('Support Models must be a JSON array.', 'error');
return;
}
} catch (e) {
setToast('Invalid JSON format for Support Models.', 'error');
return;
}
// Attempt to parse parameters JSON
try {
JSON.parse(newApiKey.value.parameters);
} catch (e) {
setToast('Invalid JSON format for Parameters.', 'error');
return;
}
const res = await keyStore.createKey(newApiKey.value);
if (res.data?.code === 200) {
error.value = null;
resetNewApiKey();
setToast('API Key created successfully', 'success')
// Optionally navigate or reset form
emit('closeModal', true)
} else {
setToast(res.error || res.data?.message || 'Failed to create API Key', 'error')
}
} catch (err) {
console.log('createApiKey error:', err)
error.value = err || 'Failed to create API Key'
// setToast(error.response?.data?.error || 'Failed to create API Key', 'error')
}
}
const emit = defineEmits(['closeModal'])
</script>
<style scoped>
/* Minimal custom styles if absolutely necessary */
.collapse .collapse-title {
min-height: 0;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div class="min-h-screen bg-base-100 p-4 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">
<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
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
<div class="form-control">
<label for="name" class="label">
<span class="label-text">
Name <span class="text-red-500">*</span>
</span>
</label>
<input id="name" type="text" v-model="key.name" placeholder="API Key Name"
class="input input-sm input-bordered w-full" required />
</div>
<div class="form-control">
<label for="apitype" class="label">
<span class="label-text">
Type <span class="text-red-500">*</span>
</span>
</label>
<div class="relative">
<select id="apitype" v-model="key.type" class="select select-sm select-bordered w-full pl-10"
required>
<option class="disabled" value="" selected>Select API Type</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<button type="button" class="absolute inset-y-0 left-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="key.type === 'openai'">
<img src="../../assets/openai.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="key.type === 'claude'">
<img src="../../assets/claude.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="key.type === 'gemini'">
<img src="../../assets/gemini.svg" class="w-5 h-5" alt="">
</template>
<template v-else="key.type">
<img src="../../assets/logo.svg" class="w-5 h-5" alt="">
</template>
</button>
</div>
</div>
<div class="form-control">
<label for="apikey" class="label">
<span class="label-text">
API Key <span class="text-red-500">*</span>
</span>
</label>
<input id="apikey" type="text" v-model="key.apikey" placeholder="Your API Key"
class="input input-sm input-bordered w-full" required />
</div>
<div class="form-control">
<label for="endpoint" class="label">
<span class="label-text">Endpoint</span>
</label>
<input id="endpoint" type="text" v-model="key.endpoint" placeholder="API Endpoint URL"
class="input input-sm input-bordered w-full" />
</div>
</div>
</div>
<div class="collapse collapse-arrow">
<input type="checkbox" checked class="min-h-0 py-2" />
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
Advanced Options
</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="resource_name" class="label">
<span class="label-text">Resource Name</span>
</label>
<input id="resource_name" type="text" v-model="key.resource_name" placeholder="Resource Name"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="deployment_name" class="label">
<span class="label-text">Deployment Name</span>
</label>
<input id="deployment_name" type="text" v-model="key.deployment_name"
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="api_secret" class="label">
<span class="label-text">API Secret</span>
</label>
<input id="api_secret" type="text" v-model="key.api_secret" placeholder="API Secret"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="model_prefix" class="label">
<span class="label-text">Model Prefix</span>
</label>
<input id="model_prefix" type="text" v-model="key.model_prefix" placeholder="Model Prefix"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="model_alias" class="label">
<span class="label-text">Model Alias</span>
</label>
<textarea id="model_alias" v-model="key.model_alias" placeholder="{}"
class="textarea textarea-sm textarea-bordered w-full"></textarea>
</div>
<div class="form-control">
<label for="parameters" class="label">
<span class="label-text">Parameters (JSON)</span>
</label>
<textarea id="parameters" v-model="key.parameters" placeholder="{}"
class="textarea textarea-sm textarea-bordered w-full"></textarea>
</div>
<div class="md:col-span-2 form-control">
<label for="support_models" class="label">
<span class="label-text">Support Models</span>
</label>
<!-- <textarea id="support_models" v-model="key.support_models_text"
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
<el-input-tag v-model="key.support_models_array" :trigger="'Enter'" clearable
placeholder="Please input" @change="onchange_supportmodel"/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<div class="flex items-center space-x-3 h-9">
<input type="checkbox" v-model="key.active"
:class="`toggle toggle-sm ${key.active ? 'toggle-success' : 'toggle-error'}`" />
<span class="text-sm text-base-content/90">
{{ key.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-4 items-center gap-2">
<button @click="cancel" class="btn btn-sm btn-outline">Back</button>
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success">
Update
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue';
import { useRoute,useRouter } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next';
import { useKeyStore } from '../../stores/key';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
const route = useRoute();
const router = useRouter();
const keyStore = useKeyStore();
const { setToast } = inject('toast');
const keyId = computed(() => route.query.id);
onMounted(async () => {
});
const key = computed(() => keyStore.key);
const loading = computed(() => keyStore.loading);
onMounted(async () => {
console.log('keyId', keyId.value)
if (keyId.value) {
await keyStore.fetchKey(keyId.value);
}
});
const onchange_supportmodel = () => {
key.value.support_models = JSON.stringify(key.value.support_models_array)
}
const updateKey = async () => {
try {
const res = await keyStore.updateKey(key.value);
console.log('updateKey', res)
if (res.data?.code == 200) {
setToast(`Key ${key.value.name} updated`, 'success');
}
await keyStore.refreshKey(key.value.id);
} catch (err) {
console.error('Error updating key:', err);
}
};
const cancel = () => {
router.push({ name: 'ApiKey' });
}
</script>

View File

@@ -0,0 +1,316 @@
<template>
<div class="min-h-screen bg-base-100 p-4">
<!-- Breadcrumb and Title -->
<BreadcrumbHeader />
<!-- <div v-if="keyStore.loading" class="loading loading-spinner loading-lg"></div> -->
<div class="flex flex-wrap gap-2 mb-4" v-if="keys">
<div class="flex flex-1 items-center space-x-2">
<input
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[150px] lg:w-[250px]"
placeholder="Filter" value="">
<div class="dropdown">
<label tabindex="0"
class="inline-flex items-center justify-center flex gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground rounded-md px-3 text-xs h-8 border-dashed">
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
<path fill="currentColor" fill-rule="evenodd"
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0a5.673 5.673 0 0 1-11.346 0M7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5"
clip-rule="evenodd"></path>
</svg>
Status
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
<a class="px-2 mx-0 hover:rounded-none">
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
{{ status }}
</a>
</li>
</ul>
</div>
</div>
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<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">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<KeyNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
<div class="dropdown dropdown-end dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
<Settings2Icon class="w-6 h-6" />
</div>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box z-[1] w-20 text-sm">
<li>
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
@click="handleBatchAction('delete')">
<TrashIcon class="w-4 h-4" />删除
</div>
</li>
<hr class="-mx-2 my-1 border-base-content/10">
<li>
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
@click="handleBatchAction('disable')">
<BadgeXIcon class="w-4 h-4" />禁用
</div>
</li>
<li>
<div class="btn btn-xs p-1 text-xs hover:bg-green-100 hover:text-green-600 transition-colors"
@click="handleBatchAction('enable')">
<BadgeCheckIcon class="w-4 h-4" />启用
</div>
</li>
</ul>
</div>
</div>
<!-- Table -->
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
<table class="table table-sm">
<!-- Table Header -->
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
</th>
<th>Type</th>
<th>Name</th>
<th>Active</th>
<th>Key</th>
<th>Endpoint</th>
<th></th>
</tr>
</thead>
<!-- Table Body -->
<tbody>
<tr v-for="key in keys" :key="key.id"
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
<td>
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
@change="toggleUserSelection(key)" />
</td>
<td class="text-xs dark:text-white">{{ key.type }}</td>
<td class="text-xs dark:text-white">{{ key.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
v-model="key.active" @change="updateStatus(key)" />
</td>
<td class="text-xs dark:text-white">{{ key.apikey }}</td>
<td class="text-xs dark:text-white">{{ key.endpoint }}</td>
<td>
<div class="flex gap-1">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
</button>
</div>
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30" @click="confirmDeleteKey(key)">
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[ 10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, inject, computed } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/Pagination.vue';
import KeyNew from '@/views/dashboard/KeyNew.vue';
import { useKeyStore } from '@/stores/key';
import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, Infinity
} from 'lucide-vue-next';
const router = useRouter();
const keyStore = useKeyStore();
const { setToast } = inject('toast');
onMounted(async () => {
await keyStore.fetchKeys();
})
const keys = computed(() => keyStore.keys);
// 用户数据
const currentPage = ref(1);
const pageSize = ref(10);
const totalItems = computed(() => keyStore.totalKeys);
// 封装公共的用户列表获取方法
const fetchKeys = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => {
currentPage.value = page || currentPage.value;
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
await keyStore.fetchKeys(size, page, active);
};
// 组件挂载时加载用户数据
// onMounted(async () => {
// await fetchKeys();
// });
// 分页与页面大小变化
const changePage = async (page, size) => {
if (page == currentPage.value && size == pageSize.value) {
return
}
currentPage.value = page;
pageSize.value = size;
await fetchKeys();
};
const changePageSize = changePage;
// 复选框选择状态
const selectAll = ref(false)
const selectedKeys = ref([])
const toggleSelectAll = () => {
if (keys.value.length === 0) {
return
}
keys.value.forEach(key => key.selected = selectAll.value)
if (selectAll.value) {
// Select all on the current page
selectedKeys.value = users.value.map(user => user)
} else {
// Clear all selections
selectedKeys.value = []
}
}
const toggleUserSelection = (key) => {
if (selectedKeys.value.includes(key)) {
selectedKeys.value = selectedKeys.value.filter(selected => selected !== key);
} else {
selectedKeys.value.push(key);
}
selectAll.value = selectedKeys.value.length === keys.value.length;
};
// 状态筛选
const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive([]);
const toggleStatusFilter = async (status) => {
const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status);
if (index > -1) {
selectedStatuses.splice(index, 1);
} else {
selectedStatuses.push({ status, value: statusValue });
}
await fetchKeys(undefined, 1, undefined);
};
// 处理批量操作
const handleBatchAction = async (action) => {
if (selectedKeys.value.length === 0) {
return setToast('请选择数据', 'error');
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast('无效的操作 ${action}', 'error');
}
try {
const res = await keyStore.keyOption(action, selectedKeys.value.map(item => item.id));
if (res.data?.code === 200) {
setToast(`Key ${action} Success`, 'success');
} else {
setToast(res.data.error || `${action} Failed`, 'error');
}
selectedKeys.value = [];
selectAll.value = false;
await fetchKeys();
} catch (error) {
console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error');
}
};
// 更新用户状态
const updateStatus = async (key) => {
try {
const action = key.active ? 'enable' : 'disable';
const res = await keyStore.keyOption(action, [key.id]);
if (res.data?.code === 200) {
setToast(`Key ${key.name} has been ${action}`, 'success');
}
await fetchKeys();
} catch (error) {
console.error('状态更新失败:', error);
setToast('状态更新失败', 'error');
}
};
const viewKey = (key) => {
router.push({ name: 'ApiKeyView', query: { id: key.id } });
}
// 删除用户
const confirmDeleteKey = async (key) => {
if (confirm(`确认删除 ${key.name}?`)) {
await deleteKey(key);
}
};
const deleteKey = async (key) => {
try {
const res = await keyStore.keyOption('delete', [key.id]);
if (res.data?.code === 200) {
setToast('删除成功', 'success');
}
await fetchKeys();
} catch (error) {
console.error('删除失败:', error);
setToast('删除失败', 'error');
}
};
// 关闭模态框
const modalRef = ref(null);
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await fetchKeys();
};
</script>

View File

@@ -0,0 +1,291 @@
<template>
<div class="min-h-screen bg-base-100 p-3 md:p-6">
<BreadcrumbHeader />
<div class="card shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl mb-6" >
<div :class="['card-body text-white', getGradientClass()]">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<div class="avatar">
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full ring ring-white ring-offset-base-100 ring-offset-2"
v-if="!user?.avatar_url">
<div
class="bg-white text-primary-content font-bold flex items-center justify-center w-full h-full">
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full ring ring-white ring-offset-base-100 ring-offset-2"
v-else>
<img :src="user?.avatar_url" :alt="user?.name" />
</div>
</div>
<div class="text-center sm:text-left">
<h1 class="text-2xl sm:text-4xl font-extrabold tracking-tight">
<span class="mr-2">👋 </span>{{ getTimeOfDay() }}{{ user?.name || user?.username }}
</h1>
<p class="text-white/80 text-base sm:text-lg mt-1 font-medium">欢迎回到您的个人仪表盘</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 lg:gap-8">
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
<div class="card-body p-4 md:p-6">
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
基本信息
</h2>
<div class="divider my-1"></div>
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">用户名</span>
<span class="badge">{{ user?.username }}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">显示名称</span>
<span class="badge">{{ user?.name || '-' }}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">邮箱</span>
<span class="badge truncate max-w-full">{{ user?.email || '-' }}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">角色</span>
<span class="badge" :class="user?.role > 0 ? 'badge-warning' : 'badge-ghost'">{{
getRoleName(user?.role || 0) }}</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
<div class="card-body p-4 md:p-6">
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
账户状态
</h2>
<div class="divider my-1"></div>
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">状态</span>
<span :class="[
'badge badge-outline',
user?.active ? 'badge-success' : 'badge-error'
]">
{{ user?.active ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">时区</span>
<span class="badge ">{{ user?.timezone || 'UTC' }}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">语言</span>
<span class="badge">{{ user?.language || 'en' }}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">最后活动</span>
<span class="badge">{{ getLastActive() }}</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
<div class="card-body p-4 md:p-6">
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
配额信息
</h2>
<div class="divider my-1"></div>
<div class="space-y-4">
<div v-if="user?.unlimited_quota">
<div class="flex flex-wrap justify-between mb-2">
<span class="font-semibold text-base-content/70">使用量</span>
<span class="badge badge-lg text-success">无限制</span>
</div>
</div>
<div v-else>
<div class="flex flex-wrap justify-between mb-2">
<span class="font-semibold text-base-content/70">使用量</span>
<span :class="getQuotaColor">
{{ formatQuota(user?.used_quota || 0, user?.quota||0) }}
</span>
</div>
<progress class="progress w-full"
:class="getQuotaColorClass"
:value="quotaPercentage"
max="100"></progress>
</div>
<div class="stats stats-vertical shadow w-full bg-base-100">
<div class="stat p-2 md:p-4">
<div class="stat-title text-xs md:text-sm">创建时间</div>
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.created_at) }}
</div>
</div>
<div class="stat p-2 md:p-4">
<div class="stat-title text-xs md:text-sm">更新时间</div>
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.updated_at) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const router = useRouter();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
onMounted(async () => {
if (!authStore.isLoggedIn) {
router.push('/login');
};
await authStore.refreshProfile();
});
const getTimeOfDay = () => {
const hour = new Date().getHours();
if (hour < 12) return '早上好';
if (hour < 18) return '下午好';
return '晚上好';
};
const formatQuota = (used, total) => {
if (total === 0) return '无限制';
// 格式化金额
const formatCurrency = (amount) => {
if (amount === 0) return '$0';
return `$${amount.toFixed(2)}`;
};
const percentage = (used / total) * 100;
return `${formatCurrency(used)} / ${formatCurrency(total)} (${percentage.toFixed(1)}%)`;
};
const getRoleName = (role) => {
switch (role) {
case 20: return 'Root';
case 10: return 'Admin';
default: return 'User';
}
};
// 格式化日期时间
function formatDateTime(unixTimestamp) {
// 如果时间戳不存在或为0返回'未知'
if (!unixTimestamp) return '未知';
// 将Unix时间戳转换为毫秒
const date = new Date(unixTimestamp * 1000);
// 获取日期和时间的各个部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
// 返回格式化的日期时间字符串
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
// 获取背景渐变类
const getGradientClass = () => {
const hour = new Date().getHours();
if (hour < 6) return 'bg-gradient-to-r from-[#e0f2f1] to-[#1a1a1a] bg-opacity-50 backdrop-blur-lg'; // 深夜到黎明:柔和的薄荷绿渐变到微黑
if (hour < 12) return 'bg-gradient-to-r from-[#8cc7f1] to-[#cf6f26] bg-opacity-50 backdrop-blur-lg'; // 早晨:温暖的杏仁色渐变到深灰
if (hour < 18) return 'bg-gradient-to-r from-[#e3f2fd] to-[#ad4212] bg-opacity-50 backdrop-blur-lg'; // 下午:清新的天空蓝渐变到近黑
return 'bg-gradient-to-r from-[#000000] to-[#434343] bg-opacity-50 backdrop-blur-lg'; // 夜晚:纯黑到深灰
};
// 计算配额百分比
const quotaPercentage = computed(() => {
if (!user.value || user.value.unlimited_quota || !user.value.quota) return 0;
return (user.value.used_quota / user.value.quota) * 100;
});
// 获取配额颜色
const getQuotaColor = computed(() => {
const percentage = quotaPercentage.value;
if (percentage < 50) return 'text-success';
if (percentage < 80) return 'text-warning';
return 'text-error';
});
// 获取配额颜色类
const getQuotaColorClass = computed(() => {
const percentage = quotaPercentage.value;
if (percentage < 50) return 'progress-success';
if (percentage < 80) return 'progress-warning';
return 'progress-error';
});
// 用户最后活动时间
const getLastActive = () => {
if (!user.value || !user.value?.updated_at) return '未知';
const lastActive = new Date(user.value.updated_at * 1000);
const now = new Date();
const diff = now - lastActive;
// 转换为天/小时/分钟
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}天前`;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}小时前`;
const minutes = Math.floor(diff / (1000 * 60));
if (minutes > 0) return `${minutes}分钟前`;
return '刚刚';
};
</script>
<style scoped>
/* 添加一些过渡动画 */
.card {
transform-origin: center;
backface-visibility: hidden;
}
.card:hover {
transform: translateY(-4px);
}
/* 确保响应式设计中文本不会溢出 */
.badge {
white-space: normal;
height: auto;
min-height: 1.6rem;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div class="min-h-screen bg-base-100 p-4 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="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">
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="avatar" v-else>
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
<img :src="user?.avatar_url" :alt="user?.name" />
</div>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-base-content">
{{ user?.name }}
</h2>
<p class="text-sm text-base-content/80">{{ user?.username }}</p>
<div class="flex items-center gap-2 mt-2">
<span class="badge badge-outline"
:class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
formatRole(user?.role) }}</span>
</div>
</div>
<div class="text-right md:text-left">
<div class="flex items-center gap-2">
<span class="font-medium text-base-content/90">Status:</span>
<span class="badge badge-outline"
:class="user.active ? 'badge-success' : 'badge-error'">
{{ user.active ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="mt-2">
<span class="font-medium text-base-content/90">Quota:</span>
<template v-if="user.unlimited_quota">
<Infinity class="inline-block w-9 text-base-content/70" />
<span class="text-sm text-base-content/70">({{ user.used_quota }} used)</span>
</template>
<template v-else>
<span class="text-sm text-base-content/70">{{ user.used_quota }} / {{ user.quota
}}</span>
</template>
</div>
</div>
</div>
<div class="divider mt-4 mb-0"></div>
<form @submit.prevent="confirmUpdateBasicInfo" class="space-y-4 mt-4">
<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" /> Basic Information
</h3>
<div v-if="basicinfo_error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>{{ basicinfo_error }}</span>
</div>
<button class="btn btn-sm" @click="basicinfo_error = null">X</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<div class="form-control w-full">
<label for="name" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
</label>
<input id="name" type="text" v-model="basicinfo.name" placeholder="Full name"
class="input input-bordered input-sm w-full" />
</div>
<div class="form-control w-full">
<label for="username" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">
Username <span class="text-red-500">*</span>
</span>
</label>
<input id="username" type="text" v-model="basicinfo.username"
placeholder="Select a username" class="input input-bordered input-sm w-full"
required />
</div>
<div class="form-control w-full">
<label for="email" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Email
Address</span>
</label>
<div class="relative">
<input id="email" type="email" v-model="basicinfo.email"
placeholder="email@example.com" class="input input-bordered input-sm w-full" />
<button type="button" @click="toggleEmailVerify" tabindex="-1"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
aria-label="Toggle email verification">
<BadgeCheck v-if="user.email_verified"
class="w-4 h-4 bg-green-300 rounded-full" />
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
<Send class="w-4 h-4" />
</div>
</button>
</div>
</div>
</div>
<div class="card-actions justify-end pt-4">
<button type="submit" class="btn btn-outline btn-success btn-sm">
Update Information
</button>
</div>
</form>
</div>
</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="flex justify-center items-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
</div>
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-4">
<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>
<div v-if="password_error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>{{ password_error }}</span>
</div>
<button class="btn btn-sm" @click="password_error = null">X</button>
</div>
<form @submit.prevent="updatePassword" class="space-y-3">
<div class="form-control">
<label for="old_password" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Old Password</span>
</label>
<input id="old_password" type="password" v-model="passwordData.oldPassword"
placeholder="Enter current password" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label for="new_password" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">New Password</span>
</label>
<div class="relative">
<input id="new_password" :type="isNewPasswordVisible ? 'text' : 'password'"
v-model="passwordData.newPassword" placeholder="Enter new password"
class="input input-bordered input-sm w-full pr-10" />
<button type="button" @click="toggleNewPasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
aria-label="Toggle new password visibility">
<EyeOff v-if="!isNewPasswordVisible" class="w-4 h-4" />
<Eye v-else class="w-4 h-4" />
</button>
</div>
</div>
<div class="form-control">
<label for="confirm_password" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Confirm New
Password</span>
</label>
<div class="relative">
<input id="confirm_password" :type="isConfirmPasswordVisible ? 'text' : 'password'"
v-model="passwordData.confirmPassword" placeholder="Confirm new password"
class="input input-bordered input-sm w-full pr-10" />
<button type="button" @click="toggleConfirmPasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
aria-label="Toggle confirm password visibility">
<EyeOff v-if="!isConfirmPasswordVisible" class="w-4 h-4" />
<Eye v-else class="w-4 h-4" />
</button>
</div>
</div>
<div class="card-actions justify-end pt-4">
<button type="submit" class="btn btn-outline btn-warning btn-sm">
Change Password
</button>
</div>
</form>
</div>
</div>
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-4">
<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 v-if="passkeys" class="overflow-x-auto -mx-6">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Name</th>
<th class="px-2 py-3">Create Time</th>
<th class="px-2 py-3">SignCount</th>
<th class="px-2 py-3">Remark</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="passkey in passkeys" :key="passkey.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ passkey.name }}</td>
<td class="font-mono text-xs px-2 py-3">{{ formatDateTime(passkey.created_at) }}</td>
<td class="font-mono text-xs px-2 py-3">{{ passkey.sign_count }}</td>
<td class="font-mono text-xs px-2 py-3">{{ passkey.device_type }}</td>
<td class="text-right px-2 py-3">
<button class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRmovePasskey(passkey)" aria-label="Revoke token">
<TrashIcon class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-center text-base-content/70 py-4">No passkeys found</p>
</div>
</div>
<div class="card-actions justify-end pt-4">
<button class="btn btn-outline btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth">
Add Passkey
</button>
</div>
</div>
</div>
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
<div class="card-body space-y-4">
<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>
<p class="text-sm text-base-content/80">Manage your linked social accounts for easier login.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="border rounded-md p-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<Github class="w-6 h-6 text-base-content/80" />
<span>GitHub</span>
</div>
<button class="btn btn-sm" :class="isGithubConnected ? 'btn-success' : 'btn-outline'">
{{ isGithubConnected ? 'Disconnect' : 'Connect' }}
</button>
</div>
<div class="border rounded-md p-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<Send class="w-6 h-6 fill-current text-gray-500" />
<span>Telegram</span>
</div>
<button class="btn btn-sm" :class="isTelegramConnected ? 'btn-success' : 'btn-outline'">
{{ isTelegramConnected ? 'Disconnect' : 'Connect' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue';
import { useRoute } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Bookmark, Infinity, Github, Info } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '../../stores/auth';
import { useWebAuthStore } from '../../stores/webauth';
import { formatDateTime} from '@/utils/format-date';
const route = useRoute();
const authStore = useAuthStore();
const webAuthStore = useWebAuthStore();
const { setToast } = inject('toast');
const loading = computed(() => authStore.loading);
const user = computed(() => authStore.user);
const basicinfo_error = ref(null);
const password_error = ref(null);
const basicinfo = ref({
name: user.value?.name || '',
username: user.value?.username || '',
email: user.value?.email || '',
});
const passwordData = ref({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const isNewPasswordVisible = ref(false);
const isConfirmPasswordVisible = ref(false);
const isGithubConnected = ref(false); // Replace with actual status
const isTelegramConnected = ref(false); // Replace with actual status
const supportWebAuth = ref(false);
onMounted(async () => {
await authStore.refreshProfile();
basicinfo.value = {
name: user.value?.name || '',
username: user.value?.username || '',
email: user.value?.email || '',
};
await webAuthStore.getPasskeys();
if (window.PublicKeyCredential) {
console.log('WebAuthn is supported');
} else {
console.log('WebAuthn is not supported');
}
supportWebAuth.value = !!window.PublicKeyCredential;
});
const confirmUpdateBasicInfo = () => {
updateBasicInfo();
};
const updateBasicInfo = async () => {
if (!basicinfo.value) return;
try {
console.log('updateBasicInfo', basicinfo.value);
const res = await authStore.updateProfile(basicinfo.value);
if (res.data?.code == 200) {
setToast('Basic information updated successfully', 'success');
}
await authStore.refreshProfile(); // Refresh user data
basicinfo_error.value = null; // Clear error
} catch (err) {
console.log('Error updating basic info:', err);
basicinfo_error.value = err || '更新失败';
setToast('Failed to update basic information', 'error');
}
};
const updatePassword = async () => {
if (!user.value) return;
if (passwordData.value.newPassword !== passwordData.value.confirmPassword) {
setToast('新密码和确认密码不匹配', 'error');
return;
}
try {
const payload = {
password: passwordData.value.oldPassword,
newpassword: passwordData.value.newPassword,
};
console.log('payload', payload)
const res = await authStore.updatePassword(payload);
if (res.data?.code == 200) {
setToast('Password updated successfully', 'success');
}
passwordData.value.oldPassword = '';
passwordData.value.newPassword = '';
passwordData.value.confirmPassword = '';
password_error.value = null; // Clear error
} catch (err) {
console.error('Error updating password:', err);
password_error.value = err || '更新失败';
}
};
// 格式化角色
const formatRole = (role) => {
switch (true) {
case role > 10:
return 'Root';
case role > 0:
return 'Admin';
default:
return 'User';
}
};
const toggleEmailVerify = () => {
if (user.value && !user.value.email_verified) {
// todo: Implement logic to send verification email
setToast('todo,Sending verification email...', 'info');
return;
}
}
const toggleNewPasswordVisibility = () => {
isNewPasswordVisible.value = !isNewPasswordVisible.value;
};
const toggleConfirmPasswordVisibility = () => {
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
};
const toggleGithubConnection = () => {
isGithubConnected.value = !isGithubConnected.value;
setToast(`GitHub ${isGithubConnected.value ? 'connected' : 'disconnected'}`, 'info');
// Implement actual connection/disconnection logic here
};
const toggleTelegramConnection = () => {
isTelegramConnected.value = !isTelegramConnected.value;
setToast(`Telegram ${isTelegramConnected.value ? 'connected' : 'disconnected'}`, 'info');
// Implement actual connection/disconnection logic here
};
const newpasskey = async () => {
try {
await webAuthStore.addPasskey();
} catch (err) {
console.log('err', err);
}
};
const passkeys = computed(() => webAuthStore.passkeys);
const getPasskeys = async () => {
try {
await webAuthStore.getPasskeys();
} catch (err) {
console.log('err', err);
}
}
const confirmRmovePasskey = async (passkey) => {
if(confirm(`确认删除 ${passkey.name}?`)) {
await removePasskey(passkey.id)
}
}
const removePasskey = async (id) => {
try {
const res = await webAuthStore.deletePasskey(id);
if (res.data?.code == 200) {
setToast('Passkey removed successfully', 'success');
}
await getPasskeys();
} catch (err) {
console.log('err', err);
}
}
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div class="min-h-screen bg-base-100 p-4 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="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">
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="avatar" v-else>
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
<img :src="user?.avatar_url" :alt="user?.name" />
</div>
</div>
<div class="flex-grow text-center md:text-left">
<h2 class="text-2xl font-semibold text-base-content">
{{ user?.name || user?.username }}
</h2>
<div class="flex items-center justify-center md:justify-start gap-2 mt-2">
<span class="badge border-none px-0">
<CircleCheckBig v-if="user.active" class="h-5 w-5 bg-green-300 rounded-full" />
<CircleX v-else class="h-5 w-5 bg-rose-300 rounded-full" />
</span>
<span class="badge badge-outline"
:class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
formatRole(user?.role) }}</span>
</div>
</div>
<div class="flex gap-2 mt-4 md:mt-0">
<input type="checkbox" class="toggle toggle-md"
:class="user.active ? 'toggle-success' : 'toggle-error'" v-model="user.active"
/>
</div>
</div>
<div class="divider mt-1 mb-0"></div>
<form @submit.prevent="updateUser" class="space-y-4">
<div class="space-y-3">
<h3 class="text-base font-medium text-base-content mb-3">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<div class="form-control w-full">
<label for="name" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
</label>
<input id="name" type="text" v-model="user.name" placeholder="Full name"
class="input input-bordered input-sm w-full" />
</div>
<div class="form-control w-full">
<label for="username" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">
Username <span class="text-red-500">*</span>
</span>
</label>
<input id="username" type="text" v-model="user.username"
placeholder="Select a username" class="input input-bordered input-sm w-full"
required />
</div>
<div class="form-control w-full">
<label for="email" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Email
Address</span>
</label>
<div class="relative">
<input id="email" type="email" v-model="user.email"
placeholder="email@example.com"
class="input input-bordered input-sm w-full" />
<button type="button" @click="toggleEmailVerify"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
aria-label="Toggle password visibility">
<BadgeCheck v-if="user.email_verified"
class="w-4 h-4 bg-green-300 rounded-full" />
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
<Send class="w-4 h-4" />
</div>
</button>
</div>
</div>
<div class="form-control w-full">
<label for="password" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">
Password <span class="text-xs text-base-content/60">(Leave blank to keep
unchanged)</span>
</span>
</label>
<div class="relative">
<input id="password" :type="isPasswordVisible ? 'text' : 'password'"
v-model="user.password" placeholder="Enter new password"
class="input input-bordered input-sm w-full pr-10" />
<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 focus:outline-none rounded-r-md"
aria-label="Toggle password visibility">
<EyeOff v-if="!isPasswordVisible" class="w-4 h-4" />
<Eye v-else class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<div class="collapse collapse-arrow border border-base-300/30 rounded-md mt-4">
<input type="checkbox" class="min-h-0 py-2" checked />
<div class="collapse-title text-base font-medium min-h-0 py-2">
Advanced Options
</div>
<div class="collapse-content">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 pt-2">
<div class="form-control w-full">
<label for="role" class="label pb-1">
<span
class="label-text text-sm font-medium text-base-content/80">Role</span>
</label>
<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>
</select>
</div>
<div class="form-control w-full">
<label for="language" class="label pb-1">
<span
class="label-text text-sm font-medium text-base-content/80">Language</span>
</label>
<select id="language" v-model="user.language"
class="select select-bordered select-sm w-full">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
<div class="md:col-span-2 form-control w-full">
<label for="quota" class="label pb-1">
<span
class="label-text text-sm font-medium text-base-content/80">Quota</span>
</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" />
<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>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="card-actions justify-end pt-4">
<button type="submit" class="btn btn-outline btn-success btn-sm">
Update
</button>
</div>
</form>
</div>
</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="flex justify-center items-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue';
import { useRoute } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
import { useAuthStore } from '../../stores/auth';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
const route = useRoute();
const authStore = useAuthStore();
const { setToast } = inject('toast');
const loading = computed(() => authStore.loading);
const user = computed(() => authStore.user);
onMounted(async () => {
await authStore.refreshProfile()
});
const updateUser = async () => {
if (!user.value) return;
try {
const payload = {
name: user.value.name,
username: user.value.username,
email: user.value.email,
language: user.value.language,
};
if (user.value.password) {
payload.password = user.value.password;
}
const res = await userStore.editUser(userId.value, payload);
console.log('updateUser', res)
if (res.data?.code == 200) {
setToast(`User ${userId.value} updated`, 'success');
}
await userStore.refreshUser(userId.value);
} catch (err) {
console.error('Error updating user:', err.response?.data?.data?.error);
}
};
//显示密码
const isPasswordVisible = ref(false);
const togglePasswordVisibility = () => {
isPasswordVisible.value = !isPasswordVisible.value;
};
// 格式化角色
const formatRole = (role) => {
switch (true) {
case role > 10:
return 'Root';
case role > 0:
return 'Admin';
default:
return 'U';
}
};
const toggleEmailVerify = () => {
if (user.value && !user.value.email_verified) {
// todo
return
}
}
</script>

View File

@@ -0,0 +1,251 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<BreadcrumbHeader />
<div class="max-w-3xl mx-auto">
<div class="mb-6 text-center md:text-left">
<h1 class="text-2xl font-semibold text-base-content">
Create New Token
</h1>
<p class="text-sm text-base-content/70 mt-1">
Enter the token's details below.
</p>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>Error! {{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createToken" class="card-body space-y-5">
<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
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
<div class="form-control">
<label for="username" class="label">
<span class="label-text">
Name <span class="text-red-500">*</span>
</span>
</label>
<input id="username" type="text" v-model="newToken.name" placeholder="Select a username"
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>
<div class="collapse collapse-arrow">
<input type="checkbox" v-model="showAdvancedOptions" value="false" class="min-h-0 py-2" />
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
Advanced Options
</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 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"/>
<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>
</label>
<div class="flex items-center space-x-3">
<input id="quota" type="number" v-model="newToken.quota" placeholder="Enter quota amount"
class="input input-sm input-bordered flex-grow" :disabled="newToken.unlimited_quota" />
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
<input type="checkbox" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
<span class="text-sm text-base-content/90">Unlimited</span>
</label>
</div>
</div>
<div class="md:col-span-2 form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<div class="flex items-center space-x-3 h-9">
<input type="checkbox" v-model="newToken.active"
:class="`toggle toggle-sm ${newToken.active ? 'toggle-success' : 'toggle-error'}`" />
<span class="text-sm text-base-content/90">
{{ newToken.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-4 gap-5">
<button type="button" class="btn btn-outline btn-error btn-sm h-9 px-4 text-sm font-medium" @click="cancel">
Cancel
</button>
<button type="submit" class="btn btn-outline btn-sm h-9 px-4 text-sm font-medium" :disabled="!isFormValid">
Create
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, inject ,watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { Eye, EyeOff } from 'lucide-vue-next'
import { dateToUnix } from '@/utils/format-date.js'
const router = useRouter()
const authStore = useAuthStore()
const { setToast } = inject('toast')
const error = ref(null)
const user = computed(() =>authStore.user);
const showAdvancedOptions = ref(false)
const newToken = ref({
name: '',
key: '',
user_id: user.user_id,
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired:true,
})
const resetnewToken = () => {
newToken.value = {
name: '',
key: '',
user_id: '',
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired:true,
}
}
watch(
() => newToken.value.never_expired,
(newNeverExpiredValue) => {
if (newNeverExpiredValue) {
newToken.value.expired_at = 0;
}
}
);
watch(
() =>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{
newToken.value.expired_at = 0;
}
}
)
const isFormValid = computed(() => {
return newToken.value.name
})
const createToken = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields Name.', 'error')
return
}
try {
const res = await authStore.createToken(newToken.value)
if (res.data?.code === 200) {
error.value = null;
resetnewToken();
setToast(`Token ${newToken.value.name} created`, 'success')
emit('closeModal', true)
} else {
console.log(res)
error.value = res.data?.error || 'Failed to create token'
}
} catch (err) {
error.value = err.response?.data?.error || 'Failed to create token'
}
}
const cancel = () => {
resetnewToken()
emit('closeModal', false)
}
const deleteToken = async (id) => {
console.log(id)
}
// 显示密码
const isPasswordVisible = ref(false);
function togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
const emit = defineEmits(['closeModal'])
</script>
<style scoped>
/* Minimal custom styles if absolutely necessary */
.collapse .collapse-title {
min-height: 0;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div class="min-h-screen bg-base-100 p-4">
<!-- Breadcrumb and Title -->
<BreadcrumbHeader />
<div class="flex flex-wrap gap-2 mb-4 justify-end items-center">
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<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">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<TokenNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<!-- Table -->
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
<div class="card-body">
<h3 class="card-title text-lg">Tokens</h3>
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Token Name</th>
<th class="px-2 py-3">Status</th>
<th class="px-2 py-3">Key</th>
<th class="px-2 py-3">Expired At</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used Quota</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs"
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
@change="updateStatus(token)" />
</td>
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
<td class="px-2 py-3">
<template v-if="token.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="px-2 py-3">{{ token.used_quota }}</td>
<td class="text-right px-2 py-3 flex justify-between items-center gap-1">
<div class="md:tooltip" data-tip="clean usedquota">
<button class="btn btn-ghost btn-xs btn-square text-sky-300" @click="cleanUsedToken(token)"
aria-label="Revoke token">
<Eraser class="w-4 h-4" />
</button>
</div>
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRevokeToken(token)" aria-label="Revoke token">
<TrashIcon class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, inject, computed,watch } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import TokenNew from '@/views/dashboard/TokenNew.vue';
import { useAuthStore } from '@/stores/auth';
import {
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser
} from 'lucide-vue-next';
import { unixToDate } from '@/utils/format-date';
const router = useRouter();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
const { setToast } = inject('toast');
onMounted(async () => {
await authStore.refreshProfile();
})
watch(() => authStore.user, async (newUser) => {
if (newUser.expired_at>0) {
newUser.format_expired_at = unixToDate(newUser.expired_at);
}
})
const updateStatus = async (token) => {
console.log(token);
try {
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
if (res.data?.code == 200) {
setToast(`Token ${token.name} updated`, 'success');
}
} catch (error) {
token.active = !token.active
console.log(error.response.data.error);
setToast(error.response.data.error, 'error');
}
}
const confirmRevokeToken = async (token) => {
if (confirm(`确认删除 ${token.name}?`)) {
await revokeToken(token);
}
}
const revokeToken = async (token) => {
try {
const res = await authStore.deleteToken(token.id);
if (res.data?.code == 200) {
setToast(`Token ${token.name} revoked`, 'success');
}
await authStore.refreshProfile();
} catch (error) {
setToast(error.response.data.error, 'error');
}
}
const cleanUsedToken = async (token) => {
if (token.used_quota == 0 || token.used_quota == null) {
return;
}
try {
const res = await authStore.resetToken(token.id);
console.log('cleanUsedToken', res);
if (res.data?.code == 200) {
setToast(`Token ${token.name} used quota reset`, 'success');
}
await authStore.refreshProfile();
} catch (error) {
console.log(error);
setToast(error, 'error');
}
}
// 关闭模态框
const modalRef = ref(null);
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await authStore.refreshProfile();
};
</script>

View File

@@ -0,0 +1,320 @@
<template>
<div class="min-h-screen bg-base-100 p-4">
<!-- Breadcrumb and Title -->
<BreadcrumbHeader />
<!-- Search and Controls -->
<!-- <div v-if="userStore.loading" class="loading loading-spinner loading-lg"></div> -->
<div class="flex flex-wrap gap-2 mb-4">
<div class="flex flex-1 items-center space-x-2">
<input
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[150px] lg:w-[250px]"
placeholder="Filter" value="">
<div class="dropdown">
<label tabindex="0"
class="inline-flex items-center justify-center flex gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground rounded-md px-3 text-xs h-8 border-dashed">
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
<path fill="currentColor" fill-rule="evenodd"
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0a5.673 5.673 0 0 1-11.346 0M7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5"
clip-rule="evenodd"></path>
</svg>
Status
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
<a class="px-2 mx-0 hover:rounded-none">
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
{{ status }}
</a>
</li>
</ul>
</div>
</div>
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<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">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<UserNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
<div class="dropdown dropdown-end dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
<Settings2Icon class="w-6 h-6" />
</div>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box z-[1] w-20 text-sm">
<li>
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
@click="handleBatchAction('delete')">
<TrashIcon class="w-4 h-4" />删除
</div>
</li>
<hr class="-mx-2 my-1 border-base-content/10">
<li>
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
@click="handleBatchAction('disable')">
<BadgeXIcon class="w-4 h-4" />禁用
</div>
</li>
<li>
<div class="btn btn-xs p-1 text-xs hover:bg-green-100 hover:text-green-600 transition-colors"
@click="handleBatchAction('enable')">
<BadgeCheckIcon class="w-4 h-4" />启用
</div>
</li>
</ul>
</div>
</div>
<!-- Table -->
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
<table class="table table-sm">
<!-- Table Header -->
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
</th>
<th>ID</th>
<th>Name</th>
<th>Active</th>
<th>Quota</th>
<th>UsedQuota</th>
<th></th>
</tr>
</thead>
<!-- Table Body -->
<tbody>
<tr v-for="user in users" :key="user.id"
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
<td>
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
@change="toggleUserSelection(user)" />
</td>
<td class="text-xs dark:text-white">{{ user.id }}</td>
<td class="text-xs dark:text-white">{{ user.username }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" />
</td>
<td class="text-xs dark:text-white">
<template v-if="user.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ user.quota }}</template>
</td>
<td class="text-xs dark:text-white">{{ user.used_quota }}</td>
<td>
<div class="flex gap-1">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
</button>
</div>
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role<20">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
@click="confirmDeleteUser(user)">
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, inject, computed } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/Pagination.vue';
import UserNew from '@/views/dashboard/UserNew.vue';
import { useUserStore } from '@/stores/user';
import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, Infinity
} from 'lucide-vue-next';
const router = useRouter();
const userStore = useUserStore();
const users = computed(() => userStore.users);
const { setToast } = inject('toast');
// 用户数据
const currentPage = ref(1);
const pageSize = ref(10);
const totalItems = computed(() => userStore.totalUsers);
// 封装公共的用户列表获取方法
const listUsers = async (size = pageSize.value, page = currentPage.value, active=selectedStatuses.map(status => status.value)) => {
currentPage.value = page || currentPage.value;
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
await userStore.listUser(size, page, active);
};
// 组件挂载时加载用户数据
onMounted(() => {
listUsers();
});
// 分页与页面大小变化
const changePage = async (page, size) => {
if (page == currentPage.value && size == pageSize.value) {
return
}
currentPage.value = page;
pageSize.value = size;
await listUsers();
};
const changePageSize = changePage;
// 复选框选择状态
const selectAll = ref(false)
const selectedUsers = ref([])
const toggleSelectAll = () => {
users.value.forEach(key => key.selected = selectAll.value)
if (selectAll.value) {
// Select all users on the current page
selectedUsers.value = users.value.map(user => user)
} else {
// Clear all selections
selectedUsers.value = []
}
}
const toggleUserSelection = (user) => {
if (selectedUsers.value.includes(user)) {
selectedUsers.value = selectedUsers.value.filter(selectedUser => selectedUser !== user);
} else {
selectedUsers.value.push(user);
}
selectAll.value = selectedUsers.value.length === users.value.length;
};
// 状态筛选
const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive([]);
const toggleStatusFilter = async (status) => {
const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status);
if (index > -1) {
selectedStatuses.splice(index, 1);
} else {
selectedStatuses.push({ status, value: statusValue });
}
await listUsers(undefined,1,undefined);
};
// 处理批量操作
const handleBatchAction = async (action) => {
if (selectedUsers.value.length === 0) {
return setToast('请选择用户', 'error');
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast('无效的操作 ${action}', 'error');
}
if (selectedUsers.value.length === 0) {
return setToast('请选择用户', 'error');
}
try {
const res = await userStore.userOption(action, selectedUsers.value.map(user => user.id));
if (res.data?.code === 200) {
setToast(`Users ${action} Success`, 'success');
} else {
setToast(res.error || `${action} Failed`, 'error');
}
selectedUsers.value = [];
selectAll.value = false;
await listUsers();
} catch (error) {
console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error');
}
};
// 更新用户状态
const updateStatus = async (user) => {
try {
const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) {
setToast(`User ${user.name} has been ${action}`, 'success');
} else {
setToast(res.error || `用户 ${user.id} ${action} 失败`, 'error');
}
await listUsers();
} catch (error) {
console.error('状态更新失败:', error);
setToast('状态更新失败', 'error');
}
};
const viewUser = (user) => {
router.push({ name: 'UserView', query: { id: user.id } });
}
const confirmDeleteUser = (user) => {
if (confirm(`确认删除 ${user.username}?`)) {
deleteUser(user);
}
};
// 删除用户
const deleteUser = async (user) => {
try {
const res = await userStore.userOption('delete', [user.id]);
if (res.data?.code === 200) {
setToast('用户删除成功', 'success');
} else {
setToast(res.error || '删除失败', 'error');
}
await listUsers();
} catch (error) {
console.error('删除失败:', error);
setToast('删除失败', 'error');
}
};
// 关闭模态框
const modalRef = ref(null);
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await listUsers();
};
</script>

View File

@@ -0,0 +1,255 @@
<template>
<div class="min-h-screen bg-base-100 p-4 md:p-6">
<BreadcrumbHeader />
<div class="max-w-3xl mx-auto">
<div class="mb-6 text-center md:text-left">
<h1 class="text-2xl font-semibold text-base-content">
Create New User
</h1>
<p class="text-sm text-base-content/70 mt-1">
Enter the user's details below.
</p>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>Error! {{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createUser" class="card-body space-y-5">
<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
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
<div class="form-control">
<label for="username" class="label">
<span class="label-text">
Username <span class="text-red-500">*</span>
</span>
</label>
<input id="username" type="text" v-model="newUser.username" placeholder="Select a username"
class="input input-sm input-bordered w-full" required />
</div>
<div class="form-control">
<label for="password" class="label">
<span class="label-text">
Password <span class="text-red-500">*</span>
</span>
</label>
<div class="relative">
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="newUser.password"
class="input input-sm input-bordered w-full" required />
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
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 class="form-control">
<label for="name" class="label">
<span class="label-text">Name</span>
</label>
<input id="name" type="text" v-model="newUser.name" placeholder="Full name"
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<label for="email" class="label">
<span class="label-text">Email Address</span>
</label>
<input id="email" type="email" v-model="newUser.email" placeholder="email@example.com"
class="input input-sm input-bordered w-full" />
</div>
</div>
</div>
<div class="collapse collapse-arrow">
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
Advanced Options
</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="role" class="label">
<span class="label-text">Role</span>
</label>
<select id="role" v-model="newUser.role" class="select select-sm select-bordered w-full">
<option :value="0">User</option>
<option :value="10">Admin</option>
</select>
</div>
<div class="form-control">
<label for="language" class="label">
<span class="label-text">Language</span>
</label>
<select id="language" v-model="newUser.language" class="select select-sm select-bordered w-full">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
<div class="md:col-span-2 form-control">
<label for="quota" class="label">
<span class="label-text">Quota</span>
</label>
<div class="flex items-center space-x-3">
<input id="quota" type="number" v-model="newUser.quota" placeholder="Enter quota amount"
class="input input-sm input-bordered flex-grow" :disabled="newUser.unlimited_quota" />
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
<input type="checkbox" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
<span class="text-sm text-base-content/90">Unlimited</span>
</label>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<div class="flex items-center space-x-3 h-9">
<input type="checkbox" v-model="newUser.active"
:class="`toggle toggle-sm ${newUser.active ? 'toggle-success' : 'toggle-error'}`" />
<span class="text-sm text-base-content/90">
{{ newUser.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-outline btn-sm h-9 px-4 text-sm font-medium" :disabled="!isFormValid">
Create User
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { Eye, EyeOff } from 'lucide-vue-next'
const router = useRouter()
const userStore = useUserStore()
const { setToast } = inject('toast')
const error = ref(null)
// Control advanced options visibility
const showAdvancedOptions = ref(false)
// Initialize user object
const newUser = ref({
name: '',
username: '',
email: '',
password: '',
role: 0, // Default to Regular User
active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited)
unlimited_quota: true, // Default to unlimited
language: 'en', // Default language
})
const resetNewUser = () => {
newUser.value = {
name: '',
username: '',
email: '',
password: '',
role: 0, // Default to Regular User
active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited)
unlimitedQuota: true, // Default to unlimited
language: 'en', // Default language
}
}
// Form validation
const isFormValid = computed(() => {
return newUser.value.username &&
newUser.value.password // Password is required for creation
})
// Create user method
const createUser = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields (Username, Password).', 'error')
return
}
try {
const res = await userStore.createUser({
username: newUser.value.username,
password: newUser.value.password,
email: newUser.value.email,
name: newUser.value.name || newUser.value.username, // Use username if name is empty
role: newUser.value.role,
active: newUser.value.active,
quota: newUser.value.quota,
unlimited_quota: newUser.value.unlimited_quota,
language: newUser.value.language
});
if (res.data?.code === 200) {
error.value = null;
resetNewUser();
setToast('User created successfully', 'success')
// Optionally navigate or reset form
emit('closeModal', true)
} else {
setToast(res.error || res.data?.message || 'Failed to create user', 'error')
}
} catch (err) {
error.value = err.response?.data?.error || 'Failed to create user'
// setToast(error.response?.data?.error || 'Failed to create user', 'error')
}
}
// 显示密码
const isPasswordVisible = ref(false);
function togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
const emit = defineEmits(['closeModal'])
</script>
<style scoped>
/* Minimal custom styles if absolutely necessary */
.collapse .collapse-title {
min-height: 0;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<div class="min-h-screen bg-base-100 p-4 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="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">
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="avatar" v-else>
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
<img :src="user?.avatar_url" :alt="user?.name" />
</div>
</div>
<div class="flex-grow text-center md:text-left">
<h2 class="text-2xl font-semibold text-base-content">
{{ user?.name || user?.username }}
</h2>
<div class="flex items-center justify-center md:justify-start gap-2 mt-2">
<span class="badge border-none px-0">
<CircleCheckBig v-if="user.active" class="h-5 w-5 bg-green-300 rounded-full" />
<CircleX v-else class="h-5 w-5 bg-rose-300 rounded-full" />
</span>
<span class="badge badge-outline" :class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
formatRole(user?.role) }}</span>
</div>
</div>
<div class="flex gap-2 mt-4 md:mt-0">
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" />
</div>
</div>
<div class="divider mt-1 mb-0"></div>
<form @submit.prevent="updateUser" class="space-y-4">
<div class="space-y-3">
<h3 class="text-base font-medium text-base-content mb-3">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<div class="form-control w-full">
<label for="name" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
</label>
<input id="name" type="text" v-model="user.name" placeholder="Full name"
class="input input-bordered input-sm w-full" />
</div>
<div class="form-control w-full">
<label for="username" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">
Username <span class="text-red-500">*</span>
</span>
</label>
<input id="username" type="text" v-model="user.username" placeholder="Select a username"
class="input input-bordered input-sm w-full" required />
</div>
<div class="form-control w-full">
<label for="email" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Email Address</span>
</label>
<div class="relative">
<input id="email" type="email" v-model="user.email" placeholder="email@example.com"
class="input input-bordered input-sm w-full" />
<button type="button" @click="toggleEmailVerify" tabindex="-1"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
aria-label="Toggle password visibility">
<BadgeCheck v-if="user.email_verified" class="w-4 h-4 bg-green-300 rounded-full" />
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
<Send class="w-4 h-4" />
</div>
</button>
</div>
</div>
<div class="form-control w-full">
<label for="password" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">
Password <span class="text-xs text-base-content/60">(Leave blank to keep unchanged)</span>
</span>
</label>
<div class="relative">
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="user.password"
placeholder="Enter new password" class="input input-bordered input-sm w-full pr-10" />
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
aria-label="Toggle password visibility">
<EyeOff v-if="!isPasswordVisible" class="w-4 h-4" />
<Eye v-else class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<div class="collapse collapse-arrow border border-base-300/30 rounded-md mt-4">
<input type="checkbox" class="min-h-0 py-2" checked />
<div class="collapse-title text-base font-medium min-h-0 py-2">
Advanced Options
</div>
<div class="collapse-content">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 pt-2">
<div class="form-control w-full">
<label for="role" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Role</span>
</label>
<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>
</select>
</div>
<div class="form-control w-full">
<label for="language" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Language</span>
</label>
<select id="language" v-model="user.language" class="select select-bordered select-sm w-full">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
<div class="md:col-span-2 form-control w-full">
<label for="quota" class="label pb-1">
<span class="label-text text-sm font-medium text-base-content/80">Quota</span>
</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" />
<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>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="card-actions justify-end pt-4">
<button type="submit" class="btn btn-outline btn-success btn-sm">
Update
</button>
</div>
</form>
</div>
</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="flex justify-center items-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
</div>
<!-- Token 显示 -->
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
<div class="card-body">
<h3 class="card-title text-lg">Tokens</h3>
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Token Name</th>
<th class="px-2 py-3">Key</th>
<th class="px-2 py-3">Expired At</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used Quota</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
<td class="px-2 py-3">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
<td class="px-2 py-3">
<template v-if="token.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ token.quota }}</template>
</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">
<TrashIcon class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue';
import { useRoute } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
import { useUserStore } from '../../stores/user';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
const route = useRoute();
const userStore = useUserStore();
const { setToast } = inject('toast');
const userId = computed(() => route.query.id);
onMounted(async () => {
if (userId.value) {
await userStore.getUser(userId.value);
}
});
const user = computed(() => userStore.user);
const loading = computed(() => userStore.loading); // Access loading state
// 更新状态
const updateStatus = async (user) => {
try {
const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) {
setToast(`User ${user.id} ${action} Success`, 'success');
} else {
setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
}
await userStore.refreshUser(user.id);
} catch (error) {
user.active = !user.active;
console.error('状态更新失败:', error);
// setToast(error.response.data?.error || '状态更新失败', 'error');
}
};
const updateUser = async () => {
if (!user.value) return;
try {
const payload = {
name: user.value.name,
username: user.value.username,
email: user.value.email,
role: user.value.role,
language: user.value.language,
quota: user.value.quota,
unlimited_quota: user.value.unlimited_quota,
};
// Only include password if it's not empty
if (user.value.password) {
payload.password = user.value.password;
}
const res = await userStore.editUser(userId.value, payload);
console.log('updateUser',res)
if (res.data?.code == 200) {
setToast(`User ${userId.value} updated`, 'success');
}
await userStore.refreshUser(userId.value);
} catch (err) {
console.error('Error updating user:', err.response?.data?.data?.error);
}
};
//显示密码
const isPasswordVisible = ref(false);
const togglePasswordVisibility = () => {
isPasswordVisible.value = !isPasswordVisible.value;
};
// 格式化角色
const formatRole = (role) => {
switch (true) {
case role>10:
return 'Root';
case role>0:
return 'Admin';
default:
return 'U';
}
};
// const updateStatus = async (updatedUser) => {
// try {
// const response = await userStore.editUser(updatedUser.id, { active: updatedUser.active });
// if (response.data?.data?.code == 200) {
// setToast('User ${updatedUser.id} status updated', 'success');
// }
// } catch (err) {
// updatedUser.active = !updatedUser.active;
// console.log('Error updating user status:', err.response?.data?.data?.error);
// setToast(err.response?.data?.data?.error, 'error')
// }
// }
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return null;
try {
return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(dateString * 1000)); // Multiply by 1000 for JavaScript Date
} catch (e) {
console.error("Error formatting date:", e);
return 'Invalid Date';
}
};
// 删除token
const revokeToken = (tokenId) => {
console.log('Revoking token:', tokenId);
};
const toggleEmailVerify = () => {
if (user.value && !user.value.email_verified) {
// todo
return
}
}
</script>

View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
import daisyui from 'daisyui';
export default{
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [daisyui],
daisyui: {
themes: ["light", "dark","cupcake","emerald","pastel"], // 可以根据需要添加或修改主题
},
}

40
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import basicSsl from '@vitejs/plugin-basic-ssl'; // 推荐使用这个插件简化自签名证书管理
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(),basicSsl()],
server: {
https: true, // 启用 HTTPS
host: 'localhost', // 确保 host 是 localhost
port: 5173,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
// sourcemap: true,
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('src/components')) {
return 'components';
}
if (id.includes('src/views/dashboard')) {
return 'views-dashboard'
}
if (id.includes('src/stores')) {
return 'stores';
}
}
}
}
},
});