mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge branch 'main' of github.com:Wei-Shaw/claude-relay-service
* 'main' of github.com:Wei-Shaw/claude-relay-service: chore: sync VERSION file with release v1.1.72 [skip ci] fix: 修复账户管理和API Key管理的多个问题 fix: 修复 GitHub Actions 版本检测对合并提交的处理
This commit is contained in:
22
.github/workflows/auto-release-pipeline.yml
vendored
22
.github/workflows/auto-release-pipeline.yml
vendored
@@ -24,8 +24,28 @@ jobs:
|
|||||||
- name: Check if version bump is needed
|
- name: Check if version bump is needed
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
# 获取当前提交的文件变更
|
# 检测是否是合并提交
|
||||||
|
PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | wc -w)
|
||||||
|
PARENT_COUNT=$((PARENT_COUNT - 1))
|
||||||
|
echo "Parent count: $PARENT_COUNT"
|
||||||
|
|
||||||
|
if [ "$PARENT_COUNT" -gt 1 ]; then
|
||||||
|
# 合并提交:获取合并进来的所有文件变更
|
||||||
|
echo "Detected merge commit, getting all merged changes"
|
||||||
|
# 获取合并基准点
|
||||||
|
MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null || echo "")
|
||||||
|
if [ -n "$MERGE_BASE" ]; then
|
||||||
|
# 获取从合并基准到 HEAD 的所有变更
|
||||||
|
CHANGED_FILES=$(git diff --name-only $MERGE_BASE..HEAD)
|
||||||
|
else
|
||||||
|
# 如果无法获取合并基准,使用第二个父提交
|
||||||
|
CHANGED_FILES=$(git diff --name-only HEAD^2..HEAD)
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# 普通提交:获取相对于上一个提交的变更
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD)
|
CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
echo "$CHANGED_FILES"
|
echo "$CHANGED_FILES"
|
||||||
|
|
||||||
|
|||||||
@@ -870,7 +870,19 @@ const form = ref({
|
|||||||
apiUrl: props.account?.apiUrl || '',
|
apiUrl: props.account?.apiUrl || '',
|
||||||
apiKey: props.account?.apiKey || '',
|
apiKey: props.account?.apiKey || '',
|
||||||
priority: props.account?.priority || 50,
|
priority: props.account?.priority || 50,
|
||||||
supportedModels: props.account?.supportedModels?.join('\n') || '',
|
supportedModels: (() => {
|
||||||
|
const models = props.account?.supportedModels;
|
||||||
|
if (!models) return '';
|
||||||
|
// 处理对象格式(Claude Console 的新格式)
|
||||||
|
if (typeof models === 'object' && !Array.isArray(models)) {
|
||||||
|
return Object.keys(models).join('\n');
|
||||||
|
}
|
||||||
|
// 处理数组格式(向后兼容)
|
||||||
|
if (Array.isArray(models)) {
|
||||||
|
return models.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})(),
|
||||||
userAgent: props.account?.userAgent || '',
|
userAgent: props.account?.userAgent || '',
|
||||||
rateLimitDuration: props.account?.rateLimitDuration || 60
|
rateLimitDuration: props.account?.rateLimitDuration || 60
|
||||||
})
|
})
|
||||||
@@ -1362,7 +1374,19 @@ watch(() => props.account, (newAccount) => {
|
|||||||
apiUrl: newAccount.apiUrl || '',
|
apiUrl: newAccount.apiUrl || '',
|
||||||
apiKey: '', // 编辑模式不显示现有的 API Key
|
apiKey: '', // 编辑模式不显示现有的 API Key
|
||||||
priority: newAccount.priority || 50,
|
priority: newAccount.priority || 50,
|
||||||
supportedModels: newAccount.supportedModels?.join('\n') || '',
|
supportedModels: (() => {
|
||||||
|
const models = newAccount.supportedModels;
|
||||||
|
if (!models) return '';
|
||||||
|
// 处理对象格式(Claude Console 的新格式)
|
||||||
|
if (typeof models === 'object' && !Array.isArray(models)) {
|
||||||
|
return Object.keys(models).join('\n');
|
||||||
|
}
|
||||||
|
// 处理数组格式(向后兼容)
|
||||||
|
if (Array.isArray(models)) {
|
||||||
|
return models.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})(),
|
||||||
userAgent: newAccount.userAgent || '',
|
userAgent: newAccount.userAgent || '',
|
||||||
rateLimitDuration: newAccount.rateLimitDuration || 60
|
rateLimitDuration: newAccount.rateLimitDuration || 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,87 +425,27 @@
|
|||||||
<div class="grid grid-cols-1 gap-3">
|
<div class="grid grid-cols-1 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||||
<select
|
<AccountSelector
|
||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
class="form-input w-full"
|
platform="claude"
|
||||||
|
:accounts="localAccounts.claude"
|
||||||
|
:groups="localAccounts.claudeGroups"
|
||||||
:disabled="form.permissions === 'gemini'"
|
:disabled="form.permissions === 'gemini'"
|
||||||
>
|
placeholder="请选择Claude账号"
|
||||||
<option value="">
|
default-option-text="使用共享账号池"
|
||||||
使用共享账号池
|
/>
|
||||||
</option>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
|
||||||
label="调度分组"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="group in localAccounts.claudeGroups"
|
|
||||||
:key="`group:${group.id}`"
|
|
||||||
:value="`group:${group.id}`"
|
|
||||||
>
|
|
||||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth').length > 0"
|
|
||||||
label="Claude OAuth 专属账号"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth')"
|
|
||||||
:key="account.id"
|
|
||||||
:value="account.id"
|
|
||||||
>
|
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console').length > 0"
|
|
||||||
label="Claude Console 专属账号"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console')"
|
|
||||||
:key="account.id"
|
|
||||||
:value="`console:${account.id}`"
|
|
||||||
>
|
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||||
<select
|
<AccountSelector
|
||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
class="form-input w-full"
|
platform="gemini"
|
||||||
|
:accounts="localAccounts.gemini"
|
||||||
|
:groups="localAccounts.geminiGroups"
|
||||||
:disabled="form.permissions === 'claude'"
|
:disabled="form.permissions === 'claude'"
|
||||||
>
|
placeholder="请选择Gemini账号"
|
||||||
<option value="">
|
default-option-text="使用共享账号池"
|
||||||
使用共享账号池
|
/>
|
||||||
</option>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
|
||||||
label="调度分组"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="group in localAccounts.geminiGroups"
|
|
||||||
:key="`group:${group.id}`"
|
|
||||||
:value="`group:${group.id}`"
|
|
||||||
>
|
|
||||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.gemini.filter(a => a.accountType === 'dedicated').length > 0"
|
|
||||||
label="Gemini 专属账号"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="account in localAccounts.gemini.filter(a => a.accountType === 'dedicated')"
|
|
||||||
:key="account.id"
|
|
||||||
:value="account.id"
|
|
||||||
>
|
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
@@ -665,6 +605,7 @@ import { showToast } from '@/utils/toast'
|
|||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
|
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
accounts: {
|
accounts: {
|
||||||
|
|||||||
@@ -294,87 +294,27 @@
|
|||||||
<div class="grid grid-cols-1 gap-3">
|
<div class="grid grid-cols-1 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||||
<select
|
<AccountSelector
|
||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
class="form-input w-full"
|
platform="claude"
|
||||||
|
:accounts="localAccounts.claude"
|
||||||
|
:groups="localAccounts.claudeGroups"
|
||||||
:disabled="form.permissions === 'gemini'"
|
:disabled="form.permissions === 'gemini'"
|
||||||
>
|
placeholder="请选择Claude账号"
|
||||||
<option value="">
|
default-option-text="使用共享账号池"
|
||||||
使用共享账号池
|
/>
|
||||||
</option>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
|
||||||
label="调度分组"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="group in localAccounts.claudeGroups"
|
|
||||||
:key="`group:${group.id}`"
|
|
||||||
:value="`group:${group.id}`"
|
|
||||||
>
|
|
||||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth').length > 0"
|
|
||||||
label="Claude OAuth 专属账号"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-oauth')"
|
|
||||||
:key="account.id"
|
|
||||||
:value="account.id"
|
|
||||||
>
|
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console').length > 0"
|
|
||||||
label="Claude Console 专属账号"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="account in localAccounts.claude.filter(a => a.accountType === 'dedicated' && a.platform === 'claude-console')"
|
|
||||||
:key="account.id"
|
|
||||||
:value="`console:${account.id}`"
|
|
||||||
>
|
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||||
<select
|
<AccountSelector
|
||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
class="form-input w-full"
|
platform="gemini"
|
||||||
|
:accounts="localAccounts.gemini"
|
||||||
|
:groups="localAccounts.geminiGroups"
|
||||||
:disabled="form.permissions === 'claude'"
|
:disabled="form.permissions === 'claude'"
|
||||||
>
|
placeholder="请选择Gemini账号"
|
||||||
<option value="">
|
default-option-text="使用共享账号池"
|
||||||
使用共享账号池
|
/>
|
||||||
</option>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
|
||||||
label="调度分组"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="group in localAccounts.geminiGroups"
|
|
||||||
:key="`group:${group.id}`"
|
|
||||||
:value="`group:${group.id}`"
|
|
||||||
>
|
|
||||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup
|
|
||||||
v-if="localAccounts.gemini.filter(a => a.accountType === 'dedicated').length > 0"
|
|
||||||
label="Gemini 专属账号"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="account in localAccounts.gemini.filter(a => a.accountType === 'dedicated')"
|
|
||||||
:key="account.id"
|
|
||||||
:value="account.id"
|
|
||||||
>
|
|
||||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
@@ -538,6 +478,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
|
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apiKey: {
|
apiKey: {
|
||||||
@@ -638,11 +579,32 @@ const updateApiKey = async () => {
|
|||||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
claudeAccountId: form.claudeAccountId || null,
|
|
||||||
geminiAccountId: form.geminiAccountId || null,
|
|
||||||
tags: form.tags
|
tags: form.tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理Claude账户绑定(区分OAuth和Console)
|
||||||
|
if (form.claudeAccountId) {
|
||||||
|
if (form.claudeAccountId.startsWith('console:')) {
|
||||||
|
// Claude Console账户
|
||||||
|
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
|
||||||
|
} else if (!form.claudeAccountId.startsWith('group:')) {
|
||||||
|
// Claude OAuth账户(非分组)
|
||||||
|
data.claudeAccountId = form.claudeAccountId;
|
||||||
|
} else {
|
||||||
|
// 分组
|
||||||
|
data.claudeAccountId = form.claudeAccountId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.claudeAccountId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini账户绑定
|
||||||
|
if (form.geminiAccountId) {
|
||||||
|
data.geminiAccountId = form.geminiAccountId;
|
||||||
|
} else {
|
||||||
|
data.geminiAccountId = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 模型限制 - 始终提交这些字段
|
// 模型限制 - 始终提交这些字段
|
||||||
data.enableModelRestriction = form.enableModelRestriction
|
data.enableModelRestriction = form.enableModelRestriction
|
||||||
data.restrictedModels = form.restrictedModels
|
data.restrictedModels = form.restrictedModels
|
||||||
@@ -747,7 +709,12 @@ onMounted(async () => {
|
|||||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||||
form.permissions = props.apiKey.permissions || 'all'
|
form.permissions = props.apiKey.permissions || 'all'
|
||||||
|
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||||
|
if (props.apiKey.claudeConsoleAccountId) {
|
||||||
|
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
||||||
|
} else {
|
||||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||||
|
}
|
||||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||||
form.allowedClients = props.apiKey.allowedClients || []
|
form.allowedClients = props.apiKey.allowedClients || []
|
||||||
|
|||||||
468
web/admin-spa/src/components/common/AccountSelector.vue
Normal file
468
web/admin-spa/src/components/common/AccountSelector.vue
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="triggerRef" class="relative">
|
||||||
|
<!-- 选择器主体 -->
|
||||||
|
<div
|
||||||
|
class="form-input w-full cursor-pointer flex items-center justify-between"
|
||||||
|
:class="{ 'opacity-50': disabled }"
|
||||||
|
@click="!disabled && toggleDropdown()"
|
||||||
|
>
|
||||||
|
<span :class="modelValue ? 'text-gray-900' : 'text-gray-500'">{{ selectedLabel }}</span>
|
||||||
|
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': showDropdown }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下拉菜单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-100"
|
||||||
|
enter-from-class="transform opacity-0 scale-95"
|
||||||
|
enter-to-class="transform opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-in duration-75"
|
||||||
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showDropdown"
|
||||||
|
ref="dropdownRef"
|
||||||
|
class="absolute z-50 bg-white rounded-lg shadow-lg border border-gray-200 flex flex-col"
|
||||||
|
:style="dropdownStyle"
|
||||||
|
>
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div class="p-3 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索账号名称..."
|
||||||
|
class="form-input w-full text-sm"
|
||||||
|
style="padding-left: 40px; padding-right: 36px;"
|
||||||
|
@input="handleSearch"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" />
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选项列表 -->
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
<!-- 默认选项 -->
|
||||||
|
<div
|
||||||
|
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||||
|
:class="{ 'bg-blue-50': !modelValue }"
|
||||||
|
@click="selectAccount(null)"
|
||||||
|
>
|
||||||
|
<span class="text-gray-700">{{ defaultOptionText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分组选项 -->
|
||||||
|
<div v-if="filteredGroups.length > 0">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||||
|
调度分组
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="group in filteredGroups"
|
||||||
|
:key="`group:${group.id}`"
|
||||||
|
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||||
|
:class="{ 'bg-blue-50': modelValue === `group:${group.id}` }"
|
||||||
|
@click="selectAccount(`group:${group.id}`)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-700">{{ group.name }}</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ group.memberCount || 0 }} 个成员</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OAuth 账号 -->
|
||||||
|
<div v-if="filteredOAuthAccounts.length > 0">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||||
|
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="account in filteredOAuthAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||||
|
:class="{ 'bg-blue-50': modelValue === account.id }"
|
||||||
|
@click="selectAccount(account.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-700">{{ account.name }}</span>
|
||||||
|
<span
|
||||||
|
class="ml-2 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
>
|
||||||
|
{{ account.status === 'active' ? '正常' : '异常' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ formatDate(account.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Console 账号(仅 Claude) -->
|
||||||
|
<div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||||
|
Claude Console 专属账号
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="account in filteredConsoleAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||||
|
:class="{ 'bg-blue-50': modelValue === `console:${account.id}` }"
|
||||||
|
@click="selectAccount(`console:${account.id}`)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-700">{{ account.name }}</span>
|
||||||
|
<span
|
||||||
|
class="ml-2 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
>
|
||||||
|
{{ account.status === 'active' ? '正常' : '异常' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ formatDate(account.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无搜索结果 -->
|
||||||
|
<div v-if="searchQuery && !hasResults" class="px-4 py-8 text-center text-gray-500">
|
||||||
|
<i class="fas fa-search text-2xl mb-2" />
|
||||||
|
<p class="text-sm">没有找到匹配的账号</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => ['claude', 'gemini'].includes(value)
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '请选择账号'
|
||||||
|
},
|
||||||
|
defaultOptionText: {
|
||||||
|
type: String,
|
||||||
|
default: '使用共享账号池'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchInput = ref(null)
|
||||||
|
const dropdownRef = ref(null)
|
||||||
|
const dropdownStyle = ref({})
|
||||||
|
const triggerRef = ref(null)
|
||||||
|
const lastDirection = ref('') // 记住上次的显示方向
|
||||||
|
|
||||||
|
// 获取选中的标签
|
||||||
|
const selectedLabel = computed(() => {
|
||||||
|
// 如果没有选中值,显示默认选项文本
|
||||||
|
if (!props.modelValue) return props.defaultOptionText
|
||||||
|
|
||||||
|
// 分组
|
||||||
|
if (props.modelValue.startsWith('group:')) {
|
||||||
|
const groupId = props.modelValue.substring(6)
|
||||||
|
const group = props.groups.find(g => g.id === groupId)
|
||||||
|
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console 账号
|
||||||
|
if (props.modelValue.startsWith('console:')) {
|
||||||
|
const accountId = props.modelValue.substring(8)
|
||||||
|
const account = props.accounts.find(a => a.id === accountId && a.platform === 'claude-console')
|
||||||
|
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth 账号
|
||||||
|
const account = props.accounts.find(a => a.id === props.modelValue)
|
||||||
|
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按创建时间倒序排序账号
|
||||||
|
const sortedAccounts = computed(() => {
|
||||||
|
return [...props.accounts].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.createdAt || 0)
|
||||||
|
const dateB = new Date(b.createdAt || 0)
|
||||||
|
return dateB - dateA // 倒序排序
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤的分组
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
if (!searchQuery.value) return props.groups
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return props.groups.filter(group =>
|
||||||
|
group.name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤的 OAuth 账号
|
||||||
|
const filteredOAuthAccounts = computed(() => {
|
||||||
|
let accounts = sortedAccounts.value.filter(a =>
|
||||||
|
a.accountType === 'dedicated' &&
|
||||||
|
(props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
accounts = accounts.filter(account =>
|
||||||
|
account.name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤的 Console 账号
|
||||||
|
const filteredConsoleAccounts = computed(() => {
|
||||||
|
if (props.platform !== 'claude') return []
|
||||||
|
|
||||||
|
let accounts = sortedAccounts.value.filter(a =>
|
||||||
|
a.accountType === 'dedicated' && a.platform === 'claude-console'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
accounts = accounts.filter(account =>
|
||||||
|
account.name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否有搜索结果
|
||||||
|
const hasResults = computed(() => {
|
||||||
|
return filteredGroups.value.length > 0 ||
|
||||||
|
filteredOAuthAccounts.value.length > 0 ||
|
||||||
|
filteredConsoleAccounts.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffInHours = (now - date) / (1000 * 60 * 60)
|
||||||
|
|
||||||
|
if (diffInHours < 24) {
|
||||||
|
return '今天创建'
|
||||||
|
} else if (diffInHours < 48) {
|
||||||
|
return '昨天创建'
|
||||||
|
} else if (diffInHours < 168) { // 7天内
|
||||||
|
return `${Math.floor(diffInHours / 24)} 天前`
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新下拉菜单位置
|
||||||
|
const updateDropdownPosition = () => {
|
||||||
|
if (!showDropdown.value || !dropdownRef.value || !triggerRef.value) return
|
||||||
|
|
||||||
|
const trigger = triggerRef.value
|
||||||
|
if (!trigger) return
|
||||||
|
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const windowWidth = window.innerWidth
|
||||||
|
const spaceBelow = windowHeight - rect.bottom
|
||||||
|
const spaceAbove = rect.top
|
||||||
|
const margin = 8 // 边距
|
||||||
|
|
||||||
|
// 获取下拉框的高度
|
||||||
|
const dropdownHeight = dropdownRef.value.offsetHeight
|
||||||
|
|
||||||
|
// 计算最大可用高度
|
||||||
|
const maxHeightBelow = spaceBelow - margin
|
||||||
|
const maxHeightAbove = spaceAbove - margin
|
||||||
|
|
||||||
|
// 决定显示方向和最大高度
|
||||||
|
let showAbove = false
|
||||||
|
let maxHeight = maxHeightBelow
|
||||||
|
|
||||||
|
// 优先使用上次的方向,除非空间不足
|
||||||
|
if (lastDirection.value === 'above' && maxHeightAbove >= 150) {
|
||||||
|
showAbove = true
|
||||||
|
maxHeight = maxHeightAbove
|
||||||
|
} else if (lastDirection.value === 'below' && maxHeightBelow >= 150) {
|
||||||
|
showAbove = false
|
||||||
|
maxHeight = maxHeightBelow
|
||||||
|
} else {
|
||||||
|
// 如果没有历史方向或空间不足,选择空间更大的方向
|
||||||
|
if (maxHeightAbove > maxHeightBelow && maxHeightBelow < 200) {
|
||||||
|
showAbove = true
|
||||||
|
maxHeight = maxHeightAbove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记住这次的方向
|
||||||
|
lastDirection.value = showAbove ? 'above' : 'below'
|
||||||
|
|
||||||
|
// 确保下拉框不超出视窗左右边界
|
||||||
|
let left = rect.left
|
||||||
|
const dropdownWidth = rect.width
|
||||||
|
if (left + dropdownWidth > windowWidth - margin) {
|
||||||
|
left = windowWidth - dropdownWidth - margin
|
||||||
|
}
|
||||||
|
if (left < margin) {
|
||||||
|
left = margin
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdownStyle.value = {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
maxHeight: `${Math.min(maxHeight, 400)}px`, // 限制最大高度为400px
|
||||||
|
...(showAbove
|
||||||
|
? { bottom: `${windowHeight - rect.top}px` }
|
||||||
|
: { top: `${rect.bottom}px` }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换下拉菜单
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
if (!showDropdown.value && triggerRef.value) {
|
||||||
|
// 在显示前就设置初始样式,避免闪烁
|
||||||
|
const rect = triggerRef.value.getBoundingClientRect()
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const spaceBelow = windowHeight - rect.bottom
|
||||||
|
const margin = 8
|
||||||
|
|
||||||
|
// 预先设置一个合理的初始位置
|
||||||
|
dropdownStyle.value = {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
maxHeight: `${Math.min(spaceBelow - margin, 400)}px`,
|
||||||
|
top: `${rect.bottom}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showDropdown.value = !showDropdown.value
|
||||||
|
if (showDropdown.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
updateDropdownPosition()
|
||||||
|
searchInput.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择账号
|
||||||
|
const selectAccount = (value) => {
|
||||||
|
emit('update:modelValue', value || '')
|
||||||
|
showDropdown.value = false
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
// 搜索时自动触发
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除搜索
|
||||||
|
const clearSearch = () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
searchInput.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (!triggerRef.value?.contains(event.target) && !dropdownRef.value?.contains(event.target)) {
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听滚动更新位置
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (showDropdown.value) {
|
||||||
|
updateDropdownPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
window.addEventListener('scroll', handleScroll, true)
|
||||||
|
window.addEventListener('resize', updateDropdownPosition)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('scroll', handleScroll, true)
|
||||||
|
window.removeEventListener('resize', updateDropdownPosition)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听下拉菜单状态变化
|
||||||
|
watch(showDropdown, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
// 关闭时重置方向,下次打开重新计算
|
||||||
|
lastDirection.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e0 #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #a0aec0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user