feat: LDAP适配深色模式

This commit is contained in:
shaw
2025-09-02 14:43:30 +08:00
parent 86c243e1a4
commit 1a9746c84d
8 changed files with 550 additions and 130 deletions

View File

@@ -97,9 +97,3 @@ USER_MANAGEMENT_ENABLED=false
DEFAULT_USER_ROLE=user
USER_SESSION_TIMEOUT=86400000
MAX_API_KEYS_PER_USER=5
# 📢 Webhook 通知配置
WEBHOOK_ENABLED=true
WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify
WEBHOOK_TIMEOUT=10000
WEBHOOK_RETRIES=3

291
src/utils/inputValidator.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* 输入验证工具类
* 提供各种输入验证和清理功能,防止注入攻击
*/
class InputValidator {
/**
* 验证用户名
* @param {string} username - 用户名
* @returns {string} 验证后的用户名
* @throws {Error} 如果用户名无效
*/
validateUsername(username) {
if (!username || typeof username !== 'string') {
throw new Error('用户名必须是非空字符串')
}
const trimmed = username.trim()
// 长度检查
if (trimmed.length < 3 || trimmed.length > 64) {
throw new Error('用户名长度必须在3-64个字符之间')
}
// 格式检查:只允许字母、数字、下划线、连字符
const usernameRegex = /^[a-zA-Z0-9_-]+$/
if (!usernameRegex.test(trimmed)) {
throw new Error('用户名只能包含字母、数字、下划线和连字符')
}
// 不能以连字符开头或结尾
if (trimmed.startsWith('-') || trimmed.endsWith('-')) {
throw new Error('用户名不能以连字符开头或结尾')
}
return trimmed
}
/**
* 验证电子邮件
* @param {string} email - 电子邮件地址
* @returns {string} 验证后的电子邮件
* @throws {Error} 如果电子邮件无效
*/
validateEmail(email) {
if (!email || typeof email !== 'string') {
throw new Error('电子邮件必须是非空字符串')
}
const trimmed = email.trim().toLowerCase()
// 基本格式验证
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
if (!emailRegex.test(trimmed)) {
throw new Error('电子邮件格式无效')
}
// 长度限制
if (trimmed.length > 254) {
throw new Error('电子邮件地址过长')
}
return trimmed
}
/**
* 验证密码强度
* @param {string} password - 密码
* @returns {boolean} 验证结果
*/
validatePassword(password) {
if (!password || typeof password !== 'string') {
throw new Error('密码必须是非空字符串')
}
// 最小长度
if (password.length < 8) {
throw new Error('密码至少需要8个字符')
}
// 最大长度防止DoS攻击
if (password.length > 128) {
throw new Error('密码不能超过128个字符')
}
return true
}
/**
* 验证角色
* @param {string} role - 用户角色
* @returns {string} 验证后的角色
* @throws {Error} 如果角色无效
*/
validateRole(role) {
const validRoles = ['admin', 'user', 'viewer']
if (!role || typeof role !== 'string') {
throw new Error('角色必须是非空字符串')
}
const trimmed = role.trim().toLowerCase()
if (!validRoles.includes(trimmed)) {
throw new Error(`角色必须是以下之一: ${validRoles.join(', ')}`)
}
return trimmed
}
/**
* 验证Webhook URL
* @param {string} url - Webhook URL
* @returns {string} 验证后的URL
* @throws {Error} 如果URL无效
*/
validateWebhookUrl(url) {
if (!url || typeof url !== 'string') {
throw new Error('Webhook URL必须是非空字符串')
}
const trimmed = url.trim()
// URL格式验证
try {
const urlObj = new URL(trimmed)
// 只允许HTTP和HTTPS协议
if (!['http:', 'https:'].includes(urlObj.protocol)) {
throw new Error('Webhook URL必须使用HTTP或HTTPS协议')
}
// 防止SSRF攻击禁止访问内网地址
const hostname = urlObj.hostname.toLowerCase()
const dangerousHosts = [
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'169.254.169.254', // AWS元数据服务
'metadata.google.internal' // GCP元数据服务
]
if (dangerousHosts.includes(hostname)) {
throw new Error('Webhook URL不能指向内部服务')
}
// 检查是否是内网IP
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/
if (ipRegex.test(hostname)) {
const parts = hostname.split('.').map(Number)
// 检查私有IP范围
if (
parts[0] === 10 || // 10.0.0.0/8
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
(parts[0] === 192 && parts[1] === 168) // 192.168.0.0/16
) {
throw new Error('Webhook URL不能指向私有IP地址')
}
}
return trimmed
} catch (error) {
if (error.message.includes('Webhook URL')) {
throw error
}
throw new Error('Webhook URL格式无效')
}
}
/**
* 验证显示名称
* @param {string} displayName - 显示名称
* @returns {string} 验证后的显示名称
* @throws {Error} 如果显示名称无效
*/
validateDisplayName(displayName) {
if (!displayName || typeof displayName !== 'string') {
throw new Error('显示名称必须是非空字符串')
}
const trimmed = displayName.trim()
// 长度检查
if (trimmed.length < 1 || trimmed.length > 100) {
throw new Error('显示名称长度必须在1-100个字符之间')
}
// 禁止特殊控制字符(排除常见的换行和制表符)
// eslint-disable-next-line no-control-regex
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
if (controlCharRegex.test(trimmed)) {
throw new Error('显示名称不能包含控制字符')
}
return trimmed
}
/**
* 清理HTML标签防止XSS
* @param {string} input - 输入字符串
* @returns {string} 清理后的字符串
*/
sanitizeHtml(input) {
if (!input || typeof input !== 'string') {
return ''
}
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;')
}
/**
* 验证API Key名称
* @param {string} name - API Key名称
* @returns {string} 验证后的名称
* @throws {Error} 如果名称无效
*/
validateApiKeyName(name) {
if (!name || typeof name !== 'string') {
throw new Error('API Key名称必须是非空字符串')
}
const trimmed = name.trim()
// 长度检查
if (trimmed.length < 1 || trimmed.length > 100) {
throw new Error('API Key名称长度必须在1-100个字符之间')
}
// 禁止特殊控制字符(排除常见的换行和制表符)
// eslint-disable-next-line no-control-regex
const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
if (controlCharRegex.test(trimmed)) {
throw new Error('API Key名称不能包含控制字符')
}
return trimmed
}
/**
* 验证分页参数
* @param {number} page - 页码
* @param {number} limit - 每页数量
* @returns {{page: number, limit: number}} 验证后的分页参数
*/
validatePagination(page, limit) {
const pageNum = parseInt(page, 10) || 1
const limitNum = parseInt(limit, 10) || 20
if (pageNum < 1) {
throw new Error('页码必须大于0')
}
if (limitNum < 1 || limitNum > 100) {
throw new Error('每页数量必须在1-100之间')
}
return {
page: pageNum,
limit: limitNum
}
}
/**
* 验证UUID格式
* @param {string} uuid - UUID字符串
* @returns {string} 验证后的UUID
* @throws {Error} 如果UUID无效
*/
validateUuid(uuid) {
if (!uuid || typeof uuid !== 'string') {
throw new Error('UUID必须是非空字符串')
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidRegex.test(uuid)) {
throw new Error('UUID格式无效')
}
return uuid.toLowerCase()
}
}
module.exports = new InputValidator()

View File

@@ -20,30 +20,43 @@
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, watch, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// 根据路由设置当前激活的标签
const activeTab = ref('dashboard')
const tabRouteMap = {
// 根据 LDAP 配置动态生成路由映射
const tabRouteMap = computed(() => {
const baseMap = {
dashboard: '/dashboard',
apiKeys: '/api-keys',
accounts: '/accounts',
userManagement: '/user-management',
tutorial: '/tutorial',
settings: '/settings'
}
}
// 只有在 LDAP 启用时才包含用户管理路由
if (authStore.oemSettings?.ldapEnabled) {
baseMap.userManagement = '/user-management'
}
return baseMap
})
// 初始化当前激活的标签
const initActiveTab = () => {
const currentPath = route.path
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
const tabKey = Object.keys(tabRouteMap.value).find(
(key) => tabRouteMap.value[key] === currentPath
)
if (tabKey) {
activeTab.value = tabKey
@@ -73,7 +86,7 @@ initActiveTab()
watch(
() => route.path,
(newPath) => {
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
const tabKey = Object.keys(tabRouteMap.value).find((key) => tabRouteMap.value[key] === newPath)
if (tabKey) {
activeTab.value = tabKey
} else {
@@ -96,7 +109,7 @@ watch(
// 处理标签切换
const handleTabChange = async (tabKey) => {
// 如果已经在目标路由,不需要做任何事
if (tabRouteMap[tabKey] === route.path) {
if (tabRouteMap.value[tabKey] === route.path) {
return
}
@@ -105,7 +118,7 @@ const handleTabChange = async (tabKey) => {
// 使用 await 确保路由切换完成
try {
await router.push(tabRouteMap[tabKey])
await router.push(tabRouteMap.value[tabKey])
// 等待下一个DOM更新周期确保组件正确渲染
await nextTick()
} catch (err) {

View File

@@ -37,6 +37,9 @@
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
defineProps({
activeTab: {
type: String,
@@ -46,14 +49,33 @@ defineProps({
defineEmits(['tab-change'])
const tabs = [
const authStore = useAuthStore()
// 根据 LDAP 配置动态生成 tabs
const tabs = computed(() => {
const baseTabs = [
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
{ key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' },
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
]
// 只有在 LDAP 启用时才显示用户管理
if (authStore.oemSettings?.ldapEnabled) {
baseTabs.push({
key: 'userManagement',
name: '用户管理',
shortName: '用户',
icon: 'fas fa-users'
})
}
baseTabs.push(
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
]
)
return baseTabs
})
</script>
<style scoped>

View File

@@ -1024,14 +1024,11 @@ const sortedAccounts = computed(() => {
const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true
try {
// 构建查询参数
// 构建查询参数(移除分组参数,因为在前端处理)
const params = {}
if (platformFilter.value !== 'all') {
params.platform = platformFilter.value
}
if (groupFilter.value !== 'all') {
params.groupId = groupFilter.value
}
// 根据平台筛选决定需要请求哪些接口
const requests = []
@@ -1187,7 +1184,27 @@ const loadAccounts = async (forceReload = false) => {
allAccounts.push(...azureOpenaiAccounts)
}
accounts.value = allAccounts
// 根据分组筛选器过滤账户
let filteredAccounts = allAccounts
if (groupFilter.value !== 'all') {
if (groupFilter.value === 'ungrouped') {
// 筛选未分组的账户(没有 groupInfos 或 groupInfos 为空数组)
filteredAccounts = allAccounts.filter((account) => {
return !account.groupInfos || account.groupInfos.length === 0
})
} else {
// 筛选属于特定分组的账户
filteredAccounts = allAccounts.filter((account) => {
if (!account.groupInfos || account.groupInfos.length === 0) {
return false
}
// 检查账户是否属于选中的分组
return account.groupInfos.some((group) => group.id === groupFilter.value)
})
}
}
accounts.value = filteredAccounts
} catch (error) {
showToast('加载账户失败', 'error')
} finally {

View File

@@ -1,13 +1,13 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white shadow">
<nav class="bg-white shadow dark:bg-gray-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex items-center">
<div class="flex flex-shrink-0 items-center">
<svg
class="h-8 w-8 text-blue-600"
class="h-8 w-8 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -19,7 +19,7 @@
stroke-width="2"
/>
</svg>
<span class="ml-2 text-xl font-bold text-gray-900">Claude Relay</span>
<span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span>
</div>
<div class="ml-10">
<div class="flex items-baseline space-x-4">
@@ -27,8 +27,8 @@
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'overview'
? 'bg-blue-100 text-blue-700'
: 'text-gray-500 hover:text-gray-700'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('overview')"
>
@@ -38,8 +38,8 @@
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'api-keys'
? 'bg-blue-100 text-blue-700'
: 'text-gray-500 hover:text-gray-700'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('api-keys')"
>
@@ -49,8 +49,8 @@
:class="[
'rounded-md px-3 py-2 text-sm font-medium',
activeTab === 'usage'
? 'bg-blue-100 text-blue-700'
: 'text-gray-500 hover:text-gray-700'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="handleTabChange('usage')"
>
@@ -60,11 +60,15 @@
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-700">
<div class="text-sm text-gray-700 dark:text-gray-300">
Welcome, <span class="font-medium">{{ userStore.userName }}</span>
</div>
<!-- 主题切换按钮 -->
<ThemeToggle mode="icon" />
<button
class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@click="handleLogout"
>
Logout
@@ -79,13 +83,15 @@
<!-- Overview Tab -->
<div v-if="activeTab === 'overview'" class="space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Dashboard Overview</h1>
<p class="mt-2 text-sm text-gray-600">Welcome to your Claude Relay dashboard</p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard Overview</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Welcome to your Claude Relay dashboard
</p>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-5">
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -105,8 +111,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Active API Keys</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Active API Keys
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ apiKeysStats.active }}
</dd>
</dl>
@@ -115,7 +123,7 @@
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -135,8 +143,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Deleted API Keys</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Deleted API Keys
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ apiKeysStats.deleted }}
</dd>
</dl>
@@ -145,12 +155,12 @@
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-gray-400"
class="h-6 w-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -165,8 +175,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Requests
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ formatNumber(userProfile?.totalUsage?.requests || 0) }}
</dd>
</dl>
@@ -175,12 +187,12 @@
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-gray-400"
class="h-6 w-6 text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -195,8 +207,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Input Tokens
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }}
</dd>
</dl>
@@ -205,12 +219,12 @@
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-gray-400"
class="h-6 w-6 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -225,8 +239,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Cost
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
@@ -237,48 +253,50 @@
</div>
<!-- User Info -->
<div class="rounded-lg bg-white shadow">
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Account Information</h3>
<div class="mt-5 border-t border-gray-200">
<dl class="divide-y divide-gray-200">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
Account Information
</h3>
<div class="mt-5 border-t border-gray-200 dark:border-gray-700">
<dl class="divide-y divide-gray-200 dark:divide-gray-700">
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Username</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Username</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.username }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Display Name</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Display Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.displayName || 'N/A' }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Email</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ userProfile?.email || 'N/A' }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Role</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{{ userProfile?.role || 'user' }}
</span>
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Member Since</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Member Since</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ formatDate(userProfile?.createdAt) }}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Last Login</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Login</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
{{ formatDate(userProfile?.lastLoginAt) || 'N/A' }}
</dd>
</div>
@@ -305,12 +323,15 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/toast'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
import UserUsageStats from '@/components/user/UserUsageStats.vue'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const activeTab = ref('overview')
const userProfile = ref(null)
@@ -387,6 +408,8 @@ const loadApiKeysStats = async () => {
}
onMounted(() => {
// 初始化主题
themeStore.initTheme()
loadUserProfile()
loadApiKeysStats()
})

View File

@@ -1,9 +1,21 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<div
class="relative flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8"
>
<!-- 主题切换按钮 -->
<div class="fixed right-4 top-4 z-10">
<ThemeToggle mode="dropdown" />
</div>
<div class="w-full max-w-md space-y-8">
<div>
<div class="mx-auto flex h-12 w-auto items-center justify-center">
<svg class="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="h-8 w-8 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
stroke-linecap="round"
@@ -11,23 +23,30 @@
stroke-width="2"
/>
</svg>
<span class="ml-2 text-xl font-bold text-gray-900">Claude Relay</span>
<span class="ml-2 text-xl font-bold text-gray-900 dark:text-white">Claude Relay</span>
</div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">User Sign In</h2>
<p class="mt-2 text-center text-sm text-gray-600">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
User Sign In
</h2>
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Sign in to your account to manage your API keys
</p>
</div>
<div class="rounded-lg bg-white px-6 py-8 shadow">
<div class="rounded-lg bg-white px-6 py-8 shadow dark:bg-gray-800 dark:shadow-xl">
<form class="space-y-6" @submit.prevent="handleLogin">
<div>
<label class="block text-sm font-medium text-gray-700" for="username"> Username </label>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
for="username"
>
Username
</label>
<div class="mt-1">
<input
id="username"
v-model="form.username"
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm"
:disabled="loading"
name="username"
placeholder="Enter your username"
@@ -38,12 +57,17 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="password"> Password </label>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
for="password"
>
Password
</label>
<div class="mt-1">
<input
id="password"
v-model="form.password"
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
class="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:ring-blue-400 sm:text-sm"
:disabled="loading"
name="password"
placeholder="Enter your password"
@@ -53,7 +77,10 @@
</div>
</div>
<div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
<div
v-if="error"
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
@@ -65,14 +92,14 @@
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ error }}</p>
<p class="text-sm text-red-700 dark:text-red-400">{{ error }}</p>
</div>
</div>
</div>
<div>
<button
class="group relative flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
class="group relative flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-400 dark:focus:ring-offset-gray-800"
:disabled="loading || !form.username || !form.password"
type="submit"
>
@@ -103,7 +130,10 @@
</div>
<div class="text-center">
<router-link class="text-sm text-blue-600 hover:text-blue-500" to="/admin-login">
<router-link
class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
to="/admin-login"
>
Admin Login
</router-link>
</div>
@@ -114,13 +144,16 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/toast'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const loading = ref(false)
const error = ref('')
@@ -154,6 +187,11 @@ const handleLogin = async () => {
loading.value = false
}
}
onMounted(() => {
// 初始化主题(因为该页面不在 MainLayout 内)
themeStore.initTheme()
})
</script>
<style scoped>

View File

@@ -3,8 +3,8 @@
<!-- Header -->
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">User Management</h1>
<p class="mt-2 text-sm text-gray-700">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
Manage users, their API keys, and view usage statistics
</p>
</div>
@@ -29,7 +29,7 @@
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -49,15 +49,19 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Users</dt>
<dd class="text-lg font-medium text-gray-900">{{ userStats?.totalUsers || 0 }}</dd>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Users
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalUsers || 0 }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -77,15 +81,19 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Active Users</dt>
<dd class="text-lg font-medium text-gray-900">{{ userStats?.activeUsers || 0 }}</dd>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Active Users
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.activeUsers || 0 }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -105,8 +113,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total API Keys</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total API Keys
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalApiKeys || 0 }}
</dd>
</dl>
@@ -115,7 +125,7 @@
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-800">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
@@ -135,8 +145,10 @@
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Cost
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
</dd>
</dl>
@@ -147,7 +159,7 @@
</div>
<!-- Search and Filters -->
<div class="rounded-lg bg-white shadow">
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
<div class="px-4 py-5 sm:p-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="space-y-4 sm:flex sm:items-center sm:space-x-4 sm:space-y-0">
@@ -171,7 +183,7 @@
</div>
<input
v-model="searchQuery"
class="block w-full rounded-md border-gray-300 pl-10 focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 pl-10 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
placeholder="Search users..."
type="search"
@input="debouncedSearch"
@@ -183,7 +195,7 @@
<div>
<select
v-model="selectedRole"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers"
>
<option value="">All Roles</option>
@@ -196,7 +208,7 @@
<div>
<select
v-model="selectedStatus"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers"
>
<option value="">All Status</option>
@@ -210,11 +222,11 @@
</div>
<!-- Users Table -->
<div class="overflow-hidden bg-white shadow sm:rounded-md">
<div class="border-b border-gray-200 px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
<div class="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-md">
<div class="border-b border-gray-200 px-4 py-5 dark:border-gray-700 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
Users
<span v-if="!loading" class="text-sm text-gray-500"
<span v-if="!loading" class="text-sm text-gray-500 dark:text-gray-400"
>({{ filteredUsers.length }} of {{ users.length }})</span
>
</h3>
@@ -242,18 +254,24 @@
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500">Loading users...</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading users...</p>
</div>
<!-- Users List -->
<ul v-else-if="filteredUsers.length > 0" class="divide-y divide-gray-200" role="list">
<ul
v-else-if="filteredUsers.length > 0"
class="divide-y divide-gray-200 dark:divide-gray-700"
role="list"
>
<li v-for="user in filteredUsers" :key="user.id" class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-1 items-center">
<div class="flex-shrink-0">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-300 dark:bg-gray-600"
>
<svg
class="h-6 w-6 text-gray-600"
class="h-6 w-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -269,14 +287,16 @@
</div>
<div class="ml-4 min-w-0 flex-1">
<div class="flex items-center">
<p class="truncate text-sm font-medium text-gray-900">
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
{{ user.displayName || user.username }}
</p>
<div class="ml-2 flex items-center space-x-2">
<span
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
user.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
]"
>
{{ user.isActive ? 'Active' : 'Disabled' }}
@@ -285,15 +305,17 @@
:class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
]"
>
{{ user.role }}
</span>
</div>
</div>
<div class="mt-1 flex items-center space-x-4 text-sm text-gray-500">
<div
class="mt-1 flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400"
>
<span>@{{ user.username }}</span>
<span v-if="user.email">{{ user.email }}</span>
<span>{{ user.apiKeyCount || 0 }} API keys</span>
@@ -304,7 +326,7 @@
</div>
<div
v-if="user.totalUsage"
class="mt-1 flex items-center space-x-4 text-xs text-gray-400"
class="mt-1 flex items-center space-x-4 text-xs text-gray-400 dark:text-gray-500"
>
<span>{{ formatNumber(user.totalUsage.requests || 0) }} requests</span>
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
@@ -415,8 +437,8 @@
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No users found</h3>
<p class="mt-1 text-sm text-gray-500">
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No users found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
searchQuery ? 'No users match your search criteria.' : 'No users have been created yet.'
}}