mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-05-02 04:38:36 +00:00
1
This commit is contained in:
@@ -3804,8 +3804,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { getModels } from '@/utils/http_apis'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
@@ -4110,20 +4111,20 @@ const allowedModels = ref([
|
||||
'claude-3-5-haiku-20241022'
|
||||
]) // 白名单模式下选中的模型列表
|
||||
|
||||
// 常用模型列表
|
||||
const commonModels = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', color: 'blue' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', color: 'blue' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', color: 'indigo' },
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', color: 'green' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', color: 'emerald' },
|
||||
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4', color: 'purple' },
|
||||
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', color: 'purple' },
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat', color: 'cyan' },
|
||||
{ value: 'Qwen', label: 'Qwen', color: 'orange' },
|
||||
{ value: 'Kimi', label: 'Kimi', color: 'pink' },
|
||||
{ value: 'GLM', label: 'GLM', color: 'teal' }
|
||||
]
|
||||
// 常用模型列表(从 API 获取)
|
||||
const commonModels = ref([])
|
||||
|
||||
// 加载模型列表
|
||||
const loadCommonModels = async () => {
|
||||
try {
|
||||
const result = await getModels()
|
||||
if (result.success && result.data?.all) {
|
||||
commonModels.value = result.data.all
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型映射表数据
|
||||
const modelMappings = ref([])
|
||||
@@ -4330,7 +4331,7 @@ const loadAccountUsage = async () => {
|
||||
if (!isEdit.value || !props.account?.id) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
|
||||
const response = await httpApi.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
|
||||
if (response) {
|
||||
// 更新表单中的使用量数据
|
||||
form.value.dailyUsage = response.dailyUsage || 0
|
||||
@@ -5733,7 +5734,7 @@ const filteredGroups = computed(() => {
|
||||
const loadGroups = async () => {
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
const response = await httpApi.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast('加载分组列表失败', 'error')
|
||||
@@ -6186,7 +6187,7 @@ watch(
|
||||
// 否则查找账户所属的分组
|
||||
const checkPromises = groups.value.map(async (group) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/account-groups/${group.id}/members`)
|
||||
const response = await httpApi.get(`/admin/account-groups/${group.id}/members`)
|
||||
const members = response.data || []
|
||||
if (members.some((m) => m.id === newAccount.id)) {
|
||||
foundGroupIds.push(group.id)
|
||||
@@ -6214,7 +6215,7 @@ watch(
|
||||
// 获取统一 User-Agent 信息
|
||||
const fetchUnifiedUserAgent = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/claude-code-version')
|
||||
const response = await httpApi.get('/admin/claude-code-version')
|
||||
if (response.success && response.userAgent) {
|
||||
unifiedUserAgent.value = response.userAgent
|
||||
} else {
|
||||
@@ -6230,7 +6231,7 @@ const fetchUnifiedUserAgent = async () => {
|
||||
const clearUnifiedCache = async () => {
|
||||
clearingCache.value = true
|
||||
try {
|
||||
const response = await apiClient.post('/admin/claude-code-version/clear')
|
||||
const response = await httpApi.post('/admin/claude-code-version/clear')
|
||||
if (response.success) {
|
||||
unifiedUserAgent.value = ''
|
||||
showToast('统一User-Agent缓存已清除', 'success')
|
||||
@@ -6336,6 +6337,9 @@ onMounted(() => {
|
||||
initModelMappings()
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
loadCommonModels()
|
||||
|
||||
// 获取Claude Code统一User-Agent信息
|
||||
fetchUnifiedUserAgent()
|
||||
// 如果是编辑模式且是Claude Console账户,加载使用情况
|
||||
|
||||
@@ -220,8 +220,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { API_PREFIX } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { API_PREFIX } from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
||||
@@ -70,7 +70,13 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
||||
<select
|
||||
v-model="selectedModel"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
:disabled="testStatus === 'testing'"
|
||||
>
|
||||
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +183,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { API_PREFIX } from '@/config/api'
|
||||
import { API_PREFIX } from '@/utils/http_apis'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -199,36 +205,112 @@ const errorMessage = ref('')
|
||||
const testDuration = ref(0)
|
||||
const testStartTime = ref(null)
|
||||
const eventSource = ref(null)
|
||||
const selectedModel = ref('')
|
||||
|
||||
// 测试模型
|
||||
const testModel = ref('claude-sonnet-4-5-20250929')
|
||||
// 可用模型列表 - 根据账户类型
|
||||
const availableModels = computed(() => {
|
||||
if (!props.account) return []
|
||||
const platform = props.account.platform
|
||||
const modelLists = {
|
||||
claude: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||
'claude-console': [
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-haiku-20241022'
|
||||
],
|
||||
bedrock: [
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-haiku-20241022'
|
||||
],
|
||||
gemini: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'],
|
||||
'openai-responses': ['gpt-4o-mini', 'gpt-4o', 'o3-mini'],
|
||||
'azure-openai': [props.account.deploymentName || 'gpt-4o-mini'],
|
||||
droid: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||
ccr: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022']
|
||||
}
|
||||
return modelLists[platform] || []
|
||||
})
|
||||
|
||||
// 默认测试模型
|
||||
const defaultModel = computed(() => {
|
||||
if (!props.account) return ''
|
||||
const platform = props.account.platform
|
||||
const models = {
|
||||
claude: 'claude-sonnet-4-5-20250929',
|
||||
'claude-console': 'claude-sonnet-4-5-20250929',
|
||||
bedrock: 'claude-sonnet-4-5-20250929',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
'openai-responses': 'gpt-4o-mini',
|
||||
'azure-openai': props.account.deploymentName || 'gpt-4o-mini',
|
||||
droid: 'claude-sonnet-4-20250514',
|
||||
ccr: 'claude-sonnet-4-20250514'
|
||||
}
|
||||
return models[platform] || ''
|
||||
})
|
||||
|
||||
// 监听账户变化,重置选中的模型
|
||||
watch(
|
||||
() => props.account,
|
||||
() => {
|
||||
selectedModel.value = defaultModel.value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 是否使用 SSE 流式响应
|
||||
const useSSE = computed(() => {
|
||||
if (!props.account) return false
|
||||
return ['claude', 'claude-console'].includes(props.account.platform)
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const platformLabel = computed(() => {
|
||||
if (!props.account) return '未知'
|
||||
const platform = props.account.platform
|
||||
if (platform === 'claude') return 'Claude OAuth'
|
||||
if (platform === 'claude-console') return 'Claude Console'
|
||||
return platform
|
||||
const labels = {
|
||||
claude: 'Claude OAuth',
|
||||
'claude-console': 'Claude Console',
|
||||
bedrock: 'AWS Bedrock',
|
||||
gemini: 'Gemini',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
droid: 'Droid',
|
||||
ccr: 'CCR'
|
||||
}
|
||||
return labels[platform] || platform
|
||||
})
|
||||
|
||||
const platformIcon = computed(() => {
|
||||
if (!props.account) return 'fas fa-question'
|
||||
const platform = props.account.platform
|
||||
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
|
||||
return 'fas fa-robot'
|
||||
const icons = {
|
||||
claude: 'fas fa-brain',
|
||||
'claude-console': 'fas fa-brain',
|
||||
bedrock: 'fab fa-aws',
|
||||
gemini: 'fas fa-gem',
|
||||
'openai-responses': 'fas fa-code',
|
||||
'azure-openai': 'fab fa-microsoft',
|
||||
droid: 'fas fa-robot',
|
||||
ccr: 'fas fa-key'
|
||||
}
|
||||
return icons[platform] || 'fas fa-robot'
|
||||
})
|
||||
|
||||
const platformBadgeClass = computed(() => {
|
||||
if (!props.account) return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
const platform = props.account.platform
|
||||
if (platform === 'claude') {
|
||||
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
const classes = {
|
||||
claude: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300',
|
||||
'claude-console': 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300',
|
||||
bedrock: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300',
|
||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300',
|
||||
'openai-responses': 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300',
|
||||
'azure-openai': 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300',
|
||||
droid: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300',
|
||||
ccr: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
|
||||
}
|
||||
if (platform === 'claude-console') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
return classes[platform] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
})
|
||||
|
||||
const statusTitle = computed(() => {
|
||||
@@ -247,15 +329,16 @@ const statusTitle = computed(() => {
|
||||
})
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
const apiName = platformLabel.value || 'API'
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return '点击下方按钮开始测试账户连通性'
|
||||
case 'testing':
|
||||
return '正在发送测试请求并等待响应'
|
||||
case 'success':
|
||||
return '账户可以正常访问 Claude API'
|
||||
return `账户可以正常访问 ${apiName}`
|
||||
case 'error':
|
||||
return errorMessage.value || '无法连接到 Claude API'
|
||||
return errorMessage.value || `无法连接到 ${apiName}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
@@ -340,13 +423,17 @@ const statusTextClass = computed(() => {
|
||||
function getTestEndpoint() {
|
||||
if (!props.account) return ''
|
||||
const platform = props.account.platform
|
||||
if (platform === 'claude') {
|
||||
return `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test`
|
||||
const endpoints = {
|
||||
claude: `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test`,
|
||||
'claude-console': `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`,
|
||||
bedrock: `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`,
|
||||
gemini: `${API_PREFIX}/admin/gemini-accounts/${props.account.id}/test`,
|
||||
'openai-responses': `${API_PREFIX}/admin/openai-responses-accounts/${props.account.id}/test`,
|
||||
'azure-openai': `${API_PREFIX}/admin/azure-openai-accounts/${props.account.id}/test`,
|
||||
droid: `${API_PREFIX}/admin/droid-accounts/${props.account.id}/test`,
|
||||
ccr: `${API_PREFIX}/admin/ccr-accounts/${props.account.id}/test`
|
||||
}
|
||||
if (platform === 'claude-console') {
|
||||
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
||||
}
|
||||
return ''
|
||||
return endpoints[platform] || ''
|
||||
}
|
||||
|
||||
async function startTest() {
|
||||
@@ -375,14 +462,14 @@ async function startTest() {
|
||||
// 获取认证token
|
||||
const authToken = localStorage.getItem('authToken')
|
||||
|
||||
// 使用fetch发送POST请求并处理SSE
|
||||
// 使用fetch发送POST请求
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||
},
|
||||
body: JSON.stringify({ model: testModel.value })
|
||||
body: JSON.stringify({ model: selectedModel.value })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -390,31 +477,46 @@ async function startTest() {
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
// 处理SSE流
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let streamDone = false
|
||||
// 根据账户类型处理响应
|
||||
if (useSSE.value) {
|
||||
// SSE 流式响应 (Claude/Console)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let streamDone = false
|
||||
|
||||
while (!streamDone) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
streamDone = true
|
||||
continue
|
||||
}
|
||||
while (!streamDone) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
streamDone = true
|
||||
continue
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6))
|
||||
handleSSEEvent(data)
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6))
|
||||
handleSSEEvent(data)
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON 响应 (其他平台)
|
||||
const data = await response.json()
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
|
||||
if (data.success) {
|
||||
testStatus.value = 'success'
|
||||
responseText.value = data.data?.responseText || 'Test passed'
|
||||
} else {
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = data.message || 'Test failed'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
testStatus.value = 'error'
|
||||
|
||||
@@ -381,13 +381,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
accountId: {
|
||||
@@ -417,6 +428,39 @@ const searchQuery = ref('')
|
||||
const searchMode = ref('fuzzy') // 'fuzzy' | 'exact'
|
||||
const batchDeleting = ref(false)
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
// 掩码显示 API Key(提前声明供 computed 使用)
|
||||
const maskApiKey = (key) => {
|
||||
if (!key || key.length < 12) {
|
||||
@@ -474,7 +518,7 @@ const errorKeysCount = computed(() => {
|
||||
const loadApiKeys = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/droid-accounts/${props.accountId}`)
|
||||
const response = await httpApi.get(`/admin/droid-accounts/${props.accountId}`)
|
||||
const account = response.data
|
||||
|
||||
// 解析 apiKeys
|
||||
@@ -549,7 +593,15 @@ const loadApiKeys = async () => {
|
||||
|
||||
// 删除 API Key
|
||||
const deleteApiKey = async (apiKey) => {
|
||||
if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
|
||||
if (
|
||||
!(await showConfirm(
|
||||
'删除 API Key',
|
||||
`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`,
|
||||
'删除',
|
||||
'取消',
|
||||
'danger'
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -561,7 +613,7 @@ const deleteApiKey = async (apiKey) => {
|
||||
apiKeyUpdateMode: 'delete'
|
||||
}
|
||||
|
||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
|
||||
showToast('API Key 已删除', 'success')
|
||||
await loadApiKeys()
|
||||
@@ -577,9 +629,13 @@ const deleteApiKey = async (apiKey) => {
|
||||
// 重置 API Key 状态
|
||||
const resetApiKeyStatus = async (apiKey) => {
|
||||
if (
|
||||
!confirm(
|
||||
`确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`
|
||||
)
|
||||
!(await showConfirm(
|
||||
'重置状态',
|
||||
`确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`,
|
||||
'重置',
|
||||
'取消',
|
||||
'warning'
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -598,7 +654,7 @@ const resetApiKeyStatus = async (apiKey) => {
|
||||
apiKeyUpdateMode: 'update'
|
||||
}
|
||||
|
||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
|
||||
showToast('API Key 状态已重置', 'success')
|
||||
await loadApiKeys()
|
||||
@@ -619,7 +675,15 @@ const deleteAllErrorKeys = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除所有 ${errorKeys.length} 个异常状态的 API Key 吗?此操作不可恢复!`)) {
|
||||
if (
|
||||
!(await showConfirm(
|
||||
'删除异常 API Key',
|
||||
`确定要删除所有 ${errorKeys.length} 个异常状态的 API Key 吗?此操作不可恢复!`,
|
||||
'删除',
|
||||
'取消',
|
||||
'danger'
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -631,7 +695,7 @@ const deleteAllErrorKeys = async () => {
|
||||
apiKeyUpdateMode: 'delete'
|
||||
}
|
||||
|
||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
|
||||
showToast(`成功删除 ${errorKeys.length} 个异常 API Key`, 'success')
|
||||
await loadApiKeys()
|
||||
@@ -652,15 +716,21 @@ const deleteAllKeys = async () => {
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`确定要删除所有 ${apiKeys.value.length} 个 API Key 吗?此操作不可恢复!\n\n请再次确认:这将删除该账户下的所有 API Key。`
|
||||
)
|
||||
!(await showConfirm(
|
||||
'删除全部 API Key',
|
||||
`确定要删除所有 ${apiKeys.value.length} 个 API Key 吗?此操作不可恢复!\n\n请再次确认:这将删除该账户下的所有 API Key。`,
|
||||
'删除',
|
||||
'取消',
|
||||
'danger'
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 二次确认
|
||||
if (!confirm('最后确认:真的要删除所有 API Key 吗?')) {
|
||||
if (
|
||||
!(await showConfirm('最后确认', '真的要删除所有 API Key 吗?', '确认删除', '取消', 'danger'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -672,7 +742,7 @@ const deleteAllKeys = async () => {
|
||||
apiKeyUpdateMode: 'delete'
|
||||
}
|
||||
|
||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||
|
||||
showToast(`成功删除所有 ${keysToDelete.length} 个 API Key`, 'success')
|
||||
await loadApiKeys()
|
||||
|
||||
@@ -259,8 +259,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -344,7 +344,7 @@ const submit = async () => {
|
||||
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
|
||||
updates.apiKey = form.value.apiKey
|
||||
}
|
||||
const res = await apiClient.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
||||
const res = await httpApi.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
||||
if (res.success) {
|
||||
// 不在这里显示 toast,由父组件统一处理
|
||||
emit('success')
|
||||
@@ -367,7 +367,7 @@ const submit = async () => {
|
||||
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||
quotaResetTime: form.value.quotaResetTime || '00:00'
|
||||
}
|
||||
const res = await apiClient.post('/admin/ccr-accounts', payload)
|
||||
const res = await httpApi.post('/admin/ccr-accounts', payload)
|
||||
if (res.success) {
|
||||
// 不在这里显示 toast,由父组件统一处理
|
||||
emit('success')
|
||||
|
||||
@@ -21,72 +21,36 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 添加分组按钮 -->
|
||||
<div class="mb-6">
|
||||
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
|
||||
<i class="fas fa-plus mr-2" />
|
||||
创建新分组
|
||||
<!-- Tab 切换栏 -->
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tab in platformTabs"
|
||||
:key="tab.key"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-all',
|
||||
activeTab === tab.key
|
||||
? tab.key === 'claude'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: tab.key === 'gemini'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: tab.key === 'droid'
|
||||
? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300'
|
||||
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="ml-1 text-xs opacity-70">({{ platformCounts[tab.key] }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建分组表单 -->
|
||||
<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="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
|
||||
<div class="flex gap-4">
|
||||
<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 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>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
|
||||
<span class="text-sm text-gray-700">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="droid" />
|
||||
<span class="text-sm text-gray-700">Droid</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
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" />
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 添加分组按钮 -->
|
||||
<div class="mb-6">
|
||||
<button class="btn btn-primary px-4 py-2" @click="openCreateForm">
|
||||
<i class="fas fa-plus mr-2" />
|
||||
创建新分组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 分组列表 -->
|
||||
@@ -96,14 +60,17 @@
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-else-if="filteredGroups.length === 0"
|
||||
class="rounded-lg bg-gray-50 py-8 text-center dark:bg-gray-800"
|
||||
>
|
||||
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300 dark:text-gray-600" />
|
||||
<p class="text-gray-500 dark:text-gray-400">暂无分组</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
class="rounded-lg border bg-white p-4 transition-shadow hover:shadow-md"
|
||||
>
|
||||
@@ -239,13 +206,106 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建分组模态框 -->
|
||||
<div
|
||||
v-if="showCreateForm"
|
||||
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-lg p-4 sm:p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">创建新分组</h3>
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||
@click="cancelCreate"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>分组名称 *</label
|
||||
>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>平台类型 *</label
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<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 dark:text-gray-300">Claude</span>
|
||||
</label>
|
||||
<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 dark:text-gray-300">Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="droid" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>描述 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-4 py-2"
|
||||
:disabled="!createForm.name || !createForm.platform || creating"
|
||||
@click="createGroup"
|
||||
>
|
||||
<div v-if="creating" class="loading-spinner mr-2" />
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelCreate">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<ConfirmModal
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
:message="`确定要删除分组 "${deletingGroup?.name}" 吗?此操作不可撤销。`"
|
||||
:show="showDeleteConfirm"
|
||||
title="确认删除"
|
||||
type="danger"
|
||||
@cancel="cancelDelete"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'refresh'])
|
||||
|
||||
@@ -253,6 +313,35 @@ const show = ref(true)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
|
||||
// Tab 切换
|
||||
const activeTab = ref('all')
|
||||
const platformTabs = [
|
||||
{ key: 'all', label: '全部', color: 'gray' },
|
||||
{ key: 'claude', label: 'Claude', color: 'purple' },
|
||||
{ key: 'gemini', label: 'Gemini', color: 'blue' },
|
||||
{ key: 'openai', label: 'OpenAI', color: 'gray' },
|
||||
{ key: 'droid', label: 'Droid', color: 'cyan' }
|
||||
]
|
||||
|
||||
// 各平台分组数量
|
||||
const platformCounts = computed(() => {
|
||||
const counts = { all: groups.value.length }
|
||||
platformTabs.slice(1).forEach((tab) => {
|
||||
counts[tab.key] = groups.value.filter((g) => g.platform === tab.key).length
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
// 过滤后的分组列表
|
||||
const filteredGroups = computed(() => {
|
||||
if (activeTab.value === 'all') return groups.value
|
||||
return groups.value.filter((g) => g.platform === activeTab.value)
|
||||
})
|
||||
|
||||
// 删除确认
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingGroup = ref(null)
|
||||
|
||||
// 创建表单
|
||||
const showCreateForm = ref(false)
|
||||
const creating = ref(false)
|
||||
@@ -283,7 +372,7 @@ const formatDate = (dateStr) => {
|
||||
const loadGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
const response = await httpApi.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast('加载分组列表失败', 'error')
|
||||
@@ -301,7 +390,7 @@ const createGroup = async () => {
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await apiClient.post('/admin/account-groups', {
|
||||
await httpApi.post('/admin/account-groups', {
|
||||
name: createForm.value.name,
|
||||
platform: createForm.value.platform,
|
||||
description: createForm.value.description
|
||||
@@ -318,6 +407,12 @@ const createGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开创建表单(根据当前 Tab 预选平台)
|
||||
const openCreateForm = () => {
|
||||
createForm.value.platform = activeTab.value !== 'all' ? activeTab.value : 'claude'
|
||||
showCreateForm.value = true
|
||||
}
|
||||
|
||||
// 取消创建
|
||||
const cancelCreate = () => {
|
||||
showCreateForm.value = false
|
||||
@@ -348,7 +443,7 @@ const updateGroup = async () => {
|
||||
|
||||
updating.value = true
|
||||
try {
|
||||
await apiClient.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
||||
await httpApi.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description
|
||||
})
|
||||
@@ -375,20 +470,23 @@ const cancelEdit = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
const deleteGroup = async (group) => {
|
||||
// 删除分组 - 打开确认对话框
|
||||
const deleteGroup = (group) => {
|
||||
if (group.memberCount > 0) {
|
||||
showToast('分组内还有成员,无法删除', 'error')
|
||||
return
|
||||
}
|
||||
deletingGroup.value = group
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGroup.value) return
|
||||
try {
|
||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
||||
await httpApi.del(`/admin/account-groups/${deletingGroup.value.id}`)
|
||||
showToast('分组删除成功', 'success')
|
||||
cancelDelete()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
@@ -396,6 +494,12 @@ const deleteGroup = async (group) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 取消删除
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirm.value = false
|
||||
deletingGroup.value = null
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
|
||||
@@ -794,7 +794,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -192,8 +192,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -221,7 +221,7 @@ const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await apiClient.patch(`/users/${props.user.id}/role`, {
|
||||
const response = await httpApi.patch(`/users/${props.user.id}/role`, {
|
||||
role: selectedRole.value
|
||||
})
|
||||
|
||||
|
||||
@@ -347,8 +347,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -394,10 +394,10 @@ const loadUsageStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [statsResponse, userResponse] = await Promise.all([
|
||||
apiClient.get(`/users/${props.user.id}/usage-stats`, {
|
||||
httpApi.get(`/users/${props.user.id}/usage-stats`, {
|
||||
params: { period: selectedPeriod.value }
|
||||
}),
|
||||
apiClient.get(`/users/${props.user.id}`)
|
||||
httpApi.get(`/users/${props.user.id}`)
|
||||
])
|
||||
|
||||
if (statsResponse.success) {
|
||||
|
||||
@@ -85,19 +85,59 @@
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
|
||||
>
|
||||
<i class="fas fa-link" />
|
||||
/api/v1/messages
|
||||
{{ serviceConfig.displayEndpoint }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<select
|
||||
v-model="testModel"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<option v-for="model in availableModels" :key="model.value" :value="model.value">
|
||||
{{ model.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ testModel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">最大输出 Token</span>
|
||||
<select
|
||||
v-model="maxTokens"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<option v-for="opt in maxTokensOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">模拟客户端</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Claude Code</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">测试服务</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
serviceConfig.name
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词输入 -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
提示词
|
||||
</label>
|
||||
<textarea
|
||||
v-model="testPrompt"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
placeholder="输入测试提示词..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -200,8 +240,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { API_PREFIX } from '@/config/api'
|
||||
import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
|
||||
import { API_PREFIX } from '@/utils/http_apis'
|
||||
import { getModels } from '@/utils/http_apis'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -217,6 +258,12 @@ const props = defineProps({
|
||||
apiKeyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 服务类型: claude, gemini, openai
|
||||
serviceType: {
|
||||
type: String,
|
||||
default: 'claude',
|
||||
validator: (value) => ['claude', 'gemini', 'openai'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -233,6 +280,77 @@ const abortController = ref(null)
|
||||
// 测试模型
|
||||
const testModel = ref('claude-sonnet-4-5-20250929')
|
||||
|
||||
// 测试提示词
|
||||
const testPrompt = ref('hi')
|
||||
|
||||
// 最大输出 token
|
||||
const maxTokens = ref(1000)
|
||||
const maxTokensOptions = [
|
||||
{ value: 100, label: '100' },
|
||||
{ value: 500, label: '500' },
|
||||
{ value: 1000, label: '1000' },
|
||||
{ value: 2000, label: '2000' },
|
||||
{ value: 4096, label: '4096' }
|
||||
]
|
||||
|
||||
// 从 API 获取的模型列表
|
||||
const modelsFromApi = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: []
|
||||
})
|
||||
|
||||
// 加载模型列表
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const result = await getModels()
|
||||
if (result.success && result.data) {
|
||||
modelsFromApi.value = {
|
||||
claude: result.data.claude || [],
|
||||
gemini: result.data.gemini || [],
|
||||
openai: result.data.openai || []
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务配置
|
||||
const serviceConfig = computed(() => {
|
||||
const configs = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
endpoint: '/api-key/test',
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
displayEndpoint: '/api/v1/messages'
|
||||
},
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
endpoint: '/api-key/test-gemini',
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
displayEndpoint: '/gemini/v1/models/:model:streamGenerateContent'
|
||||
},
|
||||
openai: {
|
||||
name: 'OpenAI (Codex)',
|
||||
endpoint: '/api-key/test-openai',
|
||||
defaultModel: 'gpt-5',
|
||||
displayEndpoint: '/openai/responses'
|
||||
}
|
||||
}
|
||||
return configs[props.serviceType] || configs.claude
|
||||
})
|
||||
|
||||
// 可用模型列表(从 API 获取)
|
||||
const availableModels = computed(() => {
|
||||
return modelsFromApi.value[props.serviceType] || []
|
||||
})
|
||||
|
||||
// 组件挂载时加载模型
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const displayName = computed(() => {
|
||||
return props.apiKeyName || '当前 API Key'
|
||||
@@ -370,7 +488,7 @@ async function startTest() {
|
||||
|
||||
// 使用公开的测试端点,不需要管理员认证
|
||||
// apiStats 路由挂载在 /apiStats 下
|
||||
const endpoint = `${API_PREFIX}/apiStats/api-key/test`
|
||||
const endpoint = `${API_PREFIX}/apiStats${serviceConfig.value.endpoint}`
|
||||
|
||||
try {
|
||||
// 使用fetch发送POST请求并处理SSE
|
||||
@@ -381,7 +499,9 @@ async function startTest() {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: props.apiKeyValue,
|
||||
model: testModel.value
|
||||
model: testModel.value,
|
||||
prompt: testPrompt.value,
|
||||
maxTokens: maxTokens.value
|
||||
}),
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
@@ -483,10 +603,23 @@ watch(
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
// 重置为当前服务的默认模型
|
||||
testModel.value = serviceConfig.value.defaultModel
|
||||
// 重置提示词和 maxTokens
|
||||
testPrompt.value = 'hi'
|
||||
maxTokens.value = 1000
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听服务类型变化,重置模型
|
||||
watch(
|
||||
() => props.serviceType,
|
||||
() => {
|
||||
testModel.value = serviceConfig.value.defaultModel
|
||||
}
|
||||
)
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (abortController.value) {
|
||||
|
||||
@@ -172,12 +172,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
apiKeys: {
|
||||
@@ -190,6 +203,39 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const showPreview = ref(false)
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
// 获取基础名称
|
||||
const baseName = computed(() => {
|
||||
if (props.apiKeys.length > 0) {
|
||||
@@ -282,45 +328,29 @@ const downloadApiKeys = () => {
|
||||
|
||||
// 关闭弹窗(带确认)
|
||||
const handleClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'返回下载'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'返回下载',
|
||||
'warning'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// 直接关闭(不带确认)
|
||||
const handleDirectClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
'确定要关闭吗?',
|
||||
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
|
||||
'仍然关闭',
|
||||
'返回下载'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm('您还没有下载 API Keys,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'确定要关闭吗?',
|
||||
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
|
||||
'仍然关闭',
|
||||
'返回下载',
|
||||
'warning'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -446,9 +446,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -588,15 +588,15 @@ const refreshAccounts = async () => {
|
||||
droidData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'),
|
||||
apiClient.get('/admin/droid-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
httpApi.get('/admin/claude-accounts'),
|
||||
httpApi.get('/admin/claude-console-accounts'),
|
||||
httpApi.get('/admin/gemini-accounts'),
|
||||
httpApi.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
||||
httpApi.get('/admin/openai-accounts'),
|
||||
httpApi.get('/admin/openai-responses-accounts'),
|
||||
httpApi.get('/admin/bedrock-accounts'),
|
||||
httpApi.get('/admin/droid-accounts'),
|
||||
httpApi.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -801,7 +801,7 @@ const batchUpdateApiKeys = async () => {
|
||||
updates.tagOperation = tagOperation.value
|
||||
}
|
||||
|
||||
const result = await apiClient.put('/admin/api-keys/batch', {
|
||||
const result = await httpApi.put('/admin/api-keys/batch', {
|
||||
keyIds: props.selectedKeys,
|
||||
updates
|
||||
})
|
||||
|
||||
@@ -579,55 +579,59 @@
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
:checked="form.permissions === 'all'"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="all"
|
||||
type="checkbox"
|
||||
@change="toggleAllServices"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="claude"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="gemini"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="openai"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="droid"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
控制此 API Key 可以访问哪些服务
|
||||
控制此 API Key 可以访问哪些服务,可多选
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -662,7 +666,7 @@
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
||||
:disabled="!isServiceEnabled('claude')"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
@@ -676,7 +680,7 @@
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
||||
:disabled="!isServiceEnabled('gemini')"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
@@ -690,7 +694,7 @@
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="!isServiceEnabled('openai')"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
@@ -704,7 +708,7 @@
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="!isServiceEnabled('claude')"
|
||||
:groups="[]"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
@@ -718,7 +722,7 @@
|
||||
v-model="form.droidAccountId"
|
||||
:accounts="localAccounts.droid"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
||||
:disabled="!isServiceEnabled('droid')"
|
||||
:groups="localAccounts.droidGroups"
|
||||
placeholder="请选择Droid账号"
|
||||
platform="droid"
|
||||
@@ -884,16 +888,29 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
accounts: {
|
||||
@@ -918,6 +935,40 @@ const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
const localAccounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
@@ -980,6 +1031,39 @@ const form = reactive({
|
||||
tags: []
|
||||
})
|
||||
|
||||
// 多选服务
|
||||
const allServices = ['claude', 'gemini', 'openai', 'droid']
|
||||
const selectedServices = ref([...allServices])
|
||||
|
||||
// 切换全部服务
|
||||
const toggleAllServices = (event) => {
|
||||
if (event.target.checked) {
|
||||
selectedServices.value = [...allServices]
|
||||
form.permissions = 'all'
|
||||
} else {
|
||||
selectedServices.value = []
|
||||
form.permissions = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 更新权限
|
||||
const updatePermissions = () => {
|
||||
if (selectedServices.value.length === allServices.length) {
|
||||
form.permissions = 'all'
|
||||
} else if (selectedServices.value.length === 1) {
|
||||
form.permissions = selectedServices.value[0]
|
||||
} else if (selectedServices.value.length > 1) {
|
||||
form.permissions = selectedServices.value.join(',')
|
||||
} else {
|
||||
form.permissions = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 检查服务是否启用
|
||||
const isServiceEnabled = (service) => {
|
||||
return form.permissions === 'all' || selectedServices.value.includes(service)
|
||||
}
|
||||
|
||||
// 加载支持的客户端和已存在的标签
|
||||
onMounted(async () => {
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
@@ -1046,15 +1130,15 @@ const refreshAccounts = async () => {
|
||||
droidData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'),
|
||||
apiClient.get('/admin/droid-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
httpApi.get('/admin/claude-accounts'),
|
||||
httpApi.get('/admin/claude-console-accounts'),
|
||||
httpApi.get('/admin/gemini-accounts'),
|
||||
httpApi.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
||||
httpApi.get('/admin/openai-accounts'),
|
||||
httpApi.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
httpApi.get('/admin/bedrock-accounts'),
|
||||
httpApi.get('/admin/droid-accounts'),
|
||||
httpApi.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -1331,18 +1415,13 @@ const createApiKey = async () => {
|
||||
|
||||
// 检查是否设置了时间窗口但费用限制为0
|
||||
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续创建',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续创建',
|
||||
'返回修改',
|
||||
'warning'
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
@@ -1435,7 +1514,7 @@ const createApiKey = async () => {
|
||||
name: form.name
|
||||
}
|
||||
|
||||
const result = await apiClient.post('/admin/api-keys', data)
|
||||
const result = await httpApi.post('/admin/api-keys', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 创建成功', 'success')
|
||||
@@ -1453,7 +1532,7 @@ const createApiKey = async () => {
|
||||
count: form.batchCount
|
||||
}
|
||||
|
||||
const result = await apiClient.post('/admin/api-keys/batch', data)
|
||||
const result = await httpApi.post('/admin/api-keys/batch', data)
|
||||
|
||||
if (result.success) {
|
||||
showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
|
||||
|
||||
@@ -412,55 +412,59 @@
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>服务权限</label
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
:checked="form.permissions === 'all'"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="all"
|
||||
type="checkbox"
|
||||
@change="toggleAllServices"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">全部服务</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="claude"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="gemini"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="openai"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
v-model="selectedServices"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
value="droid"
|
||||
@change="updatePermissions"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
控制此 API Key 可以访问哪些服务
|
||||
控制此 API Key 可以访问哪些服务,可多选
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -495,7 +499,7 @@
|
||||
v-model="form.claudeAccountId"
|
||||
:accounts="localAccounts.claude"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
||||
:disabled="!isServiceEnabled('claude')"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
@@ -509,7 +513,7 @@
|
||||
v-model="form.geminiAccountId"
|
||||
:accounts="localAccounts.gemini"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
||||
:disabled="!isServiceEnabled('gemini')"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
@@ -523,7 +527,7 @@
|
||||
v-model="form.openaiAccountId"
|
||||
:accounts="localAccounts.openai"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="!isServiceEnabled('openai')"
|
||||
:groups="localAccounts.openaiGroups"
|
||||
placeholder="请选择OpenAI账号"
|
||||
platform="openai"
|
||||
@@ -537,7 +541,7 @@
|
||||
v-model="form.bedrockAccountId"
|
||||
:accounts="localAccounts.bedrock"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||
:disabled="!isServiceEnabled('claude')"
|
||||
:groups="[]"
|
||||
placeholder="请选择Bedrock账号"
|
||||
platform="bedrock"
|
||||
@@ -551,7 +555,7 @@
|
||||
v-model="form.droidAccountId"
|
||||
:accounts="localAccounts.droid"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
||||
:disabled="!isServiceEnabled('droid')"
|
||||
:groups="localAccounts.droidGroups"
|
||||
placeholder="请选择Droid账号"
|
||||
platform="droid"
|
||||
@@ -722,16 +726,29 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
@@ -762,6 +779,40 @@ const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
const localAccounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
@@ -816,6 +867,50 @@ const form = reactive({
|
||||
ownerId: '' // 新增:所有者ID
|
||||
})
|
||||
|
||||
// 多选服务
|
||||
const allServices = ['claude', 'gemini', 'openai', 'droid']
|
||||
const selectedServices = ref([...allServices])
|
||||
|
||||
// 切换全部服务
|
||||
const toggleAllServices = (event) => {
|
||||
if (event.target.checked) {
|
||||
selectedServices.value = [...allServices]
|
||||
form.permissions = 'all'
|
||||
} else {
|
||||
selectedServices.value = []
|
||||
form.permissions = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 更新权限
|
||||
const updatePermissions = () => {
|
||||
if (selectedServices.value.length === allServices.length) {
|
||||
form.permissions = 'all'
|
||||
} else if (selectedServices.value.length === 1) {
|
||||
form.permissions = selectedServices.value[0]
|
||||
} else if (selectedServices.value.length > 1) {
|
||||
form.permissions = selectedServices.value.join(',')
|
||||
} else {
|
||||
form.permissions = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 检查服务是否启用
|
||||
const isServiceEnabled = (service) => {
|
||||
return form.permissions === 'all' || selectedServices.value.includes(service)
|
||||
}
|
||||
|
||||
// 根据 permissions 初始化 selectedServices
|
||||
const initSelectedServices = (permissions) => {
|
||||
if (permissions === 'all') {
|
||||
selectedServices.value = [...allServices]
|
||||
} else if (permissions) {
|
||||
selectedServices.value = permissions.split(',').filter((s) => allServices.includes(s))
|
||||
} else {
|
||||
selectedServices.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 添加限制的模型
|
||||
const addRestrictedModel = () => {
|
||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||
@@ -869,18 +964,13 @@ const removeTag = (index) => {
|
||||
const updateApiKey = async () => {
|
||||
// 检查是否设置了时间窗口但费用限制为0
|
||||
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
||||
let confirmed = false
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续保存',
|
||||
'返回修改'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'费用限制提醒',
|
||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||
'继续保存',
|
||||
'返回修改',
|
||||
'warning'
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
@@ -989,7 +1079,7 @@ const updateApiKey = async () => {
|
||||
data.ownerId = form.ownerId
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
const result = await httpApi.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
emit('success')
|
||||
@@ -1019,15 +1109,15 @@ const refreshAccounts = async () => {
|
||||
droidData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/gemini-api-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'),
|
||||
apiClient.get('/admin/droid-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
httpApi.get('/admin/claude-accounts'),
|
||||
httpApi.get('/admin/claude-console-accounts'),
|
||||
httpApi.get('/admin/gemini-accounts'),
|
||||
httpApi.get('/admin/gemini-api-accounts'),
|
||||
httpApi.get('/admin/openai-accounts'),
|
||||
httpApi.get('/admin/openai-responses-accounts'),
|
||||
httpApi.get('/admin/bedrock-accounts'),
|
||||
httpApi.get('/admin/droid-accounts'),
|
||||
httpApi.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -1140,7 +1230,7 @@ const refreshAccounts = async () => {
|
||||
// 加载用户列表
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/users')
|
||||
const response = await httpApi.get('/admin/users')
|
||||
if (response.success) {
|
||||
availableUsers.value = response.data || []
|
||||
}
|
||||
@@ -1242,6 +1332,7 @@ onMounted(async () => {
|
||||
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||
form.permissions = props.apiKey.permissions || 'all'
|
||||
initSelectedServices(form.permissions)
|
||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||
if (props.apiKey.claudeConsoleAccountId) {
|
||||
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
||||
|
||||
@@ -211,11 +211,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -232,6 +245,39 @@ const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
// 表单数据
|
||||
const localForm = reactive({
|
||||
expireDuration: '',
|
||||
@@ -401,21 +447,13 @@ const handleSave = () => {
|
||||
|
||||
// 立即激活
|
||||
const handleActivateNow = async () => {
|
||||
// 使用确认弹窗
|
||||
let confirmed = true
|
||||
if (window.showConfirm) {
|
||||
confirmed = await window.showConfirm(
|
||||
'激活 API Key',
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`,
|
||||
'确定激活',
|
||||
'取消'
|
||||
)
|
||||
} else {
|
||||
// 降级方案
|
||||
confirmed = confirm(
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`
|
||||
)
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'激活 API Key',
|
||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`,
|
||||
'确定激活',
|
||||
'取消',
|
||||
'warning'
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
|
||||
@@ -126,12 +126,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
@@ -144,6 +157,39 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const showFullKey = ref(false)
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
// 获取 API Base URL 前缀
|
||||
const getBaseUrlPrefix = () => {
|
||||
// 优先使用环境变量配置的自定义前缀
|
||||
@@ -249,45 +295,29 @@ const copyKeyOnly = async () => {
|
||||
|
||||
// 关闭弹窗(带确认)
|
||||
const handleClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'取消'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'关闭提醒',
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||
'确定关闭',
|
||||
'取消',
|
||||
'warning'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// 直接关闭(不带确认)
|
||||
const handleDirectClose = async () => {
|
||||
if (window.showConfirm) {
|
||||
const confirmed = await window.showConfirm(
|
||||
'确定要关闭吗?',
|
||||
'您还没有保存API Key,关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
|
||||
'仍然关闭',
|
||||
'返回复制'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm('您还没有保存API Key,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
'确定要关闭吗?',
|
||||
'您还没有保存API Key,关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
|
||||
'仍然关闭',
|
||||
'返回复制',
|
||||
'warning'
|
||||
)
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { formatNumber } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
||||
@@ -97,8 +97,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
|
||||
const props = defineProps({
|
||||
apiKey: {
|
||||
@@ -206,7 +206,7 @@ const renewApiKey = async () => {
|
||||
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
|
||||
}
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
const result = await httpApi.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 续期成功', 'success')
|
||||
|
||||
@@ -262,11 +262,11 @@ const hasValidInput = computed(() => {
|
||||
}
|
||||
|
||||
:global(.dark) .wide-card-input:focus {
|
||||
border-color: #60a5fa;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(96, 165, 250, 0.15),
|
||||
0 0 0 3px rgba(var(--primary-rgb), 0.15),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||
background: rgba(31, 41, 55, 0.95);
|
||||
background: var(--glass-strong-color);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@@ -289,18 +289,18 @@ const hasValidInput = computed(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
@@ -322,8 +322,8 @@ const hasValidInput = computed(() => {
|
||||
}
|
||||
|
||||
:global(.dark) .security-notice {
|
||||
background: rgba(31, 41, 55, 0.8) !important;
|
||||
border: 1px solid rgba(75, 85, 99, 0.5) !important;
|
||||
background: var(--glass-strong-color) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
@@ -334,8 +334,8 @@ const hasValidInput = computed(() => {
|
||||
}
|
||||
|
||||
:global(.dark) .security-notice:hover {
|
||||
background: rgba(31, 41, 55, 0.9) !important;
|
||||
border-color: rgba(75, 85, 99, 0.6) !important;
|
||||
background: var(--glass-strong-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ const hasValidInput = computed(() => {
|
||||
}
|
||||
|
||||
:global(.dark) .mode-switch-group {
|
||||
background: #1f2937;
|
||||
background: var(--bg-gradient-start);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ const hasValidInput = computed(() => {
|
||||
}
|
||||
|
||||
:global(.dark) .mode-switch-btn {
|
||||
color: #9ca3af;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mode-switch-btn:hover:not(.active) {
|
||||
@@ -407,12 +407,12 @@ const hasValidInput = computed(() => {
|
||||
|
||||
.mode-switch-btn.active {
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
box-shadow: 0 2px 4px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.mode-switch-btn.active:hover {
|
||||
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 4px 6px rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.mode-switch-btn i {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-4 md:gap-6">
|
||||
<div class="flex h-full flex-col gap-3 sm:gap-4 md:gap-6">
|
||||
<!-- 限制配置 / 聚合模式提示 -->
|
||||
<div class="card flex h-full flex-col p-4 md:p-6">
|
||||
<div class="card flex h-full flex-col p-3 sm:p-4 md:p-6">
|
||||
<h3
|
||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||
class="mb-2 flex items-center text-base font-bold text-gray-900 dark:text-gray-100 sm:mb-3 sm:text-lg 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" />
|
||||
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="card p-4 md:p-6">
|
||||
<div class="mb-4 md:mb-6">
|
||||
<div class="card p-3 sm:p-4 md:p-6">
|
||||
<div class="mb-2 sm:mb-3 md:mb-4">
|
||||
<h3
|
||||
class="flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:text-xl"
|
||||
class="flex flex-col text-base font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center sm:text-lg md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
|
||||
模型使用统计
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>({{ periodLabel }})</span
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计加载状态 -->
|
||||
<div v-if="modelStatsLoading" class="py-6 text-center md:py-8">
|
||||
<div v-if="loading" class="py-6 text-center md:py-8">
|
||||
<i
|
||||
class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl"
|
||||
/>
|
||||
@@ -23,49 +23,41 @@
|
||||
</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="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 dark:text-gray-100 md:text-lg">
|
||||
<div v-else-if="stats.length > 0" class="space-y-2">
|
||||
<div v-for="(model, index) in stats" :key="index" class="model-usage-item">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h4
|
||||
class="cursor-pointer text-sm font-bold text-gray-900 hover:text-indigo-600 dark:text-gray-100 dark:hover:text-indigo-400"
|
||||
title="点击复制"
|
||||
@click="copyModelName(model.model)"
|
||||
>
|
||||
{{ model.model }}
|
||||
<i class="fas fa-copy ml-1 text-xs text-gray-400" />
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
||||
{{ model.requests }} 次请求
|
||||
</p>
|
||||
</div>
|
||||
<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 text-gray-600 dark:text-gray-400 md:text-sm">总费用</div>
|
||||
</div>
|
||||
</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 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">输入 Token</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.inputTokens) }}
|
||||
<div class="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ model.requests }}次</span>
|
||||
<span>输入:{{ formatNumber(model.inputTokens) }}</span>
|
||||
<span>输出:{{ formatNumber(model.outputTokens) }}</span>
|
||||
<span v-if="model.cacheCreateTokens"
|
||||
>缓存创建:{{ formatNumber(model.cacheCreateTokens) }}</span
|
||||
>
|
||||
<span v-if="model.cacheReadTokens"
|
||||
>缓存读取:{{ formatNumber(model.cacheReadTokens) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">输出 Token</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.outputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">缓存创建</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.cacheCreateTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400">缓存读取</div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(model.cacheReadTokens) }}
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-xs sm:text-sm">
|
||||
<span class="text-gray-500">官方API</span>
|
||||
<span class="ml-1 font-semibold text-green-600">
|
||||
{{ model.formatted?.total || '$0.00' }}
|
||||
</span>
|
||||
<template v-if="serviceRates?.rates">
|
||||
<span class="ml-2 text-gray-500">折合CC</span>
|
||||
<span class="ml-1 font-semibold text-amber-600 dark:text-amber-400">
|
||||
{{ calculateCcCost(model) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,19 +66,74 @@
|
||||
<!-- 无模型数据 -->
|
||||
<div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 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>
|
||||
<p class="text-sm md:text-base">暂无{{ periodLabel }}模型使用数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import { copyText } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
period: {
|
||||
type: String,
|
||||
default: 'daily',
|
||||
validator: (value) => ['daily', 'monthly', 'alltime'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore)
|
||||
const { dailyModelStats, monthlyModelStats, alltimeModelStats, modelStatsLoading, serviceRates } =
|
||||
storeToRefs(apiStatsStore)
|
||||
|
||||
// 根据 period 选择对应的数据
|
||||
const stats = computed(() => {
|
||||
if (props.period === 'daily') return dailyModelStats.value
|
||||
if (props.period === 'monthly') return monthlyModelStats.value
|
||||
if (props.period === 'alltime') return alltimeModelStats.value
|
||||
return []
|
||||
})
|
||||
|
||||
const loading = computed(() => modelStatsLoading.value)
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
if (props.period === 'daily') return '今日'
|
||||
if (props.period === 'monthly') return '本月'
|
||||
if (props.period === 'alltime') return '所有时间'
|
||||
return ''
|
||||
})
|
||||
|
||||
// 复制模型名称
|
||||
const copyModelName = (name) => copyText(name, '模型名称已复制')
|
||||
|
||||
// 根据模型名称判断服务类型
|
||||
const getServiceFromModel = (model) => {
|
||||
if (!model) return 'claude'
|
||||
const m = model.toLowerCase()
|
||||
if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku'))
|
||||
return 'claude'
|
||||
if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'codex'
|
||||
if (m.includes('gemini')) return 'gemini'
|
||||
if (m.includes('droid') || m.includes('factory')) return 'droid'
|
||||
if (m.includes('bedrock') || m.includes('amazon')) return 'bedrock'
|
||||
if (m.includes('azure')) return 'azure'
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// 计算 CC 扣费
|
||||
const calculateCcCost = (model) => {
|
||||
const cost = model.costs?.total || 0
|
||||
if (!cost || !serviceRates.value?.rates) return '$0.00'
|
||||
const service = getServiceFromModel(model.model)
|
||||
const rate = serviceRates.value.rates[service] || 1.0
|
||||
const ccCost = cost * rate
|
||||
if (ccCost >= 1) return '$' + ccCost.toFixed(2)
|
||||
if (ccCost >= 0.01) return '$' + ccCost.toFixed(4)
|
||||
return '$' + ccCost.toFixed(6)
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
|
||||
256
web/admin-spa/src/components/apistats/ServiceCostCards.vue
Normal file
256
web/admin-spa/src/components/apistats/ServiceCostCards.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div v-if="serviceRates && modelStats.length > 0" class="card p-3 sm:p-4 md:p-6">
|
||||
<h3
|
||||
class="mb-2 flex items-center justify-between text-base font-bold text-gray-900 dark:text-gray-100 sm:mb-3 sm:text-lg md:mb-4 md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-coins mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
|
||||
服务费用统计
|
||||
</span>
|
||||
<span class="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
CC 倍率基准: Claude = 1.0
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="service in serviceStats"
|
||||
:key="service.name"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<!-- 服务名和倍率 -->
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ service.label }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
{{ service.rate }}x
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Token 详情 -->
|
||||
<div class="mb-2 space-y-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div class="flex justify-between">
|
||||
<span>输入</span>
|
||||
<span class="text-gray-900 dark:text-gray-200">{{
|
||||
formatNumber(service.inputTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>输出</span>
|
||||
<span class="text-gray-900 dark:text-gray-200">{{
|
||||
formatNumber(service.outputTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="service.cacheCreateTokens" class="flex justify-between">
|
||||
<span>缓存创建</span>
|
||||
<span class="text-gray-900 dark:text-gray-200">{{
|
||||
formatNumber(service.cacheCreateTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="service.cacheReadTokens" class="flex justify-between">
|
||||
<span>缓存读取</span>
|
||||
<span class="text-gray-900 dark:text-gray-200">{{
|
||||
formatNumber(service.cacheReadTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 费用 -->
|
||||
<div class="mb-2 space-y-0.5 border-t border-gray-200 pt-2 text-xs dark:border-gray-700">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">官方API</span>
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||
{{ service.officialCost }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">折合CC</span>
|
||||
<span class="font-semibold text-amber-600 dark:text-amber-400">
|
||||
{{ service.ccCost }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格参考 -->
|
||||
<div
|
||||
v-if="service.pricing"
|
||||
class="space-y-0.5 border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-500"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<span>输入</span>
|
||||
<span>{{ service.pricing.input }}/M</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>输出</span>
|
||||
<span>{{ service.pricing.output }}/M</span>
|
||||
</div>
|
||||
<div v-if="service.pricing.cacheCreate" class="flex justify-between">
|
||||
<span>缓存创建</span>
|
||||
<span>{{ service.pricing.cacheCreate }}/M</span>
|
||||
</div>
|
||||
<div v-if="service.pricing.cacheRead" class="flex justify-between">
|
||||
<span>缓存读取</span>
|
||||
<span>{{ service.pricing.cacheRead }}/M</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const { modelStats, serviceRates } = storeToRefs(apiStatsStore)
|
||||
|
||||
// 服务标签映射
|
||||
const serviceLabels = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
droid: 'Droid',
|
||||
bedrock: 'Bedrock',
|
||||
azure: 'Azure',
|
||||
ccr: 'CCR'
|
||||
}
|
||||
|
||||
// 根据模型名称判断服务类型
|
||||
const getServiceFromModel = (model) => {
|
||||
if (!model) return 'claude'
|
||||
const m = model.toLowerCase()
|
||||
if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku'))
|
||||
return 'claude'
|
||||
if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'codex'
|
||||
if (m.includes('gemini')) return 'gemini'
|
||||
if (m.includes('droid') || m.includes('factory')) return 'droid'
|
||||
if (m.includes('bedrock') || m.includes('amazon')) return 'bedrock'
|
||||
if (m.includes('azure')) return 'azure'
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// 按服务聚合统计
|
||||
const serviceStats = computed(() => {
|
||||
if (!serviceRates.value?.rates || !modelStats.value?.length) return []
|
||||
|
||||
const stats = {}
|
||||
|
||||
// 初始化所有服务
|
||||
Object.keys(serviceRates.value.rates).forEach((service) => {
|
||||
stats[service] = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
pricing: null
|
||||
}
|
||||
})
|
||||
|
||||
// 聚合模型数据
|
||||
modelStats.value.forEach((model) => {
|
||||
const service = getServiceFromModel(model.model)
|
||||
if (stats[service]) {
|
||||
stats[service].inputTokens += model.inputTokens || 0
|
||||
stats[service].outputTokens += model.outputTokens || 0
|
||||
stats[service].cacheCreateTokens += model.cacheCreateTokens || 0
|
||||
stats[service].cacheReadTokens += model.cacheReadTokens || 0
|
||||
stats[service].cost += model.costs?.total || 0
|
||||
if (!stats[service].pricing && model.pricing) {
|
||||
stats[service].pricing = model.pricing
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 转换为数组并计算 CC 费用
|
||||
return Object.entries(stats)
|
||||
.filter(
|
||||
([, data]) =>
|
||||
data.inputTokens > 0 || data.outputTokens > 0 || data.cacheCreateTokens > 0 || data.cost > 0
|
||||
)
|
||||
.map(([service, data]) => {
|
||||
const rate = serviceRates.value.rates[service] || 1.0
|
||||
const ccCostValue = data.cost * rate
|
||||
const p = data.pricing
|
||||
return {
|
||||
name: service,
|
||||
label: serviceLabels[service] || service,
|
||||
rate: rate,
|
||||
inputTokens: data.inputTokens,
|
||||
outputTokens: data.outputTokens,
|
||||
cacheCreateTokens: data.cacheCreateTokens,
|
||||
cacheReadTokens: data.cacheReadTokens,
|
||||
officialCost: formatCost(data.cost),
|
||||
ccCost: formatCost(ccCostValue),
|
||||
pricing: p
|
||||
? {
|
||||
input: formatCost(p.input * 1e6),
|
||||
output: formatCost(p.output * 1e6),
|
||||
cacheCreate: p.cacheCreate ? formatCost(p.cacheCreate * 1e6) : null,
|
||||
cacheRead: p.cacheRead ? formatCost(p.cacheRead * 1e6) : null
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens))
|
||||
})
|
||||
|
||||
// 格式化费用
|
||||
const formatCost = (cost) => {
|
||||
if (!cost || cost === 0) return '$0.00'
|
||||
if (cost >= 1) return '$' + cost.toFixed(2)
|
||||
if (cost >= 0.01) return '$' + cost.toFixed(4)
|
||||
return '$' + cost.toFixed(6)
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num) return '0'
|
||||
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'
|
||||
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'
|
||||
if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .card:hover {
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.5),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-6 md:space-y-8">
|
||||
<div class="space-y-4 sm:space-y-6 md:space-y-8">
|
||||
<div
|
||||
class="grid grid-cols-1 items-stretch gap-4 md:gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]"
|
||||
class="grid grid-cols-1 items-stretch gap-3 sm:gap-4 md:gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]"
|
||||
>
|
||||
<!-- 基础信息 / 批量概要 -->
|
||||
<div class="card-section">
|
||||
@@ -60,9 +60,16 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="info-grid">
|
||||
<div class="info-item">
|
||||
<div
|
||||
class="info-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
title="点击复制"
|
||||
@click="copyText(statsData.name)"
|
||||
>
|
||||
<p class="info-label">名称</p>
|
||||
<p class="info-value break-all">{{ statsData.name }}</p>
|
||||
<p class="info-value flex items-center gap-1 break-all">
|
||||
{{ statsData.name }}
|
||||
<i class="fas fa-copy text-xs text-gray-400" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<p class="info-label">状态</p>
|
||||
@@ -282,6 +289,7 @@ import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import dayjs from 'dayjs'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import { copyText } from '@/utils/tools'
|
||||
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="card p-4 md:p-6">
|
||||
<div class="card p-3 sm:p-4 md:p-6">
|
||||
<h3
|
||||
class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
|
||||
class="mb-2 flex flex-col text-base font-bold text-gray-900 dark:text-gray-100 sm:mb-3 sm:flex-row sm:items-center sm:text-lg md:mb-4 md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
|
||||
|
||||
@@ -9,9 +9,25 @@
|
||||
<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"
|
||||
:class="[
|
||||
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl',
|
||||
dialogType === 'danger'
|
||||
? 'bg-gradient-to-br from-red-500 to-red-600'
|
||||
: dialogType === 'warning'
|
||||
? 'bg-gradient-to-br from-amber-500 to-amber-600'
|
||||
: 'bg-primary'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle text-lg text-white" />
|
||||
<i
|
||||
:class="[
|
||||
'text-lg text-white',
|
||||
dialogType === 'danger'
|
||||
? 'fas fa-trash-alt'
|
||||
: dialogType === 'warning'
|
||||
? 'fas fa-exclamation-triangle'
|
||||
: 'fas fa-question-circle'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
@@ -32,8 +48,14 @@
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-warning px-6 py-3"
|
||||
:class="{ 'cursor-not-allowed opacity-50': isProcessing }"
|
||||
:class="[
|
||||
'btn px-6 py-3',
|
||||
dialogType === 'danger'
|
||||
? 'btn-danger'
|
||||
: dialogType === 'warning'
|
||||
? 'btn-warning'
|
||||
: 'btn-primary'
|
||||
]"
|
||||
:disabled="isProcessing"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
@@ -57,6 +79,7 @@ const title = ref('')
|
||||
const message = ref('')
|
||||
const confirmText = ref('确认')
|
||||
const cancelText = ref('取消')
|
||||
const dialogType = ref('primary') // primary | warning | danger
|
||||
let resolvePromise = null
|
||||
|
||||
// 显示确认对话框
|
||||
@@ -64,13 +87,15 @@ const showConfirm = (
|
||||
titleText,
|
||||
messageText,
|
||||
confirmTextParam = '确认',
|
||||
cancelTextParam = '取消'
|
||||
cancelTextParam = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
title.value = titleText
|
||||
message.value = messageText
|
||||
confirmText.value = confirmTextParam
|
||||
cancelText.value = cancelTextParam
|
||||
dialogType.value = type
|
||||
isVisible.value = true
|
||||
isProcessing.value = false
|
||||
resolvePromise = resolve
|
||||
@@ -155,8 +180,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
:global(.dark) .modal-content {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
background: var(--bg-gradient-start);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
@@ -172,6 +197,11 @@ defineExpose({
|
||||
@apply bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
@apply text-white hover:opacity-90 focus:ring-indigo-500;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-white;
|
||||
}
|
||||
@@ -208,7 +238,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
:global(.dark) .modal-content::-webkit-scrollbar-track {
|
||||
background: #374151;
|
||||
background: var(--bg-gradient-mid);
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb {
|
||||
@@ -217,7 +247,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
:global(.dark) .modal-content::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
background: var(--bg-gradient-end);
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
>
|
||||
<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"
|
||||
:class="[
|
||||
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full',
|
||||
type === 'danger'
|
||||
? 'bg-gradient-to-br from-red-400 to-red-500'
|
||||
: type === 'warning'
|
||||
? 'bg-gradient-to-br from-yellow-400 to-yellow-500'
|
||||
: 'bg-primary'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-exclamation text-xl text-white" />
|
||||
<i
|
||||
:class="[
|
||||
'text-xl text-white',
|
||||
type === 'danger'
|
||||
? 'fas fa-trash-alt'
|
||||
: type === 'warning'
|
||||
? 'fas fa-exclamation'
|
||||
: 'fas fa-question'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="mb-2 text-lg font-bold text-gray-900 dark:text-white">
|
||||
@@ -28,7 +44,14 @@
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<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"
|
||||
:class="[
|
||||
'flex-1 rounded-xl px-4 py-2.5 font-medium text-white shadow-sm transition-all',
|
||||
type === 'danger'
|
||||
? 'bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700'
|
||||
: type === 'warning'
|
||||
? 'bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600'
|
||||
: 'bg-primary hover:opacity-90'
|
||||
]"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
@@ -60,6 +83,11 @@ defineProps({
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消'
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary', // primary | warning | danger
|
||||
validator: (value) => ['primary', 'warning', 'danger'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +1,87 @@
|
||||
<template>
|
||||
<div class="theme-toggle-container">
|
||||
<!-- 紧凑模式:仅显示图标按钮 -->
|
||||
<button
|
||||
v-if="mode === 'compact'"
|
||||
class="theme-toggle-button"
|
||||
:title="themeTooltip"
|
||||
@click="handleCycleTheme"
|
||||
>
|
||||
<transition mode="out-in" name="fade">
|
||||
<i v-if="themeStore.themeMode === 'light'" key="sun" class="fas fa-sun" />
|
||||
<i v-else-if="themeStore.themeMode === 'dark'" key="moon" class="fas fa-moon" />
|
||||
<i v-else key="auto" class="fas fa-circle-half-stroke" />
|
||||
</transition>
|
||||
</button>
|
||||
<div v-if="mode === 'compact'" class="flex items-center gap-2">
|
||||
<!-- 色系切换按钮 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="color-scheme-button"
|
||||
:title="`色系: ${themeStore.currentColorScheme.name}`"
|
||||
@click="toggleColorMenu"
|
||||
>
|
||||
<span class="color-dot" :style="{ background: themeStore.currentColorScheme.primary }" />
|
||||
</button>
|
||||
<!-- 色系下拉菜单 -->
|
||||
<transition name="dropdown">
|
||||
<div v-if="showColorMenu" class="color-menu">
|
||||
<button
|
||||
v-for="(scheme, key) in themeStore.ColorSchemes"
|
||||
:key="key"
|
||||
class="color-option"
|
||||
:class="{ active: themeStore.colorScheme === key }"
|
||||
:title="scheme.name"
|
||||
@click="selectColorScheme(key)"
|
||||
>
|
||||
<span
|
||||
class="color-preview"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.secondary} 100%)`
|
||||
}"
|
||||
/>
|
||||
<span class="color-name">{{ scheme.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<!-- 主题切换按钮 -->
|
||||
<button class="theme-toggle-button" :title="themeTooltip" @click="handleCycleTheme">
|
||||
<transition mode="out-in" name="fade">
|
||||
<i v-if="themeStore.themeMode === 'light'" key="sun" class="fas fa-sun" />
|
||||
<i v-else-if="themeStore.themeMode === 'dark'" key="moon" class="fas fa-moon" />
|
||||
<i v-else key="auto" class="fas fa-circle-half-stroke" />
|
||||
</transition>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 下拉菜单模式 - 改为创意切换开关 -->
|
||||
<div v-else-if="mode === 'dropdown'" class="theme-switch-wrapper">
|
||||
<!-- 色系切换按钮 -->
|
||||
<div class="relative mr-3">
|
||||
<button
|
||||
class="color-scheme-button-lg"
|
||||
:title="`色系: ${themeStore.currentColorScheme.name}`"
|
||||
@click="toggleColorMenu"
|
||||
>
|
||||
<span
|
||||
class="color-dot-lg"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${themeStore.currentColorScheme.primary} 0%, ${themeStore.currentColorScheme.secondary} 100%)`
|
||||
}"
|
||||
/>
|
||||
<i class="fas fa-palette ml-1 text-xs opacity-60" />
|
||||
</button>
|
||||
<!-- 色系下拉菜单 -->
|
||||
<transition name="dropdown">
|
||||
<div v-if="showColorMenu" class="color-menu">
|
||||
<button
|
||||
v-for="(scheme, key) in themeStore.ColorSchemes"
|
||||
:key="key"
|
||||
class="color-option"
|
||||
:class="{ active: themeStore.colorScheme === key }"
|
||||
:title="scheme.name"
|
||||
@click="selectColorScheme(key)"
|
||||
>
|
||||
<span
|
||||
class="color-preview"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.secondary} 100%)`
|
||||
}"
|
||||
/>
|
||||
<span class="color-name">{{ scheme.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<button
|
||||
class="theme-switch"
|
||||
:class="{
|
||||
@@ -67,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
// Props
|
||||
@@ -88,6 +154,9 @@ defineProps({
|
||||
// Store
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// 色系菜单状态
|
||||
const showColorMenu = ref(false)
|
||||
|
||||
// 主题选项配置
|
||||
const themeOptions = [
|
||||
{
|
||||
@@ -124,6 +193,34 @@ const handleCycleTheme = () => {
|
||||
const selectTheme = (mode) => {
|
||||
themeStore.setThemeMode(mode)
|
||||
}
|
||||
|
||||
const toggleColorMenu = () => {
|
||||
showColorMenu.value = !showColorMenu.value
|
||||
}
|
||||
|
||||
const selectColorScheme = (scheme) => {
|
||||
themeStore.setColorScheme(scheme)
|
||||
showColorMenu.value = false
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
!e.target.closest('.color-scheme-button') &&
|
||||
!e.target.closest('.color-scheme-button-lg') &&
|
||||
!e.target.closest('.color-menu')
|
||||
) {
|
||||
showColorMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -205,10 +302,10 @@ const selectTheme = (mode) => {
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 15px rgba(102, 126, 234, 0.3),
|
||||
0 4px 15px color-mix(in srgb, var(--primary-color) 30%, transparent),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -218,7 +315,7 @@ const selectTheme = (mode) => {
|
||||
.theme-switch:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 6px 20px rgba(102, 126, 234, 0.4),
|
||||
0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -241,16 +338,15 @@ const selectTheme = (mode) => {
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 自动模式样式 - 静态蓝紫渐变设计(优化版) */
|
||||
/* 自动模式样式 - 使用主题色混合 */
|
||||
.theme-switch.is-auto {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#c4b5fd 0%,
|
||||
/* 更柔和的起始:淡紫 */ #a78bfa 15%,
|
||||
/* 浅紫 */ #818cf8 40%,
|
||||
/* 紫蓝 */ #6366f1 60%,
|
||||
/* 靛蓝 */ #4f46e5 85%,
|
||||
/* 深蓝紫 */ #4338ca 100% /* 更深的结束:深紫 */
|
||||
color-mix(in srgb, var(--accent-color) 70%, white) 0%,
|
||||
var(--accent-color) 25%,
|
||||
color-mix(in srgb, var(--primary-color) 80%, var(--accent-color)) 50%,
|
||||
var(--primary-color) 75%,
|
||||
var(--secondary-color) 100%
|
||||
);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
@@ -258,7 +354,7 @@ const selectTheme = (mode) => {
|
||||
background-size: 120% 120%;
|
||||
background-position: center;
|
||||
box-shadow:
|
||||
0 4px 15px rgba(139, 92, 246, 0.25),
|
||||
0 4px 15px color-mix(in srgb, var(--secondary-color) 25%, transparent),
|
||||
inset 0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
inset 0 -1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -426,7 +522,7 @@ const selectTheme = (mode) => {
|
||||
|
||||
.handle-icon .fa-circle-half-stroke {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 4px rgba(167, 139, 250, 0.5));
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent-color) 50%, transparent));
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -508,4 +604,78 @@ const selectTheme = (mode) => {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 色系切换按钮样式 */
|
||||
.color-scheme-button {
|
||||
@apply flex items-center justify-center;
|
||||
@apply h-9 w-9 rounded-full;
|
||||
@apply bg-white/90 dark:bg-gray-800/90;
|
||||
@apply hover:bg-white dark:hover:bg-gray-700;
|
||||
@apply border border-gray-200/50 dark:border-gray-600/50;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply shadow-md hover:shadow-lg;
|
||||
@apply hover:scale-110 active:scale-95;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-scheme-button-lg {
|
||||
@apply flex items-center justify-center;
|
||||
@apply h-9 rounded-full px-3;
|
||||
@apply bg-white/90 dark:bg-gray-800/90;
|
||||
@apply hover:bg-white dark:hover:bg-gray-700;
|
||||
@apply border border-gray-200/50 dark:border-gray-600/50;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply shadow-md hover:shadow-lg;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
@apply h-5 w-5 rounded-full;
|
||||
@apply shadow-inner;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.color-dot-lg {
|
||||
@apply h-5 w-5 rounded-full;
|
||||
@apply shadow-inner;
|
||||
}
|
||||
|
||||
.color-scheme-button:hover .color-dot {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 色系下拉菜单 */
|
||||
.color-menu {
|
||||
@apply absolute left-0 top-full mt-2;
|
||||
@apply bg-white dark:bg-gray-800;
|
||||
@apply rounded-xl shadow-xl;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
@apply min-w-[140px] p-2;
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
@apply flex w-full items-center gap-2;
|
||||
@apply rounded-lg px-3 py-2;
|
||||
@apply text-sm text-gray-700 dark:text-gray-300;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-gray-700;
|
||||
@apply transition-all duration-150;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-option.active {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
@apply h-5 w-5 rounded-full;
|
||||
@apply shadow-sm;
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.color-name {
|
||||
@apply flex-1 text-left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,8 +163,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
:global(.dark) .toast {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
background: var(--bg-gradient-start);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -234,8 +234,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
:global(.dark) .toast-close:hover {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
background: var(--bg-gradient-mid);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
|
||||
@@ -52,7 +52,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { formatNumber } from '@/utils/tools'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const chartCanvas = ref(null)
|
||||
|
||||
@@ -39,8 +39,10 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useChartConfig } from '@/composables/useChartConfig'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const themeStore = useThemeStore()
|
||||
const chartCanvas = ref(null)
|
||||
let chart = null
|
||||
|
||||
@@ -83,16 +85,16 @@ const createChart = () => {
|
||||
{
|
||||
label: '请求次数',
|
||||
data: dashboardStore.trendData.map((item) => item.requests),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||
borderColor: themeStore.currentColorScheme.primary,
|
||||
backgroundColor: getGradient(ctx, themeStore.currentColorScheme.primary, 0.1),
|
||||
yAxisID: 'y',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Token使用量',
|
||||
data: dashboardStore.trendData.map((item) => item.tokens),
|
||||
borderColor: '#f093fb',
|
||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||
borderColor: themeStore.currentColorScheme.accent,
|
||||
backgroundColor: getGradient(ctx, themeStore.currentColorScheme.accent, 0.1),
|
||||
yAxisID: 'y1',
|
||||
tension: 0.4
|
||||
}
|
||||
@@ -169,6 +171,14 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听色系变化,重新创建图表
|
||||
watch(
|
||||
() => themeStore.colorScheme,
|
||||
() => {
|
||||
createChart()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<!-- 用户菜单 -->
|
||||
<div class="user-menu-container relative">
|
||||
<button
|
||||
class="user-menu-button flex items-center gap-2 rounded-2xl bg-gradient-to-r from-blue-500 to-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 hover:shadow-xl active:scale-95 sm:px-4 sm:py-2.5"
|
||||
class="user-menu-button flex items-center gap-2 rounded-2xl px-3 py-2 text-sm font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 hover:shadow-xl active:scale-95 sm:px-4 sm:py-2.5"
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
>
|
||||
<i class="fas fa-user-circle text-sm sm:text-base" />
|
||||
@@ -263,16 +263,29 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
:message="confirmModalConfig.message"
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalConfig.title"
|
||||
:type="confirmModalConfig.type"
|
||||
@cancel="handleCancelModal"
|
||||
@confirm="handleConfirmModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -308,6 +321,39 @@ const changePasswordForm = reactive({
|
||||
newUsername: ''
|
||||
})
|
||||
|
||||
// ConfirmModal 状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmModalConfig = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'primary'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirmModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(true)
|
||||
}
|
||||
const handleCancelModal = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve.value?.(false)
|
||||
}
|
||||
|
||||
// 检查更新(同时获取版本信息)
|
||||
const checkForUpdates = async () => {
|
||||
if (versionInfo.value.checkingUpdate) {
|
||||
@@ -317,7 +363,7 @@ const checkForUpdates = async () => {
|
||||
versionInfo.value.checkingUpdate = true
|
||||
|
||||
try {
|
||||
const result = await apiClient.get('/admin/check-updates')
|
||||
const result = await httpApi.get('/admin/check-updates')
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data
|
||||
@@ -397,7 +443,7 @@ const changePassword = async () => {
|
||||
changePasswordLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await apiClient.post('/web/auth/change-password', {
|
||||
const data = await httpApi.post('/web/auth/change-password', {
|
||||
currentPassword: changePasswordForm.currentPassword,
|
||||
newPassword: changePasswordForm.newPassword,
|
||||
newUsername: changePasswordForm.newUsername || undefined
|
||||
@@ -426,8 +472,15 @@ const changePassword = async () => {
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
const logout = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
'退出登录',
|
||||
'确定要退出登录吗?',
|
||||
'确定退出',
|
||||
'取消',
|
||||
'warning'
|
||||
)
|
||||
if (confirmed) {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
showToast('已安全退出', 'success')
|
||||
@@ -465,6 +518,12 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 38px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.user-menu-button:hover {
|
||||
box-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.4);
|
||||
}
|
||||
|
||||
/* 添加光泽效果 */
|
||||
|
||||
@@ -39,6 +39,7 @@ const tabRouteMap = computed(() => {
|
||||
dashboard: '/dashboard',
|
||||
apiKeys: '/api-keys',
|
||||
accounts: '/accounts',
|
||||
quotaCards: '/quota-cards',
|
||||
tutorial: '/tutorial',
|
||||
settings: '/settings'
|
||||
}
|
||||
@@ -67,6 +68,7 @@ const initActiveTab = () => {
|
||||
Dashboard: 'dashboard',
|
||||
ApiKeys: 'apiKeys',
|
||||
Accounts: 'accounts',
|
||||
QuotaCards: 'quotaCards',
|
||||
Tutorial: 'tutorial',
|
||||
Settings: 'settings'
|
||||
}
|
||||
@@ -96,6 +98,7 @@ watch(
|
||||
Dashboard: 'dashboard',
|
||||
ApiKeys: 'apiKeys',
|
||||
Accounts: 'accounts',
|
||||
QuotaCards: 'quotaCards',
|
||||
Tutorial: 'tutorial',
|
||||
Settings: 'settings'
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ const tabs = computed(() => {
|
||||
const baseTabs = [
|
||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
|
||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
|
||||
{ key: 'quotaCards', name: '额度卡', shortName: '额度卡', icon: 'fas fa-ticket-alt' }
|
||||
]
|
||||
|
||||
// 只有在 LDAP 启用时才显示用户管理
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
||||
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
@@ -351,7 +351,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
|
||||
Reference in New Issue
Block a user