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
|
DEFAULT_USER_ROLE=user
|
||||||
USER_SESSION_TIMEOUT=86400000
|
USER_SESSION_TIMEOUT=86400000
|
||||||
MAX_API_KEYS_PER_USER=5
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
import TabBar from './TabBar.vue'
|
import TabBar from './TabBar.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// 根据路由设置当前激活的标签
|
// 根据路由设置当前激活的标签
|
||||||
const activeTab = ref('dashboard')
|
const activeTab = ref('dashboard')
|
||||||
|
|
||||||
const tabRouteMap = {
|
// 根据 LDAP 配置动态生成路由映射
|
||||||
dashboard: '/dashboard',
|
const tabRouteMap = computed(() => {
|
||||||
apiKeys: '/api-keys',
|
const baseMap = {
|
||||||
accounts: '/accounts',
|
dashboard: '/dashboard',
|
||||||
userManagement: '/user-management',
|
apiKeys: '/api-keys',
|
||||||
tutorial: '/tutorial',
|
accounts: '/accounts',
|
||||||
settings: '/settings'
|
tutorial: '/tutorial',
|
||||||
}
|
settings: '/settings'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在 LDAP 启用时才包含用户管理路由
|
||||||
|
if (authStore.oemSettings?.ldapEnabled) {
|
||||||
|
baseMap.userManagement = '/user-management'
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseMap
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化当前激活的标签
|
// 初始化当前激活的标签
|
||||||
const initActiveTab = () => {
|
const initActiveTab = () => {
|
||||||
const currentPath = route.path
|
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) {
|
if (tabKey) {
|
||||||
activeTab.value = tabKey
|
activeTab.value = tabKey
|
||||||
@@ -73,7 +86,7 @@ initActiveTab()
|
|||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
(newPath) => {
|
(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) {
|
if (tabKey) {
|
||||||
activeTab.value = tabKey
|
activeTab.value = tabKey
|
||||||
} else {
|
} else {
|
||||||
@@ -96,7 +109,7 @@ watch(
|
|||||||
// 处理标签切换
|
// 处理标签切换
|
||||||
const handleTabChange = async (tabKey) => {
|
const handleTabChange = async (tabKey) => {
|
||||||
// 如果已经在目标路由,不需要做任何事
|
// 如果已经在目标路由,不需要做任何事
|
||||||
if (tabRouteMap[tabKey] === route.path) {
|
if (tabRouteMap.value[tabKey] === route.path) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +118,7 @@ const handleTabChange = async (tabKey) => {
|
|||||||
|
|
||||||
// 使用 await 确保路由切换完成
|
// 使用 await 确保路由切换完成
|
||||||
try {
|
try {
|
||||||
await router.push(tabRouteMap[tabKey])
|
await router.push(tabRouteMap.value[tabKey])
|
||||||
// 等待下一个DOM更新周期,确保组件正确渲染
|
// 等待下一个DOM更新周期,确保组件正确渲染
|
||||||
await nextTick()
|
await nextTick()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
activeTab: {
|
activeTab: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -46,14 +49,33 @@ defineProps({
|
|||||||
|
|
||||||
defineEmits(['tab-change'])
|
defineEmits(['tab-change'])
|
||||||
|
|
||||||
const tabs = [
|
const authStore = useAuthStore()
|
||||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
|
||||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
// 根据 LDAP 配置动态生成 tabs
|
||||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
|
const tabs = computed(() => {
|
||||||
{ key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' },
|
const baseTabs = [
|
||||||
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
|
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||||
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
|
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||||
]
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1024,14 +1024,11 @@ const sortedAccounts = computed(() => {
|
|||||||
const loadAccounts = async (forceReload = false) => {
|
const loadAccounts = async (forceReload = false) => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 构建查询参数
|
// 构建查询参数(移除分组参数,因为在前端处理)
|
||||||
const params = {}
|
const params = {}
|
||||||
if (platformFilter.value !== 'all') {
|
if (platformFilter.value !== 'all') {
|
||||||
params.platform = platformFilter.value
|
params.platform = platformFilter.value
|
||||||
}
|
}
|
||||||
if (groupFilter.value !== 'all') {
|
|
||||||
params.groupId = groupFilter.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据平台筛选决定需要请求哪些接口
|
// 根据平台筛选决定需要请求哪些接口
|
||||||
const requests = []
|
const requests = []
|
||||||
@@ -1187,7 +1184,27 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
allAccounts.push(...azureOpenaiAccounts)
|
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) {
|
} catch (error) {
|
||||||
showToast('加载账户失败', 'error')
|
showToast('加载账户失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<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="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 justify-between">
|
<div class="flex h-16 justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex flex-shrink-0 items-center">
|
<div class="flex flex-shrink-0 items-center">
|
||||||
<svg
|
<svg
|
||||||
class="h-8 w-8 text-blue-600"
|
class="h-8 w-8 text-blue-600 dark:text-blue-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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>
|
||||||
<div class="ml-10">
|
<div class="ml-10">
|
||||||
<div class="flex items-baseline space-x-4">
|
<div class="flex items-baseline space-x-4">
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'rounded-md px-3 py-2 text-sm font-medium',
|
'rounded-md px-3 py-2 text-sm font-medium',
|
||||||
activeTab === 'overview'
|
activeTab === 'overview'
|
||||||
? 'bg-blue-100 text-blue-700'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
]"
|
]"
|
||||||
@click="handleTabChange('overview')"
|
@click="handleTabChange('overview')"
|
||||||
>
|
>
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'rounded-md px-3 py-2 text-sm font-medium',
|
'rounded-md px-3 py-2 text-sm font-medium',
|
||||||
activeTab === 'api-keys'
|
activeTab === 'api-keys'
|
||||||
? 'bg-blue-100 text-blue-700'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
]"
|
]"
|
||||||
@click="handleTabChange('api-keys')"
|
@click="handleTabChange('api-keys')"
|
||||||
>
|
>
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'rounded-md px-3 py-2 text-sm font-medium',
|
'rounded-md px-3 py-2 text-sm font-medium',
|
||||||
activeTab === 'usage'
|
activeTab === 'usage'
|
||||||
? 'bg-blue-100 text-blue-700'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
]"
|
]"
|
||||||
@click="handleTabChange('usage')"
|
@click="handleTabChange('usage')"
|
||||||
>
|
>
|
||||||
@@ -60,11 +60,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<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>
|
Welcome, <span class="font-medium">{{ userStore.userName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 主题切换按钮 -->
|
||||||
|
<ThemeToggle mode="icon" />
|
||||||
|
|
||||||
<button
|
<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"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
@@ -79,13 +83,15 @@
|
|||||||
<!-- Overview Tab -->
|
<!-- Overview Tab -->
|
||||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Dashboard Overview</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard Overview</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">Welcome to your Claude Relay dashboard</p>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Welcome to your Claude Relay dashboard
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-5">
|
<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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -105,8 +111,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Active API Keys</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Active API Keys
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ apiKeysStats.active }}
|
{{ apiKeysStats.active }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -115,7 +123,7 @@
|
|||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -135,8 +143,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Deleted API Keys</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Deleted API Keys
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ apiKeysStats.deleted }}
|
{{ apiKeysStats.deleted }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -145,12 +155,12 @@
|
|||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="h-6 w-6 text-blue-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -165,8 +175,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total Requests</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Total Requests
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ formatNumber(userProfile?.totalUsage?.requests || 0) }}
|
{{ formatNumber(userProfile?.totalUsage?.requests || 0) }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -175,12 +187,12 @@
|
|||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="h-6 w-6 text-purple-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -195,8 +207,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Input Tokens</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Input Tokens
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }}
|
{{ formatNumber(userProfile?.totalUsage?.inputTokens || 0) }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -205,12 +219,12 @@
|
|||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="h-6 w-6 text-yellow-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -225,8 +239,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Total Cost
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }}
|
${{ (userProfile?.totalUsage?.totalCost || 0).toFixed(4) }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -237,48 +253,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- 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">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Account Information</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
||||||
<div class="mt-5 border-t border-gray-200">
|
Account Information
|
||||||
<dl class="divide-y divide-gray-200">
|
</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">
|
<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>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Username</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||||
{{ userProfile?.username }}
|
{{ userProfile?.username }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
|
<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>
|
<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 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||||
{{ userProfile?.displayName || 'N/A' }}
|
{{ userProfile?.displayName || 'N/A' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
|
<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>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||||
{{ userProfile?.email || 'N/A' }}
|
{{ userProfile?.email || 'N/A' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
|
<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>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||||
<span
|
<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' }}
|
{{ userProfile?.role || 'user' }}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
|
<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>
|
<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 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||||
{{ formatDate(userProfile?.createdAt) }}
|
{{ formatDate(userProfile?.createdAt) }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
|
<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>
|
<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 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0">
|
||||||
{{ formatDate(userProfile?.lastLoginAt) || 'N/A' }}
|
{{ formatDate(userProfile?.lastLoginAt) || 'N/A' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,12 +323,15 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||||
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
|
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
|
||||||
import UserUsageStats from '@/components/user/UserUsageStats.vue'
|
import UserUsageStats from '@/components/user/UserUsageStats.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
const activeTab = ref('overview')
|
const activeTab = ref('overview')
|
||||||
const userProfile = ref(null)
|
const userProfile = ref(null)
|
||||||
@@ -387,6 +408,8 @@ const loadApiKeysStats = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 初始化主题
|
||||||
|
themeStore.initTheme()
|
||||||
loadUserProfile()
|
loadUserProfile()
|
||||||
loadApiKeysStats()
|
loadApiKeysStats()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
<template>
|
<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 class="w-full max-w-md space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<div class="mx-auto flex h-12 w-auto items-center justify-center">
|
<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
|
<path
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -11,23 +23,30 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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>
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">User Sign In</h2>
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
<p class="mt-2 text-center text-sm text-gray-600">
|
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
|
Sign in to your account to manage your API keys
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<form class="space-y-6" @submit.prevent="handleLogin">
|
||||||
<div>
|
<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">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
v-model="form.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"
|
:disabled="loading"
|
||||||
name="username"
|
name="username"
|
||||||
placeholder="Enter your username"
|
placeholder="Enter your username"
|
||||||
@@ -38,12 +57,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
v-model="form.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"
|
:disabled="loading"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
@@ -53,7 +77,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
@@ -65,14 +92,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<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"
|
:disabled="loading || !form.username || !form.password"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
@@ -103,7 +130,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<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
|
Admin Login
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,13 +144,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -154,6 +187,11 @@ const handleLogin = async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始化主题(因为该页面不在 MainLayout 内)
|
||||||
|
themeStore.initTheme()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">User Management</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">
|
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
Manage users, their API keys, and view usage statistics
|
Manage users, their API keys, and view usage statistics
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -49,15 +49,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total Users</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">{{ userStats?.totalUsers || 0 }}</dd>
|
Total Users
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ userStats?.totalUsers || 0 }}
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -77,15 +81,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Active Users</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">{{ userStats?.activeUsers || 0 }}</dd>
|
Active Users
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ userStats?.activeUsers || 0 }}
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -105,8 +113,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total API Keys</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Total API Keys
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ userStats?.totalApiKeys || 0 }}
|
{{ userStats?.totalApiKeys || 0 }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -115,7 +125,7 @@
|
|||||||
</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="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -135,8 +145,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="truncate text-sm font-medium text-gray-500">Total Cost</dt>
|
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
Total Cost
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
|
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -147,7 +159,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
<!-- 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="px-4 py-5 sm:p-6">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<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">
|
<div class="space-y-4 sm:flex sm:items-center sm:space-x-4 sm:space-y-0">
|
||||||
@@ -171,7 +183,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
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..."
|
placeholder="Search users..."
|
||||||
type="search"
|
type="search"
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
@@ -183,7 +195,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<select
|
<select
|
||||||
v-model="selectedRole"
|
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"
|
@change="loadUsers"
|
||||||
>
|
>
|
||||||
<option value="">All Roles</option>
|
<option value="">All Roles</option>
|
||||||
@@ -196,7 +208,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<select
|
<select
|
||||||
v-model="selectedStatus"
|
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"
|
@change="loadUsers"
|
||||||
>
|
>
|
||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
@@ -210,11 +222,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<div class="overflow-hidden bg-white shadow sm:rounded-md">
|
<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 sm:px-6">
|
<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">
|
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
||||||
Users
|
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
|
>({{ filteredUsers.length }} of {{ users.length }})</span
|
||||||
>
|
>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -242,18 +254,24 @@
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Users List -->
|
<!-- 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">
|
<li v-for="user in filteredUsers" :key="user.id" class="px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex min-w-0 flex-1 items-center">
|
<div class="flex min-w-0 flex-1 items-center">
|
||||||
<div class="flex-shrink-0">
|
<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
|
<svg
|
||||||
class="h-6 w-6 text-gray-600"
|
class="h-6 w-6 text-gray-600 dark:text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -269,14 +287,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4 min-w-0 flex-1">
|
<div class="ml-4 min-w-0 flex-1">
|
||||||
<div class="flex items-center">
|
<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 }}
|
{{ user.displayName || user.username }}
|
||||||
</p>
|
</p>
|
||||||
<div class="ml-2 flex items-center space-x-2">
|
<div class="ml-2 flex items-center space-x-2">
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
'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' }}
|
{{ user.isActive ? 'Active' : 'Disabled' }}
|
||||||
@@ -285,15 +305,17 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
user.role === 'admin'
|
user.role === 'admin'
|
||||||
? 'bg-purple-100 text-purple-800'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||||
: 'bg-blue-100 text-blue-800'
|
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ user.role }}
|
{{ user.role }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>@{{ user.username }}</span>
|
||||||
<span v-if="user.email">{{ user.email }}</span>
|
<span v-if="user.email">{{ user.email }}</span>
|
||||||
<span>{{ user.apiKeyCount || 0 }} API keys</span>
|
<span>{{ user.apiKeyCount || 0 }} API keys</span>
|
||||||
@@ -304,7 +326,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="user.totalUsage"
|
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>{{ formatNumber(user.totalUsage.requests || 0) }} requests</span>
|
||||||
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
|
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
|
||||||
@@ -415,8 +437,8 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No users found</h3>
|
<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">
|
<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.'
|
searchQuery ? 'No users match your search criteria.' : 'No users have been created yet.'
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user