refactor: standardize code formatting and linting configuration

- Replace .eslintrc.js with .eslintrc.cjs for better ES module compatibility
- Add .prettierrc configuration for consistent code formatting
- Update package.json with new lint and format scripts
- Add nodemon.json for development hot reloading configuration
- Standardize code formatting across all JavaScript and Vue files
- Update web admin SPA with improved linting rules and formatting
- Add prettier configuration to web admin SPA

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
千羽
2025-08-07 18:19:31 +09:00
parent 4a0eba117c
commit 8a74bf5afe
124 changed files with 20878 additions and 18757 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,158 +1,118 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
>
<div class="modal-content w-full max-w-4xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-4 sm:mb-6">
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-4xl overflow-y-auto p-4 sm:p-6 md:p-8"
>
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg sm:rounded-xl flex items-center justify-center">
<i class="fas fa-layer-group text-white text-sm sm:text-base" />
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-layer-group text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
账户分组管理
</h3>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">账户分组管理</h3>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<!-- 添加分组按钮 -->
<div class="mb-6">
<button
class="btn btn-primary px-4 py-2"
@click="showCreateForm = true"
>
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
<i class="fas fa-plus mr-2" />
创建新分组
</button>
</div>
<!-- 创建分组表单 -->
<div
v-if="showCreateForm"
class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200"
>
<h4 class="text-lg font-semibold text-gray-900 mb-4">
创建新分组
</h4>
<div v-if="showCreateForm" class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h4 class="mb-4 text-lg font-semibold text-gray-900">创建新分组</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
<input
v-model="createForm.name"
type="text"
class="form-input w-full"
placeholder="输入分组名称"
>
type="text"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型 *</label>
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
v-model="createForm.platform"
type="radio"
value="claude"
class="mr-2"
>
<label class="flex cursor-pointer items-center">
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
<span class="text-sm text-gray-700">Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="createForm.platform"
type="radio"
value="gemini"
class="mr-2"
>
<label class="flex cursor-pointer items-center">
<input v-model="createForm.platform" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700">Gemini</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
<textarea
v-model="createForm.description"
rows="2"
class="form-input w-full resize-none"
placeholder="分组描述..."
rows="2"
/>
</div>
<div class="flex gap-3">
<button
class="btn btn-primary px-4 py-2"
:disabled="!createForm.name || !createForm.platform || creating"
@click="createGroup"
>
<div
v-if="creating"
class="loading-spinner mr-2"
/>
<div v-if="creating" class="loading-spinner mr-2" />
{{ creating ? '创建中...' : '创建' }}
</button>
<button
class="btn btn-secondary px-4 py-2"
@click="cancelCreate"
>
取消
</button>
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
</div>
</div>
</div>
<!-- 分组列表 -->
<div class="space-y-4">
<div
v-if="loading"
class="text-center py-8"
>
<div v-if="loading" class="py-8 text-center">
<div class="loading-spinner-lg mx-auto mb-4" />
<p class="text-gray-500">
加载中...
</p>
<p class="text-gray-500">加载中...</p>
</div>
<div
v-else-if="groups.length === 0"
class="text-center py-8 bg-gray-50 rounded-lg"
>
<i class="fas fa-layer-group text-4xl text-gray-300 mb-4" />
<p class="text-gray-500">
暂无分组
</p>
<div v-else-if="groups.length === 0" class="rounded-lg bg-gray-50 py-8 text-center">
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300" />
<p class="text-gray-500">暂无分组</p>
</div>
<div
v-else
class="grid gap-4 grid-cols-1 md:grid-cols-2"
>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="group in groups"
:key="group.id"
class="bg-white rounded-lg border p-4 hover:shadow-md transition-shadow"
class="rounded-lg border bg-white p-4 transition-shadow hover:shadow-md"
>
<div class="flex items-start justify-between mb-3">
<div class="mb-3 flex items-start justify-between">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">
{{ group.name }}
</h4>
<p class="text-sm text-gray-500 mt-1">
<p class="mt-1 text-sm text-gray-500">
{{ group.description || '暂无描述' }}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<div class="ml-4 flex items-center gap-2">
<span
:class="[
'px-2 py-1 text-xs font-medium rounded-full',
group.platform === 'claude'
'rounded-full px-2 py-1 text-xs font-medium',
group.platform === 'claude'
? 'bg-purple-100 text-purple-700'
: 'bg-blue-100 text-blue-700'
]"
@@ -161,7 +121,7 @@
</span>
</div>
</div>
<div class="flex items-center justify-between text-sm text-gray-600">
<div class="flex items-center gap-4">
<span>
@@ -175,16 +135,16 @@
</div>
<div class="flex items-center gap-2">
<button
class="text-blue-600 hover:text-blue-800 transition-colors"
class="text-blue-600 transition-colors hover:text-blue-800"
title="编辑"
@click="editGroup(group)"
>
<i class="fas fa-edit" />
</button>
<button
class="text-red-600 hover:text-red-800 transition-colors"
title="删除"
class="text-red-600 transition-colors hover:text-red-800"
:disabled="group.memberCount > 0"
title="删除"
@click="deleteGroup(group)"
>
<i class="fas fa-trash" />
@@ -196,72 +156,59 @@
</div>
</div>
</div>
<!-- 编辑分组模态框 -->
<div
v-if="showEditForm"
class="fixed inset-0 modal z-60 flex items-center justify-center p-3 sm:p-4"
class="modal z-60 fixed inset-0 flex items-center justify-center p-3 sm:p-4"
>
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-900">
编辑分组
</h3>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="cancelEdit"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
<button class="text-gray-400 transition-colors hover:text-gray-600" @click="cancelEdit">
<i class="fas fa-times" />
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
<input
v-model="editForm.name"
type="text"
class="form-input w-full"
placeholder="输入分组名称"
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型</label>
<div class="px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-600">
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
<span class="text-xs text-gray-500 ml-2">(不可修改)</span>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
<textarea
v-model="editForm.description"
rows="2"
class="form-input w-full resize-none"
placeholder="分组描述..."
type="text"
/>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型</label>
<div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
<span class="ml-2 text-xs text-gray-500">(不可修改)</span>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
<textarea
v-model="editForm.description"
class="form-input w-full resize-none"
placeholder="分组描述..."
rows="2"
/>
</div>
<div class="flex gap-3 pt-4">
<button
class="btn btn-primary px-4 py-2 flex-1"
class="btn btn-primary flex-1 px-4 py-2"
:disabled="!editForm.name || updating"
@click="updateGroup"
>
<div
v-if="updating"
class="loading-spinner mr-2"
/>
<div v-if="updating" class="loading-spinner mr-2" />
{{ updating ? '更新中...' : '更新' }}
</button>
<button
class="btn btn-secondary px-4 py-2 flex-1"
@click="cancelEdit"
>
取消
</button>
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">取消</button>
</div>
</div>
</div>
@@ -325,7 +272,7 @@ const createGroup = async () => {
showToast('请填写必填项', 'error')
return
}
creating.value = true
try {
await apiClient.post('/admin/account-groups', {
@@ -333,7 +280,7 @@ const createGroup = async () => {
platform: createForm.value.platform,
description: createForm.value.description
})
showToast('分组创建成功', 'success')
cancelCreate()
await loadGroups()
@@ -372,14 +319,14 @@ const updateGroup = async () => {
showToast('请填写分组名称', 'error')
return
}
updating.value = true
try {
await apiClient.put(`/admin/account-groups/${editingGroup.value.id}`, {
name: editForm.value.name,
description: editForm.value.description
})
showToast('分组更新成功', 'success')
cancelEdit()
await loadGroups()
@@ -408,11 +355,11 @@ const deleteGroup = async (group) => {
showToast('分组内还有成员,无法删除', 'error')
return
}
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
return
}
try {
await apiClient.delete(`/admin/account-groups/${group.id}`)
showToast('分组删除成功', 'success')
@@ -427,4 +374,4 @@ const deleteGroup = async (group) => {
onMounted(() => {
loadGroups()
})
</script>
</script>

View File

@@ -2,66 +2,55 @@
<div class="space-y-6">
<!-- Claude OAuth流程 -->
<div v-if="platform === 'claude'">
<div class="bg-blue-50 p-6 rounded-lg border border-blue-200">
<div class="rounded-lg border border-blue-200 bg-blue-50 p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
>
<i class="fas fa-link text-white" />
</div>
<div class="flex-1">
<h4 class="font-semibold text-blue-900 mb-3">
Claude 账户授权
</h4>
<p class="text-sm text-blue-800 mb-4">
请按照以下步骤完成 Claude 账户的授权
</p>
<h4 class="mb-3 font-semibold text-blue-900">Claude 账户授权</h4>
<p class="mb-4 text-sm text-blue-800">请按照以下步骤完成 Claude 账户授权</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">
点击下方按钮生成授权链接
</p>
<button
<p class="mb-2 font-medium text-blue-900">点击下方按钮生成授权链接</p>
<button
v-if="!authUrl"
:disabled="loading"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
>
<i
v-if="!loading"
class="fas fa-link mr-2"
/>
<div
v-else
class="loading-spinner mr-2"
/>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div
v-else
class="space-y-3"
>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
type="text"
:value="authUrl"
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs"
readonly
class="form-input flex-1 text-xs font-mono bg-gray-50"
>
<button
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
type="text"
:value="authUrl"
/>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<button
<button
class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl"
>
@@ -71,56 +60,58 @@
</div>
</div>
</div>
<!-- 步骤2: 访问链接并授权 -->
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">
在浏览器中打开链接并完成授权
</p>
<p class="text-sm text-blue-700 mb-2">
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
<p class="mb-2 text-sm text-blue-700">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
<p class="text-xs text-yellow-800">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p>
</div>
</div>
</div>
</div>
<!-- 步骤3: 输入授权码 -->
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">
输入 Authorization Code
</p>
<p class="text-sm text-blue-700 mb-3">
授权完成后页面会显示一个 <strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
<p class="mb-2 font-medium text-blue-900">输入 Authorization Code</p>
<p class="mb-3 text-sm text-blue-700">
授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-key text-blue-500 mr-2" />Authorization Code
<label class="mb-2 block text-sm font-semibold text-gray-700">
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label>
<textarea
v-model="authCode"
rows="3"
<textarea
v-model="authCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude页面获取的Authorization Code..."
rows="3"
/>
</div>
<p class="text-xs text-gray-500 mt-2">
<p class="mt-2 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code
</p>
@@ -133,69 +124,58 @@
</div>
</div>
</div>
<!-- Gemini OAuth流程 -->
<div v-else-if="platform === 'gemini'">
<div class="bg-green-50 p-6 rounded-lg border border-green-200">
<div class="rounded-lg border border-green-200 bg-green-50 p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-500"
>
<i class="fas fa-robot text-white" />
</div>
<div class="flex-1">
<h4 class="font-semibold text-green-900 mb-3">
Gemini 账户授权
</h4>
<p class="text-sm text-green-800 mb-4">
请按照以下步骤完成 Gemini 账户的授权
</p>
<h4 class="mb-3 font-semibold text-green-900">Gemini 账户授权</h4>
<p class="mb-4 text-sm text-green-800">请按照以下步骤完成 Gemini 账户授权</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
<div class="rounded-lg border border-green-300 bg-white/80 p-4">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="font-medium text-green-900 mb-2">
点击下方按钮生成授权链接
</p>
<button
<p class="mb-2 font-medium text-green-900">点击下方按钮生成授权链接</p>
<button
v-if="!authUrl"
:disabled="loading"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
>
<i
v-if="!loading"
class="fas fa-link mr-2"
/>
<div
v-else
class="loading-spinner mr-2"
/>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div
v-else
class="space-y-3"
>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
type="text"
:value="authUrl"
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs"
readonly
class="form-input flex-1 text-xs font-mono bg-gray-50"
>
<button
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
type="text"
:value="authUrl"
/>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<button
<button
class="text-xs text-green-600 hover:text-green-700"
@click="regenerateAuthUrl"
>
@@ -205,59 +185,60 @@
</div>
</div>
</div>
<!-- 步骤2: 操作说明 -->
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
<div class="rounded-lg border border-green-300 bg-white/80 p-4">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">
在浏览器中打开链接并完成授权
</p>
<p class="text-sm text-blue-700 mb-2">
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
<p class="mb-2 text-sm text-blue-700">
请在新标签页中打开授权链接登录您的 Gemini 账户并授权
</p>
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
<p class="text-xs text-yellow-800">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p>
</div>
</div>
</div>
</div>
<!-- 步骤3: 输入授权码 -->
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
<div class="rounded-lg border border-green-300 bg-white/80 p-4">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="font-medium text-green-900 mb-2">
输入 Authorization Code
</p>
<p class="text-sm text-green-700 mb-3">
<p class="mb-2 font-medium text-green-900">输入 Authorization Code</p>
<p class="mb-3 text-sm text-green-700">
授权完成后页面会显示一个 Authorization Code请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-key text-green-500 mr-2" />Authorization Code
<label class="mb-2 block text-sm font-semibold text-gray-700">
<i class="fas fa-key mr-2 text-green-500" />Authorization Code
</label>
<textarea
v-model="authCode"
rows="3"
<textarea
v-model="authCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Gemini页面获取的Authorization Code..."
rows="3"
/>
</div>
<div class="mt-2 space-y-1">
<p class="text-xs text-gray-600">
<i class="fas fa-check-circle text-green-500 mr-1" />
请粘贴从Gemini页面复制的Authorization Code
<i class="fas fa-check-circle mr-1 text-green-500" />
请粘贴从Gemini页面复制的Authorization Code
</p>
</div>
</div>
@@ -269,25 +250,22 @@
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
type="button"
@click="$emit('back')"
>
上一步
</button>
<button
type="button"
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchange || exchanging"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
type="button"
@click="exchangeCode"
>
<div
v-if="exchanging"
class="loading-spinner mr-2"
/>
<div v-if="exchanging" class="loading-spinner mr-2" />
{{ exchanging ? '验证中...' : '完成授权' }}
</button>
</div>
@@ -330,15 +308,15 @@ const canExchange = computed(() => {
// 监听授权码输入自动提取URL中的code参数
watch(authCode, (newValue) => {
if (!newValue || typeof newValue !== 'string') return
const trimmedValue = newValue.trim()
// 如果内容为空,不处理
if (!trimmedValue) return
// 检查是否是 URL 格式(包含 http:// 或 https://
const isUrl = trimmedValue.startsWith('http://') || trimmedValue.startsWith('https://')
// 如果是 URL 格式
if (isUrl) {
// 检查是否是正确的 localhost:45462 开头的 URL
@@ -346,7 +324,7 @@ watch(authCode, (newValue) => {
try {
const url = new URL(trimmedValue)
const code = url.searchParams.get('code')
if (code) {
// 成功提取授权码
authCode.value = code
@@ -367,7 +345,7 @@ watch(authCode, (newValue) => {
try {
const url = new URL(trimmedValue)
const code = url.searchParams.get('code')
if (code) {
authCode.value = code
showToast('成功提取授权码!', 'success')
@@ -387,16 +365,18 @@ watch(authCode, (newValue) => {
const generateAuthUrl = async () => {
loading.value = true
try {
const proxyConfig = props.proxy?.enabled ? {
proxy: {
type: props.proxy.type,
host: props.proxy.host,
port: parseInt(props.proxy.port),
username: props.proxy.username || null,
password: props.proxy.password || null
}
} : {}
const proxyConfig = props.proxy?.enabled
? {
proxy: {
type: props.proxy.type,
host: props.proxy.host,
port: parseInt(props.proxy.port),
username: props.proxy.username || null,
password: props.proxy.password || null
}
}
: {}
if (props.platform === 'claude') {
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
authUrl.value = result.authUrl
@@ -448,11 +428,11 @@ const copyAuthUrl = async () => {
// 交换授权码
const exchangeCode = async () => {
if (!canExchange.value) return
exchanging.value = true
try {
let data = {}
if (props.platform === 'claude') {
// Claude使用sessionId和callbackUrl即授权码
data = {
@@ -466,7 +446,7 @@ const exchangeCode = async () => {
sessionId: sessionId.value
}
}
// 添加代理配置(如果启用)
if (props.proxy?.enabled) {
data.proxy = {
@@ -477,14 +457,14 @@ const exchangeCode = async () => {
password: props.proxy.password || null
}
}
let tokenInfo
if (props.platform === 'claude') {
tokenInfo = await accountsStore.exchangeClaudeCode(data)
} else if (props.platform === 'gemini') {
tokenInfo = await accountsStore.exchangeGeminiCode(data)
}
emit('success', tokenInfo)
} catch (error) {
showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
@@ -492,4 +472,4 @@ const exchangeCode = async () => {
exchanging.value = false
}
}
</script>
</script>

View File

@@ -1,117 +1,97 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">
代理设置 (可选)
</h4>
<label class="flex items-center cursor-pointer">
<input
v-model="proxy.enabled"
<h4 class="text-sm font-semibold text-gray-700">代理设置 (可选)</h4>
<label class="flex cursor-pointer items-center">
<input
v-model="proxy.enabled"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
/>
<span class="ml-2 text-sm text-gray-700">启用代理</span>
</label>
</div>
<div
v-if="proxy.enabled"
class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4"
>
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-gray-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-server text-white text-sm" />
<div v-if="proxy.enabled" class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 flex items-start gap-3">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-500">
<i class="fas fa-server text-sm text-white" />
</div>
<div class="flex-1">
<p class="text-sm text-gray-700">
配置代理以访问受限的网络资源支持 SOCKS5 HTTP 代理
</p>
<p class="text-xs text-gray-500 mt-1">
<p class="mt-1 text-xs text-gray-500">
请确保代理服务器稳定可用否则会影响账户的正常使用
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">代理类型</label>
<select
v-model="proxy.type"
class="form-input w-full"
>
<option value="socks5">
SOCKS5
</option>
<option value="http">
HTTP
</option>
<option value="https">
HTTPS
</option>
<label class="mb-2 block text-sm font-medium text-gray-700">代理类型</label>
<select v-model="proxy.type" class="form-input w-full">
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">主机地址</label>
<input
v-model="proxy.host"
type="text"
placeholder="例如: 192.168.1.100"
<label class="mb-2 block text-sm font-medium text-gray-700">主机地址</label>
<input
v-model="proxy.host"
class="form-input w-full"
>
placeholder="例如: 192.168.1.100"
type="text"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">端口</label>
<input
v-model="proxy.port"
type="number"
placeholder="例如: 1080"
<label class="mb-2 block text-sm font-medium text-gray-700">端口</label>
<input
v-model="proxy.port"
class="form-input w-full"
>
placeholder="例如: 1080"
type="number"
/>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center">
<input
id="proxyAuth"
<input
id="proxyAuth"
v-model="showAuth"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label
for="proxyAuth"
class="ml-2 text-sm text-gray-700 cursor-pointer"
>
/>
<label class="ml-2 cursor-pointer text-sm text-gray-700" for="proxyAuth">
需要身份验证
</label>
</div>
<div
v-if="showAuth"
class="grid grid-cols-2 gap-4"
>
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
<input
v-model="proxy.username"
type="text"
placeholder="代理用户名"
<label class="mb-2 block text-sm font-medium text-gray-700">用户名</label>
<input
v-model="proxy.username"
class="form-input w-full"
>
placeholder="代理用户名"
type="text"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
<label class="mb-2 block text-sm font-medium text-gray-700">密码</label>
<div class="relative">
<input
v-model="proxy.password"
:type="showPassword ? 'text' : 'password'"
placeholder="代理密码"
<input
v-model="proxy.password"
class="form-input w-full pr-10"
>
<button
placeholder="代理密码"
:type="showPassword ? 'text' : 'password'"
/>
<button
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" />
@@ -120,11 +100,12 @@
</div>
</div>
</div>
<div class="bg-blue-50 p-3 rounded-lg border border-blue-200">
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3">
<p class="text-xs text-blue-700">
<i class="fas fa-info-circle mr-1" />
<strong>提示</strong>代理设置将用于所有与此账户相关的API请求请确保代理服务器支持HTTPS流量转发
<strong>提示</strong
>代理设置将用于所有与此账户相关的API请求请确保代理服务器支持HTTPS流量转发
</p>
</div>
</div>
@@ -132,7 +113,7 @@
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
const props = defineProps({
modelValue: {
@@ -158,38 +139,60 @@ const showAuth = ref(!!(proxy.value.username || proxy.value.password))
const showPassword = ref(false)
// 监听modelValue变化只在真正需要更新时才更新
watch(() => props.modelValue, (newVal) => {
// 只有当值真正不同时才更新,避免循环
if (JSON.stringify(newVal) !== JSON.stringify(proxy.value)) {
proxy.value = { ...newVal }
showAuth.value = !!(newVal.username || newVal.password)
}
}, { deep: true })
watch(
() => props.modelValue,
(newVal) => {
// 只有当值真正不同时才更新,避免循环
if (JSON.stringify(newVal) !== JSON.stringify(proxy.value)) {
proxy.value = { ...newVal }
showAuth.value = !!(newVal.username || newVal.password)
}
},
{ deep: true }
)
// 监听各个字段单独变化,而不是整个对象
watch(() => proxy.value.enabled, (newVal) => {
emitUpdate()
})
watch(
() => proxy.value.enabled,
() => {
emitUpdate()
}
)
watch(() => proxy.value.type, (newVal) => {
emitUpdate()
})
watch(
() => proxy.value.type,
() => {
emitUpdate()
}
)
watch(() => proxy.value.host, (newVal) => {
emitUpdate()
})
watch(
() => proxy.value.host,
() => {
emitUpdate()
}
)
watch(() => proxy.value.port, (newVal) => {
emitUpdate()
})
watch(
() => proxy.value.port,
() => {
emitUpdate()
}
)
watch(() => proxy.value.username, (newVal) => {
emitUpdate()
})
watch(
() => proxy.value.username,
() => {
emitUpdate()
}
)
watch(() => proxy.value.password, (newVal) => {
emitUpdate()
})
watch(
() => proxy.value.password,
() => {
emitUpdate()
}
)
// 监听认证开关
watch(showAuth, (newVal) => {
@@ -207,17 +210,17 @@ function emitUpdate() {
if (updateTimer) {
clearTimeout(updateTimer)
}
// 设置新的定时器,延迟发送更新
updateTimer = setTimeout(() => {
const data = { ...proxy.value }
// 如果不需要认证,清空用户名密码
if (!showAuth.value) {
data.username = ''
data.password = ''
}
emit('update:modelValue', data)
}, 100) // 100ms 延迟
}
@@ -228,4 +231,4 @@ onUnmounted(() => {
clearTimeout(updateTimer)
}
})
</script>
</script>

View File

@@ -1,122 +1,131 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-8"
>
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-layer-group text-white text-lg" />
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
>
<i class="fas fa-layer-group text-lg text-white" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">
批量创建成功
</h3>
<p class="text-sm text-gray-600">
成功创建 {{ apiKeys.length }} API Key
</p>
<h3 class="text-xl font-bold text-gray-900">批量创建成功</h3>
<p class="text-sm text-gray-600">成功创建 {{ apiKeys.length }} API Key</p>
</div>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
<button
class="text-gray-400 transition-colors hover:text-gray-600"
title="直接关闭(不推荐)"
@click="handleDirectClose"
>
<i class="fas fa-times text-xl" />
</button>
</div>
<!-- 警告提示 -->
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
<div class="mb-6 border-l-4 border-amber-400 bg-amber-50 p-4">
<div class="flex items-start">
<div class="w-6 h-6 bg-amber-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-exclamation-triangle text-white text-sm" />
<div
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-amber-400"
>
<i class="fas fa-exclamation-triangle text-sm text-white" />
</div>
<div class="ml-3">
<h5 class="font-semibold text-amber-900 mb-1">
重要提醒
</h5>
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
<p class="text-sm text-amber-800">
这是您唯一能看到所有 API Key 的机会关闭此窗口后系统将不再显示完整的 API Key请立即下载并妥善保存
这是您唯一能看到所有 API Key 的机会关闭此窗口后系统将不再显示完整的 API
Key请立即下载并妥善保存
</p>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
<div class="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<div
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-blue-600 font-medium">
创建数量
</p>
<p class="text-2xl font-bold text-blue-900 mt-1">
<p class="text-xs font-medium text-blue-600">创建数量</p>
<p class="mt-1 text-2xl font-bold text-blue-900">
{{ apiKeys.length }}
</p>
</div>
<div class="w-10 h-10 bg-blue-500 bg-opacity-20 rounded-lg flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500 bg-opacity-20"
>
<i class="fas fa-key text-blue-600" />
</div>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
<div
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-green-600 font-medium">
基础名称
</p>
<p class="text-lg font-bold text-green-900 mt-1 truncate">
<p class="text-xs font-medium text-green-600">基础名称</p>
<p class="mt-1 truncate text-lg font-bold text-green-900">
{{ baseName }}
</p>
</div>
<div class="w-10 h-10 bg-green-500 bg-opacity-20 rounded-lg flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500 bg-opacity-20"
>
<i class="fas fa-tag text-green-600" />
</div>
</div>
</div>
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200">
<div
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-purple-600 font-medium">
权限范围
</p>
<p class="text-lg font-bold text-purple-900 mt-1">
<p class="text-xs font-medium text-purple-600">权限范围</p>
<p class="mt-1 text-lg font-bold text-purple-900">
{{ getPermissionText() }}
</p>
</div>
<div class="w-10 h-10 bg-purple-500 bg-opacity-20 rounded-lg flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500 bg-opacity-20"
>
<i class="fas fa-shield-alt text-purple-600" />
</div>
</div>
</div>
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-4 border border-orange-200">
<div
class="rounded-lg border border-orange-200 bg-gradient-to-br from-orange-50 to-orange-100 p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-orange-600 font-medium">
过期时间
</p>
<p class="text-lg font-bold text-orange-900 mt-1">
<p class="text-xs font-medium text-orange-600">过期时间</p>
<p class="mt-1 text-lg font-bold text-orange-900">
{{ getExpiryText() }}
</p>
</div>
<div class="w-10 h-10 bg-orange-500 bg-opacity-20 rounded-lg flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500 bg-opacity-20"
>
<i class="fas fa-clock text-orange-600" />
</div>
</div>
</div>
</div>
<!-- API Keys 预览 -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700">API Keys 预览</label>
<div class="flex items-center gap-2">
<button
<button
class="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
type="button"
class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
@click="togglePreview"
>
<i :class="['fas', showPreview ? 'fa-eye-slash' : 'fa-eye']" />
@@ -125,35 +134,35 @@
<span class="text-xs text-gray-500">最多显示前10个</span>
</div>
</div>
<div
v-if="showPreview"
class="bg-gray-900 rounded-lg p-4 max-h-48 overflow-y-auto custom-scrollbar"
class="custom-scrollbar max-h-48 overflow-y-auto rounded-lg bg-gray-900 p-4"
>
<pre class="text-xs text-gray-300 font-mono">{{ getPreviewText() }}</pre>
<pre class="font-mono text-xs text-gray-300">{{ getPreviewText() }}</pre>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button
class="flex-1 btn btn-primary py-3 px-6 font-semibold flex items-center justify-center gap-2"
<button
class="btn btn-primary flex flex-1 items-center justify-center gap-2 px-6 py-3 font-semibold"
@click="downloadApiKeys"
>
<i class="fas fa-download" />
下载所有 API Keys
</button>
<button
class="px-6 py-3 bg-gray-200 text-gray-800 rounded-xl font-semibold hover:bg-gray-300 transition-colors border border-gray-300"
<button
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300"
@click="handleClose"
>
我已保存
</button>
</div>
<!-- 额外提示 -->
<div class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-xs text-blue-700 flex items-start">
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p class="flex items-start text-xs text-blue-700">
<i class="fas fa-info-circle mr-2 mt-0.5 flex-shrink-0" />
<span>
下载的文件格式为文本文件.txt每行包含一个 API Key
@@ -197,9 +206,9 @@ const getPermissionText = () => {
if (props.apiKeys.length === 0) return '未知'
const permissions = props.apiKeys[0].permissions
const permissionMap = {
'all': '全部服务',
'claude': '仅 Claude',
'gemini': '仅 Gemini'
all: '全部服务',
claude: '仅 Claude',
gemini: '仅 Gemini'
}
return permissionMap[permissions] || permissions
}
@@ -209,11 +218,11 @@ const getExpiryText = () => {
if (props.apiKeys.length === 0) return '未知'
const expiresAt = props.apiKeys[0].expiresAt
if (!expiresAt) return '永不过期'
const expiryDate = new Date(expiresAt)
const now = new Date()
const diffDays = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24))
if (diffDays <= 7) return `${diffDays}`
if (diffDays <= 30) return `${Math.ceil(diffDays / 7)}`
if (diffDays <= 365) return `${Math.ceil(diffDays / 30)}个月`
@@ -228,44 +237,46 @@ const togglePreview = () => {
// 获取预览文本
const getPreviewText = () => {
const previewKeys = props.apiKeys.slice(0, 10)
const lines = previewKeys.map((key, index) => {
const lines = previewKeys.map((key) => {
return `${key.name}: ${key.apiKey || key.key || ''}`
})
if (props.apiKeys.length > 10) {
lines.push(`... 还有 ${props.apiKeys.length - 10} 个 API Key`)
}
return lines.join('\n')
}
// 下载 API Keys
const downloadApiKeys = () => {
// 生成文件内容
const content = props.apiKeys.map(key => {
return `${key.name}: ${key.apiKey || key.key || ''}`
}).join('\n')
const content = props.apiKeys
.map((key) => {
return `${key.name}: ${key.apiKey || key.key || ''}`
})
.join('\n')
// 创建 Blob 对象
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// 生成文件名(包含时间戳)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
link.download = `api-keys-${baseName.value}-${timestamp}.txt`
// 触发下载
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 释放 URL 对象
URL.revokeObjectURL(url)
showToast('API Keys 文件已下载', 'success')
}
@@ -306,9 +317,7 @@ const handleDirectClose = async () => {
}
} else {
// 降级方案
const confirmed = confirm(
'您还没有下载 API Keys关闭后将无法再次查看。\n\n确定要关闭吗'
)
const confirmed = confirm('您还没有下载 API Keys关闭后将无法再次查看。\n\n确定要关闭吗')
if (confirmed) {
emit('close')
}
@@ -321,4 +330,4 @@ pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,62 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4">
<div class="modal-content w-full max-w-4xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-4 sm:mb-6">
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content mx-auto flex max-h-[90vh] w-full max-w-4xl flex-col p-4 sm:p-6 md:p-8"
>
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg sm:rounded-xl flex items-center justify-center">
<i class="fas fa-edit text-white text-sm sm:text-base" />
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-edit text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
编辑 API Key
</h3>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">编辑 API Key</h3>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<form
class="space-y-4 sm:space-y-6 modal-scroll-content custom-scrollbar flex-1"
class="modal-scroll-content custom-scrollbar flex-1 space-y-4 sm:space-y-6"
@submit.prevent="updateApiKey"
>
<div>
<label class="block text-xs sm:text-sm font-semibold text-gray-700 mb-1.5 sm:mb-3">名称</label>
<input
:value="form.name"
type="text"
disabled
class="form-input w-full bg-gray-100 cursor-not-allowed text-sm"
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm"
>名称</label
>
<p class="text-xs text-gray-500 mt-1 sm:mt-2">
名称不可修改
</p>
<input
class="form-input w-full cursor-not-allowed bg-gray-100 text-sm"
disabled
type="text"
:value="form.name"
/>
<p class="mt-1 text-xs text-gray-500 sm:mt-2">名称不可修改</p>
</div>
<!-- 标签 -->
<div>
<label class="block text-xs sm:text-sm font-semibold text-gray-700 mb-1.5 sm:mb-3">标签</label>
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm"
>标签</label
>
<div class="space-y-4">
<!-- 已选择的标签 -->
<div v-if="form.tags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">
已选择的标签:
</div>
<div class="mb-2 text-xs font-medium text-gray-600">已选择的标签:</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in form.tags"
:key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
:key="'selected-' + index"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800"
>
{{ tag }}
<button
class="ml-1 hover:text-blue-900"
type="button"
class="ml-1 hover:text-blue-900"
@click="removeTag(index)"
>
<i class="fas fa-times text-xs" />
@@ -62,428 +64,388 @@
</span>
</div>
</div>
<!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">
点击选择已有标签:
</div>
<div class="mb-2 text-xs font-medium text-gray-600">点击选择已有标签:</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700"
type="button"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
@click="selectTag(tag)"
>
<i class="fas fa-tag text-gray-500 text-xs" />
<i class="fas fa-tag text-xs text-gray-500" />
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">
创建新标签:
</div>
<div class="mb-2 text-xs font-medium text-gray-600">创建新标签:</div>
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
<input
v-model="newTag"
class="form-input flex-1"
placeholder="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
>
/>
<button
class="rounded-lg bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
type="button"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
@click="addTag"
>
<i class="fas fa-plus" />
</button>
</div>
</div>
<p class="text-xs text-gray-500">
用于标记不同团队或用途方便筛选管理
</p>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-xs" />
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3">
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500"
>
<i class="fas fa-tachometer-alt text-xs text-white" />
</div>
<h4 class="font-semibold text-gray-800 text-sm">
速率限制设置 (可选)
</h4>
<h4 class="text-sm font-semibold text-gray-800">速率限制设置 (可选)</h4>
</div>
<div class="space-y-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
<label class="mb-1 block text-xs font-medium text-gray-700"
>时间窗口 (分钟)</label
>
<input
v-model="form.rateLimitWindow"
class="form-input w-full text-sm"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
时间段单位
</p>
placeholder="无限制"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500">时间段单位</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
<label class="mb-1 block text-xs font-medium text-gray-700">请求次数限制</label>
<input
v-model="form.rateLimitRequests"
class="form-input w-full text-sm"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
窗口内最大请求
</p>
placeholder="无限制"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大请求</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
<input
v-model="form.tokenLimit"
type="number"
placeholder="无限制"
<label class="mb-1 block text-xs font-medium text-gray-700">Token 限制</label>
<input
v-model="form.tokenLimit"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
窗口内最大Token
</p>
placeholder="无限制"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大Token</p>
</div>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-2">
<h5 class="text-xs font-semibold text-blue-800 mb-1">
💡 使用示例
</h5>
<div class="text-xs text-blue-700 space-y-0.5">
<div><strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求</div>
<div><strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token</div>
<div><strong>示例3:</strong> 窗口=30请求=50Token=100000 30分钟50次请求且不超10万Token</div>
<div class="rounded-lg bg-blue-100 p-2">
<h5 class="mb-1 text-xs font-semibold text-blue-800">💡 使用示例</h5>
<div class="space-y-0.5 text-xs text-blue-700">
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
</div>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>每日费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = '50'"
>
$50
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = '200'"
>
$200
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.dailyCostLimit"
type="number"
min="0"
step="0.01"
placeholder="0 表示无限制"
<input
v-model="form.dailyCostLimit"
class="form-input w-full"
>
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500">
设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
<input
v-model="form.concurrencyLimit"
type="number"
min="0"
placeholder="0 表示无限制"
<label class="mb-3 block text-sm font-semibold text-gray-700">并发限制</label>
<input
v-model="form.concurrencyLimit"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">
设置此 API Key 可同时处理的最大请求数
</p>
min="0"
placeholder="0 表示无限制"
type="number"
/>
<p class="mt-2 text-xs text-gray-500">设置此 API Key 可同时处理的最大请求数</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<label class="mb-3 block text-sm font-semibold text-gray-700">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
value="all"
class="mr-2"
>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="all" />
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
value="claude"
class="mr-2"
>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
<span class="text-sm text-gray-700"> Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
value="gemini"
class="mr-2"
>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700"> Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">
控制此 API Key 可以访问哪些服务
</p>
<p class="mt-2 text-xs text-gray-500">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700">专属账号绑定</label>
<button
type="button"
class="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="刷新账号列表"
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="accountsLoading"
title="刷新账号列表"
type="button"
@click="refreshAccounts"
>
<i :class="['fas', accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt', 'text-xs']" />
<i
:class="[
'fas',
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt',
'text-xs'
]"
/>
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<label class="mb-1 block text-sm font-medium text-gray-600">Claude 专属账号</label>
<AccountSelector
v-model="form.claudeAccountId"
platform="claude"
:accounts="localAccounts.claude"
:groups="localAccounts.claudeGroups"
:disabled="form.permissions === 'gemini'"
placeholder="请选择Claude账号"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'gemini'"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<label class="mb-1 block text-sm font-medium text-gray-600">Gemini 专属账号</label>
<AccountSelector
v-model="form.geminiAccountId"
platform="gemini"
:accounts="localAccounts.gemini"
:groups="localAccounts.geminiGroups"
:disabled="form.permissions === 'claude'"
placeholder="请选择Gemini账号"
default-option-text="使用共享账号池"
:disabled="form.permissions === 'claude'"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
/>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
修改绑定账号将影响此API Key的请求路由
</p>
<p class="mt-2 text-xs text-gray-500">修改绑定账号将影响此API Key的请求路由</p>
</div>
<div>
<div class="flex items-center mb-3">
<input
id="editEnableModelRestriction"
v-model="form.enableModelRestriction"
<div class="mb-3 flex items-center">
<input
id="editEnableModelRestriction"
v-model="form.enableModelRestriction"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
/>
<label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700"
for="editEnableModelRestriction"
class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer"
>
启用模型限制
</label>
</div>
<div
v-if="form.enableModelRestriction"
class="space-y-3"
>
<div v-if="form.enableModelRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
<span
v-for="(model, index) in form.restrictedModels"
<label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label>
<div
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2"
>
<span
v-for="(model, index) in form.restrictedModels"
:key="index"
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm text-red-800"
>
{{ model }}
<button
type="button"
<button
class="ml-2 text-red-600 hover:text-red-800"
type="button"
@click="removeRestrictedModel(index)"
>
<i class="fas fa-times text-xs" />
</button>
</span>
<span
v-if="form.restrictedModels.length === 0"
class="text-gray-400 text-sm"
>
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400">
暂无限制的模型
</span>
</div>
<div class="space-y-3">
<!-- 快速添加按钮 -->
<div class="flex flex-wrap gap-2">
<button
v-for="model in availableQuickModels"
<button
v-for="model in availableQuickModels"
:key="model"
class="flex-shrink-0 rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-200 sm:text-sm"
type="button"
class="px-3 py-1 text-xs sm:text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex-shrink-0"
@click="quickAddRestrictedModel(model)"
>
{{ model }}
</button>
<span
v-if="availableQuickModels.length === 0"
class="text-gray-400 text-sm italic"
class="text-sm italic text-gray-400"
>
所有常用模型已在限制列表中
</span>
</div>
<!-- 手动输入 -->
<div class="flex gap-2">
<input
<input
v-model="form.modelInput"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1"
placeholder="输入模型名称,按回车添加"
type="text"
@keydown.enter.prevent="addRestrictedModel"
>
<button
/>
<button
class="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
type="button"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
@click="addRestrictedModel"
>
<i class="fas fa-plus" />
</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
<p class="mt-2 text-xs text-gray-500">
设置此API Key无法访问的模型例如claude-opus-4-20250514
</p>
</div>
</div>
</div>
<!-- 客户端限制 -->
<div>
<div class="flex items-center mb-3">
<input
id="editEnableClientRestriction"
v-model="form.enableClientRestriction"
<div class="mb-3 flex items-center">
<input
id="editEnableClientRestriction"
v-model="form.enableClientRestriction"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
/>
<label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700"
for="editEnableClientRestriction"
class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer"
>
启用客户端限制
</label>
</div>
<div
v-if="form.enableClientRestriction"
class="space-y-3"
>
<div v-if="form.enableClientRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
<p class="text-xs text-gray-500 mb-3">
勾选允许使用此API Key的客户端
</p>
<label class="mb-2 block text-sm font-medium text-gray-600">允许的客户端</label>
<p class="mb-3 text-xs text-gray-500">勾选允许使用此API Key的客户端</p>
<div class="space-y-2">
<div
v-for="client in supportedClients"
:key="client.id"
class="flex items-start"
>
<input
:id="`edit_client_${client.id}`"
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
:id="`edit_client_${client.id}`"
v-model="form.allowedClients"
class="mt-0.5 h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox"
:value="client.id"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
>
<label
:for="`edit_client_${client.id}`"
class="ml-2 flex-1 cursor-pointer"
>
/>
<label class="ml-2 flex-1 cursor-pointer" :for="`edit_client_${client.id}`">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
<span class="block text-xs text-gray-500">{{ client.description }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
type="button"
@click="$emit('close')"
>
取消
</button>
<button
type="submit"
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="loading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
type="submit"
>
<div
v-if="loading"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-save mr-2"
/>
<div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" />
{{ loading ? '保存中...' : '保存修改' }}
</button>
</div>
@@ -496,7 +458,6 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth'
import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api'
@@ -515,7 +476,7 @@ const props = defineProps({
const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore()
// const authStore = useAuthStore()
const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
@@ -531,7 +492,7 @@ const availableTags = ref([])
// 计算未选择的标签
const unselectedTags = computed(() => {
return availableTags.value.filter(tag => !form.tags.includes(tag))
return availableTags.value.filter((tag) => !form.tags.includes(tag))
})
// 表单数据
@@ -553,7 +514,6 @@ const form = reactive({
tags: []
})
// 添加限制的模型
const addRestrictedModel = () => {
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
@@ -568,14 +528,11 @@ const removeRestrictedModel = (index) => {
}
// 常用模型列表
const commonModels = ref([
'claude-opus-4-20250514',
'claude-opus-4-1-20250805'
])
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
// 可用的快捷模型(过滤掉已在限制列表中的)
const availableQuickModels = computed(() => {
return commonModels.value.filter(model => !form.restrictedModels.includes(model))
return commonModels.value.filter((model) => !form.restrictedModels.includes(model))
})
// 快速添加限制的模型
@@ -609,57 +566,70 @@ const removeTag = (index) => {
// 更新 API Key
const updateApiKey = async () => {
loading.value = true
try {
// 准备提交的数据
const data = {
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : 0,
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0,
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
: 0,
rateLimitRequests:
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: 0,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
: 0,
dailyCostLimit:
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
permissions: form.permissions,
tags: form.tags
}
// 处理Claude账户绑定区分OAuth和Console
if (form.claudeAccountId) {
if (form.claudeAccountId.startsWith('console:')) {
// Claude Console账户
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
data.claudeAccountId = null; // 清空OAuth账号
data.claudeConsoleAccountId = form.claudeAccountId.substring(8)
data.claudeAccountId = null // 清空OAuth账号
} else if (!form.claudeAccountId.startsWith('group:')) {
// Claude OAuth账户非分组
data.claudeAccountId = form.claudeAccountId;
data.claudeConsoleAccountId = null; // 清空Console账号
data.claudeAccountId = form.claudeAccountId
data.claudeConsoleAccountId = null // 清空Console账号
} else {
// 分组
data.claudeAccountId = form.claudeAccountId;
data.claudeConsoleAccountId = null; // 清空Console账号
data.claudeAccountId = form.claudeAccountId
data.claudeConsoleAccountId = null // 清空Console账号
}
} else {
// 使用共享池,清空所有绑定
data.claudeAccountId = null;
data.claudeConsoleAccountId = null;
data.claudeAccountId = null
data.claudeConsoleAccountId = null
}
// Gemini账户绑定
if (form.geminiAccountId) {
data.geminiAccountId = form.geminiAccountId;
data.geminiAccountId = form.geminiAccountId
} else {
data.geminiAccountId = null;
data.geminiAccountId = null
}
// 模型限制 - 始终提交这些字段
data.enableModelRestriction = form.enableModelRestriction
data.restrictedModels = form.restrictedModels
// 客户端限制 - 始终提交这些字段
data.enableClientRestriction = form.enableClientRestriction
data.allowedClients = form.allowedClients
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) {
emit('success')
emit('close')
@@ -683,12 +653,12 @@ const refreshAccounts = async () => {
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
const claudeAccounts = []
if (claudeData.success) {
claudeData.data?.forEach(account => {
claudeData.data?.forEach((account) => {
claudeAccounts.push({
...account,
platform: 'claude-oauth',
@@ -696,9 +666,9 @@ const refreshAccounts = async () => {
})
})
}
if (claudeConsoleData.success) {
claudeConsoleData.data?.forEach(account => {
claudeConsoleData.data?.forEach((account) => {
claudeAccounts.push({
...account,
platform: 'claude-console',
@@ -706,23 +676,23 @@ const refreshAccounts = async () => {
})
})
}
localAccounts.value.claude = claudeAccounts
if (geminiData.success) {
localAccounts.value.gemini = (geminiData.data || []).map(account => ({
localAccounts.value.gemini = (geminiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
// 处理分组数据
if (groupsData.success) {
const allGroups = groupsData.data || []
localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
}
showToast('账号列表已刷新', 'success')
} catch (error) {
showToast('刷新账号列表失败', 'error')
@@ -736,7 +706,7 @@ onMounted(async () => {
// 加载支持的客户端和已存在的标签
supportedClients.value = await clientsStore.loadSupportedClients()
availableTags.value = await apiKeysStore.fetchTags()
// 初始化账号数据
if (props.accounts) {
localAccounts.value = {
@@ -746,7 +716,7 @@ onMounted(async () => {
geminiGroups: props.accounts.geminiGroups || []
}
}
form.name = props.apiKey.name
form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
@@ -772,4 +742,4 @@ onMounted(async () => {
<style scoped>
/* 表单样式由全局样式提供 */
</style>
</style>

View File

@@ -1,42 +1,39 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
>
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- 模态框内容 -->
<div class="modal-content w-full max-w-lg p-8 mx-auto">
<div class="modal-content mx-auto w-full max-w-lg p-8">
<!-- 头部 -->
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-amber-500 to-orange-600 rounded-xl flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-600"
>
<i class="fas fa-clock text-white" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">
修改过期时间
</h3>
<h3 class="text-xl font-bold text-gray-900">修改过期时间</h3>
<p class="text-sm text-gray-600">
"{{ apiKey.name || 'API Key' }}" 设置新的过期时间
</p>
</div>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
class="text-gray-400 transition-colors hover:text-gray-600"
@click="$emit('close')"
>
<i class="fas fa-times text-xl" />
</button>
</div>
<div class="space-y-6">
<!-- 当前状态显示 -->
<div class="bg-gradient-to-r from-gray-50 to-gray-100 rounded-lg p-4 border border-gray-200">
<div
class="rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-600 font-medium mb-1">
当前过期时间
</p>
<p class="mb-1 text-xs font-medium text-gray-600">当前过期时间</p>
<p class="text-sm font-semibold text-gray-800">
<template v-if="apiKey.expiresAt">
{{ formatExpireDate(apiKey.expiresAt) }}
@@ -54,26 +51,28 @@
</template>
</p>
</div>
<div class="w-12 h-12 bg-white rounded-lg flex items-center justify-center shadow-sm">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm">
<i
:class="[
'fas fa-hourglass-half text-lg',
apiKey.expiresAt && isExpired(apiKey.expiresAt) ? 'text-red-500' : 'text-gray-400'
apiKey.expiresAt && isExpired(apiKey.expiresAt)
? 'text-red-500'
: 'text-gray-400'
]"
/>
</div>
</div>
</div>
<!-- 快捷选项 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">选择新的期限</label>
<div class="grid grid-cols-3 gap-2 mb-3">
<label class="mb-3 block text-sm font-semibold text-gray-700">选择新的期限</label>
<div class="mb-3 grid grid-cols-3 gap-2">
<button
v-for="option in quickOptions"
:key="option.value"
:class="[
'px-3 py-2 rounded-lg text-sm font-medium transition-all',
'rounded-lg px-3 py-2 text-sm font-medium transition-all',
localForm.expireDuration === option.value
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
@@ -84,7 +83,7 @@
</button>
<button
:class="[
'px-3 py-2 rounded-lg text-sm font-medium transition-all',
'rounded-lg px-3 py-2 text-sm font-medium transition-all',
localForm.expireDuration === 'custom'
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
@@ -96,33 +95,28 @@
</button>
</div>
</div>
<!-- 自定义日期选择 -->
<div
v-if="localForm.expireDuration === 'custom'"
class="animate-fadeIn"
>
<label class="block text-sm font-semibold text-gray-700 mb-2">选择日期和时间</label>
<input
v-model="localForm.customExpireDate"
type="datetime-local"
<div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn">
<label class="mb-2 block text-sm font-semibold text-gray-700">选择日期和时间</label>
<input
v-model="localForm.customExpireDate"
class="form-input w-full"
:min="minDateTime"
type="datetime-local"
@change="updateCustomExpiryPreview"
>
<p class="text-xs text-gray-500 mt-2">
选择一个未来的日期和时间作为过期时间
</p>
/>
<p class="mt-2 text-xs text-gray-500">选择一个未来的日期和时间作为过期时间</p>
</div>
<!-- 预览新的过期时间 -->
<div
<div
v-if="localForm.expiresAt !== apiKey.expiresAt"
class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-4 border border-blue-200"
class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-blue-700 font-medium mb-1">
<p class="mb-1 text-xs font-medium text-blue-700">
<i class="fas fa-arrow-right mr-1" />
新的过期时间
</p>
@@ -143,33 +137,27 @@
</template>
</p>
</div>
<div class="w-12 h-12 bg-white rounded-lg flex items-center justify-center shadow-sm">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm">
<i class="fas fa-check text-lg text-green-500" />
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 pt-2">
<button
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
@click="$emit('close')"
>
取消
</button>
<button
class="flex-1 btn btn-primary py-2.5 px-4 font-semibold"
class="btn btn-primary flex-1 px-4 py-2.5 font-semibold"
:disabled="saving || localForm.expiresAt === apiKey.expiresAt"
@click="handleSave"
>
<div
v-if="saving"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-save mr-2"
/>
<div v-if="saving" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" />
{{ saving ? '保存中...' : '保存更改' }}
</button>
</div>
@@ -224,23 +212,29 @@ const minDateTime = computed(() => {
})
// 监听显示状态,初始化表单
watch(() => props.show, (newVal) => {
if (newVal) {
initializeForm()
watch(
() => props.show,
(newVal) => {
if (newVal) {
initializeForm()
}
}
})
)
// 监听 apiKey 变化,重新初始化
watch(() => props.apiKey?.id, (newId) => {
if (newId && props.show) {
initializeForm()
watch(
() => props.apiKey?.id,
(newId) => {
if (newId && props.show) {
initializeForm()
}
}
})
)
// 初始化表单
const initializeForm = () => {
saving.value = false
if (props.apiKey.expiresAt) {
localForm.expireDuration = 'custom'
localForm.customExpireDate = new Date(props.apiKey.expiresAt).toISOString().slice(0, 16)
@@ -255,23 +249,23 @@ const initializeForm = () => {
// 选择快捷选项
const selectQuickOption = (value) => {
localForm.expireDuration = value
if (!value) {
localForm.expiresAt = null
return
}
if (value === 'custom') {
return
}
const now = new Date()
const match = value.match(/(\d+)([dhmy])/)
if (match) {
const [, num, unit] = match
const amount = parseInt(num)
switch (unit) {
case 'd':
now.setDate(now.getDate() + amount)
@@ -286,7 +280,7 @@ const selectQuickOption = (value) => {
now.setFullYear(now.getFullYear() + amount)
break
}
localForm.expiresAt = now.toISOString()
}
}
@@ -320,12 +314,12 @@ const isExpired = (dateString) => {
// 获取过期状态
const getExpiryStatus = (expiresAt) => {
if (!expiresAt) return null
const now = new Date()
const expiryDate = new Date(expiresAt)
const diffMs = expiryDate - now
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffMs < 0) {
return {
text: '已过期',
@@ -396,7 +390,11 @@ defineExpose({
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</style>

View File

@@ -1,97 +1,100 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-lg overflow-y-auto p-8"
>
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-check text-white text-lg" />
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
>
<i class="fas fa-check text-lg text-white" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">
API Key 创建成功
</h3>
<p class="text-sm text-gray-600">
请妥善保存您的 API Key
</p>
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
<p class="text-sm text-gray-600">请妥善保存您的 API Key</p>
</div>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
<button
class="text-gray-400 transition-colors hover:text-gray-600"
title="直接关闭(不推荐)"
@click="handleDirectClose"
>
<i class="fas fa-times text-xl" />
</button>
</div>
<!-- 警告提示 -->
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
<div class="mb-6 border-l-4 border-amber-400 bg-amber-50 p-4">
<div class="flex items-start">
<div class="w-6 h-6 bg-amber-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-exclamation-triangle text-white text-sm" />
<div
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-amber-400"
>
<i class="fas fa-exclamation-triangle text-sm text-white" />
</div>
<div class="ml-3">
<h5 class="font-semibold text-amber-900 mb-1">
重要提醒
</h5>
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
<p class="text-sm text-amber-800">
这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API Key请立即复制并妥善保存
这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API
Key请立即复制并妥善保存
</p>
</div>
</div>
</div>
<!-- API Key 信息 -->
<div class="space-y-4 mb-6">
<div class="mb-6 space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key 名称</label>
<div class="p-3 bg-gray-50 rounded-lg border">
<span class="text-gray-900 font-medium">{{ apiKey.name }}</span>
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key 名称</label>
<div class="rounded-lg border bg-gray-50 p-3">
<span class="font-medium text-gray-900">{{ apiKey.name }}</span>
</div>
</div>
<div v-if="apiKey.description">
<label class="block text-sm font-semibold text-gray-700 mb-2">备注</label>
<div class="p-3 bg-gray-50 rounded-lg border">
<label class="mb-2 block text-sm font-semibold text-gray-700">备注</label>
<div class="rounded-lg border bg-gray-50 p-3">
<span class="text-gray-700">{{ apiKey.description || '无描述' }}</span>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key</label>
<div class="relative">
<div class="p-4 pr-14 bg-gray-900 rounded-lg border font-mono text-sm text-white break-all min-h-[60px] flex items-center">
<div
class="flex min-h-[60px] items-center break-all rounded-lg border bg-gray-900 p-4 pr-14 font-mono text-sm text-white"
>
{{ getDisplayedApiKey() }}
</div>
<div class="absolute top-3 right-3">
<button
type="button"
class="btn-icon-sm hover:bg-gray-800 bg-gray-700"
<div class="absolute right-3 top-3">
<button
class="btn-icon-sm bg-gray-700 hover:bg-gray-800"
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
type="button"
@click="toggleKeyVisibility"
>
<i :class="['fas', showFullKey ? 'fa-eye-slash' : 'fa-eye', 'text-gray-300']" />
</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
<p class="mt-2 text-xs text-gray-500">
点击眼睛图标切换显示模式使用下方按钮复制完整 API Key
</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button
class="flex-1 btn btn-primary py-3 px-6 font-semibold flex items-center justify-center gap-2"
<button
class="btn btn-primary flex flex-1 items-center justify-center gap-2 px-6 py-3 font-semibold"
@click="copyApiKey"
>
<i class="fas fa-copy" />
复制 API Key
</button>
<button
class="px-6 py-3 bg-gray-200 text-gray-800 rounded-xl font-semibold hover:bg-gray-300 transition-colors border border-gray-300"
<button
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300"
@click="handleClose"
>
我已保存
@@ -126,13 +129,15 @@ const toggleKeyVisibility = () => {
const getDisplayedApiKey = () => {
const key = props.apiKey.apiKey || props.apiKey.key || ''
if (!key) return ''
if (showFullKey.value) {
return key
} else {
// 显示前8个字符和后4个字符中间用●代替
if (key.length <= 12) return key
return key.substring(0, 8) + '●'.repeat(Math.max(0, key.length - 12)) + key.substring(key.length - 4)
return (
key.substring(0, 8) + '●'.repeat(Math.max(0, key.length - 12)) + key.substring(key.length - 4)
)
}
}
@@ -143,7 +148,7 @@ const copyApiKey = async () => {
showToast('API Key 不存在', 'error')
return
}
try {
await navigator.clipboard.writeText(key)
showToast('API Key 已复制到剪贴板', 'success')
@@ -202,9 +207,7 @@ const handleDirectClose = async () => {
}
} else {
// 降级方案
const confirmed = confirm(
'您还没有保存API Key关闭后将无法再次查看。\n\n确定要关闭吗'
)
const confirmed = confirm('您还没有保存API Key关闭后将无法再次查看。\n\n确定要关闭吗')
if (confirmed) {
emit('close')
}
@@ -217,4 +220,4 @@ pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</style>

View File

@@ -1,116 +1,92 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="modal-content mx-auto flex max-h-[90vh] w-full max-w-md flex-col p-8">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
>
<i class="fas fa-clock text-white" />
</div>
<h3 class="text-xl font-bold text-gray-900">
续期 API Key
</h3>
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
<button
class="text-gray-400 transition-colors hover:text-gray-600"
@click="$emit('close')"
>
<i class="fas fa-times text-xl" />
</button>
</div>
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="modal-scroll-content custom-scrollbar flex-1 space-y-6">
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-info text-white text-sm" />
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
>
<i class="fas fa-info text-sm text-white" />
</div>
<div>
<h4 class="font-semibold text-gray-800 mb-1">
API Key 信息
</h4>
<h4 class="mb-1 font-semibold text-gray-800">API Key 信息</h4>
<p class="text-sm text-gray-700">
{{ apiKey.name }}
</p>
<p class="text-xs text-gray-600 mt-1">
当前过期时间{{ apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期' }}
<p class="mt-1 text-xs text-gray-600">
当前过期时间{{
apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期'
}}
</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
<select
v-model="form.renewDuration"
<label class="mb-3 block text-sm font-semibold text-gray-700">续期时长</label>
<select
v-model="form.renewDuration"
class="form-input w-full"
@change="updateRenewExpireAt"
>
<option value="7d">
延长 7
</option>
<option value="30d">
延长 30
</option>
<option value="90d">
延长 90
</option>
<option value="180d">
延长 180
</option>
<option value="365d">
延长 365
</option>
<option value="custom">
自定义日期
</option>
<option value="permanent">
设为永不过期
</option>
<option value="7d">延长 7 </option>
<option value="30d">延长 30 </option>
<option value="90d">延长 90 </option>
<option value="180d">延长 180 </option>
<option value="365d">延长 365 </option>
<option value="custom">自定义日期</option>
<option value="permanent">设为永不过期</option>
</select>
<div
v-if="form.renewDuration === 'custom'"
class="mt-3"
>
<input
v-model="form.customExpireDate"
type="datetime-local"
<div v-if="form.renewDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full"
:min="minDateTime"
type="datetime-local"
@change="updateCustomRenewExpireAt"
>
/>
</div>
<p
v-if="form.newExpiresAt"
class="text-xs text-gray-500 mt-2"
>
<p v-if="form.newExpiresAt" class="mt-2 text-xs text-gray-500">
新的过期时间{{ formatExpireDate(form.newExpiresAt) }}
</p>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
type="button"
@click="$emit('close')"
>
取消
</button>
<button
type="button"
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="loading || !form.renewDuration"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
type="button"
@click="renewApiKey"
>
<div
v-if="loading"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-clock mr-2"
/>
<div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-clock mr-2" />
{{ loading ? '续期中...' : '确认续期' }}
</button>
</div>
@@ -122,7 +98,6 @@
<script setup>
import { ref, reactive, computed } from 'vue'
import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth'
import { apiClient } from '@/config/api'
const props = defineProps({
@@ -134,7 +109,6 @@ const props = defineProps({
const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore()
const loading = ref(false)
// 表单数据
@@ -174,28 +148,29 @@ const updateRenewExpireAt = () => {
form.newExpiresAt = null
return
}
if (form.renewDuration === 'permanent') {
form.newExpiresAt = null
return
}
if (form.renewDuration === 'custom') {
return
}
// 计算新的过期时间
const baseDate = props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
? new Date(props.apiKey.expiresAt)
: new Date()
const baseDate =
props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
? new Date(props.apiKey.expiresAt)
: new Date()
const duration = form.renewDuration
const match = duration.match(/(\d+)([dhmy])/)
if (match) {
const [, value, unit] = match
const num = parseInt(value)
switch (unit) {
case 'd':
baseDate.setDate(baseDate.getDate() + num)
@@ -210,7 +185,7 @@ const updateRenewExpireAt = () => {
baseDate.setFullYear(baseDate.getFullYear() + num)
break
}
form.newExpiresAt = baseDate.toISOString()
}
}
@@ -225,14 +200,14 @@ const updateCustomRenewExpireAt = () => {
// 续期 API Key
const renewApiKey = async () => {
loading.value = true
try {
const data = {
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
}
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) {
showToast('API Key 续期成功', 'success')
emit('success')
@@ -253,4 +228,4 @@ updateRenewExpireAt()
<style scoped>
/* 表单样式由全局样式提供 */
</style>
</style>

View File

@@ -1,88 +1,87 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
>
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 backdrop-blur-sm"
@click="close"
/>
<div class="fixed inset-0 bg-gray-900 bg-opacity-50 backdrop-blur-sm" @click="close" />
<!-- 模态框 -->
<div class="modal-content w-[95%] sm:w-full max-w-2xl sm:max-w-3xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col relative">
<div
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
>
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-4 sm:mb-6">
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg sm:rounded-xl flex items-center justify-center">
<i class="fas fa-chart-line text-white text-sm sm:text-base" />
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-chart-line text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">
使用统计详情 - {{ apiKey.name }}
</h3>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
@click="close"
>
<button class="p-1 text-gray-400 transition-colors hover:text-gray-600" @click="close">
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<!-- 内容区 -->
<div class="modal-scroll-content custom-scrollbar flex-1 overflow-y-auto">
<!-- 总体统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- 请求统计卡片 -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
<div class="flex items-center justify-between mb-3">
<div
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">总请求数</span>
<i class="fas fa-paper-plane text-blue-500" />
</div>
<div class="text-2xl font-bold text-gray-900">
{{ formatNumber(totalRequests) }}
</div>
<div class="text-xs text-gray-600 mt-1">
<div class="mt-1 text-xs text-gray-600">
今日: {{ formatNumber(dailyRequests) }}
</div>
</div>
<!-- Token统计卡片 -->
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
<div class="flex items-center justify-between mb-3">
<div
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">总Token数</span>
<i class="fas fa-coins text-green-500" />
</div>
<div class="text-2xl font-bold text-gray-900">
{{ formatTokenCount(totalTokens) }}
</div>
<div class="text-xs text-gray-600 mt-1">
<div class="mt-1 text-xs text-gray-600">
今日: {{ formatTokenCount(dailyTokens) }}
</div>
</div>
<!-- 费用统计卡片 -->
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-lg p-4 border border-yellow-200">
<div class="flex items-center justify-between mb-3">
<div
class="rounded-lg border border-yellow-200 bg-gradient-to-br from-yellow-50 to-yellow-100 p-4"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">总费用</span>
<i class="fas fa-dollar-sign text-yellow-600" />
</div>
<div class="text-2xl font-bold text-gray-900">
${{ totalCost.toFixed(4) }}
</div>
<div class="text-xs text-gray-600 mt-1">
今日: ${{ dailyCost.toFixed(4) }}
</div>
<div class="text-2xl font-bold text-gray-900">${{ totalCost.toFixed(4) }}</div>
<div class="mt-1 text-xs text-gray-600">今日: ${{ dailyCost.toFixed(4) }}</div>
</div>
<!-- 平均统计卡片 -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200">
<div class="flex items-center justify-between mb-3">
<div
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">平均速率</span>
<i class="fas fa-tachometer-alt text-purple-500" />
</div>
<div class="text-sm space-y-1">
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">RPM:</span>
<span class="font-semibold">{{ rpm }}</span>
@@ -97,14 +96,14 @@
<!-- Token详细分布 -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center">
<i class="fas fa-chart-pie text-indigo-500 mr-2" />
<h4 class="mb-3 flex items-center text-sm font-semibold text-gray-700">
<i class="fas fa-chart-pie mr-2 text-indigo-500" />
Token 使用分布
</h4>
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
<div class="space-y-3 rounded-lg bg-gray-50 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-arrow-down text-green-500 mr-2" />
<i class="fas fa-arrow-down mr-2 text-green-500" />
<span class="text-sm text-gray-600">输入 Token</span>
</div>
<span class="text-sm font-semibold text-gray-900">
@@ -113,31 +112,25 @@
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-arrow-up text-blue-500 mr-2" />
<i class="fas fa-arrow-up mr-2 text-blue-500" />
<span class="text-sm text-gray-600">输出 Token</span>
</div>
<span class="text-sm font-semibold text-gray-900">
{{ formatTokenCount(outputTokens) }}
</span>
</div>
<div
v-if="cacheCreateTokens > 0"
class="flex items-center justify-between"
>
<div v-if="cacheCreateTokens > 0" class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-save text-purple-500 mr-2" />
<i class="fas fa-save mr-2 text-purple-500" />
<span class="text-sm text-gray-600">缓存创建 Token</span>
</div>
<span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheCreateTokens) }}
</span>
</div>
<div
v-if="cacheReadTokens > 0"
class="flex items-center justify-between"
>
<div v-if="cacheReadTokens > 0" class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-download text-purple-500 mr-2" />
<i class="fas fa-download mr-2 text-purple-500" />
<span class="text-sm text-gray-600">缓存读取 Token</span>
</div>
<span class="text-sm font-semibold text-purple-600">
@@ -148,33 +141,33 @@
</div>
<!-- 限制信息 -->
<div
v-if="hasLimits"
class="mb-6"
>
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center">
<i class="fas fa-shield-alt text-red-500 mr-2" />
<div v-if="hasLimits" class="mb-6">
<h4 class="mb-3 flex items-center text-sm font-semibold text-gray-700">
<i class="fas fa-shield-alt mr-2 text-red-500" />
限制设置
</h4>
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
<div
v-if="apiKey.dailyCostLimit > 0"
class="space-y-2"
>
<div class="space-y-3 rounded-lg bg-gray-50 p-4">
<div v-if="apiKey.dailyCostLimit > 0" class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">每日费用限制</span>
<span class="font-semibold text-gray-900">
${{ apiKey.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full transition-all duration-300"
:class="dailyCostPercentage >= 100 ? 'bg-red-500' : dailyCostPercentage >= 80 ? 'bg-yellow-500' : 'bg-green-500'"
:class="
dailyCostPercentage >= 100
? 'bg-red-500'
: dailyCostPercentage >= 80
? 'bg-yellow-500'
: 'bg-green-500'
"
:style="{ width: Math.min(dailyCostPercentage, 100) + '%' }"
/>
</div>
<div class="text-xs text-gray-500 text-right">
<div class="text-right text-xs text-gray-500">
已使用 {{ dailyCostPercentage.toFixed(1) }}%
</div>
</div>
@@ -189,50 +182,42 @@
</span>
</div>
<div
v-if="apiKey.rateLimitWindow > 0"
class="space-y-2"
>
<div v-if="apiKey.rateLimitWindow > 0" class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">时间窗口</span>
<span class="font-semibold text-indigo-600">
{{ apiKey.rateLimitWindow }} 分钟
</span>
</div>
<!-- 请求次数限制 -->
<div
v-if="apiKey.rateLimitRequests > 0"
class="space-y-1"
>
<div v-if="apiKey.rateLimitRequests > 0" class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">请求限制</span>
<span class="font-semibold text-gray-900">
{{ apiKey.currentWindowRequests || 0 }} / {{ apiKey.rateLimitRequests }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full transition-all duration-300"
:class="windowRequestProgressColor"
:style="{ width: windowRequestProgress + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div
v-if="apiKey.tokenLimit > 0"
class="space-y-1"
>
<div v-if="apiKey.tokenLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Token限制</span>
<span class="font-semibold text-gray-900">
{{ formatTokenCount(apiKey.currentWindowTokens || 0) }} / {{ formatTokenCount(apiKey.tokenLimit) }}
{{ formatTokenCount(apiKey.currentWindowTokens || 0) }} /
{{ formatTokenCount(apiKey.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full transition-all duration-300"
:class="windowTokenProgressColor"
:style="{ width: windowTokenProgress + '%' }"
@@ -245,12 +230,8 @@
</div>
<!-- 底部按钮 -->
<div class="mt-4 sm:mt-6 flex justify-end gap-2 sm:gap-3">
<button
type="button"
class="btn btn-secondary px-4 py-2 text-sm"
@click="close"
>
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
关闭
</button>
</div>
@@ -276,24 +257,26 @@ const props = defineProps({
const emit = defineEmits(['close'])
// 计算属性
const totalRequests = computed(() => (props.apiKey.usage?.total?.requests) || 0)
const dailyRequests = computed(() => (props.apiKey.usage?.daily?.requests) || 0)
const totalTokens = computed(() => (props.apiKey.usage?.total?.tokens) || 0)
const dailyTokens = computed(() => (props.apiKey.usage?.daily?.tokens) || 0)
const totalCost = computed(() => (props.apiKey.usage?.total?.cost) || 0)
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
const dailyRequests = computed(() => props.apiKey.usage?.daily?.requests || 0)
const totalTokens = computed(() => props.apiKey.usage?.total?.tokens || 0)
const dailyTokens = computed(() => props.apiKey.usage?.daily?.tokens || 0)
const totalCost = computed(() => props.apiKey.usage?.total?.cost || 0)
const dailyCost = computed(() => props.apiKey.dailyCost || 0)
const inputTokens = computed(() => (props.apiKey.usage?.total?.inputTokens) || 0)
const outputTokens = computed(() => (props.apiKey.usage?.total?.outputTokens) || 0)
const cacheCreateTokens = computed(() => (props.apiKey.usage?.total?.cacheCreateTokens) || 0)
const cacheReadTokens = computed(() => (props.apiKey.usage?.total?.cacheReadTokens) || 0)
const rpm = computed(() => (props.apiKey.usage?.averages?.rpm) || 0)
const tpm = computed(() => (props.apiKey.usage?.averages?.tpm) || 0)
const inputTokens = computed(() => props.apiKey.usage?.total?.inputTokens || 0)
const outputTokens = computed(() => props.apiKey.usage?.total?.outputTokens || 0)
const cacheCreateTokens = computed(() => props.apiKey.usage?.total?.cacheCreateTokens || 0)
const cacheReadTokens = computed(() => props.apiKey.usage?.total?.cacheReadTokens || 0)
const rpm = computed(() => props.apiKey.usage?.averages?.rpm || 0)
const tpm = computed(() => props.apiKey.usage?.averages?.tpm || 0)
const hasLimits = computed(() => {
return props.apiKey.dailyCostLimit > 0 ||
props.apiKey.concurrencyLimit > 0 ||
props.apiKey.rateLimitWindow > 0 ||
props.apiKey.tokenLimit > 0
return (
props.apiKey.dailyCostLimit > 0 ||
props.apiKey.concurrencyLimit > 0 ||
props.apiKey.rateLimitWindow > 0 ||
props.apiKey.tokenLimit > 0
)
})
const dailyCostPercentage = computed(() => {
@@ -304,7 +287,8 @@ const dailyCostPercentage = computed(() => {
// 窗口请求进度
const windowRequestProgress = computed(() => {
if (!props.apiKey.rateLimitRequests || props.apiKey.rateLimitRequests === 0) return 0
const percentage = ((props.apiKey.currentWindowRequests || 0) / props.apiKey.rateLimitRequests) * 100
const percentage =
((props.apiKey.currentWindowRequests || 0) / props.apiKey.rateLimitRequests) * 100
return Math.min(percentage, 100)
})
@@ -352,4 +336,4 @@ const close = () => {
<style scoped>
/* 使用项目的通用样式,不需要额外定义 */
</style>
</style>

View File

@@ -1,58 +1,48 @@
<template>
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
<div class="api-input-wide-card glass-strong mb-8 rounded-3xl p-6 shadow-xl">
<!-- 标题区域 -->
<div class="wide-card-title text-center mb-6">
<h2 class="text-2xl font-bold mb-2">
<div class="wide-card-title mb-6 text-center">
<h2 class="mb-2 text-2xl font-bold">
<i class="fas fa-chart-line mr-3" />
使用统计查询
</h2>
<p class="text-base text-gray-600">
查询您的 API Key 使用情况和统计数据
</p>
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
</div>
<!-- 输入区域 -->
<div class="max-w-4xl mx-auto">
<div class="mx-auto max-w-4xl">
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
<!-- API Key 输入 -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-2 text-gray-700">
<label class="mb-2 block text-sm font-medium text-gray-700">
<i class="fas fa-key mr-2" />
输入您的 API Key
</label>
<input
v-model="apiKey"
type="password"
placeholder="请输入您的 API Key (cr_...)"
<input
v-model="apiKey"
class="wide-card-input w-full"
:disabled="loading"
placeholder="请输入您的 API Key (cr_...)"
type="password"
@keyup.enter="queryStats"
>
/>
</div>
<!-- 查询按钮 -->
<div class="lg:col-span-1">
<label class="hidden lg:block text-sm font-medium mb-2 text-gray-700">
&nbsp;
</label>
<button
<label class="mb-2 hidden text-sm font-medium text-gray-700 lg:block"> &nbsp; </label>
<button
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
:disabled="loading || !apiKey.trim()"
class="btn btn-primary btn-query w-full h-full flex items-center justify-center gap-2"
@click="queryStats"
>
<i
v-if="loading"
class="fas fa-spinner loading-spinner"
/>
<i
v-else
class="fas fa-search"
/>
<i v-if="loading" class="fas fa-spinner loading-spinner" />
<i v-else class="fas fa-search" />
{{ loading ? '查询中...' : '查询统计' }}
</button>
</div>
</div>
<!-- 安全提示 -->
<div class="security-notice mt-4">
<i class="fas fa-shield-alt mr-2" />
@@ -77,7 +67,7 @@ const { queryStats } = apiStatsStore
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
@@ -85,7 +75,7 @@ const { queryStats } = apiStatsStore
}
.api-input-wide-card:hover {
box-shadow:
box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
@@ -135,7 +125,7 @@ const { queryStats } = apiStatsStore
.wide-card-input:focus {
outline: none;
border-color: #60a5fa;
box-shadow:
box-shadow:
0 0 0 3px rgba(96, 165, 250, 0.2),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: white;
@@ -162,14 +152,14 @@ const { queryStats } = apiStatsStore
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow:
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1);
}
@@ -209,8 +199,12 @@ const { queryStats } = apiStatsStore
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式优化 */
@@ -218,33 +212,33 @@ const { queryStats } = apiStatsStore
.api-input-wide-card {
padding: 1.25rem;
}
.wide-card-title {
margin-bottom: 1.25rem;
}
.wide-card-title h2 {
font-size: 1.5rem;
}
.wide-card-title p {
font-size: 0.875rem;
}
.api-input-grid {
gap: 1rem;
}
.wide-card-input {
padding: 12px 14px;
font-size: 15px;
}
.btn-query {
padding: 12px 20px;
font-size: 15px;
}
.security-notice {
padding: 10px 14px;
font-size: 0.8rem;
@@ -255,23 +249,23 @@ const { queryStats } = apiStatsStore
.api-input-wide-card {
padding: 1rem;
}
.wide-card-title h2 {
font-size: 1.25rem;
}
.wide-card-title p {
font-size: 0.8rem;
}
.wide-card-input {
padding: 10px 12px;
font-size: 14px;
}
.btn-query {
padding: 10px 16px;
font-size: 14px;
}
}
</style>
</style>

View File

@@ -2,89 +2,101 @@
<div>
<!-- 限制配置 -->
<div class="card p-4 md:p-6">
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex items-center text-gray-900">
<i class="fas fa-shield-alt mr-2 md:mr-3 text-red-500 text-sm md:text-base" />
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl">
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
限制配置
</h3>
<div class="space-y-4 md:space-y-5">
<!-- 每日费用限制 -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600 text-sm md:text-base font-medium">每日费用限制</span>
<span class="text-xs md:text-sm text-gray-500">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 md:text-base">每日费用限制</span>
<span class="text-xs text-gray-500 md:text-sm">
<span v-if="statsData.limits.dailyCostLimit > 0">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{ statsData.limits.dailyCostLimit.toFixed(2) }}
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{
statsData.limits.dailyCostLimit.toFixed(2)
}}
</span>
<span v-else class="flex items-center gap-1">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / <i class="fas fa-infinity" />
</span>
</span>
</div>
<div v-if="statsData.limits.dailyCostLimit > 0" class="w-full bg-gray-200 rounded-full h-2">
<div
:class="getDailyCostProgressColor()"
<div
v-if="statsData.limits.dailyCostLimit > 0"
class="h-2 w-full rounded-full bg-gray-200"
>
<div
class="h-2 rounded-full transition-all duration-300"
:class="getDailyCostProgressColor()"
:style="{ width: getDailyCostProgress() + '%' }"
/>
</div>
<div v-else class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: 0%" />
<div v-else class="h-2 w-full rounded-full bg-gray-200">
<div class="h-2 rounded-full bg-green-500" style="width: 0%" />
</div>
</div>
<!-- 时间窗口限制 -->
<div v-if="statsData.limits.rateLimitWindow > 0 && (statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600 text-sm md:text-base font-medium">
<div
v-if="
statsData.limits.rateLimitWindow > 0 &&
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 md:text-base">
时间窗口限制 ({{ statsData.limits.rateLimitWindow }}分钟)
</span>
</div>
<!-- 请求次数限制 -->
<div v-if="statsData.limits.rateLimitRequests > 0" class="space-y-1.5 mb-3">
<div class="flex justify-between items-center text-xs md:text-sm">
<div v-if="statsData.limits.rateLimitRequests > 0" class="mb-3 space-y-1.5">
<div class="flex items-center justify-between text-xs md:text-sm">
<span class="text-gray-500">请求次数</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowRequests) }} / {{ formatNumber(statsData.limits.rateLimitRequests) }}
{{ formatNumber(statsData.limits.currentWindowRequests) }} /
{{ formatNumber(statsData.limits.rateLimitRequests) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowRequestProgressColor()"
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWindowRequestProgressColor()"
:style="{ width: getWindowRequestProgress() + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div v-if="statsData.limits.tokenLimit > 0" class="space-y-1.5">
<div class="flex justify-between items-center text-xs md:text-sm">
<div class="flex items-center justify-between text-xs md:text-sm">
<span class="text-gray-500">Token 使用量</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowTokens) }} / {{ formatNumber(statsData.limits.tokenLimit) }}
{{ formatNumber(statsData.limits.currentWindowTokens) }} /
{{ formatNumber(statsData.limits.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowTokenProgressColor()"
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWindowTokenProgressColor()"
:style="{ width: getWindowTokenProgress() + '%' }"
/>
</div>
</div>
<div class="mt-2 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</div>
</div>
<!-- 其他限制信息 -->
<div class="pt-2 border-t border-gray-100 space-y-2">
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">并发限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<div class="space-y-2 border-t border-gray-100 pt-2">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">并发限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base">
<span v-if="statsData.limits.concurrencyLimit > 0">
{{ statsData.limits.concurrencyLimit }}
</span>
@@ -93,39 +105,39 @@
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">模型限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">模型限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base">
<span
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
v-if="
statsData.restrictions.enableModelRestriction &&
statsData.restrictions.restrictedModels.length > 0
"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
</span>
<span
v-else
class="text-green-600"
>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有模型
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">客户端限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">客户端限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base">
<span
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
v-if="
statsData.restrictions.enableClientRestriction &&
statsData.restrictions.allowedClients.length > 0
"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
</span>
<span
v-else
class="text-green-600"
>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</span>
@@ -137,61 +149,71 @@
<!-- 详细限制信息 -->
<div
v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
class="card p-4 md:p-6 mt-4 md:mt-6"
v-if="
(statsData.restrictions.enableModelRestriction &&
statsData.restrictions.restrictedModels.length > 0) ||
(statsData.restrictions.enableClientRestriction &&
statsData.restrictions.allowedClients.length > 0)
"
class="card mt-4 p-4 md:mt-6 md:p-6"
>
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex items-center text-gray-900">
<i class="fas fa-list-alt mr-2 md:mr-3 text-amber-500 text-sm md:text-base" />
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl">
<i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
详细限制信息
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2">
<!-- 模型限制详情 -->
<div
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="bg-amber-50 border border-amber-200 rounded-lg p-3 md:p-4"
v-if="
statsData.restrictions.enableModelRestriction &&
statsData.restrictions.restrictedModels.length > 0
"
class="rounded-lg border border-amber-200 bg-amber-50 p-3 md:p-4"
>
<h4 class="font-bold text-amber-800 mb-2 md:mb-3 flex items-center text-sm md:text-base">
<i class="fas fa-robot mr-1 md:mr-2 text-xs md:text-sm" />
<h4 class="mb-2 flex items-center text-sm font-bold text-amber-800 md:mb-3 md:text-base">
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
受限模型列表
</h4>
<div class="space-y-1 md:space-y-2">
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="bg-white rounded px-2 md:px-3 py-1 md:py-2 text-xs md:text-sm border border-amber-200"
v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="rounded border border-amber-200 bg-white px-2 py-1 text-xs md:px-3 md:py-2 md:text-sm"
>
<i class="fas fa-ban mr-1 md:mr-2 text-red-500 text-xs" />
<span class="text-gray-800 break-all">{{ model }}</span>
<i class="fas fa-ban mr-1 text-xs text-red-500 md:mr-2" />
<span class="break-all text-gray-800">{{ model }}</span>
</div>
</div>
<p class="text-xs text-amber-700 mt-2 md:mt-3">
<p class="mt-2 text-xs text-amber-700 md:mt-3">
<i class="fas fa-info-circle mr-1" />
此 API Key 不能访问以上列出的模型
</p>
</div>
<!-- 客户端限制详情 -->
<div
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="bg-blue-50 border border-blue-200 rounded-lg p-3 md:p-4"
v-if="
statsData.restrictions.enableClientRestriction &&
statsData.restrictions.allowedClients.length > 0
"
class="rounded-lg border border-blue-200 bg-blue-50 p-3 md:p-4"
>
<h4 class="font-bold text-blue-800 mb-2 md:mb-3 flex items-center text-sm md:text-base">
<i class="fas fa-desktop mr-1 md:mr-2 text-xs md:text-sm" />
<h4 class="mb-2 flex items-center text-sm font-bold text-blue-800 md:mb-3 md:text-base">
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
允许的客户端
</h4>
<div class="space-y-1 md:space-y-2">
<div
v-for="client in statsData.restrictions.allowedClients"
:key="client"
class="bg-white rounded px-2 md:px-3 py-1 md:py-2 text-xs md:text-sm border border-blue-200"
v-for="client in statsData.restrictions.allowedClients"
:key="client"
class="rounded border border-blue-200 bg-white px-2 py-1 text-xs md:px-3 md:py-2 md:text-sm"
>
<i class="fas fa-check mr-1 md:mr-2 text-green-500 text-xs" />
<span class="text-gray-800 break-all">{{ client }}</span>
<i class="fas fa-check mr-1 text-xs text-green-500 md:mr-2" />
<span class="break-all text-gray-800">{{ client }}</span>
</div>
</div>
<p class="text-xs text-blue-700 mt-2 md:mt-3">
<p class="mt-2 text-xs text-blue-700 md:mt-3">
<i class="fas fa-info-circle mr-1" />
API Key 只能被以上列出的客户端使用
</p>
@@ -213,9 +235,9 @@ const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
@@ -228,8 +250,10 @@ const formatNumber = (num) => {
// 获取每日费用进度
const getDailyCostProgress = () => {
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0) return 0
const percentage = (statsData.value.limits.currentDailyCost / statsData.value.limits.dailyCostLimit) * 100
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0)
return 0
const percentage =
(statsData.value.limits.currentDailyCost / statsData.value.limits.dailyCostLimit) * 100
return Math.min(percentage, 100)
}
@@ -243,8 +267,10 @@ const getDailyCostProgressColor = () => {
// 获取窗口请求进度
const getWindowRequestProgress = () => {
if (!statsData.value.limits.rateLimitRequests || statsData.value.limits.rateLimitRequests === 0) return 0
const percentage = (statsData.value.limits.currentWindowRequests / statsData.value.limits.rateLimitRequests) * 100
if (!statsData.value.limits.rateLimitRequests || statsData.value.limits.rateLimitRequests === 0)
return 0
const percentage =
(statsData.value.limits.currentWindowRequests / statsData.value.limits.rateLimitRequests) * 100
return Math.min(percentage, 100)
}
@@ -259,7 +285,8 @@ const getWindowRequestProgressColor = () => {
// 获取窗口Token进度
const getWindowTokenProgress = () => {
if (!statsData.value.limits.tokenLimit || statsData.value.limits.tokenLimit === 0) return 0
const percentage = (statsData.value.limits.currentWindowTokens / statsData.value.limits.tokenLimit) * 100
const percentage =
(statsData.value.limits.currentWindowTokens / statsData.value.limits.tokenLimit) * 100
return Math.min(percentage, 100)
}
@@ -278,7 +305,7 @@ const getWindowTokenProgressColor = () => {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
@@ -298,8 +325,8 @@ const getWindowTokenProgressColor = () => {
.card:hover {
transform: translateY(-2px);
box-shadow:
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
</style>
</style>

View File

@@ -1,84 +1,64 @@
<template>
<div class="card p-4 md:p-6">
<div class="mb-4 md:mb-6">
<h3 class="text-lg md:text-xl font-bold flex flex-col sm:flex-row sm:items-center text-gray-900">
<h3
class="flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:text-xl"
>
<span class="flex items-center">
<i class="fas fa-robot mr-2 md:mr-3 text-indigo-500 text-sm md:text-base" />
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
模型使用统计
</span>
<span class="text-xs md:text-sm font-normal text-gray-600 sm:ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
>
</h3>
</div>
<!-- 模型统计加载状态 -->
<div
v-if="modelStatsLoading"
class="text-center py-6 md:py-8"
>
<i class="fas fa-spinner loading-spinner text-xl md:text-2xl mb-2 text-gray-600" />
<p class="text-gray-600 text-sm md:text-base">
加载模型统计数据中...
</p>
<div v-if="modelStatsLoading" class="py-6 text-center md:py-8">
<i class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 md:text-2xl" />
<p class="text-sm text-gray-600 md:text-base">加载模型统计数据中...</p>
</div>
<!-- 模型统计数据 -->
<div
v-else-if="modelStats.length > 0"
class="space-y-3 md:space-y-4"
>
<div
v-for="(model, index) in modelStats"
:key="index"
class="model-usage-item"
>
<div class="flex justify-between items-start mb-2 md:mb-3">
<div class="flex-1 min-w-0">
<h4 class="font-bold text-base md:text-lg text-gray-900 break-all">
<div v-else-if="modelStats.length > 0" class="space-y-3 md:space-y-4">
<div v-for="(model, index) in modelStats" :key="index" class="model-usage-item">
<div class="mb-2 flex items-start justify-between md:mb-3">
<div class="min-w-0 flex-1">
<h4 class="break-all text-base font-bold text-gray-900 md:text-lg">
{{ model.model }}
</h4>
<p class="text-gray-600 text-xs md:text-sm">
{{ model.requests }} 次请求
</p>
<p class="text-xs text-gray-600 md:text-sm">{{ model.requests }} 次请求</p>
</div>
<div class="text-right flex-shrink-0 ml-3">
<div class="text-base md:text-lg font-bold text-green-600">
<div class="ml-3 flex-shrink-0 text-right">
<div class="text-base font-bold text-green-600 md:text-lg">
{{ model.formatted?.total || '$0.000000' }}
</div>
<div class="text-xs md:text-sm text-gray-600">
总费用
</div>
<div class="text-xs text-gray-600 md:text-sm">总费用</div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 md:gap-3 text-xs md:text-sm">
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">
输入 Token
</div>
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm">
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">输入 Token</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.inputTokens) }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">
输出 Token
</div>
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">输出 Token</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.outputTokens) }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">
缓存创建
</div>
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">缓存创建</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.cacheCreateTokens) }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">
缓存读取
</div>
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">缓存读取</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.cacheReadTokens) }}
</div>
@@ -88,11 +68,8 @@
</div>
<!-- 无模型数据 -->
<div
v-else
class="text-center py-6 md:py-8 text-gray-500"
>
<i class="fas fa-chart-pie text-2xl md:text-3xl mb-3" />
<div v-else class="py-6 text-center text-gray-500 md:py-8">
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
<p class="text-sm md:text-base">
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
</p>
@@ -112,9 +89,9 @@ const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
@@ -132,7 +109,7 @@ const formatNumber = (num) => {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
@@ -152,7 +129,7 @@ const formatNumber = (num) => {
.card:hover {
transform: translateY(-2px);
box-shadow:
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
@@ -186,7 +163,7 @@ const formatNumber = (num) => {
.model-usage-item:hover {
transform: translateY(-2px);
box-shadow:
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-color: rgba(255, 255, 255, 0.3);
@@ -199,8 +176,12 @@ const formatNumber = (num) => {
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式优化 */
@@ -214,9 +195,9 @@ const formatNumber = (num) => {
.model-usage-item {
padding: 10px;
}
.model-usage-item .grid {
grid-template-columns: 1fr;
}
}
</style>
</style>

View File

@@ -1,68 +1,65 @@
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 mb-6 md:mb-8">
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
<!-- API Key 基本信息 -->
<div class="card p-4 md:p-6">
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex items-center text-gray-900">
<i class="fas fa-info-circle mr-2 md:mr-3 text-blue-500 text-sm md:text-base" />
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl">
<i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" />
API Key 信息
</h3>
<div class="space-y-2 md:space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">名称</span>
<span class="font-medium text-gray-900 text-sm md:text-base break-all">{{ statsData.name }}</span>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">名称</span>
<span class="break-all text-sm font-medium text-gray-900 md:text-base">{{
statsData.name
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">状态</span>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">状态</span>
<span
class="text-sm font-medium md:text-base"
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
class="font-medium text-sm md:text-base"
>
<i
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
class="mr-1 text-xs md:text-sm"
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
/>
{{ statsData.isActive ? '活跃' : '已停用' }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">权限</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatPermissions(statsData.permissions) }}</span>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">权限</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{
formatPermissions(statsData.permissions)
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">创建时间</span>
<span class="font-medium text-gray-900 text-xs md:text-base break-all">{{ formatDate(statsData.createdAt) }}</span>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">创建时间</span>
<span class="break-all text-xs font-medium text-gray-900 md:text-base">{{
formatDate(statsData.createdAt)
}}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-gray-600 text-sm md:text-base flex-shrink-0 mt-1">过期时间</span>
<div
v-if="statsData.expiresAt"
class="text-right"
>
<div class="flex items-start justify-between">
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 md:text-base">过期时间</span>
<div v-if="statsData.expiresAt" class="text-right">
<div
v-if="isApiKeyExpired(statsData.expiresAt)"
class="text-red-600 font-medium text-sm md:text-base"
class="text-sm font-medium text-red-600 md:text-base"
>
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
已过期
</div>
<div
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
class="text-orange-600 font-medium text-xs md:text-base break-all"
class="break-all text-xs font-medium text-orange-600 md:text-base"
>
<i class="fas fa-clock mr-1 text-xs md:text-sm" />
{{ formatExpireDate(statsData.expiresAt) }}
</div>
<div
v-else
class="text-gray-900 font-medium text-xs md:text-base break-all"
>
<div v-else class="break-all text-xs font-medium text-gray-900 md:text-base">
{{ formatExpireDate(statsData.expiresAt) }}
</div>
</div>
<div
v-else
class="text-gray-400 font-medium text-sm md:text-base"
>
<div v-else class="text-sm font-medium text-gray-400 md:text-base">
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
永不过期
</div>
@@ -72,43 +69,47 @@
<!-- 使用统计概览 -->
<div class="card p-4 md:p-6">
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex flex-col sm:flex-row sm:items-center text-gray-900">
<h3
class="mb-3 flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:mb-4 md:text-xl"
>
<span class="flex items-center">
<i class="fas fa-chart-bar mr-2 md:mr-3 text-green-500 text-sm md:text-base" />
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
使用统计概览
</span>
<span class="text-xs md:text-sm font-normal text-gray-600 sm:ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
>
</h3>
<div class="grid grid-cols-2 gap-3 md:gap-4">
<div class="stat-card text-center">
<div class="text-lg md:text-3xl font-bold text-green-600">
<div class="text-lg font-bold text-green-600 md:text-3xl">
{{ formatNumber(currentPeriodData.requests) }}
</div>
<div class="text-xs md:text-sm text-gray-600">
<div class="text-xs text-gray-600 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
</div>
</div>
<div class="stat-card text-center">
<div class="text-lg md:text-3xl font-bold text-blue-600">
<div class="text-lg font-bold text-blue-600 md:text-3xl">
{{ formatNumber(currentPeriodData.allTokens) }}
</div>
<div class="text-xs md:text-sm text-gray-600">
<div class="text-xs text-gray-600 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
</div>
</div>
<div class="stat-card text-center">
<div class="text-lg md:text-3xl font-bold text-purple-600">
<div class="text-lg font-bold text-purple-600 md:text-3xl">
{{ currentPeriodData.formattedCost || '$0.000000' }}
</div>
<div class="text-xs md:text-sm text-gray-600">
<div class="text-xs text-gray-600 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
</div>
</div>
<div class="stat-card text-center">
<div class="text-lg md:text-3xl font-bold text-yellow-600">
<div class="text-lg font-bold text-yellow-600 md:text-3xl">
{{ formatNumber(currentPeriodData.inputTokens) }}
</div>
<div class="text-xs md:text-sm text-gray-600">
<div class="text-xs text-gray-600 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
</div>
</div>
@@ -128,7 +129,7 @@ const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '无'
try {
const date = dayjs(dateString)
return date.format('YYYY年MM月DD日 HH:mm')
@@ -170,9 +171,9 @@ const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
@@ -186,11 +187,11 @@ const formatNumber = (num) => {
// 格式化权限
const formatPermissions = (permissions) => {
const permissionMap = {
'claude': 'Claude',
'gemini': 'Gemini',
'all': '全部模型'
claude: 'Claude',
gemini: 'Gemini',
all: '全部模型'
}
return permissionMap[permissions] || permissions || '未知'
}
</script>
@@ -201,7 +202,7 @@ const formatPermissions = (permissions) => {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
@@ -221,7 +222,7 @@ const formatPermissions = (permissions) => {
.card:hover {
transform: translateY(-2px);
box-shadow:
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
@@ -258,7 +259,7 @@ const formatPermissions = (permissions) => {
.stat-card:hover {
transform: translateY(-4px);
box-shadow:
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
@@ -279,4 +280,4 @@ const formatPermissions = (permissions) => {
padding: 12px;
}
}
</style>
</style>

View File

@@ -1,45 +1,59 @@
<template>
<div class="card p-4 md:p-6">
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex flex-col sm:flex-row sm:items-center text-gray-900">
<h3
class="mb-3 flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:mb-4 md:text-xl"
>
<span class="flex items-center">
<i class="fas fa-coins mr-2 md:mr-3 text-yellow-500 text-sm md:text-base" />
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
Token 使用分布
</span>
<span class="text-xs md:text-sm font-normal text-gray-600 sm:ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
>
</h3>
<div class="space-y-2 md:space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center text-sm md:text-base">
<i class="fas fa-arrow-right mr-1 md:mr-2 text-green-500 text-xs md:text-sm" />
<div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base">
<i class="fas fa-arrow-right mr-1 text-xs text-green-500 md:mr-2 md:text-sm" />
输入 Token
</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{
formatNumber(currentPeriodData.inputTokens)
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center text-sm md:text-base">
<i class="fas fa-arrow-left mr-1 md:mr-2 text-blue-500 text-xs md:text-sm" />
<div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base">
<i class="fas fa-arrow-left mr-1 text-xs text-blue-500 md:mr-2 md:text-sm" />
输出 Token
</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{
formatNumber(currentPeriodData.outputTokens)
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center text-sm md:text-base">
<i class="fas fa-save mr-1 md:mr-2 text-purple-500 text-xs md:text-sm" />
<div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base">
<i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" />
缓存创建 Token
</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{
formatNumber(currentPeriodData.cacheCreateTokens)
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center text-sm md:text-base">
<i class="fas fa-download mr-1 md:mr-2 text-orange-500 text-xs md:text-sm" />
<div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base">
<i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" />
缓存读取 Token
</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{
formatNumber(currentPeriodData.cacheReadTokens)
}}</span>
</div>
</div>
<div class="mt-3 md:mt-4 pt-3 md:pt-4 border-t border-gray-200">
<div class="flex justify-between items-center font-bold text-gray-900">
<span class="text-sm md:text-base">{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span>
<div class="mt-3 border-t border-gray-200 pt-3 md:mt-4 md:pt-4">
<div class="flex items-center justify-between font-bold text-gray-900">
<span class="text-sm md:text-base"
>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
>
<span class="text-lg md:text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
</div>
</div>
@@ -58,9 +72,9 @@ const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
@@ -78,7 +92,7 @@ const formatNumber = (num) => {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
@@ -98,8 +112,8 @@ const formatNumber = (num) => {
.card:hover {
transform: translateY(-2px);
box-shadow:
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
}
</style>
</style>

View File

@@ -2,12 +2,15 @@
<div ref="triggerRef" class="relative">
<!-- 选择器主体 -->
<div
class="form-input w-full cursor-pointer flex items-center justify-between"
class="form-input flex w-full cursor-pointer items-center justify-between"
:class="{ 'opacity-50': disabled }"
@click="!disabled && toggleDropdown()"
>
<span :class="modelValue ? 'text-gray-900' : 'text-gray-500'">{{ selectedLabel }}</span>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': showDropdown }" />
<i
class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
:class="{ 'rotate-180': showDropdown }"
/>
</div>
<!-- 下拉菜单 -->
@@ -23,26 +26,28 @@
<div
v-if="showDropdown"
ref="dropdownRef"
class="absolute z-50 bg-white rounded-lg shadow-lg border border-gray-200 flex flex-col"
class="absolute z-50 flex flex-col rounded-lg border border-gray-200 bg-white shadow-lg"
:style="dropdownStyle"
>
<!-- 搜索框 -->
<div class="p-3 border-b border-gray-200 flex-shrink-0">
<div class="flex-shrink-0 border-b border-gray-200 p-3">
<div class="relative">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="搜索账号名称..."
class="form-input w-full text-sm"
style="padding-left: 40px; padding-right: 36px;"
placeholder="搜索账号名称..."
style="padding-left: 40px; padding-right: 36px"
type="text"
@input="handleSearch"
>
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" />
/>
<i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-400"
/>
<button
v-if="searchQuery"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
type="button"
@click="clearSearch"
>
<i class="fas fa-times text-sm" />
@@ -51,10 +56,10 @@
</div>
<!-- 选项列表 -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div class="custom-scrollbar flex-1 overflow-y-auto">
<!-- 默认选项 -->
<div
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': !modelValue }"
@click="selectAccount(null)"
>
@@ -63,13 +68,11 @@
<!-- 分组选项 -->
<div v-if="filteredGroups.length > 0">
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
调度分组
</div>
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">调度分组</div>
<div
v-for="group in filteredGroups"
:key="`group:${group.id}`"
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': modelValue === `group:${group.id}` }"
@click="selectAccount(`group:${group.id}`)"
>
@@ -82,13 +85,13 @@
<!-- OAuth 账号 -->
<div v-if="filteredOAuthAccounts.length > 0">
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
</div>
<div
v-for="account in filteredOAuthAccounts"
:key="account.id"
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': modelValue === account.id }"
@click="selectAccount(account.id)"
>
@@ -96,8 +99,12 @@
<div>
<span class="text-gray-700">{{ account.name }}</span>
<span
class="ml-2 text-xs px-2 py-0.5 rounded-full"
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
"
>
{{ account.status === 'active' ? '正常' : '异常' }}
</span>
@@ -111,13 +118,13 @@
<!-- Console 账号 Claude -->
<div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0">
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">
Claude Console 专属账号
</div>
<div
v-for="account in filteredConsoleAccounts"
:key="account.id"
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': modelValue === `console:${account.id}` }"
@click="selectAccount(`console:${account.id}`)"
>
@@ -125,8 +132,12 @@
<div>
<span class="text-gray-700">{{ account.name }}</span>
<span
class="ml-2 text-xs px-2 py-0.5 rounded-full"
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
account.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
"
>
{{ account.status === 'active' ? '正常' : '异常' }}
</span>
@@ -140,7 +151,7 @@
<!-- 无搜索结果 -->
<div v-if="searchQuery && !hasResults" class="px-4 py-8 text-center text-gray-500">
<i class="fas fa-search text-2xl mb-2" />
<i class="fas fa-search mb-2 text-2xl" />
<p class="text-sm">没有找到匹配的账号</p>
</div>
</div>
@@ -199,23 +210,25 @@ const lastDirection = ref('') // 记住上次的显示方向
const selectedLabel = computed(() => {
// 如果没有选中值,显示默认选项文本
if (!props.modelValue) return props.defaultOptionText
// 分组
if (props.modelValue.startsWith('group:')) {
const groupId = props.modelValue.substring(6)
const group = props.groups.find(g => g.id === groupId)
const group = props.groups.find((g) => g.id === groupId)
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
}
// Console 账号
if (props.modelValue.startsWith('console:')) {
const accountId = props.modelValue.substring(8)
const account = props.accounts.find(a => a.id === accountId && a.platform === 'claude-console')
const account = props.accounts.find(
(a) => a.id === accountId && a.platform === 'claude-console'
)
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
}
// OAuth 账号
const account = props.accounts.find(a => a.id === props.modelValue)
const account = props.accounts.find((a) => a.id === props.modelValue)
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
})
@@ -232,51 +245,50 @@ const sortedAccounts = computed(() => {
const filteredGroups = computed(() => {
if (!searchQuery.value) return props.groups
const query = searchQuery.value.toLowerCase()
return props.groups.filter(group =>
group.name.toLowerCase().includes(query)
)
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
})
// 过滤的 OAuth 账号
const filteredOAuthAccounts = computed(() => {
let accounts = sortedAccounts.value.filter(a =>
a.accountType === 'dedicated' &&
(props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console')
let accounts = sortedAccounts.value.filter(
(a) =>
a.accountType === 'dedicated' &&
(props.platform === 'claude'
? a.platform === 'claude-oauth'
: a.platform !== 'claude-console')
)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter(account =>
account.name.toLowerCase().includes(query)
)
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 过滤的 Console 账号
const filteredConsoleAccounts = computed(() => {
if (props.platform !== 'claude') return []
let accounts = sortedAccounts.value.filter(a =>
a.accountType === 'dedicated' && a.platform === 'claude-console'
let accounts = sortedAccounts.value.filter(
(a) => a.accountType === 'dedicated' && a.platform === 'claude-console'
)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
accounts = accounts.filter(account =>
account.name.toLowerCase().includes(query)
)
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
}
return accounts
})
// 是否有搜索结果
const hasResults = computed(() => {
return filteredGroups.value.length > 0 ||
filteredOAuthAccounts.value.length > 0 ||
filteredConsoleAccounts.value.length > 0
return (
filteredGroups.value.length > 0 ||
filteredOAuthAccounts.value.length > 0 ||
filteredConsoleAccounts.value.length > 0
)
})
// 格式化日期
@@ -285,12 +297,13 @@ const formatDate = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diffInHours = (now - date) / (1000 * 60 * 60)
if (diffInHours < 24) {
return '今天创建'
} else if (diffInHours < 48) {
return '昨天创建'
} else if (diffInHours < 168) { // 7天内
} else if (diffInHours < 168) {
// 7天内
return `${Math.floor(diffInHours / 24)} 天前`
} else {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
@@ -300,28 +313,28 @@ const formatDate = (dateString) => {
// 更新下拉菜单位置
const updateDropdownPosition = () => {
if (!showDropdown.value || !dropdownRef.value || !triggerRef.value) return
const trigger = triggerRef.value
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const windowHeight = window.innerHeight
const windowWidth = window.innerWidth
const spaceBelow = windowHeight - rect.bottom
const spaceAbove = rect.top
const margin = 8 // 边距
// 获取下拉框的高度
const dropdownHeight = dropdownRef.value.offsetHeight
// const dropdownHeight = dropdownRef.value.offsetHeight
// 计算最大可用高度
const maxHeightBelow = spaceBelow - margin
const maxHeightAbove = spaceAbove - margin
// 决定显示方向和最大高度
let showAbove = false
let maxHeight = maxHeightBelow
// 优先使用上次的方向,除非空间不足
if (lastDirection.value === 'above' && maxHeightAbove >= 150) {
showAbove = true
@@ -336,10 +349,10 @@ const updateDropdownPosition = () => {
maxHeight = maxHeightAbove
}
}
// 记住这次的方向
lastDirection.value = showAbove ? 'above' : 'below'
// 确保下拉框不超出视窗左右边界
let left = rect.left
const dropdownWidth = rect.width
@@ -349,16 +362,13 @@ const updateDropdownPosition = () => {
if (left < margin) {
left = margin
}
dropdownStyle.value = {
position: 'fixed',
left: `${left}px`,
width: `${rect.width}px`,
maxHeight: `${Math.min(maxHeight, 400)}px`, // 限制最大高度为400px
...(showAbove
? { bottom: `${windowHeight - rect.top}px` }
: { top: `${rect.bottom}px` }
)
...(showAbove ? { bottom: `${windowHeight - rect.top}px` } : { top: `${rect.bottom}px` })
}
}
@@ -370,7 +380,7 @@ const toggleDropdown = () => {
const windowHeight = window.innerHeight
const spaceBelow = windowHeight - rect.bottom
const margin = 8
// 预先设置一个合理的初始位置
dropdownStyle.value = {
position: 'fixed',
@@ -465,4 +475,4 @@ watch(showDropdown, (newVal) => {
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: #a0aec0;
}
</style>
</style>

View File

@@ -1,47 +1,43 @@
<template>
<Teleport to="body">
<Transition
name="modal"
appear
>
<div
<Transition appear name="modal">
<div
v-if="isVisible"
class="fixed inset-0 modal z-[100] flex items-center justify-center p-4"
class="modal fixed inset-0 z-[100] flex items-center justify-center p-4"
@click.self="handleCancel"
>
<div class="modal-content w-full max-w-md p-6 mx-auto">
<div class="flex items-start gap-4 mb-6">
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-white text-lg" />
<div class="modal-content mx-auto w-full max-w-md p-6">
<div class="mb-6 flex items-start gap-4">
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-amber-600"
>
<i class="fas fa-exclamation-triangle text-lg text-white" />
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
<h3 class="mb-2 text-lg font-semibold text-gray-900">
{{ title }}
</h3>
<div class="text-gray-600 leading-relaxed whitespace-pre-line">
<div class="whitespace-pre-line leading-relaxed text-gray-600">
{{ message }}
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3">
<button
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
<button
class="btn bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200"
:disabled="isProcessing"
@click="handleCancel"
>
{{ cancelText }}
</button>
<button
<button
class="btn btn-warning px-6 py-3"
:class="{ 'opacity-50 cursor-not-allowed': isProcessing }"
:class="{ 'cursor-not-allowed opacity-50': isProcessing }"
:disabled="isProcessing"
@click="handleConfirm"
>
<div
v-if="isProcessing"
class="loading-spinner mr-2"
/>
<div v-if="isProcessing" class="loading-spinner mr-2" />
{{ confirmText }}
</button>
</div>
@@ -64,7 +60,12 @@ const cancelText = ref('取消')
let resolvePromise = null
// 显示确认对话框
const showConfirm = (titleText, messageText, confirmTextParam = '确认', cancelTextParam = '取消') => {
const showConfirm = (
titleText,
messageText,
confirmTextParam = '确认',
cancelTextParam = '取消'
) => {
return new Promise((resolve) => {
title.value = titleText
message.value = messageText
@@ -79,9 +80,9 @@ const showConfirm = (titleText, messageText, confirmTextParam = '确认', cancel
// 处理确认
const handleConfirm = () => {
if (isProcessing.value) return
isProcessing.value = true
// 延迟一点时间以显示loading状态
setTimeout(() => {
isVisible.value = false
@@ -96,7 +97,7 @@ const handleConfirm = () => {
// 处理取消
const handleCancel = () => {
if (isProcessing.value) return
isVisible.value = false
if (resolvePromise) {
resolvePromise(false)
@@ -107,7 +108,7 @@ const handleCancel = () => {
// 键盘事件处理
const handleKeydown = (event) => {
if (!isVisible.value) return
if (event.key === 'Escape') {
handleCancel()
} else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
@@ -150,7 +151,7 @@ defineExpose({
}
.btn {
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-danger {
@@ -162,7 +163,7 @@ defineExpose({
}
.loading-spinner {
@apply w-4 h-4 border-2 border-gray-300 border-t-white rounded-full animate-spin;
@apply h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-white;
}
/* Modal transitions */
@@ -204,4 +205,4 @@ defineExpose({
.modal-content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
</style>

View File

@@ -1,33 +1,32 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
>
<div class="modal-content w-full max-w-md p-6 mx-auto">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
<i class="fas fa-exclamation text-white text-xl" />
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="modal-content mx-auto w-full max-w-md p-6">
<div class="mb-6 flex items-start gap-4">
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-yellow-400 to-yellow-500"
>
<i class="fas fa-exclamation text-xl text-white" />
</div>
<div class="flex-1">
<h3 class="text-lg font-bold text-gray-900 mb-2">
<h3 class="mb-2 text-lg font-bold text-gray-900">
{{ title }}
</h3>
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">
<p class="whitespace-pre-line text-sm leading-relaxed text-gray-600">
{{ message }}
</p>
</div>
</div>
<div class="flex gap-3">
<button
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
<button
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200"
@click="$emit('cancel')"
>
{{ cancelText }}
</button>
<button
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
<button
class="flex-1 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-yellow-600 hover:to-orange-600"
@click="$emit('confirm')"
>
{{ confirmText }}
@@ -63,4 +62,4 @@ defineProps({
})
defineEmits(['confirm', 'cancel'])
</script>
</script>

View File

@@ -1,45 +1,35 @@
<template>
<div class="flex items-center gap-4">
<!-- Logo区域 -->
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-gray-300/30 bg-gradient-to-br from-blue-500/20 to-purple-500/20 backdrop-blur-sm"
>
<template v-if="!loading">
<img
v-if="logoSrc"
:src="logoSrc"
v-if="logoSrc"
alt="Logo"
class="w-8 h-8 object-contain"
class="h-8 w-8 object-contain"
:src="logoSrc"
@error="handleLogoError"
>
<i
v-else
class="fas fa-cloud text-xl text-gray-700"
/>
<i v-else class="fas fa-cloud text-xl text-gray-700" />
</template>
<div
v-else
class="w-8 h-8 bg-gray-300/50 rounded animate-pulse"
/>
<div v-else class="h-8 w-8 animate-pulse rounded bg-gray-300/50" />
</div>
<!-- 标题区域 -->
<div class="flex flex-col justify-center min-h-[48px]">
<div class="flex min-h-[48px] flex-col justify-center">
<div class="flex items-center gap-3">
<template v-if="!loading && title">
<h1 :class="['text-2xl font-bold header-title leading-tight', titleClass]">
<h1 :class="['header-title text-2xl font-bold leading-tight', titleClass]">
{{ title }}
</h1>
</template>
<div
v-else-if="loading"
class="h-8 w-64 bg-gray-300/50 rounded animate-pulse"
/>
<div v-else-if="loading" class="h-8 w-64 animate-pulse rounded bg-gray-300/50" />
<!-- 插槽用于版本信息等额外内容 -->
<slot name="after-title" />
</div>
<p
v-if="subtitle"
class="text-gray-600 text-sm leading-tight mt-0.5"
>
<p v-if="subtitle" class="mt-0.5 text-sm leading-tight text-gray-600">
{{ subtitle }}
</p>
</div>
@@ -98,4 +88,4 @@ const handleLogoError = (e) => {
.header-title {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
</style>
</style>

View File

@@ -2,16 +2,13 @@
<div class="stat-card">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-xs sm:text-sm font-medium text-gray-600 mb-1">
<p class="mb-1 text-xs font-medium text-gray-600 sm:text-sm">
{{ title }}
</p>
<p class="text-2xl sm:text-3xl font-bold text-gray-800">
<p class="text-2xl font-bold text-gray-800 sm:text-3xl">
{{ value }}
</p>
<p
v-if="subtitle"
class="text-xs sm:text-sm text-gray-500 mt-1.5 sm:mt-2"
>
<p v-if="subtitle" class="mt-1.5 text-xs text-gray-500 sm:mt-2 sm:text-sm">
{{ subtitle }}
</p>
</div>
@@ -62,4 +59,4 @@ const iconBgClass = computed(() => {
<style scoped>
/* 使用全局样式中定义的 .stat-card 和 .stat-icon 类 */
</style>
</style>

View File

@@ -4,11 +4,7 @@
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'toast',
`toast-${toast.type}`,
toast.isVisible ? 'toast-show' : 'toast-hide'
]"
:class="['toast', `toast-${toast.type}`, toast.isVisible ? 'toast-show' : 'toast-hide']"
@click="removeToast(toast.id)"
>
<div class="toast-content">
@@ -16,24 +12,18 @@
<i :class="getIconClass(toast.type)" />
</div>
<div class="toast-body">
<div
v-if="toast.title"
class="toast-title"
>
<div v-if="toast.title" class="toast-title">
{{ toast.title }}
</div>
<div class="toast-message">
{{ toast.message }}
</div>
</div>
<button
class="toast-close"
@click.stop="removeToast(toast.id)"
>
<button class="toast-close" @click.stop="removeToast(toast.id)">
<i class="fas fa-times" />
</button>
</div>
<div
<div
v-if="toast.duration > 0"
class="toast-progress"
:style="{ animationDuration: `${toast.duration}ms` }"
@@ -72,34 +62,34 @@ const addToast = (message, type = 'info', title = null, duration = 5000) => {
duration,
isVisible: false
}
toasts.value.push(toast)
// 下一帧显示动画
setTimeout(() => {
toast.isVisible = true
}, 10)
// 自动移除
if (duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
return id
}
// 移除Toast
const removeToast = (id) => {
const index = toasts.value.findIndex(toast => toast.id === id)
const index = toasts.value.findIndex((toast) => toast.id === id)
if (index > -1) {
const toast = toasts.value[index]
toast.isVisible = false
// 等待动画完成后移除
setTimeout(() => {
const currentIndex = toasts.value.findIndex(t => t.id === id)
const currentIndex = toasts.value.findIndex((t) => t.id === id)
if (currentIndex > -1) {
toasts.value.splice(currentIndex, 1)
}
@@ -109,10 +99,10 @@ const removeToast = (id) => {
// 清除所有Toast
const clearAllToasts = () => {
toasts.value.forEach(toast => {
toasts.value.forEach((toast) => {
toast.isVisible = false
})
setTimeout(() => {
toasts.value.length = 0
}, 300)
@@ -351,7 +341,7 @@ defineExpose({
right: 10px;
left: 10px;
}
.toast {
min-width: auto;
max-width: none;
@@ -377,4 +367,4 @@ defineExpose({
.toast-list-move {
transition: transform 0.3s ease;
}
</style>
</style>

View File

@@ -1,66 +1,45 @@
<template>
<div class="glass-strong rounded-3xl p-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<h2 class="flex items-center text-xl font-bold text-gray-800">
<i class="fas fa-robot mr-2 text-purple-500" />
模型使用分布
</h2>
<el-radio-group
v-model="modelPeriod"
size="small"
@change="handlePeriodChange"
>
<el-radio-button label="daily">
今日
</el-radio-button>
<el-radio-button label="total">
累计
</el-radio-button>
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
<el-radio-button label="daily"> 今日 </el-radio-button>
<el-radio-button label="total"> 累计 </el-radio-button>
</el-radio-group>
</div>
<div
v-if="dashboardStore.dashboardModelStats.length === 0"
class="text-center py-12 text-gray-500"
class="py-12 text-center text-gray-500"
>
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30" />
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
<p>暂无模型使用数据</p>
</div>
<div
v-else
class="grid grid-cols-1 lg:grid-cols-2 gap-6"
>
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 饼图 -->
<div
class="relative"
style="height: 300px;"
>
<div class="relative" style="height: 300px">
<canvas ref="chartCanvas" />
</div>
<!-- 数据列表 -->
<div class="space-y-3">
<div
v-for="(stat, index) in sortedStats"
:key="stat.model"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
:key="stat.model"
class="flex items-center justify-between rounded-lg bg-gray-50 p-3"
>
<div class="flex items-center gap-3">
<div
class="w-4 h-4 rounded"
:style="`background-color: ${getColor(index)}`"
/>
<div class="h-4 w-4 rounded" :style="`background-color: ${getColor(index)}`" />
<span class="font-medium text-gray-700">{{ stat.model }}</span>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800">
{{ formatNumber(stat.requests) }} 请求
</p>
<p class="text-sm text-gray-500">
{{ formatNumber(stat.totalTokens) }} tokens
</p>
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
</div>
</div>
</div>
@@ -93,23 +72,25 @@ const getColor = (index) => {
const createChart = () => {
if (!chartCanvas.value || !dashboardStore.dashboardModelStats.length) return
if (chart) {
chart.destroy()
}
const { colorSchemes } = useChartConfig()
const colors = colorSchemes.primary
chart = new Chart(chartCanvas.value, {
type: 'doughnut',
data: {
labels: sortedStats.value.map(stat => stat.model),
datasets: [{
data: sortedStats.value.map(stat => stat.requests),
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
borderWidth: 0
}]
labels: sortedStats.value.map((stat) => stat.model),
datasets: [
{
data: sortedStats.value.map((stat) => stat.requests),
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
borderWidth: 0
}
]
},
options: {
responsive: true,
@@ -120,9 +101,13 @@ const createChart = () => {
},
tooltip: {
callbacks: {
label: function(context) {
label: function (context) {
const stat = sortedStats.value[context.dataIndex]
const percentage = ((stat.requests / dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) * 100).toFixed(1)
const percentage = (
(stat.requests /
dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) *
100
).toFixed(1)
return [
`${stat.model}: ${percentage}%`,
`请求: ${formatNumber(stat.requests)}`,
@@ -141,9 +126,13 @@ const handlePeriodChange = async () => {
createChart()
}
watch(() => dashboardStore.dashboardModelStats, () => {
createChart()
}, { deep: true })
watch(
() => dashboardStore.dashboardModelStats,
() => {
createChart()
},
{ deep: true }
)
onMounted(() => {
createChart()
@@ -158,4 +147,4 @@ onUnmounted(() => {
<style scoped>
/* 组件特定样式 */
</style>
</style>

View File

@@ -1,25 +1,17 @@
<template>
<div class="glass-strong rounded-3xl p-6 mb-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<div class="glass-strong mb-8 rounded-3xl p-6">
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<h2 class="flex items-center text-xl font-bold text-gray-800">
<i class="fas fa-chart-area mr-2 text-blue-500" />
使用趋势
</h2>
<div class="flex items-center gap-3">
<el-radio-group
v-model="granularity"
size="small"
@change="handleGranularityChange"
>
<el-radio-button label="day">
按天
</el-radio-button>
<el-radio-button label="hour">
按小时
</el-radio-button>
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
<el-radio-button label="day"> 按天 </el-radio-button>
<el-radio-button label="hour"> 按小时 </el-radio-button>
</el-radio-group>
<el-select
v-model="trendPeriod"
size="small"
@@ -35,11 +27,8 @@
</el-select>
</div>
</div>
<div
class="relative"
style="height: 300px;"
>
<div class="relative" style="height: 300px">
<canvas ref="chartCanvas" />
</div>
</div>
@@ -66,15 +55,15 @@ const periodOptions = [
const createChart = () => {
if (!chartCanvas.value || !dashboardStore.trendData.length) return
if (chart) {
chart.destroy()
}
const { getGradient } = useChartConfig()
const ctx = chartCanvas.value.getContext('2d')
const labels = dashboardStore.trendData.map(item => {
const labels = dashboardStore.trendData.map((item) => {
if (granularity.value === 'hour') {
// 小时粒度使用hour字段
const date = new Date(item.hour)
@@ -85,7 +74,7 @@ const createChart = () => {
}
return item.date
})
chart = new Chart(ctx, {
type: 'line',
data: {
@@ -93,7 +82,7 @@ const createChart = () => {
datasets: [
{
label: '请求次数',
data: dashboardStore.trendData.map(item => item.requests),
data: dashboardStore.trendData.map((item) => item.requests),
borderColor: '#667eea',
backgroundColor: getGradient(ctx, '#667eea', 0.1),
yAxisID: 'y',
@@ -101,7 +90,7 @@ const createChart = () => {
},
{
label: 'Token使用量',
data: dashboardStore.trendData.map(item => item.tokens),
data: dashboardStore.trendData.map((item) => item.tokens),
borderColor: '#f093fb',
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
yAxisID: 'y1',
@@ -172,9 +161,13 @@ const handleGranularityChange = async () => {
createChart()
}
watch(() => dashboardStore.trendData, () => {
createChart()
}, { deep: true })
watch(
() => dashboardStore.trendData,
() => {
createChart()
},
{ deep: true }
)
onMounted(() => {
createChart()
@@ -189,4 +182,4 @@ onUnmounted(() => {
<style scoped>
/* 组件特定样式 */
</style>
</style>

View File

@@ -1,28 +1,32 @@
<template>
<!-- 顶部导航 -->
<div
class="glass-strong rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-6 mb-4 sm:mb-6 md:mb-8 shadow-xl"
style="z-index: 10; position: relative;"
class="glass-strong mb-4 rounded-xl p-3 shadow-xl sm:mb-6 sm:rounded-2xl sm:p-4 md:mb-8 md:rounded-3xl md:p-6"
style="z-index: 10; position: relative"
>
<div class="flex flex-col sm:flex-row justify-between items-center gap-3 sm:gap-4">
<div class="flex items-center gap-2 sm:gap-3 md:gap-4 w-full sm:w-auto justify-center sm:justify-start">
<LogoTitle
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row sm:gap-4">
<div
class="flex w-full items-center justify-center gap-2 sm:w-auto sm:justify-start sm:gap-3 md:gap-4"
>
<LogoTitle
:loading="oemLoading"
:title="oemSettings.siteName"
subtitle="管理后台"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
subtitle="管理后台"
:title="oemSettings.siteName"
title-class="text-white"
>
<template #after-title>
<!-- 版本信息 -->
<div class="flex items-center gap-1 sm:gap-2">
<span class="text-xs sm:text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
<span class="font-mono text-xs text-gray-400 sm:text-sm"
>v{{ versionInfo.current || '...' }}</span
>
<!-- 更新提示 -->
<a
v-if="versionInfo.hasUpdate"
<a
v-if="versionInfo.hasUpdate"
class="inline-flex animate-pulse items-center gap-1 rounded-full border border-green-600 bg-green-500 px-2 py-0.5 text-xs text-white transition-colors hover:bg-green-600"
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500 border border-green-600 rounded-full text-xs text-white hover:bg-green-600 transition-colors animate-pulse"
title="有新版本可用"
>
<i class="fas fa-arrow-up text-[10px]" />
@@ -33,9 +37,9 @@
</LogoTitle>
</div>
<!-- 用户菜单 -->
<div class="relative user-menu-container">
<button
class="btn btn-primary px-3 sm:px-4 py-2 sm:py-3 flex items-center gap-1 sm:gap-2 relative text-sm sm:text-base"
<div class="user-menu-container relative">
<button
class="btn btn-primary relative flex items-center gap-1 px-3 py-2 text-sm sm:gap-2 sm:px-4 sm:py-3 sm:text-base"
@click="userMenuOpen = !userMenuOpen"
>
<i class="fas fa-user-circle" />
@@ -45,34 +49,31 @@
:class="{ 'rotate-180': userMenuOpen }"
/>
</button>
<!-- 悬浮菜单 -->
<div
v-if="userMenuOpen"
class="absolute right-0 top-full mt-2 w-48 sm:w-56 bg-white rounded-xl shadow-xl border border-gray-200 py-2 user-menu-dropdown"
style="z-index: 999999;"
<div
v-if="userMenuOpen"
class="user-menu-dropdown absolute right-0 top-full mt-2 w-48 rounded-xl border border-gray-200 bg-white py-2 shadow-xl sm:w-56"
style="z-index: 999999"
@click.stop
>
<!-- 版本信息 -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="border-b border-gray-100 px-4 py-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">当前版本</span>
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
</div>
<div
v-if="versionInfo.hasUpdate"
class="mt-2"
>
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-green-600 font-medium">
<div v-if="versionInfo.hasUpdate" class="mt-2">
<div class="mb-2 flex items-center justify-between text-sm">
<span class="font-medium text-green-600">
<i class="fas fa-arrow-up mr-1" />有新版本
</span>
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
</div>
<a
<a
class="block w-full rounded-lg bg-green-500 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-green-600"
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank"
class="block w-full text-center px-3 py-1.5 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors"
>
<i class="fas fa-external-link-alt mr-1" />查看更新
</a>
@@ -83,28 +84,22 @@
>
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
</div>
<div
v-else
class="mt-2 text-center"
>
<div v-else class="mt-2 text-center">
<!-- 已是最新版提醒 -->
<transition
name="fade"
mode="out-in"
>
<transition mode="out-in" name="fade">
<div
v-if="versionInfo.noUpdateMessage"
key="message"
class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block"
class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5"
>
<p class="text-xs text-green-700 font-medium">
<p class="text-xs font-medium text-green-700">
<i class="fas fa-check-circle mr-1" />当前已是最新版本
</p>
</div>
<button
<button
v-else
key="button"
class="text-xs text-blue-500 hover:text-blue-700 transition-colors"
class="text-xs text-blue-500 transition-colors hover:text-blue-700"
@click="checkForUpdates()"
>
<i class="fas fa-sync-alt mr-1" />检查更新
@@ -112,19 +107,19 @@
</transition>
</div>
</div>
<button
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
<button
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50"
@click="openChangePasswordModal"
>
<i class="fas fa-key text-blue-500" />
<span>修改账户信息</span>
</button>
<hr class="my-2 border-gray-200">
<button
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
<hr class="my-2 border-gray-200" />
<button
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50"
@click="logout"
>
<i class="fas fa-sign-out-alt text-red-500" />
@@ -134,117 +129,105 @@
</div>
</div>
</div>
<!-- 修改账户信息模态框 -->
<div
v-if="showChangePasswordModal"
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4"
>
<div class="modal-content w-full max-w-md p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="modal-content mx-auto flex max-h-[90vh] w-full max-w-md flex-col p-4 sm:p-6 md:p-8">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600"
>
<i class="fas fa-key text-white" />
</div>
<h3 class="text-xl font-bold text-gray-900">
修改账户信息
</h3>
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3>
</div>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
<button
class="text-gray-400 transition-colors hover:text-gray-600"
@click="closeChangePasswordModal"
>
<i class="fas fa-times text-xl" />
</button>
</div>
<form
class="space-y-6 modal-scroll-content custom-scrollbar flex-1"
class="modal-scroll-content custom-scrollbar flex-1 space-y-6"
@submit.prevent="changePassword"
>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
<input
:value="currentUser.username || 'Admin'"
type="text"
<label class="mb-3 block text-sm font-semibold text-gray-700">当前用户名</label>
<input
class="form-input w-full cursor-not-allowed bg-gray-100"
disabled
class="form-input w-full bg-gray-100 cursor-not-allowed"
>
<p class="text-xs text-gray-500 mt-2">
当前用户名输入新用户名以修改
</p>
type="text"
:value="currentUser.username || 'Admin'"
/>
<p class="mt-2 text-xs text-gray-500">当前用户名输入新用户名以修改</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">新用户名</label>
<input
v-model="changePasswordForm.newUsername"
type="text"
<label class="mb-3 block text-sm font-semibold text-gray-700">新用户名</label>
<input
v-model="changePasswordForm.newUsername"
class="form-input w-full"
placeholder="输入新用户名(留空保持不变)"
>
<p class="text-xs text-gray-500 mt-2">
留空表示不修改用户名
</p>
type="text"
/>
<p class="mt-2 text-xs text-gray-500">留空表示不修改用户名</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">当前密码</label>
<input
v-model="changePasswordForm.currentPassword"
type="password"
required
<label class="mb-3 block text-sm font-semibold text-gray-700">当前密码</label>
<input
v-model="changePasswordForm.currentPassword"
class="form-input w-full"
placeholder="请输入当前密码"
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">新密码</label>
<input
v-model="changePasswordForm.newPassword"
type="password"
required
type="password"
/>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">新密码</label>
<input
v-model="changePasswordForm.newPassword"
class="form-input w-full"
placeholder="请输入新密码"
>
<p class="text-xs text-gray-500 mt-2">
密码长度至少8位
</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">确认新密码</label>
<input
v-model="changePasswordForm.confirmPassword"
type="password"
required
type="password"
/>
<p class="mt-2 text-xs text-gray-500">密码长度至少8位</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">确认新密码</label>
<input
v-model="changePasswordForm.confirmPassword"
class="form-input w-full"
placeholder="请再次输入新密码"
>
required
type="password"
/>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
type="button"
@click="closeChangePasswordModal"
>
取消
</button>
<button
type="submit"
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="changePasswordLoading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
type="submit"
>
<div
v-if="changePasswordLoading"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-save mr-2"
/>
<div v-if="changePasswordLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" />
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
</button>
</div>
@@ -300,30 +283,33 @@ const checkForUpdates = async () => {
if (versionInfo.value.checkingUpdate) {
return
}
versionInfo.value.checkingUpdate = true
try {
const result = await apiClient.get('/admin/check-updates')
if (result.success) {
const data = result.data
versionInfo.value.current = data.current
versionInfo.value.latest = data.latest
versionInfo.value.hasUpdate = data.hasUpdate
versionInfo.value.releaseInfo = data.releaseInfo
versionInfo.value.lastChecked = new Date()
// 保存到localStorage
localStorage.setItem('versionInfo', JSON.stringify({
current: data.current,
latest: data.latest,
lastChecked: versionInfo.value.lastChecked,
hasUpdate: data.hasUpdate,
releaseInfo: data.releaseInfo
}))
localStorage.setItem(
'versionInfo',
JSON.stringify({
current: data.current,
latest: data.latest,
lastChecked: versionInfo.value.lastChecked,
hasUpdate: data.hasUpdate,
releaseInfo: data.releaseInfo
})
)
// 如果没有更新,显示提醒
if (!data.hasUpdate) {
versionInfo.value.noUpdateMessage = true
@@ -335,7 +321,7 @@ const checkForUpdates = async () => {
}
} catch (error) {
console.error('Error checking for updates:', error)
// 尝试从localStorage读取缓存的版本信息
const cached = localStorage.getItem('versionInfo')
if (cached) {
@@ -372,26 +358,28 @@ const changePassword = async () => {
showToast('两次输入的密码不一致', 'error')
return
}
if (changePasswordForm.newPassword.length < 8) {
showToast('新密码长度至少8位', 'error')
return
}
changePasswordLoading.value = true
try {
const data = await apiClient.post('/web/auth/change-password', {
currentPassword: changePasswordForm.currentPassword,
newPassword: changePasswordForm.newPassword,
newUsername: changePasswordForm.newUsername || undefined
})
if (data.success) {
const message = changePasswordForm.newUsername ? '账户信息修改成功,请重新登录' : '密码修改成功,请重新登录'
const message = changePasswordForm.newUsername
? '账户信息修改成功,请重新登录'
: '密码修改成功,请重新登录'
showToast(message, 'success')
closeChangePasswordModal()
// 延迟后退出登录
setTimeout(() => {
authStore.logout()
@@ -427,12 +415,12 @@ const handleClickOutside = (event) => {
onMounted(() => {
checkForUpdates()
// 设置自动检查更新(每小时检查一次)
setInterval(() => {
checkForUpdates()
}, 3600000) // 1小时
document.addEventListener('click', handleClickOutside)
})
@@ -448,10 +436,12 @@ onUnmounted(() => {
}
/* fade过渡动画 */
.fade-enter-active, .fade-leave-active {
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
</style>

View File

@@ -2,25 +2,19 @@
<div class="min-h-screen p-3 sm:p-4 md:p-6">
<!-- 顶部导航 -->
<AppHeader />
<!-- 主内容区域 -->
<div
class="glass-strong rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-6 shadow-xl"
style="z-index: 1; min-height: calc(100vh - 120px);"
class="glass-strong rounded-xl p-3 shadow-xl sm:rounded-2xl sm:p-4 md:rounded-3xl md:p-6"
style="z-index: 1; min-height: calc(100vh - 120px)"
>
<!-- 标签栏 -->
<TabBar
:active-tab="activeTab"
@tab-change="handleTabChange"
/>
<TabBar :active-tab="activeTab" @tab-change="handleTabChange" />
<!-- 内容区域 -->
<div class="tab-content">
<router-view v-slot="{ Component }">
<transition
name="slide-up"
mode="out-in"
>
<transition mode="out-in" name="slide-up">
<keep-alive :include="['DashboardView', 'ApiKeysView']">
<component :is="Component" />
</keep-alive>
@@ -52,14 +46,16 @@ const tabRouteMap = {
}
// 监听路由变化,更新激活的标签
watch(() => route.path, (newPath) => {
const tabKey = Object.keys(tabRouteMap).find(
key => tabRouteMap[key] === newPath
)
if (tabKey) {
activeTab.value = tabKey
}
}, { immediate: true })
watch(
() => route.path,
(newPath) => {
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
if (tabKey) {
activeTab.value = tabKey
}
},
{ immediate: true }
)
// 处理标签切换
const handleTabChange = (tabKey) => {
@@ -72,4 +68,4 @@ const handleTabChange = (tabKey) => {
<style scoped>
/* 使用全局定义的过渡样式 */
</style>
</style>

View File

@@ -1,29 +1,25 @@
<template>
<div class="mb-4 sm:mb-6">
<!-- 移动端下拉选择器 -->
<div class="block sm:hidden bg-white/10 rounded-xl p-2 backdrop-blur-sm">
<select
<div class="block rounded-xl bg-white/10 p-2 backdrop-blur-sm sm:hidden">
<select
class="focus:ring-primary-color w-full rounded-lg bg-white/90 px-4 py-3 font-semibold text-gray-700 focus:outline-none focus:ring-2"
:value="activeTab"
class="w-full px-4 py-3 bg-white/90 rounded-lg text-gray-700 font-semibold focus:outline-none focus:ring-2 focus:ring-primary-color"
@change="$emit('tab-change', $event.target.value)"
>
<option
v-for="tab in tabs"
:key="tab.key"
:value="tab.key"
>
<option v-for="tab in tabs" :key="tab.key" :value="tab.key">
{{ tab.name }}
</option>
</select>
</div>
<!-- 桌面端标签栏 -->
<div class="hidden sm:flex flex-wrap gap-2 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
<button
v-for="tab in tabs"
<div class="hidden flex-wrap gap-2 rounded-2xl bg-white/10 p-2 backdrop-blur-sm sm:flex">
<button
v-for="tab in tabs"
:key="tab.key"
:class="[
'tab-btn flex-1 py-2 sm:py-3 px-3 sm:px-4 md:px-6 text-xs sm:text-sm font-semibold transition-all duration-300',
'tab-btn flex-1 px-3 py-2 text-xs font-semibold transition-all duration-300 sm:px-4 sm:py-3 sm:text-sm md:px-6',
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900'
]"
@click="$emit('tab-change', tab.key)"
@@ -57,4 +53,4 @@ const tabs = [
<style scoped>
/* 使用全局样式中定义的 .tab-btn 类 */
</style>
</style>