mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
resolve: 解决与upstream/dev的合并冲突
- 合并admin.js中的groupIds和autoStopOnWarning参数 - 统一AccountForm.vue中的错误提示文案和平台判断逻辑 - 保留AccountsView.vue中的分组过滤和ungrouped功能 - 确保Azure OpenAI账户创建和更新逻辑完整性 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -191,7 +191,39 @@
|
||||
<th
|
||||
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
会话窗口
|
||||
<div class="flex items-center gap-2">
|
||||
<span>会话窗口</span>
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<div>会话窗口进度表示5小时窗口的时间进度</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
|
||||
></div>
|
||||
<span>正常:请求正常处理</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
|
||||
></div>
|
||||
<span>警告:接近限制</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
|
||||
></div>
|
||||
<span>拒绝:达到速率限制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<i
|
||||
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
@@ -395,6 +427,14 @@
|
||||
>
|
||||
<i class="fas fa-pause-circle mr-1" />
|
||||
不可调度
|
||||
<el-tooltip
|
||||
v-if="getSchedulableReason(account)"
|
||||
:content="getSchedulableReason(account)"
|
||||
effect="dark"
|
||||
placement="top"
|
||||
>
|
||||
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" />
|
||||
</el-tooltip>
|
||||
</span>
|
||||
<span
|
||||
v-if="account.status === 'blocked' && account.errorMessage"
|
||||
@@ -450,15 +490,21 @@
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<div v-if="account.usage && account.usage.daily" class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500" />
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>{{ account.usage.daily.requests || 0 }} 次</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<div class="h-2 w-2 rounded-full bg-purple-500" />
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300"
|
||||
>{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span
|
||||
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300"
|
||||
>${{ calculateDailyCost(account) }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@@ -479,10 +525,33 @@
|
||||
"
|
||||
class="space-y-2"
|
||||
>
|
||||
<!-- 使用统计在顶部 -->
|
||||
<div
|
||||
v-if="account.usage && account.usage.sessionWindow"
|
||||
class="flex items-center gap-3 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
${{ formatCost(account.usage.sessionWindow.totalCost) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-24 rounded-full bg-gray-200">
|
||||
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
|
||||
:class="[
|
||||
'h-2 rounded-full transition-all duration-300',
|
||||
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
|
||||
]"
|
||||
:style="{ width: account.sessionWindow.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
@@ -490,7 +559,9 @@
|
||||
{{ account.sessionWindow.progress }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
{{
|
||||
formatSessionWindow(
|
||||
@@ -501,7 +572,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="account.sessionWindow.remainingTime > 0"
|
||||
class="font-medium text-indigo-600"
|
||||
class="font-medium text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||||
</div>
|
||||
@@ -649,21 +720,44 @@
|
||||
<div class="mb-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(account.usage?.daily?.requests || 0) }} 次
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ account.usage?.daily?.requests || 0 }} 次
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
${{ calculateDailyCost(account) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">总使用量</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(account.usage?.total?.requests || 0) }} 次
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p>
|
||||
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
${{ formatCost(account.usage.sessionWindow.totalCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm font-semibold text-gray-400">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -679,14 +773,27 @@
|
||||
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
|
||||
<el-tooltip
|
||||
content="会话窗口进度不代表使用量,仅表示距离下一个5小时窗口的剩余时间"
|
||||
placement="top"
|
||||
>
|
||||
<i
|
||||
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ account.sessionWindow.progress }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
|
||||
:class="[
|
||||
'h-full transition-all duration-300',
|
||||
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
|
||||
]"
|
||||
:style="{ width: account.sessionWindow.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
@@ -1104,7 +1211,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 {
|
||||
@@ -1129,9 +1256,11 @@ const formatNumber = (num) => {
|
||||
if (num === null || num === undefined) return '0'
|
||||
const number = Number(num)
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000).toLocaleString() + 'M'
|
||||
return (number / 1000000).toFixed(2)
|
||||
} else if (number >= 1000) {
|
||||
return (number / 1000000).toFixed(4)
|
||||
}
|
||||
return number.toLocaleString()
|
||||
return (number / 1000000).toFixed(6)
|
||||
}
|
||||
|
||||
// 格式化最后使用时间
|
||||
@@ -1346,7 +1475,8 @@ const resetAccountStatus = async (account) => {
|
||||
|
||||
if (data.success) {
|
||||
showToast('账户状态已重置', 'success')
|
||||
loadAccounts()
|
||||
// 强制刷新,绕过前端缓存,确保最终一致性
|
||||
loadAccounts(true)
|
||||
} else {
|
||||
showToast(data.message || '状态重置失败', 'error')
|
||||
}
|
||||
@@ -1475,6 +1605,55 @@ const getClaudeAccountType = (account) => {
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
// 获取停止调度的原因
|
||||
const getSchedulableReason = (account) => {
|
||||
if (account.schedulable !== false) return null
|
||||
|
||||
// Claude Console 账户的错误状态
|
||||
if (account.platform === 'claude-console') {
|
||||
if (account.status === 'unauthorized') {
|
||||
return 'API Key无效或已过期(401错误)'
|
||||
}
|
||||
if (account.overloadStatus === 'overloaded') {
|
||||
return '服务过载(529错误)'
|
||||
}
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
return '触发限流(429错误)'
|
||||
}
|
||||
if (account.status === 'blocked' && account.errorMessage) {
|
||||
return account.errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
// Claude 官方账户的错误状态
|
||||
if (account.platform === 'claude') {
|
||||
if (account.status === 'unauthorized') {
|
||||
return '认证失败(401错误)'
|
||||
}
|
||||
if (account.status === 'error' && account.errorMessage) {
|
||||
return account.errorMessage
|
||||
}
|
||||
if (account.isRateLimited) {
|
||||
return '触发限流(429错误)'
|
||||
}
|
||||
// 自动停止调度的原因
|
||||
if (account.stoppedReason) {
|
||||
return account.stoppedReason
|
||||
}
|
||||
}
|
||||
|
||||
// 通用原因
|
||||
if (account.stoppedReason) {
|
||||
return account.stoppedReason
|
||||
}
|
||||
if (account.errorMessage) {
|
||||
return account.errorMessage
|
||||
}
|
||||
|
||||
// 默认为手动停止
|
||||
return '手动停止调度'
|
||||
}
|
||||
|
||||
// 获取账户状态文本
|
||||
const getAccountStatusText = (account) => {
|
||||
// 检查是否被封锁
|
||||
@@ -1560,6 +1739,51 @@ const formatRelativeTime = (dateString) => {
|
||||
return formatLastUsed(dateString)
|
||||
}
|
||||
|
||||
// 获取会话窗口进度条的样式类
|
||||
const getSessionProgressBarClass = (status) => {
|
||||
// 根据状态返回不同的颜色类,包含防御性检查
|
||||
if (!status) {
|
||||
// 无状态信息时默认为蓝色
|
||||
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||||
}
|
||||
|
||||
// 转换为小写进行比较,避免大小写问题
|
||||
const normalizedStatus = String(status).toLowerCase()
|
||||
|
||||
if (normalizedStatus === 'rejected') {
|
||||
// 被拒绝 - 红色
|
||||
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||
} else if (normalizedStatus === 'allowed_warning') {
|
||||
// 警告状态 - 橙色/黄色
|
||||
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||||
} else {
|
||||
// 正常状态(allowed 或其他) - 蓝色
|
||||
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化费用显示
|
||||
const formatCost = (cost) => {
|
||||
if (!cost || cost === 0) return '0.0000'
|
||||
if (cost < 0.0001) return cost.toExponential(2)
|
||||
if (cost < 0.01) return cost.toFixed(6)
|
||||
if (cost < 1) return cost.toFixed(4)
|
||||
return cost.toFixed(2)
|
||||
}
|
||||
|
||||
// 计算每日费用(使用后端返回的精确费用数据)
|
||||
const calculateDailyCost = (account) => {
|
||||
if (!account.usage || !account.usage.daily) return '0.0000'
|
||||
|
||||
// 如果后端已经返回了计算好的费用,直接使用
|
||||
if (account.usage.daily.cost !== undefined) {
|
||||
return formatCost(account.usage.daily.cost)
|
||||
}
|
||||
|
||||
// 如果后端没有返回费用(旧版本),返回0
|
||||
return '0.0000'
|
||||
}
|
||||
|
||||
// 切换调度状态
|
||||
// const toggleDispatch = async (account) => {
|
||||
// await toggleSchedulable(account)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,15 @@
|
||||
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
|
||||
/>
|
||||
|
||||
<!-- 用户登录按钮 (仅在 LDAP 启用时显示) -->
|
||||
<router-link
|
||||
v-if="oemSettings.ldapEnabled"
|
||||
class="user-login-button flex items-center gap-2 rounded-2xl px-4 py-2 text-white transition-all duration-300 md:px-5 md:py-2.5"
|
||||
to="/user-login"
|
||||
>
|
||||
<i class="fas fa-user text-sm md:text-base" />
|
||||
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
|
||||
</router-link>
|
||||
<!-- 管理后台按钮 -->
|
||||
<router-link
|
||||
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
||||
@@ -309,6 +318,73 @@ watch(apiKey, (newValue) => {
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* 用户登录按钮 */
|
||||
.user-login-button {
|
||||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(52, 211, 153, 0.25),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 暗色模式下的用户登录按钮 */
|
||||
:global(.dark) .user-login-button {
|
||||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(52, 211, 153, 0.3),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-login-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.user-login-button:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow:
|
||||
0 8px 20px rgba(52, 211, 153, 0.35),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.user-login-button:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 暗色模式下的悬停效果 */
|
||||
:global(.dark) .user-login-button:hover {
|
||||
box-shadow:
|
||||
0 8px 20px rgba(52, 211, 153, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(52, 211, 153, 0.5);
|
||||
}
|
||||
|
||||
.user-login-button:active {
|
||||
transform: translateY(-1px) scale(1);
|
||||
}
|
||||
|
||||
/* 确保图标和文字在所有模式下都清晰可见 */
|
||||
.user-login-button i,
|
||||
.user-login-button span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 管理后台按钮 - 精致版本 */
|
||||
.admin-button-refined {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
@@ -41,9 +41,8 @@
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<div class="loading-spinner mx-auto mb-4">
|
||||
<p class="text-gray-500 dark:text-gray-400">正在加载设置...</p>
|
||||
</div>
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">正在加载设置...</p>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
@@ -479,6 +478,7 @@
|
||||
<option value="feishu">🟦 飞书</option>
|
||||
<option value="slack">🟣 Slack</option>
|
||||
<option value="discord">🟪 Discord</option>
|
||||
<option value="bark">🔔 Bark</option>
|
||||
<option value="custom">⚙️ 自定义</option>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
@@ -508,8 +508,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Webhook URL -->
|
||||
<div>
|
||||
<!-- Webhook URL (非Bark平台) -->
|
||||
<div v-if="platformForm.type !== 'bark'">
|
||||
<label
|
||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
@@ -548,6 +548,118 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bark 平台特有字段 -->
|
||||
<div v-if="platformForm.type === 'bark'" class="space-y-5">
|
||||
<!-- 设备密钥 -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-key mr-2 text-gray-400"></i>
|
||||
设备密钥 (Device Key)
|
||||
<span class="ml-1 text-xs text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="platformForm.deviceKey"
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 font-mono text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||
placeholder="例如:aBcDeFgHiJkLmNoPqRsTuVwX"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
在Bark App中查看您的推送密钥
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 服务器URL(可选) -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-server mr-2 text-gray-400"></i>
|
||||
服务器地址
|
||||
<span class="ml-2 text-xs text-gray-500">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="platformForm.serverUrl"
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 font-mono text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||
placeholder="默认: https://api.day.app/push"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 通知级别 -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-flag mr-2 text-gray-400"></i>
|
||||
通知级别
|
||||
</label>
|
||||
<select
|
||||
v-model="platformForm.level"
|
||||
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">自动(根据通知类型)</option>
|
||||
<option value="passive">被动</option>
|
||||
<option value="active">默认</option>
|
||||
<option value="timeSensitive">时效性</option>
|
||||
<option value="critical">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 通知声音 -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-volume-up mr-2 text-gray-400"></i>
|
||||
通知声音
|
||||
</label>
|
||||
<select
|
||||
v-model="platformForm.sound"
|
||||
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">自动(根据通知类型)</option>
|
||||
<option value="default">默认</option>
|
||||
<option value="alarm">警报</option>
|
||||
<option value="bell">铃声</option>
|
||||
<option value="birdsong">鸟鸣</option>
|
||||
<option value="electronic">电子音</option>
|
||||
<option value="glass">玻璃</option>
|
||||
<option value="horn">喇叭</option>
|
||||
<option value="silence">静音</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 分组 -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-folder mr-2 text-gray-400"></i>
|
||||
通知分组
|
||||
<span class="ml-2 text-xs text-gray-500">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="platformForm.group"
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||
placeholder="默认: claude-relay"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="mt-2 flex items-start rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<i class="fas fa-info-circle mr-2 mt-0.5 text-blue-600 dark:text-blue-400"></i>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p>1. 在iPhone上安装Bark App</p>
|
||||
<p>2. 打开App获取您的设备密钥</p>
|
||||
<p>3. 将密钥粘贴到上方输入框</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 签名设置(钉钉/飞书) -->
|
||||
<div
|
||||
v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'"
|
||||
@@ -633,7 +745,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="group flex items-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all hover:from-blue-700 hover:to-indigo-700 hover:shadow-lg disabled:cursor-not-allowed disabled:from-gray-400 disabled:to-gray-500"
|
||||
:disabled="!platformForm.url || savingPlatform"
|
||||
:disabled="!isPlatformFormValid || savingPlatform"
|
||||
@click="savePlatform"
|
||||
>
|
||||
<i
|
||||
@@ -652,7 +764,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
@@ -721,6 +833,44 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听平台类型变化,重置验证状态
|
||||
const platformTypeWatcher = watch(
|
||||
() => platformForm.value.type,
|
||||
(newType) => {
|
||||
// 切换平台类型时重置验证状态
|
||||
urlError.value = false
|
||||
urlValid.value = false
|
||||
|
||||
// 如果不是编辑模式,清空相关字段
|
||||
if (!editingPlatform.value) {
|
||||
if (newType === 'bark') {
|
||||
// 切换到Bark时,清空URL相关字段
|
||||
platformForm.value.url = ''
|
||||
platformForm.value.enableSign = false
|
||||
platformForm.value.secret = ''
|
||||
} else {
|
||||
// 切换到其他平台时,清空Bark相关字段
|
||||
platformForm.value.deviceKey = ''
|
||||
platformForm.value.serverUrl = ''
|
||||
platformForm.value.level = ''
|
||||
platformForm.value.sound = ''
|
||||
platformForm.value.group = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 计算属性:判断平台表单是否有效
|
||||
const isPlatformFormValid = computed(() => {
|
||||
if (platformForm.value.type === 'bark') {
|
||||
// Bark平台需要deviceKey
|
||||
return !!platformForm.value.deviceKey
|
||||
} else {
|
||||
// 其他平台需要URL且URL格式正确
|
||||
return !!platformForm.value.url && !urlError.value
|
||||
}
|
||||
})
|
||||
|
||||
// 页面加载时获取设置
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -747,6 +897,9 @@ onBeforeUnmount(() => {
|
||||
if (sectionWatcher) {
|
||||
sectionWatcher()
|
||||
}
|
||||
if (platformTypeWatcher) {
|
||||
platformTypeWatcher()
|
||||
}
|
||||
|
||||
// 安全关闭模态框
|
||||
if (showAddPlatformModal.value) {
|
||||
@@ -795,6 +948,13 @@ const saveWebhookConfig = async () => {
|
||||
|
||||
// 验证 URL
|
||||
const validateUrl = () => {
|
||||
// Bark平台不需要验证URL
|
||||
if (platformForm.value.type === 'bark') {
|
||||
urlError.value = false
|
||||
urlValid.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const url = platformForm.value.url
|
||||
if (!url) {
|
||||
urlError.value = false
|
||||
@@ -821,14 +981,22 @@ const validateUrl = () => {
|
||||
const savePlatform = async () => {
|
||||
if (!isMounted.value) return
|
||||
|
||||
if (!platformForm.value.url) {
|
||||
showToast('请输入Webhook URL', 'error')
|
||||
return
|
||||
}
|
||||
// Bark平台只需要deviceKey,其他平台需要URL
|
||||
if (platformForm.value.type === 'bark') {
|
||||
if (!platformForm.value.deviceKey) {
|
||||
showToast('请输入Bark设备密钥', 'error')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!platformForm.value.url) {
|
||||
showToast('请输入Webhook URL', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (urlError.value) {
|
||||
showToast('请输入有效的Webhook URL', 'error')
|
||||
return
|
||||
if (urlError.value) {
|
||||
showToast('请输入有效的Webhook URL', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savingPlatform.value = true
|
||||
@@ -925,18 +1093,26 @@ const testPlatform = async (platform) => {
|
||||
if (!isMounted.value) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
'/admin/webhook/test',
|
||||
{
|
||||
url: platform.url,
|
||||
type: platform.type,
|
||||
secret: platform.secret,
|
||||
enableSign: platform.enableSign
|
||||
},
|
||||
{
|
||||
signal: abortController.value.signal
|
||||
}
|
||||
)
|
||||
const testData = {
|
||||
type: platform.type,
|
||||
secret: platform.secret,
|
||||
enableSign: platform.enableSign
|
||||
}
|
||||
|
||||
// 根据平台类型添加不同字段
|
||||
if (platform.type === 'bark') {
|
||||
testData.deviceKey = platform.deviceKey
|
||||
testData.serverUrl = platform.serverUrl
|
||||
testData.level = platform.level
|
||||
testData.sound = platform.sound
|
||||
testData.group = platform.group
|
||||
} else {
|
||||
testData.url = platform.url
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/admin/webhook/test', testData, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
showToast('测试成功,webhook连接正常', 'success')
|
||||
}
|
||||
@@ -952,14 +1128,23 @@ const testPlatform = async (platform) => {
|
||||
const testPlatformForm = async () => {
|
||||
if (!isMounted.value) return
|
||||
|
||||
if (!platformForm.value.url) {
|
||||
showToast('请先输入Webhook URL', 'error')
|
||||
return
|
||||
}
|
||||
// Bark平台验证
|
||||
if (platformForm.value.type === 'bark') {
|
||||
if (!platformForm.value.deviceKey) {
|
||||
showToast('请先输入Bark设备密钥', 'error')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 其他平台验证URL
|
||||
if (!platformForm.value.url) {
|
||||
showToast('请先输入Webhook URL', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (urlError.value) {
|
||||
showToast('请输入有效的Webhook URL', 'error')
|
||||
return
|
||||
if (urlError.value) {
|
||||
showToast('请输入有效的Webhook URL', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
testingConnection.value = true
|
||||
@@ -1020,7 +1205,13 @@ const closePlatformModal = () => {
|
||||
name: '',
|
||||
url: '',
|
||||
enableSign: false,
|
||||
secret: ''
|
||||
secret: '',
|
||||
// Bark特有字段
|
||||
deviceKey: '',
|
||||
serverUrl: '',
|
||||
level: '',
|
||||
sound: '',
|
||||
group: ''
|
||||
}
|
||||
urlError.value = false
|
||||
urlValid.value = false
|
||||
@@ -1037,6 +1228,7 @@ const getPlatformName = (type) => {
|
||||
feishu: '飞书',
|
||||
slack: 'Slack',
|
||||
discord: 'Discord',
|
||||
bark: 'Bark',
|
||||
custom: '自定义'
|
||||
}
|
||||
return names[type] || type
|
||||
@@ -1049,6 +1241,7 @@ const getPlatformIcon = (type) => {
|
||||
feishu: 'fas fa-dove text-blue-600',
|
||||
slack: 'fab fa-slack text-purple-600',
|
||||
discord: 'fab fa-discord text-indigo-600',
|
||||
bark: 'fas fa-bell text-orange-500',
|
||||
custom: 'fas fa-webhook text-gray-600'
|
||||
}
|
||||
return icons[type] || 'fas fa-bell'
|
||||
@@ -1061,6 +1254,7 @@ const getWebhookHint = (type) => {
|
||||
feishu: '请在飞书群机器人设置中获取Webhook地址',
|
||||
slack: '请在Slack应用的Incoming Webhooks中获取地址',
|
||||
discord: '请在Discord服务器的集成设置中创建Webhook',
|
||||
bark: '请在Bark App中查看您的设备密钥',
|
||||
custom: '请输入完整的Webhook接收地址'
|
||||
}
|
||||
return hints[type] || ''
|
||||
|
||||
@@ -1639,7 +1639,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
// 当前系统选择
|
||||
const activeTutorialSystem = ref('windows')
|
||||
@@ -1653,6 +1653,14 @@ const tutorialSystems = [
|
||||
|
||||
// 获取基础URL前缀
|
||||
const getBaseUrlPrefix = () => {
|
||||
// 优先使用环境变量配置的自定义前缀
|
||||
const customPrefix = import.meta.env.VITE_API_BASE_PREFIX
|
||||
if (customPrefix) {
|
||||
// 去除末尾的斜杠
|
||||
return customPrefix.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// 否则使用当前浏览器访问地址
|
||||
// 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境
|
||||
let origin = ''
|
||||
|
||||
|
||||
420
web/admin-spa/src/views/UserDashboardView.vue
Normal file
420
web/admin-spa/src/views/UserDashboardView.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- 导航栏 -->
|
||||
<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 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"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<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">
|
||||
<button
|
||||
:class="[
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
activeTab === 'overview'
|
||||
? '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')"
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
activeTab === 'api-keys'
|
||||
? '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')"
|
||||
>
|
||||
API Keys
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
activeTab === 'usage'
|
||||
? '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')"
|
||||
>
|
||||
Usage Stats
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<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 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<div>
|
||||
<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 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-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<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 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 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 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 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 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 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 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 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>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Tab -->
|
||||
<div v-else-if="activeTab === 'api-keys'">
|
||||
<UserApiKeysManager />
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats Tab -->
|
||||
<div v-else-if="activeTab === 'usage'">
|
||||
<UserUsageStats />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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)
|
||||
const apiKeysStats = ref({ active: 0, deleted: 0 })
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabChange = (tab) => {
|
||||
activeTab.value = tab
|
||||
// Refresh API keys stats when switching to overview tab
|
||||
if (tab === 'overview') {
|
||||
loadApiKeysStats()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
showToast('Logged out successfully', 'success')
|
||||
router.push('/user-login')
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
showToast('Logout failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
try {
|
||||
userProfile.value = await userStore.getUserProfile()
|
||||
} catch (error) {
|
||||
console.error('Failed to load user profile:', error)
|
||||
showToast('Failed to load user profile', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const loadApiKeysStats = async () => {
|
||||
try {
|
||||
const allApiKeys = await userStore.getUserApiKeys(true) // Include deleted keys
|
||||
console.log('All API Keys received:', allApiKeys)
|
||||
|
||||
const activeKeys = allApiKeys.filter(
|
||||
(key) => !(key.isDeleted === 'true' || key.deletedAt) && key.isActive
|
||||
)
|
||||
const deletedKeys = allApiKeys.filter((key) => key.isDeleted === 'true' || key.deletedAt)
|
||||
|
||||
console.log('Active keys:', activeKeys)
|
||||
console.log('Deleted keys:', deletedKeys)
|
||||
console.log('Active count:', activeKeys.length)
|
||||
console.log('Deleted count:', deletedKeys.length)
|
||||
|
||||
apiKeysStats.value = { active: activeKeys.length, deleted: deletedKeys.length }
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys stats:', error)
|
||||
apiKeysStats.value = { active: 0, deleted: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化主题
|
||||
themeStore.initTheme()
|
||||
loadUserProfile()
|
||||
loadApiKeysStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
199
web/admin-spa/src/views/UserLoginView.vue
Normal file
199
web/admin-spa/src/views/UserLoginView.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<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 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"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<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 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 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 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 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"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 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"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<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 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"
|
||||
>
|
||||
<span v-if="loading" class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
{{ loading ? 'Signing In...' : 'Sign In' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
error.value = 'Please enter both username and password'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await userStore.login({
|
||||
username: form.username,
|
||||
password: form.password
|
||||
})
|
||||
|
||||
showToast('Login successful!', 'success')
|
||||
router.push('/user-dashboard')
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
error.value = err.response?.data?.message || err.message || 'Login failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化主题(因为该页面不在 MainLayout 内)
|
||||
themeStore.initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
671
web/admin-spa/src/views/UserManagementView.vue
Normal file
671
web/admin-spa/src/views/UserManagementView.vue
Normal file
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<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>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 sm:w-auto"
|
||||
:disabled="loading"
|
||||
@click="loadUsers"
|
||||
>
|
||||
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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-blue-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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 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-green-500"
|
||||
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"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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 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-purple-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h6z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-yellow-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<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">
|
||||
<!-- Search -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="relative rounded-md shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Filter -->
|
||||
<div>
|
||||
<select
|
||||
v-model="selectedRole"
|
||||
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>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<select
|
||||
v-model="selectedStatus"
|
||||
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>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<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 dark:text-gray-400"
|
||||
>({{ filteredUsers.length }} of {{ users.length }})</span
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-8 w-8 animate-spin text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<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 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 dark:bg-gray-600"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</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 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 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' }}
|
||||
</span>
|
||||
<span
|
||||
: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 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 dark:text-gray-400"
|
||||
>
|
||||
<span>@{{ user.username }}</span>
|
||||
<span v-if="user.email">{{ user.email }}</span>
|
||||
<span>{{ user.apiKeyCount || 0 }} API keys</span>
|
||||
<span v-if="user.lastLoginAt"
|
||||
>Last login: {{ formatDate(user.lastLoginAt) }}</span
|
||||
>
|
||||
<span v-else>Never logged in</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="user.totalUsage"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- View Usage Stats -->
|
||||
<button
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-blue-600"
|
||||
title="View Usage Stats"
|
||||
@click="viewUserStats(user)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Disable User API Keys -->
|
||||
<button
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="user.apiKeyCount === 0"
|
||||
title="Disable All API Keys"
|
||||
@click="disableUserApiKeys(user)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18 12M6 6l12 12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Toggle User Status -->
|
||||
<button
|
||||
:class="[
|
||||
'inline-flex items-center rounded border border-transparent p-1',
|
||||
user.isActive
|
||||
? 'text-gray-400 hover:text-red-600'
|
||||
: 'text-gray-400 hover:text-green-600'
|
||||
]"
|
||||
:title="user.isActive ? 'Disable User' : 'Enable User'"
|
||||
@click="toggleUserStatus(user)"
|
||||
>
|
||||
<svg
|
||||
v-if="user.isActive"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18 12M6 6l12 12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" 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"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Change Role -->
|
||||
<button
|
||||
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-purple-600"
|
||||
title="Change Role"
|
||||
@click="changeUserRole(user)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="py-12 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<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.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Usage Stats Modal -->
|
||||
<UserUsageStatsModal
|
||||
:show="showStatsModal"
|
||||
:user="selectedUser"
|
||||
@close="showStatsModal = false"
|
||||
/>
|
||||
|
||||
<!-- Confirm Modals -->
|
||||
<ConfirmModal
|
||||
:confirm-class="confirmAction.confirmClass"
|
||||
:confirm-text="confirmAction.confirmText"
|
||||
:message="confirmAction.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmAction.title"
|
||||
@cancel="showConfirmModal = false"
|
||||
@confirm="handleConfirmAction"
|
||||
/>
|
||||
|
||||
<!-- Change Role Modal -->
|
||||
<ChangeRoleModal
|
||||
:show="showRoleModal"
|
||||
:user="selectedUser"
|
||||
@close="showRoleModal = false"
|
||||
@updated="handleUserUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { debounce } from 'lodash-es'
|
||||
import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
|
||||
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const users = ref([])
|
||||
const userStats = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const selectedRole = ref('')
|
||||
const selectedStatus = ref('')
|
||||
|
||||
const showStatsModal = ref(false)
|
||||
const showConfirmModal = ref(false)
|
||||
const showRoleModal = ref(false)
|
||||
const selectedUser = ref(null)
|
||||
|
||||
const confirmAction = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: '',
|
||||
confirmClass: '',
|
||||
action: null
|
||||
})
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
let filtered = users.value
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(query) ||
|
||||
user.displayName?.toLowerCase().includes(query) ||
|
||||
user.email?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply role filter
|
||||
if (selectedRole.value) {
|
||||
filtered = filtered.filter((user) => user.role === selectedRole.value)
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (selectedStatus.value !== '') {
|
||||
const isActive = selectedStatus.value === 'true'
|
||||
filtered = filtered.filter((user) => user.isActive === isActive)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [usersResponse, statsResponse] = await Promise.all([
|
||||
apiClient.get('/users', {
|
||||
params: {
|
||||
role: selectedRole.value || undefined,
|
||||
isActive: selectedStatus.value !== '' ? selectedStatus.value : undefined
|
||||
}
|
||||
}),
|
||||
apiClient.get('/users/stats/overview')
|
||||
])
|
||||
|
||||
if (usersResponse.success) {
|
||||
users.value = usersResponse.users
|
||||
}
|
||||
|
||||
if (statsResponse.success) {
|
||||
userStats.value = statsResponse.stats
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
showToast('Failed to load users', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
// Search is handled by computed property
|
||||
}, 300)
|
||||
|
||||
const viewUserStats = (user) => {
|
||||
selectedUser.value = user
|
||||
showStatsModal.value = true
|
||||
}
|
||||
|
||||
const toggleUserStatus = (user) => {
|
||||
selectedUser.value = user
|
||||
confirmAction.value = {
|
||||
title: user.isActive ? 'Disable User' : 'Enable User',
|
||||
message: user.isActive
|
||||
? `Are you sure you want to disable user "${user.username}"? This will prevent them from logging in.`
|
||||
: `Are you sure you want to enable user "${user.username}"?`,
|
||||
confirmText: user.isActive ? 'Disable' : 'Enable',
|
||||
confirmClass: user.isActive ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700',
|
||||
action: 'toggleStatus'
|
||||
}
|
||||
showConfirmModal.value = true
|
||||
}
|
||||
|
||||
const disableUserApiKeys = (user) => {
|
||||
if (user.apiKeyCount === 0) return
|
||||
|
||||
selectedUser.value = user
|
||||
confirmAction.value = {
|
||||
title: 'Disable All API Keys',
|
||||
message: `Are you sure you want to disable all ${user.apiKeyCount} API keys for user "${user.username}"? This will prevent them from using the service.`,
|
||||
confirmText: 'Disable Keys',
|
||||
confirmClass: 'bg-red-600 hover:bg-red-700',
|
||||
action: 'disableKeys'
|
||||
}
|
||||
showConfirmModal.value = true
|
||||
}
|
||||
|
||||
const changeUserRole = (user) => {
|
||||
selectedUser.value = user
|
||||
showRoleModal.value = true
|
||||
}
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
const user = selectedUser.value
|
||||
const action = confirmAction.value.action
|
||||
|
||||
try {
|
||||
if (action === 'toggleStatus') {
|
||||
const response = await apiClient.patch(`/users/${user.id}/status`, {
|
||||
isActive: !user.isActive
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
const userIndex = users.value.findIndex((u) => u.id === user.id)
|
||||
if (userIndex !== -1) {
|
||||
users.value[userIndex].isActive = !user.isActive
|
||||
}
|
||||
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
|
||||
}
|
||||
} else if (action === 'disableKeys') {
|
||||
const response = await apiClient.post(`/users/${user.id}/disable-keys`)
|
||||
|
||||
if (response.success) {
|
||||
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
|
||||
await loadUsers() // Refresh to get updated counts
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action}:`, error)
|
||||
showToast(`Failed to ${action}`, 'error')
|
||||
} finally {
|
||||
showConfirmModal.value = false
|
||||
selectedUser.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserUpdated = () => {
|
||||
showRoleModal.value = false
|
||||
selectedUser.value = null
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user