feat: 添加 CCR (Claude Code Router) 账户类型支持

实现通过供应商前缀语法进行 CCR 后端路由的完整支持。
用户现在可以在 Claude Code 中使用 `/model ccr,model_name` 将请求路由到 CCR 后端。
暂时没有实现`/v1/messages/count_tokens`,因为这需要在CCR后端支持。
CCR类型的账户也暂时没有考虑模型的支持情况

## 核心实现

### 供应商前缀路由

- 添加 modelHelper 工具用于解析模型名称中的 `ccr,` 供应商前缀
- 检测到前缀时自动路由到 CCR 账户池
- 转发到 CCR 后端前移除供应商前缀

### 账户管理

- 创建 ccrAccountService 实现 CCR 账户的完整 CRUD 操作
- 支持账户属性:名称、API URL、API Key、代理、优先级、配额
- 实现账户状态:active、rate_limited、unauthorized、overloaded
- 支持模型映射和支持模型配置

### 请求转发

- 实现 ccrRelayService 处理 CCR 后端通信
- 支持流式和非流式请求
- 从 SSE 流中解析和捕获使用数据
- 支持 Bearer 和 x-api-key 两种认证格式

### 统一调度

- 将 CCR 账户集成到 unifiedClaudeScheduler
- 添加 \_selectCcrAccount 方法用于 CCR 特定账户选择
- 支持 CCR 账户的会话粘性
- 防止跨类型会话映射(CCR 会话仅用于 CCR 请求)

### 错误处理

- 实现全面的错误状态管理
- 处理 401(未授权)、429(速率限制)、529(过载)错误
- 成功请求后自动从错误状态恢复
- 支持可配置的速率限制持续时间

### Web 管理界面

- 添加 CcrAccountForm 组件用于创建/编辑 CCR 账户
- 将 CCR 账户集成到 AccountsView 中,提供完整管理功能
- 支持账户切换、重置和使用统计
- 在界面中显示账户状态和错误信息

### API 端点

- POST /admin/ccr-accounts - 创建 CCR 账户
- GET /admin/ccr-accounts - 列出所有 CCR 账户
- PUT /admin/ccr-accounts/:id - 更新 CCR 账户
- DELETE /admin/ccr-accounts/:id - 删除 CCR 账户
- PUT /admin/ccr-accounts/:id/toggle - 切换账户启用状态
- PUT /admin/ccr-accounts/:id/toggle-schedulable - 切换可调度状态
- POST /admin/ccr-accounts/:id/reset-usage - 重置每日使用量
- POST /admin/ccr-accounts/:id/reset-status - 重置错误状态

## 技术细节

- CCR 账户使用 'ccr' 作为 accountType 标识符
- 带有 `ccr,` 前缀的请求绕过普通账户池
- 转发到 CCR 后端前清理模型名称内的`ccr,`
- 从流式和非流式响应中捕获使用数据
- 支持缓存令牌跟踪(创建和读取)
This commit is contained in:
sususu98
2025-09-10 14:21:15 +08:00
parent 1c3b74f45b
commit 7f9869ae20
11 changed files with 3117 additions and 52 deletions

View File

@@ -123,6 +123,15 @@
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Bedrock</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.platform"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="ccr"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">CCR</span>
</label>
</div>
</div>
@@ -131,7 +140,8 @@
!isEdit &&
form.platform !== 'claude-console' &&
form.platform !== 'bedrock' &&
form.platform !== 'azure_openai'
form.platform !== 'azure_openai' &&
form.platform !== 'ccr'
"
>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -2247,7 +2257,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['close', 'success'])
const emit = defineEmits(['close', 'success', 'platform-changed'])
const accountsStore = useAccountsStore()
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
@@ -3439,6 +3449,17 @@ watch(setupTokenAuthCode, (newValue) => {
// 如果不是 URL保持原值兼容直接输入授权码
})
// 监听平台变化
watch(
() => form.value.platform,
(newPlatform) => {
// 当选择 CCR 平台时,通知父组件
if (!isEdit.value) {
emit('platform-changed', newPlatform)
}
}
)
// 监听账户类型变化
watch(
() => form.value.accountType,

View File

@@ -0,0 +1,454 @@
<template>
<Teleport to="body">
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8"
>
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-emerald-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-code-branch text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
{{ isEdit ? '编辑 CCR 账户' : '添加 CCR 账户' }}
</h3>
</div>
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<div class="space-y-6">
<!-- 基本信息 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>账户名称 *</label
>
<input
v-model="form.name"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500': errors.name }"
placeholder="为账户设置一个易识别的名称"
required
type="text"
/>
<p v-if="errors.name" class="mt-1 text-xs text-red-500">{{ errors.name }}</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>描述 (可选)</label
>
<textarea
v-model="form.description"
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="账户用途说明..."
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API URL *</label
>
<input
v-model="form.apiUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500': errors.apiUrl }"
placeholder="例如https://api.example.com/v1/messages"
required
type="text"
/>
<p v-if="errors.apiUrl" class="mt-1 text-xs text-red-500">{{ errors.apiUrl }}</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API Key {{ isEdit ? '(留空不更新)' : '*' }}</label
>
<input
v-model="form.apiKey"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500': errors.apiKey }"
:placeholder="isEdit ? '留空表示不更新' : '必填'"
:required="!isEdit"
type="password"
/>
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">{{ errors.apiKey }}</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>优先级</label
>
<input
v-model.number="form.priority"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
max="100"
min="1"
placeholder="默认50数字越小优先级越高"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
建议范围1-100数字越小优先级越高
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>自定义 User-Agent (可选)</label
>
<input
v-model="form.userAgent"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="留空则透传客户端 User-Agent"
type="text"
/>
</div>
</div>
<!-- 限流设置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>限流机制</label
>
<div class="mb-3">
<label class="inline-flex cursor-pointer items-center">
<input
v-model="enableRateLimit"
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>启用限流机制429 时暂停调度</span
>
</label>
</div>
<div v-if="enableRateLimit">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>限流时间 (分钟)</label
>
<input
v-model.number="form.rateLimitDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="1"
placeholder="默认60分钟"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
账号被限流后暂停调度的时间分钟
</p>
</div>
</div>
<!-- 额度管理 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>每日额度限制 ($)</label
>
<input
v-model.number="form.dailyQuota"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="0 表示不限制"
step="0.01"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置每日使用额度0 表示不限制
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>额度重置时间</label
>
<input
v-model="form.quotaResetTime"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="00:00"
type="time"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">每日自动重置额度的时间</p>
</div>
</div>
<!-- 模型映射表可选 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>模型映射表 (可选)</label
>
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30">
<p class="text-xs text-blue-700 dark:text-blue-400">
<i class="fas fa-info-circle mr-1" />
留空表示支持所有模型且不修改请求配置映射后左侧模型会被识别为支持的模型右侧是实际发送的模型
</p>
</div>
<div class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="原始模型名称"
type="text"
/>
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" />
<input
v-model="mapping.to"
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="映射后的模型名称"
type="text"
/>
<button
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
type="button"
@click="removeModelMapping(index)"
>
<i class="fas fa-trash" />
</button>
</div>
</div>
<button
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
type="button"
@click="addModelMapping"
>
<i class="fas fa-plus mr-2" /> 添加模型映射
</button>
</div>
<!-- 代理配置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>代理设置 (可选)</label
>
<ProxyConfig v-model="form.proxy" />
</div>
<!-- 操作区 -->
<div class="mt-2 flex gap-3">
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
type="button"
@click="$emit('close')"
>
取消
</button>
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="loading"
type="button"
@click="submit"
>
<div v-if="loading" class="loading-spinner mr-2" />
{{ loading ? (isEdit ? '保存中...' : '创建中...') : isEdit ? '保存' : '创建' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
const props = defineProps({
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'success'])
const show = ref(true)
const isEdit = computed(() => !!props.account)
const loading = ref(false)
const form = ref({
name: '',
description: '',
apiUrl: '',
apiKey: '',
priority: 50,
userAgent: '',
rateLimitDuration: 60,
dailyQuota: 0,
quotaResetTime: '00:00',
proxy: null,
supportedModels: {}
})
const enableRateLimit = ref(true)
const errors = ref({})
const modelMappings = ref([]) // [{from,to}]
const buildSupportedModels = () => {
const map = {}
for (const m of modelMappings.value) {
const from = (m.from || '').trim()
const to = (m.to || '').trim()
if (from && to) map[from] = to
}
return map
}
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
}
const removeModelMapping = (index) => {
modelMappings.value.splice(index, 1)
}
const validate = () => {
const e = {}
if (!form.value.name || form.value.name.trim().length === 0) e.name = '名称不能为空'
if (!form.value.apiUrl || form.value.apiUrl.trim().length === 0) e.apiUrl = 'API URL 不能为空'
if (!isEdit.value && (!form.value.apiKey || form.value.apiKey.trim().length === 0))
e.apiKey = 'API Key 不能为空'
errors.value = e
return Object.keys(e).length === 0
}
const submit = async () => {
if (!validate()) return
loading.value = true
try {
if (isEdit.value) {
// 更新
const updates = {
name: form.value.name,
description: form.value.description,
apiUrl: form.value.apiUrl,
priority: form.value.priority,
userAgent: form.value.userAgent,
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
dailyQuota: Number(form.value.dailyQuota || 0),
quotaResetTime: form.value.quotaResetTime || '00:00',
proxy: form.value.proxy || null,
supportedModels: buildSupportedModels()
}
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)
if (res.success) {
showToast('保存成功', 'success')
emit('success')
} else {
showToast(res.message || '保存失败', 'error')
}
} else {
// 创建
const payload = {
name: form.value.name,
description: form.value.description,
apiUrl: form.value.apiUrl,
apiKey: form.value.apiKey,
priority: Number(form.value.priority || 50),
supportedModels: buildSupportedModels(),
userAgent: form.value.userAgent,
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
proxy: form.value.proxy,
accountType: 'shared',
dailyQuota: Number(form.value.dailyQuota || 0),
quotaResetTime: form.value.quotaResetTime || '00:00'
}
const res = await apiClient.post('/admin/ccr-accounts', payload)
if (res.success) {
showToast('创建成功', 'success')
emit('success')
} else {
showToast(res.message || '创建失败', 'error')
}
}
} catch (err) {
showToast(err.message || '请求失败', 'error')
} finally {
loading.value = false
}
}
const populateFromAccount = () => {
if (!props.account) return
const a = props.account
form.value.name = a.name || ''
form.value.description = a.description || ''
form.value.apiUrl = a.apiUrl || ''
form.value.priority = Number(a.priority || 50)
form.value.userAgent = a.userAgent || ''
form.value.rateLimitDuration = Number(a.rateLimitDuration || 60)
form.value.dailyQuota = Number(a.dailyQuota || 0)
form.value.quotaResetTime = a.quotaResetTime || '00:00'
form.value.proxy = a.proxy || null
enableRateLimit.value = form.value.rateLimitDuration > 0
// supportedModels 对象转为数组
modelMappings.value = []
const mapping = a.supportedModels || {}
if (mapping && typeof mapping === 'object') {
for (const k of Object.keys(mapping)) {
modelMappings.value.push({ from: k, to: mapping[k] })
}
}
}
onMounted(() => {
if (isEdit.value) populateFromAccount()
})
watch(
() => props.account,
() => {
if (isEdit.value) populateFromAccount()
}
)
</script>
<style scoped>
.modal-content {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: rgba(17, 24, 39, 0.85);
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e5e7eb;
border-top: 2px solid #14b8a6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -7,7 +7,7 @@
账户管理
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
管理您的 ClaudeGeminiOpenAI Azure OpenAI 账户及代理配置
管理您的 ClaudeGeminiOpenAIAzure OpenAI CCR 账户及代理配置
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -363,6 +363,15 @@
{{ getClaudeAuthType(account) }}
</span>
</div>
<div
v-else-if="account.platform === 'ccr'"
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-emerald-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-emerald-900/20"
>
<i class="fas fa-code-branch text-xs text-teal-700 dark:text-teal-400" />
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300">CCR</span>
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
<span class="text-xs font-medium text-teal-700 dark:text-teal-300">Relay</span>
</div>
<div
v-else
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
@@ -470,7 +479,8 @@
account.platform === 'bedrock' ||
account.platform === 'gemini' ||
account.platform === 'openai' ||
account.platform === 'azure_openai'
account.platform === 'azure_openai' ||
account.platform === 'ccr'
"
class="flex items-center gap-2"
>
@@ -723,7 +733,9 @@
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
: account.platform === 'openai'
? 'bg-gradient-to-br from-gray-600 to-gray-700'
: 'bg-gradient-to-br from-blue-500 to-blue-600'
: account.platform === 'ccr'
? 'bg-gradient-to-br from-teal-500 to-emerald-600'
: 'bg-gradient-to-br from-blue-500 to-blue-600'
]"
>
<i
@@ -737,7 +749,9 @@
? 'fab fa-microsoft'
: account.platform === 'openai'
? 'fas fa-openai'
: 'fas fa-robot'
: account.platform === 'ccr'
? 'fas fa-code-branch'
: 'fas fa-robot'
]"
/>
</div>
@@ -932,14 +946,26 @@
<!-- 添加账户模态框 -->
<AccountForm
v-if="showCreateAccountModal"
@close="showCreateAccountModal = false"
v-if="showCreateAccountModal && (!newAccountPlatform || newAccountPlatform !== 'ccr')"
@close="closeCreateAccountModal"
@platform-changed="newAccountPlatform = $event"
@success="handleCreateSuccess"
/>
<CcrAccountForm
v-else-if="showCreateAccountModal && newAccountPlatform === 'ccr'"
@close="closeCreateAccountModal"
@success="handleCreateSuccess"
/>
<!-- 编辑账户模态框 -->
<CcrAccountForm
v-if="showEditAccountModal && editingAccount && editingAccount.platform === 'ccr'"
:account="editingAccount"
@close="showEditAccountModal = false"
@success="handleEditSuccess"
/>
<AccountForm
v-if="showEditAccountModal"
v-else-if="showEditAccountModal"
:account="editingAccount"
@close="showEditAccountModal = false"
@success="handleEditSuccess"
@@ -964,6 +990,7 @@ import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useConfirm } from '@/composables/useConfirm'
import AccountForm from '@/components/accounts/AccountForm.vue'
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
@@ -1003,7 +1030,8 @@ const platformOptions = ref([
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
])
const groupOptions = computed(() => {
@@ -1028,6 +1056,7 @@ const groupOptions = computed(() => {
// 模态框状态
const showCreateAccountModal = ref(false)
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
const showEditAccountModal = ref(false)
const editingAccount = ref(null)
@@ -1108,7 +1137,8 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/bedrock-accounts', { params }),
apiClient.get('/admin/gemini-accounts', { params }),
apiClient.get('/admin/openai-accounts', { params }),
apiClient.get('/admin/azure-openai-accounts', { params })
apiClient.get('/admin/azure-openai-accounts', { params }),
apiClient.get('/admin/ccr-accounts', { params })
)
} else {
// 只请求指定平台其他平台设为null占位
@@ -1173,6 +1203,17 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/azure-openai-accounts', { params })
)
break
case 'ccr':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure 占位
apiClient.get('/admin/ccr-accounts', { params })
)
break
default:
// 默认情况下返回空数组
requests.push(
@@ -1181,6 +1222,7 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] }),
Promise.resolve({ success: true, data: [] })
)
break
@@ -1193,8 +1235,15 @@ const loadAccounts = async (forceReload = false) => {
// 后端账户API已经包含分组信息不需要单独加载分组成员关系
// await loadGroupMembers(forceReload)
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
await Promise.all(requests)
const [
claudeData,
claudeConsoleData,
bedrockData,
geminiData,
openaiData,
azureOpenaiData,
ccrData
] = await Promise.all(requests)
const allAccounts = []
@@ -1262,6 +1311,15 @@ const loadAccounts = async (forceReload = false) => {
allAccounts.push(...azureOpenaiAccounts)
}
// CCR 账户
if (ccrData && ccrData.success) {
const ccrAccounts = (ccrData.data || []).map((acc) => {
// CCR 不支持 API Key 绑定,固定为 0
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 }
})
allAccounts.push(...ccrAccounts)
}
// 根据分组筛选器过滤账户
let filteredAccounts = allAccounts
if (groupFilter.value !== 'all') {
@@ -1467,9 +1525,16 @@ const formatRateLimitTime = (minutes) => {
// 打开创建账户模态框
const openCreateAccountModal = () => {
newAccountPlatform.value = null // 重置选择的平台
showCreateAccountModal.value = true
}
// 关闭创建账户模态框
const closeCreateAccountModal = () => {
showCreateAccountModal.value = false
newAccountPlatform.value = null
}
// 编辑账户
const editAccount = (account) => {
editingAccount.value = account
@@ -1515,6 +1580,8 @@ const deleteAccount = async (account) => {
endpoint = `/admin/openai-accounts/${account.id}`
} else if (account.platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}`
} else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}`
} else {
endpoint = `/admin/gemini-accounts/${account.id}`
}
@@ -1563,6 +1630,8 @@ const resetAccountStatus = async (account) => {
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
} else if (account.platform === 'claude-console') {
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
} else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
} else {
showToast('不支持的账户类型', 'error')
account.isResetting = false
@@ -1605,6 +1674,8 @@ const toggleSchedulable = async (account) => {
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'ccr') {
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
} else {
showToast('该账户类型暂不支持调度控制', 'warning')
return