mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: LDAP适配深色模式
This commit is contained in:
@@ -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
291
src/utils/inputValidator.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user