Merge pull request #292 from iRubbish/dev

feat: 新增AD域控用户认证系统
This commit is contained in:
Wesley Liddick
2025-08-28 08:43:21 +08:00
committed by GitHub
16 changed files with 3448 additions and 30 deletions

View File

@@ -20,6 +20,15 @@
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
/>
<!-- 用户登录按钮 -->
<button
class="user-login-button flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
@click="showUserLogin"
>
<i class="fas fa-user-circle text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
</button>
<!-- 管理后台按钮 -->
<router-link
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
@@ -129,6 +138,74 @@
<TutorialView />
</div>
</div>
<!-- 用户登录模态框 -->
<div v-if="showLoginModal" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm" @click="hideUserLogin"></div>
<div class="glass-strong relative w-full max-w-md rounded-2xl p-6 shadow-2xl">
<div class="mb-6 text-center">
<h2 class="mb-2 text-2xl font-bold text-gray-800 dark:text-gray-100">AD域控登录</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">使用您的域账号登录</p>
</div>
<form class="space-y-4" @submit.prevent="handleUserLogin">
<div>
<label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100">
用户名
</label>
<input
v-model="userLoginForm.username"
class="w-full rounded-xl border border-gray-300 bg-white/70 px-4 py-3 text-gray-800 placeholder-gray-500 backdrop-blur-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800/70 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="请输入域用户名"
required
type="text"
/>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100">
密码
</label>
<input
v-model="userLoginForm.password"
class="w-full rounded-xl border border-gray-300 bg-white/70 px-4 py-3 text-gray-800 placeholder-gray-500 backdrop-blur-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800/70 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="请输入域密码"
required
type="password"
/>
</div>
<div class="flex gap-3 pt-4">
<button
class="flex-1 rounded-xl border border-gray-300 bg-white/70 px-4 py-3 text-sm font-medium text-gray-700 backdrop-blur-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800/70 dark:text-gray-300 dark:hover:bg-gray-700"
type="button"
@click="hideUserLogin"
>
取消
</button>
<button
class="flex flex-1 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-500 to-purple-600 px-4 py-3 text-sm font-medium text-white backdrop-blur-sm transition-all hover:from-blue-600 hover:to-purple-700 disabled:opacity-50"
:disabled="userLoginLoading"
type="submit"
>
<div
v-if="userLoginLoading"
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
<i v-else class="fas fa-sign-in-alt"></i>
{{ userLoginLoading ? '登录中...' : '登录' }}
</button>
</div>
</form>
<div
v-if="userLoginError"
class="mt-4 rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-center text-sm text-red-800 dark:text-red-400"
>
<i class="fas fa-exclamation-triangle mr-2"></i>{{ userLoginError }}
</div>
</div>
</div>
</div>
</template>
@@ -157,6 +234,15 @@ const currentTab = ref('stats')
// 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode)
// 用户登录相关
const showLoginModal = ref(false)
const userLoginLoading = ref(false)
const userLoginError = ref('')
const userLoginForm = ref({
username: '',
password: ''
})
const {
apiKey,
apiId,
@@ -171,6 +257,63 @@ const {
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
// 用户登录相关方法
const showUserLogin = () => {
showLoginModal.value = true
userLoginError.value = ''
userLoginForm.value = {
username: '',
password: ''
}
}
const hideUserLogin = () => {
showLoginModal.value = false
userLoginError.value = ''
userLoginForm.value = {
username: '',
password: ''
}
}
const handleUserLogin = async () => {
if (!userLoginForm.value.username || !userLoginForm.value.password) {
userLoginError.value = '请输入用户名和密码'
return
}
userLoginLoading.value = true
userLoginError.value = ''
try {
const response = await fetch('/admin/ldap/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userLoginForm.value)
})
const result = await response.json()
if (result.success) {
// 保存token到localStorage
localStorage.setItem('user_token', result.token)
localStorage.setItem('user_info', JSON.stringify(result.user))
// 跳转到用户专用页面
window.location.href = '/admin-next/user-dashboard'
} else {
userLoginError.value = result.message || '登录失败'
}
} catch (error) {
console.error('用户登录错误:', error)
userLoginError.value = '网络错误,请重试'
} finally {
userLoginLoading.value = false
}
}
// 处理键盘快捷键
const handleKeyDown = (event) => {
// Ctrl/Cmd + Enter 查询
@@ -309,6 +452,55 @@ watch(apiKey, (newValue) => {
letter-spacing: -0.025em;
}
/* 用户登录按钮 */
.user-login-button {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
text-decoration: none;
box-shadow:
0 4px 12px rgba(16, 185, 129, 0.25),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
font-weight: 600;
cursor: pointer;
}
/* 暗色模式下的用户登录按钮 */
:global(.dark) .user-login-button {
background: rgba(34, 197, 94, 0.8);
border: 1px solid rgba(107, 114, 128, 0.4);
color: #f3f4f6;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
}
.user-login-button:hover {
transform: translateY(-2px) scale(1.02);
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
box-shadow:
0 8px 20px rgba(5, 150, 105, 0.35),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: white;
}
:global(.dark) .user-login-button:hover {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-color: rgba(34, 197, 94, 0.4);
box-shadow:
0 8px 20px rgba(16, 185, 129, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
color: white;
}
.user-login-button:active {
transform: translateY(-1px) scale(1);
}
/* 管理后台按钮 - 精致版本 */
.admin-button-refined {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

View File

@@ -0,0 +1,344 @@
<template>
<div class="min-h-screen p-4 md:p-6" :class="isDarkMode ? 'gradient-bg-dark' : 'gradient-bg'">
<!-- 顶部导航 -->
<div class="glass-strong mb-6 rounded-3xl p-4 shadow-xl md:mb-8 md:p-6">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<div class="flex items-center gap-4">
<LogoTitle
:loading="false"
logo-src="/assets/logo.png"
:subtitle="`欢迎,${userInfo.displayName || userInfo.username}`"
title="Claude Relay Service"
/>
<div class="text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-building mr-1"></i
>{{
userInfo.groups && userInfo.groups.length > 0
? extractGroupName(userInfo.groups[0])
: '未知部门'
}}
</div>
</div>
<div class="flex items-center gap-2 md:gap-4">
<!-- 主题切换按钮 -->
<ThemeToggle mode="dropdown" />
<!-- 分隔线 -->
<div
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
/>
<!-- 退出登录按钮 -->
<button
class="logout-button flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
@click="handleLogout"
>
<i class="fas fa-sign-out-alt text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">退出登录</span>
</button>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="mb-6 md:mb-8">
<div class="flex justify-center">
<div
class="inline-flex w-full max-w-2xl rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
>
<button
:class="['tab-pill-button', currentTab === 'api-keys' ? 'active' : '']"
@click="currentTab = 'api-keys'"
>
<i class="fas fa-key mr-1 md:mr-2" />
<span class="text-sm md:text-base">API Keys 管理</span>
</button>
<button
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
@click="currentTab = 'tutorial'"
>
<i class="fas fa-graduation-cap mr-1 md:mr-2" />
<span class="text-sm md:text-base">使用教程</span>
</button>
</div>
</div>
</div>
<!-- API Keys 管理 -->
<div v-if="currentTab === 'api-keys'" class="tab-content">
<UserApiKeysView :user-info="userInfo" />
</div>
<!-- 使用教程 -->
<div v-if="currentTab === 'tutorial'" class="tab-content">
<div class="glass-strong rounded-3xl shadow-xl">
<TutorialView />
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import TutorialView from './TutorialView.vue'
import UserApiKeysView from '@/components/user/UserApiKeysView.vue'
const themeStore = useThemeStore()
// 当前标签页
const currentTab = ref('api-keys')
// 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode)
// 用户信息
const userInfo = ref({})
// 从组名中提取部门名称
const extractGroupName = (group) => {
if (!group) return '未知部门'
// 从 "CN=总裁办,OU=微店,DC=corp,DC=weidian-inc,DC=com" 中提取 "总裁办"
const match = group.match(/CN=([^,]+)/)
return match ? match[1] : '未知部门'
}
// 退出登录
const handleLogout = () => {
localStorage.removeItem('user_token')
localStorage.removeItem('user_info')
window.location.href = '/admin-next/api-stats'
}
// 验证用户token
const verifyToken = async () => {
const token = localStorage.getItem('user_token')
if (!token) {
window.location.href = '/admin-next/api-stats'
return false
}
try {
const response = await fetch('/admin/ldap/verify-token', {
headers: {
Authorization: `Bearer ${token}`
}
})
const result = await response.json()
if (result.success) {
userInfo.value = result.user
return true
} else {
localStorage.removeItem('user_token')
localStorage.removeItem('user_info')
window.location.href = '/admin-next/api-stats'
return false
}
} catch (error) {
console.error('Token验证失败:', error)
localStorage.removeItem('user_token')
localStorage.removeItem('user_info')
window.location.href = '/admin-next/api-stats'
return false
}
}
// 初始化
onMounted(async () => {
// 初始化主题
themeStore.initTheme()
// 验证token
const isValid = await verifyToken()
if (!isValid) return
// 从localStorage获取用户信息作为备份
const storedUserInfo = localStorage.getItem('user_info')
if (storedUserInfo && !userInfo.value.username) {
userInfo.value = JSON.parse(storedUserInfo)
}
})
</script>
<style scoped>
/* 渐变背景 */
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-attachment: fixed;
min-height: 100vh;
position: relative;
}
.gradient-bg-dark {
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
background-attachment: fixed;
min-height: 100vh;
position: relative;
}
.gradient-bg::before,
.gradient-bg-dark::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
.gradient-bg::before {
background:
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
}
.gradient-bg-dark::before {
background:
radial-gradient(circle at 20% 80%, rgba(100, 116, 139, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(71, 85, 105, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(30, 41, 59, 0.1) 0%, transparent 50%);
}
/* 玻璃态效果 */
.glass-strong {
background: var(--glass-strong-color);
backdrop-filter: blur(25px);
border: 1px solid var(--border-color);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
position: relative;
z-index: 1;
}
:global(.dark) .glass-strong {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(55, 65, 81, 0.3),
inset 0 1px 0 rgba(75, 85, 99, 0.2);
}
/* 退出登录按钮 */
.logout-button {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
text-decoration: none;
box-shadow:
0 4px 12px rgba(239, 68, 68, 0.25),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
font-weight: 600;
cursor: pointer;
}
:global(.dark) .logout-button {
background: rgba(239, 68, 68, 0.8);
border: 1px solid rgba(107, 114, 128, 0.4);
color: #f3f4f6;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
}
.logout-button:hover {
transform: translateY(-2px) scale(1.02);
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
box-shadow:
0 8px 20px rgba(220, 38, 38, 0.35),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
}
:global(.dark) .logout-button:hover {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: rgba(239, 68, 68, 0.4);
box-shadow:
0 8px 20px rgba(239, 68, 68, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
}
.logout-button:active {
transform: translateY(-1px) scale(1);
}
/* Tab 胶囊按钮样式 */
.tab-pill-button {
padding: 0.5rem 1rem;
border-radius: 9999px;
font-weight: 500;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex: 1;
justify-content: center;
}
:global(html.dark) .tab-pill-button {
color: rgba(209, 213, 219, 0.8);
}
@media (min-width: 768px) {
.tab-pill-button {
padding: 0.625rem 1.25rem;
flex: none;
}
}
.tab-pill-button:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
:global(html.dark) .tab-pill-button:hover {
color: #f3f4f6;
background: rgba(100, 116, 139, 0.2);
}
.tab-pill-button.active {
background: white;
color: #764ba2;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(html.dark) .tab-pill-button.active {
background: rgba(71, 85, 105, 0.9);
color: #f3f4f6;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* Tab 内容切换动画 */
.tab-content {
animation: tabFadeIn 0.4s ease-out;
}
@keyframes tabFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>