Merge branch 'main' into test-sora
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-draggable-plus": "^0.6.1",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5"
|
||||
|
||||
32
frontend/pnpm-lock.yaml
generated
32
frontend/pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
vue-chartjs:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3))
|
||||
vue-draggable-plus:
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1(@types/sortablejs@1.15.9)
|
||||
vue-i18n:
|
||||
specifier: ^9.14.5
|
||||
version: 9.14.5(vue@3.5.26(typescript@5.6.3))
|
||||
@@ -1254,67 +1257,56 @@ packages:
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
@@ -1515,6 +1507,9 @@ packages:
|
||||
'@types/react@19.2.7':
|
||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||
|
||||
'@types/sortablejs@1.15.9':
|
||||
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -4298,6 +4293,15 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-draggable-plus@0.6.1:
|
||||
resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==}
|
||||
peerDependencies:
|
||||
'@types/sortablejs': ^1.15.0
|
||||
'@vue/composition-api': '*'
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
@@ -5958,6 +5962,8 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/sortablejs@1.15.9': {}
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
@@ -9401,6 +9407,10 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.6.3)
|
||||
|
||||
vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9):
|
||||
dependencies:
|
||||
'@types/sortablejs': 1.15.9
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
@@ -327,11 +327,34 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
||||
return data
|
||||
}
|
||||
|
||||
export interface CRSPreviewAccount {
|
||||
crs_account_id: string
|
||||
kind: string
|
||||
name: string
|
||||
platform: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface PreviewFromCRSResult {
|
||||
new_accounts: CRSPreviewAccount[]
|
||||
existing_accounts: CRSPreviewAccount[]
|
||||
}
|
||||
|
||||
export async function previewFromCrs(params: {
|
||||
base_url: string
|
||||
username: string
|
||||
password: string
|
||||
}): Promise<PreviewFromCRSResult> {
|
||||
const { data } = await apiClient.post<PreviewFromCRSResult>('/admin/accounts/sync/crs/preview', params)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function syncFromCrs(params: {
|
||||
base_url: string
|
||||
username: string
|
||||
password: string
|
||||
sync_proxies?: boolean
|
||||
selected_account_ids?: string[]
|
||||
}): Promise<{
|
||||
created: number
|
||||
updated: number
|
||||
@@ -345,7 +368,19 @@ export async function syncFromCrs(params: {
|
||||
error?: string
|
||||
}>
|
||||
}> {
|
||||
const { data } = await apiClient.post('/admin/accounts/sync/crs', params)
|
||||
const { data } = await apiClient.post<{
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
items: Array<{
|
||||
crs_account_id: string
|
||||
kind: string
|
||||
name: string
|
||||
action: string
|
||||
error?: string
|
||||
}>
|
||||
}>('/admin/accounts/sync/crs', params)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -398,6 +433,26 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OpenAI token using refresh token
|
||||
* @param refreshToken - The refresh token
|
||||
* @param proxyId - Optional proxy ID
|
||||
* @returns Token information including access_token, email, etc.
|
||||
*/
|
||||
export async function refreshOpenAIToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: { refresh_token: string; proxy_id?: number } = {
|
||||
refresh_token: refreshToken
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
const { data } = await apiClient.post<Record<string, unknown>>('/admin/openai/refresh-token', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
getById,
|
||||
@@ -418,9 +473,11 @@ export const accountsAPI = {
|
||||
getAvailableModels,
|
||||
generateAuthUrl,
|
||||
exchangeCode,
|
||||
refreshOpenAIToken,
|
||||
batchCreate,
|
||||
batchUpdateCredentials,
|
||||
bulkUpdate,
|
||||
previewFromCrs,
|
||||
syncFromCrs,
|
||||
exportData,
|
||||
importData,
|
||||
|
||||
@@ -153,6 +153,20 @@ export async function getGroupApiKeys(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group sort orders
|
||||
* @param updates - Array of { id, sort_order } objects
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function updateSortOrder(
|
||||
updates: Array<{ id: number; sort_order: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', {
|
||||
updates
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -163,7 +177,8 @@ export const groupsAPI = {
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys
|
||||
getGroupApiKeys,
|
||||
updateSortOrder
|
||||
}
|
||||
|
||||
export default groupsAPI
|
||||
|
||||
@@ -376,7 +376,6 @@ export interface PlatformAvailability {
|
||||
total_accounts: number
|
||||
available_count: number
|
||||
rate_limit_count: number
|
||||
scope_rate_limit_count?: Record<string, number>
|
||||
error_count: number
|
||||
}
|
||||
|
||||
@@ -387,7 +386,6 @@ export interface GroupAvailability {
|
||||
total_accounts: number
|
||||
available_count: number
|
||||
rate_limit_count: number
|
||||
scope_rate_limit_count?: Record<string, number>
|
||||
error_count: number
|
||||
}
|
||||
|
||||
@@ -402,7 +400,6 @@ export interface AccountAvailability {
|
||||
is_rate_limited: boolean
|
||||
rate_limit_reset_at?: string
|
||||
rate_limit_remaining_sec?: number
|
||||
scope_rate_limits?: Record<string, number>
|
||||
is_overloaded: boolean
|
||||
overload_until?: string
|
||||
overload_remaining_sec?: number
|
||||
|
||||
@@ -76,26 +76,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scope Rate Limit Indicators (Antigravity) -->
|
||||
<template v-if="activeScopeRateLimits.length > 0">
|
||||
<div v-for="item in activeScopeRateLimits" :key="item.scope" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-orange-100 px-1.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
{{ formatScopeName(item.scope) }}
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), time: formatTime(item.reset_at) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" ></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
|
||||
<template v-if="activeModelRateLimits.length > 0">
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative">
|
||||
@@ -160,15 +140,6 @@ const isRateLimited = computed(() => {
|
||||
return new Date(props.account.rate_limit_reset_at) > new Date()
|
||||
})
|
||||
|
||||
// Computed: active scope rate limits (Antigravity)
|
||||
const activeScopeRateLimits = computed(() => {
|
||||
const scopeLimits = props.account.scope_rate_limits
|
||||
if (!scopeLimits) return []
|
||||
const now = new Date()
|
||||
return Object.entries(scopeLimits)
|
||||
.filter(([, info]) => new Date(info.reset_at) > now)
|
||||
.map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
|
||||
})
|
||||
|
||||
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
|
||||
const activeModelRateLimits = computed(() => {
|
||||
|
||||
@@ -1038,10 +1038,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
<div
|
||||
v-if="form.platform !== 'gemini'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||||
@@ -1676,10 +1673,12 @@
|
||||
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
||||
:allow-multiple="form.platform === 'anthropic'"
|
||||
:show-cookie-option="form.platform === 'anthropic'"
|
||||
:show-refresh-token-option="form.platform === 'openai'"
|
||||
:platform="form.platform"
|
||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@validate-refresh-token="handleOpenAIValidateRT"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -2036,6 +2035,7 @@ interface OAuthFlowExposed {
|
||||
oauthState: string
|
||||
projectId: string
|
||||
sessionKey: string
|
||||
refreshToken: string
|
||||
inputMethod: AuthInputMethod
|
||||
reset: () => void
|
||||
}
|
||||
@@ -2316,9 +2316,9 @@ watch(
|
||||
watch(
|
||||
[accountCategory, addMethod, antigravityAccountType],
|
||||
([category, method, agType]) => {
|
||||
// Antigravity upstream 类型
|
||||
// Antigravity upstream 类型(实际创建为 apikey)
|
||||
if (form.platform === 'antigravity' && agType === 'upstream') {
|
||||
form.type = 'upstream'
|
||||
form.type = 'apikey'
|
||||
return
|
||||
}
|
||||
if (category === 'oauth-based') {
|
||||
@@ -2742,7 +2742,8 @@ const handleSubmit = async () => {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAccountAndFinish(form.platform, 'upstream', credentials)
|
||||
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||
await createAccountAndFinish(form.platform, 'apikey', credentials, extra)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
||||
} finally {
|
||||
@@ -2953,6 +2954,95 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI 手动 RT 批量验证和创建
|
||||
const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
|
||||
if (!refreshTokenInput.trim()) return
|
||||
|
||||
// Parse multiple refresh tokens (one per line)
|
||||
const refreshTokens = refreshTokenInput
|
||||
.split('\n')
|
||||
.map((rt) => rt.trim())
|
||||
.filter((rt) => rt)
|
||||
|
||||
if (refreshTokens.length === 0) {
|
||||
openaiOAuth.error.value = t('admin.accounts.oauth.openai.pleaseEnterRefreshToken')
|
||||
return
|
||||
}
|
||||
|
||||
openaiOAuth.loading.value = true
|
||||
openaiOAuth.error.value = ''
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < refreshTokens.length; i++) {
|
||||
try {
|
||||
const tokenInfo = await openaiOAuth.validateRefreshToken(
|
||||
refreshTokens[i],
|
||||
form.proxy_id
|
||||
)
|
||||
if (!tokenInfo) {
|
||||
failedCount++
|
||||
errors.push(`#${i + 1}: ${openaiOAuth.error.value || 'Validation failed'}`)
|
||||
openaiOAuth.error.value = ''
|
||||
continue
|
||||
}
|
||||
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Generate account name with index for batch
|
||||
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
notes: form.notes,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failedCount++
|
||||
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||
errors.push(`#${i + 1}: ${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Show results
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
appStore.showSuccess(
|
||||
refreshTokens.length > 1
|
||||
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||
: t('admin.accounts.accountCreated')
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
} else if (successCount > 0 && failedCount > 0) {
|
||||
appStore.showWarning(
|
||||
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
|
||||
)
|
||||
openaiOAuth.error.value = errors.join('\n')
|
||||
emit('created')
|
||||
} else {
|
||||
openaiOAuth.error.value = errors.join('\n')
|
||||
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||
}
|
||||
} finally {
|
||||
openaiOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini OAuth 授权码兑换
|
||||
const handleGeminiExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
||||
|
||||
@@ -364,6 +364,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upstream fields (only for upstream type) -->
|
||||
<div v-if="account.type === 'upstream'" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.upstream.baseUrl') }}</label>
|
||||
<input
|
||||
v-model="editBaseUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="https://s.konstants.xyz"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.upstream.baseUrlHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.upstream.apiKey') }}</label>
|
||||
<input
|
||||
v-model="editApiKey"
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antigravity model restriction (applies to all antigravity types) -->
|
||||
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
|
||||
<div v-if="account.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
@@ -1244,6 +1268,9 @@ watch(
|
||||
} else {
|
||||
selectedErrorCodes.value = []
|
||||
}
|
||||
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
editBaseUrl.value = (credentials.base_url as string) || ''
|
||||
} else {
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai'
|
||||
@@ -1584,6 +1611,22 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
} else if (props.account.type === 'upstream') {
|
||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
|
||||
newCredentials.base_url = editBaseUrl.value.trim()
|
||||
|
||||
if (editApiKey.value.trim()) {
|
||||
newCredentials.api_key = editApiKey.value.trim()
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
} else {
|
||||
// For oauth/setup-token types, only update intercept_warmup_requests if changed
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
|
||||
|
||||
<!-- Auth Method Selection -->
|
||||
<div v-if="showCookieOption" class="mb-4">
|
||||
<div v-if="showMethodSelection" class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ methodLabel }}
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
@@ -26,7 +26,7 @@
|
||||
t('admin.accounts.oauth.manualAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<label v-if="showCookieOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
@@ -37,6 +37,101 @@
|
||||
t('admin.accounts.oauth.cookieAutoAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="refresh_token"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t('admin.accounts.oauth.openai.refreshTokenAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Token Input (OpenAI only) -->
|
||||
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }}
|
||||
</p>
|
||||
|
||||
<!-- Refresh Token Input -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
Refresh Token
|
||||
<span
|
||||
v-if="parsedRefreshTokenCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedRefreshTokenCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="refreshTokenInput"
|
||||
rows="3"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedRefreshTokenCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Validate Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !refreshTokenInput.trim()"
|
||||
@click="handleValidateRefreshToken"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{
|
||||
loading
|
||||
? t('admin.accounts.oauth.openai.validating')
|
||||
: t('admin.accounts.oauth.openai.validateAndCreate')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Manual Authorization Flow -->
|
||||
<div v-else class="space-y-4">
|
||||
<div v-if="inputMethod === 'manual'" class="space-y-4">
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ oauthFollowSteps }}
|
||||
</p>
|
||||
@@ -426,12 +521,13 @@ interface Props {
|
||||
error?: string
|
||||
showHelp?: boolean
|
||||
showProxyWarning?: boolean
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
authUrl: '',
|
||||
@@ -443,6 +539,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
allowMultiple: false,
|
||||
methodLabel: 'Authorization Method',
|
||||
showCookieOption: true,
|
||||
showRefreshTokenOption: false,
|
||||
platform: 'anthropic',
|
||||
showProjectId: true
|
||||
})
|
||||
@@ -451,6 +548,7 @@ const emit = defineEmits<{
|
||||
'generate-url': []
|
||||
'exchange-code': [code: string]
|
||||
'cookie-auth': [sessionKey: string]
|
||||
'validate-refresh-token': [refreshToken: string]
|
||||
'update:inputMethod': [method: AuthInputMethod]
|
||||
}>()
|
||||
|
||||
@@ -488,10 +586,14 @@ const oauthImportantNotice = computed(() => {
|
||||
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const refreshTokenInput = ref('')
|
||||
const showHelpDialog = ref(false)
|
||||
const oauthState = ref('')
|
||||
const projectId = ref('')
|
||||
|
||||
// Computed: show method selection when either cookie or refresh token option is enabled
|
||||
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption)
|
||||
|
||||
// Clipboard
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
@@ -503,6 +605,14 @@ const parsedKeyCount = computed(() => {
|
||||
.filter((k) => k).length
|
||||
})
|
||||
|
||||
// Computed: count of refresh tokens entered
|
||||
const parsedRefreshTokenCount = computed(() => {
|
||||
return refreshTokenInput.value
|
||||
.split('\n')
|
||||
.map((rt) => rt.trim())
|
||||
.filter((rt) => rt).length
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
@@ -564,18 +674,26 @@ const handleCookieAuth = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateRefreshToken = () => {
|
||||
if (refreshTokenInput.value.trim()) {
|
||||
emit('validate-refresh-token', refreshTokenInput.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods and state
|
||||
defineExpose({
|
||||
authCode: authCodeInput,
|
||||
oauthState,
|
||||
projectId,
|
||||
sessionKey: sessionKeyInput,
|
||||
refreshToken: refreshTokenInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
oauthState.value = ''
|
||||
projectId.value = ''
|
||||
sessionKeyInput.value = ''
|
||||
refreshTokenInput.value = ''
|
||||
inputMethod.value = 'manual'
|
||||
showHelpDialog.value = false
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
|
||||
<!-- Step 1: Input credentials -->
|
||||
<form
|
||||
v-if="currentStep === 'input'"
|
||||
id="sync-from-crs-form"
|
||||
class="space-y-4"
|
||||
@submit.prevent="handlePreview"
|
||||
>
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
已有账号仅同步 CRS
|
||||
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
||||
{{ t('admin.accounts.crsUpdateBehaviorNote') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
@@ -24,26 +29,30 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
||||
<label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
|
||||
<input
|
||||
id="crs-base-url"
|
||||
v-model="form.base_url"
|
||||
type="text"
|
||||
class="input"
|
||||
required
|
||||
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||
<input v-model="form.username" type="text" class="input" autocomplete="username" />
|
||||
<label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||
<input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||
<label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||
<input
|
||||
id="crs-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="input"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -58,9 +67,101 @@
|
||||
{{ t('admin.accounts.syncProxies') }}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Step 2: Preview & select -->
|
||||
<div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4">
|
||||
<!-- Existing accounts (read-only info) -->
|
||||
<div
|
||||
v-if="previewResult.existing_accounts.length"
|
||||
class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
|
||||
>
|
||||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.accounts.crsExistingAccounts') }}
|
||||
<span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span>
|
||||
</div>
|
||||
<div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400">
|
||||
<div
|
||||
v-for="acc in previewResult.existing_accounts"
|
||||
:key="acc.crs_account_id"
|
||||
class="flex items-center gap-2 py-0.5"
|
||||
>
|
||||
<span
|
||||
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>{{ acc.platform }} / {{ acc.type }}</span>
|
||||
<span class="truncate">{{ acc.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New accounts (selectable) -->
|
||||
<div v-if="previewResult.new_accounts.length">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.crsNewAccounts') }}
|
||||
<span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
@click="selectAll"
|
||||
>{{ t('admin.accounts.crsSelectAll') }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
|
||||
@click="selectNone"
|
||||
>{{ t('admin.accounts.crsSelectNone') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
|
||||
>
|
||||
<label
|
||||
v-for="acc in previewResult.new_accounts"
|
||||
:key="acc.crs_account_id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(acc.crs_account_id)"
|
||||
class="rounded border-gray-300 dark:border-dark-600"
|
||||
@change="toggleSelect(acc.crs_account_id)"
|
||||
/>
|
||||
<span
|
||||
class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
>{{ acc.platform }} / {{ acc.type }}</span>
|
||||
<span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync options summary -->
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
|
||||
<span>{{ t('admin.accounts.syncProxies') }}:</span>
|
||||
<span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'">
|
||||
{{ form.sync_proxies ? t('common.yes') : t('common.no') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- No new accounts -->
|
||||
<div
|
||||
v-if="!previewResult.new_accounts.length"
|
||||
class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.accounts.crsNoNewAccounts') }}
|
||||
<span v-if="previewResult.existing_accounts.length">
|
||||
{{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Result -->
|
||||
<div v-else-if="currentStep === 'result' && result" class="space-y-4">
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -84,21 +185,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="sync-from-crs-form"
|
||||
:disabled="syncing"
|
||||
>
|
||||
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
||||
</button>
|
||||
<!-- Step 1: Input -->
|
||||
<template v-if="currentStep === 'input'">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="previewing"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="sync-from-crs-form"
|
||||
:disabled="previewing"
|
||||
>
|
||||
{{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Preview -->
|
||||
<template v-else-if="currentStep === 'preview'">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="syncing"
|
||||
@click="handleBack"
|
||||
>
|
||||
{{ t('admin.accounts.crsBack') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="syncing || hasNewButNoneSelected"
|
||||
@click="handleSync"
|
||||
>
|
||||
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: Result -->
|
||||
<template v-else-if="currentStep === 'result'">
|
||||
<button class="btn btn-secondary" type="button" @click="handleClose">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { PreviewFromCRSResult } from '@/api/admin/accounts'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -126,7 +263,12 @@ const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
type Step = 'input' | 'preview' | 'result'
|
||||
const currentStep = ref<Step>('input')
|
||||
const previewing = ref(false)
|
||||
const syncing = ref(false)
|
||||
const previewResult = ref<PreviewFromCRSResult | null>(null)
|
||||
const selectedIds = ref(new Set<string>())
|
||||
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
@@ -136,28 +278,90 @@ const form = reactive({
|
||||
sync_proxies: true
|
||||
})
|
||||
|
||||
const hasNewButNoneSelected = computed(() => {
|
||||
if (!previewResult.value) return false
|
||||
return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0
|
||||
})
|
||||
|
||||
const errorItems = computed(() => {
|
||||
if (!result.value?.items) return []
|
||||
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
|
||||
return result.value.items.filter(
|
||||
(i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected')
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
currentStep.value = 'input'
|
||||
previewResult.value = null
|
||||
selectedIds.value = new Set()
|
||||
result.value = null
|
||||
form.base_url = ''
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.sync_proxies = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
// 防止在同步进行中关闭对话框
|
||||
if (syncing.value) {
|
||||
if (syncing.value || previewing.value) {
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
currentStep.value = 'input'
|
||||
previewResult.value = null
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (!previewResult.value) return
|
||||
selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id))
|
||||
}
|
||||
|
||||
const selectNone = () => {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const s = new Set(selectedIds.value)
|
||||
if (s.has(id)) {
|
||||
s.delete(id)
|
||||
} else {
|
||||
s.add(id)
|
||||
}
|
||||
selectedIds.value = s
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||
return
|
||||
}
|
||||
|
||||
previewing.value = true
|
||||
try {
|
||||
const res = await adminAPI.accounts.previewFromCrs({
|
||||
base_url: form.base_url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password
|
||||
})
|
||||
previewResult.value = res
|
||||
// Auto-select all new accounts
|
||||
selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id))
|
||||
currentStep.value = 'preview'
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed'))
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
|
||||
appStore.showError(t('admin.accounts.syncMissingFields'))
|
||||
@@ -170,16 +374,18 @@ const handleSync = async () => {
|
||||
base_url: form.base_url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
sync_proxies: form.sync_proxies
|
||||
sync_proxies: form.sync_proxies,
|
||||
selected_account_ids: [...selectedIds.value]
|
||||
})
|
||||
result.value = res
|
||||
currentStep.value = 'result'
|
||||
|
||||
if (res.failed > 0) {
|
||||
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
|
||||
emit('synced')
|
||||
}
|
||||
emit('synced')
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
|
||||
} finally {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
/>
|
||||
<GroupBadge
|
||||
:name="group.name"
|
||||
:platform="group.platform"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
class="min-w-0 flex-1"
|
||||
|
||||
@@ -58,6 +58,7 @@ const icons = {
|
||||
arrowLeft: 'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18',
|
||||
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
|
||||
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
|
||||
arrowsUpDown: 'M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5',
|
||||
chevronUp: 'M5 15l7-7 7 7',
|
||||
externalLink: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
|
||||
|
||||
|
||||
43
frontend/src/components/user/UserConcurrencyCell.vue
Normal file
43
frontend/src/components/user/UserConcurrencyCell.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
statusClass
|
||||
]"
|
||||
>
|
||||
<!-- Four-square grid icon -->
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ current }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ max }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
max: number
|
||||
}>()
|
||||
|
||||
// Status color based on usage
|
||||
const statusClass = computed(() => {
|
||||
const { current, max } = props
|
||||
|
||||
// Full: red
|
||||
if (current >= max && max > 0) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
// In use: yellow
|
||||
if (current > 0) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
// Idle: gray
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
})
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export type AddMethod = 'oauth' | 'setup-token'
|
||||
export type AuthInputMethod = 'manual' | 'cookie'
|
||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token'
|
||||
|
||||
export interface OAuthState {
|
||||
authUrl: string
|
||||
|
||||
@@ -105,6 +105,32 @@ export function useOpenAIOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate refresh token and get full token info
|
||||
const validateRefreshToken = async (
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!refreshToken.trim()) {
|
||||
error.value = 'Missing refresh token'
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// Use dedicated refresh-token endpoint
|
||||
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(refreshToken.trim(), proxyId)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to validate refresh token'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build credentials for OpenAI OAuth account
|
||||
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
|
||||
const creds: Record<string, unknown> = {
|
||||
@@ -152,6 +178,7 @@ export function useOpenAIOAuth() {
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
|
||||
@@ -1042,6 +1042,10 @@ export default {
|
||||
createGroup: 'Create Group',
|
||||
editGroup: 'Edit Group',
|
||||
deleteGroup: 'Delete Group',
|
||||
sortOrder: 'Sort',
|
||||
sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first',
|
||||
sortOrderUpdated: 'Sort order updated',
|
||||
failedToUpdateSortOrder: 'Failed to update sort order',
|
||||
allPlatforms: 'All Platforms',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
@@ -1314,10 +1318,23 @@ export default {
|
||||
syncResult: 'Sync Result',
|
||||
syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}',
|
||||
syncErrors: 'Errors / Skipped Details',
|
||||
syncCompleted: 'Sync completed: created {created}, updated {updated}',
|
||||
syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}',
|
||||
syncCompletedWithErrors:
|
||||
'Sync completed with errors: failed {failed} (created {created}, updated {updated})',
|
||||
'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})',
|
||||
syncFailed: 'Sync failed',
|
||||
crsPreview: 'Preview',
|
||||
crsPreviewing: 'Previewing...',
|
||||
crsPreviewFailed: 'Preview failed',
|
||||
crsExistingAccounts: 'Existing accounts (will be updated)',
|
||||
crsNewAccounts: 'New accounts (select to sync)',
|
||||
crsSelectAll: 'Select all',
|
||||
crsSelectNone: 'Select none',
|
||||
crsNoNewAccounts: 'All CRS accounts are already synced.',
|
||||
crsWillUpdate: 'Will update {count} existing accounts.',
|
||||
crsSelectedCount: '{count} new accounts selected',
|
||||
crsUpdateBehaviorNote:
|
||||
'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.',
|
||||
crsBack: 'Back',
|
||||
editAccount: 'Edit Account',
|
||||
deleteAccount: 'Delete Account',
|
||||
searchAccounts: 'Search accounts...',
|
||||
@@ -1366,7 +1383,6 @@ export default {
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
scopeRateLimitedUntil: '{scope} rate limited until {time}',
|
||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
@@ -1679,6 +1695,9 @@ export default {
|
||||
cookieAuthFailed: 'Cookie authorization failed',
|
||||
keyAuthFailed: 'Key {index}: {error}',
|
||||
successCreated: 'Successfully created {count} account(s)',
|
||||
batchSuccess: 'Successfully created {count} account(s)',
|
||||
batchPartialSuccess: 'Partial success: {success} succeeded, {failed} failed',
|
||||
batchFailed: 'Batch creation failed',
|
||||
// OpenAI specific
|
||||
openai: {
|
||||
title: 'OpenAI Account Authorization',
|
||||
@@ -1697,7 +1716,14 @@ export default {
|
||||
authCodePlaceholder:
|
||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
authCodeHint:
|
||||
'You can copy the entire URL or just the code parameter value, the system will auto-detect'
|
||||
'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||
// Refresh Token auth
|
||||
refreshTokenAuth: 'Manual RT Input',
|
||||
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
|
||||
validating: 'Validating...',
|
||||
validateAndCreate: 'Validate & Create Account',
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
@@ -3066,7 +3092,6 @@ export default {
|
||||
empty: 'No data',
|
||||
queued: 'Queue {count}',
|
||||
rateLimited: 'Rate-limited {count}',
|
||||
scopeRateLimitedTooltip: '{scope} rate-limited ({count} accounts)',
|
||||
errorAccounts: 'Errors {count}',
|
||||
loadFailed: 'Failed to load concurrency data'
|
||||
},
|
||||
|
||||
@@ -1099,6 +1099,10 @@ export default {
|
||||
createGroup: '创建分组',
|
||||
editGroup: '编辑分组',
|
||||
deleteGroup: '删除分组',
|
||||
sortOrder: '排序',
|
||||
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
|
||||
sortOrderUpdated: '排序已更新',
|
||||
failedToUpdateSortOrder: '更新排序失败',
|
||||
deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。",
|
||||
deleteConfirmSubscription:
|
||||
"确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。",
|
||||
@@ -1402,9 +1406,22 @@ export default {
|
||||
syncResult: '同步结果',
|
||||
syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}',
|
||||
syncErrors: '错误/跳过详情',
|
||||
syncCompleted: '同步完成:创建 {created},更新 {updated}',
|
||||
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})',
|
||||
syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}',
|
||||
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})',
|
||||
syncFailed: '同步失败',
|
||||
crsPreview: '预览',
|
||||
crsPreviewing: '预览中...',
|
||||
crsPreviewFailed: '预览失败',
|
||||
crsExistingAccounts: '将自动更新的已有账号',
|
||||
crsNewAccounts: '新账号(可选择)',
|
||||
crsSelectAll: '全选',
|
||||
crsSelectNone: '全不选',
|
||||
crsNoNewAccounts: '所有 CRS 账号均已同步。',
|
||||
crsWillUpdate: '将更新 {count} 个已有账号。',
|
||||
crsSelectedCount: '已选择 {count} 个新账号',
|
||||
crsUpdateBehaviorNote:
|
||||
'已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。',
|
||||
crsBack: '返回',
|
||||
editAccount: '编辑账号',
|
||||
deleteAccount: '删除账号',
|
||||
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
||||
@@ -1502,7 +1519,6 @@ export default {
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
@@ -1821,6 +1837,9 @@ export default {
|
||||
cookieAuthFailed: 'Cookie 授权失败',
|
||||
keyAuthFailed: '密钥 {index}: {error}',
|
||||
successCreated: '成功创建 {count} 个账号',
|
||||
batchSuccess: '成功创建 {count} 个账号',
|
||||
batchPartialSuccess: '部分成功:{success} 个成功,{failed} 个失败',
|
||||
batchFailed: '批量创建失败',
|
||||
// OpenAI specific
|
||||
openai: {
|
||||
title: 'OpenAI 账户授权',
|
||||
@@ -1837,7 +1856,14 @@ export default {
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder:
|
||||
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别'
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||
// Refresh Token auth
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
||||
validating: '验证中...',
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
@@ -3239,7 +3265,6 @@ export default {
|
||||
empty: '暂无数据',
|
||||
queued: '队列 {count}',
|
||||
rateLimited: '限流 {count}',
|
||||
scopeRateLimitedTooltip: '{scope} 限流中 ({count} 个账号)',
|
||||
errorAccounts: '异常 {count}',
|
||||
loadFailed: '加载并发数据失败'
|
||||
},
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface AdminUser extends User {
|
||||
notes: string
|
||||
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
|
||||
group_rates?: Record<number, number>
|
||||
// 当前并发数(仅管理员列表接口返回)
|
||||
current_concurrency?: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
@@ -382,6 +384,9 @@ export interface AdminGroup extends Group {
|
||||
|
||||
// 分组下账号数量(仅管理员可见)
|
||||
account_count?: number
|
||||
|
||||
// 分组排序
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
@@ -602,9 +607,6 @@ export interface Account {
|
||||
temp_unschedulable_until: string | null
|
||||
temp_unschedulable_reason: string | null
|
||||
|
||||
// Antigravity scope 级限流状态
|
||||
scope_rate_limits?: Record<string, { reset_at: string; remaining_sec: number }>
|
||||
|
||||
// Session window fields (5-hour window)
|
||||
session_window_start: string | null
|
||||
session_window_end: string | null
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadAnnouncements"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="openCreateDialog" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.announcements.createAnnouncement') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Left: Search + Filters -->
|
||||
<div class="flex-1 sm:max-w-64">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -29,13 +13,27 @@
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusFilterOptions"
|
||||
class="w-40"
|
||||
@change="handleStatusChange"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusFilterOptions"
|
||||
class="w-40"
|
||||
@change="handleStatusChange"
|
||||
/>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
@click="loadAnnouncements"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="openCreateDialog" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.announcements.createAnnouncement') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button
|
||||
@click="openSortModal"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.groups.sortOrder')"
|
||||
>
|
||||
<Icon name="arrowsUpDown" size="md" class="mr-2" />
|
||||
{{ t('admin.groups.sortOrder') }}
|
||||
</button>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
@@ -1571,6 +1579,92 @@
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Sort Order Modal -->
|
||||
<BaseDialog
|
||||
:show="showSortModal"
|
||||
:title="t('admin.groups.sortOrder')"
|
||||
width="normal"
|
||||
@close="closeSortModal"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.sortOrderHint') }}
|
||||
</p>
|
||||
<VueDraggable
|
||||
v-model="sortableGroups"
|
||||
:animation="200"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="group in sortableGroups"
|
||||
:key="group.id"
|
||||
class="flex cursor-grab items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-shadow hover:shadow-md active:cursor-grabbing dark:border-dark-600 dark:bg-dark-700"
|
||||
>
|
||||
<div class="text-gray-400">
|
||||
<Icon name="menu" size="md" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ group.name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
group.platform === 'anthropic'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: group.platform === 'openai'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
: group.platform === 'antigravity'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.groups.platforms.' + group.platform) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
#{{ group.id }}
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button @click="closeSortModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="saveSortOrder"
|
||||
:disabled="sortSubmitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="sortSubmitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{{ sortSubmitting ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1592,6 +1686,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -1758,9 +1853,12 @@ let abortController: AbortController | null = null
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showSortModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const sortSubmitting = ref(false)
|
||||
const editingGroup = ref<AdminGroup | null>(null)
|
||||
const deletingGroup = ref<AdminGroup | null>(null)
|
||||
const sortableGroups = ref<AdminGroup[]>([])
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
@@ -2237,6 +2335,46 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开排序弹窗
|
||||
const openSortModal = async () => {
|
||||
try {
|
||||
// 获取所有分组(不分页)
|
||||
const allGroups = await adminAPI.groups.getAll()
|
||||
// 按 sort_order 排序
|
||||
sortableGroups.value = [...allGroups].sort((a, b) => a.sort_order - b.sort_order)
|
||||
showSortModal.value = true
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading groups for sorting:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭排序弹窗
|
||||
const closeSortModal = () => {
|
||||
showSortModal.value = false
|
||||
sortableGroups.value = []
|
||||
}
|
||||
|
||||
// 保存排序
|
||||
const saveSortOrder = async () => {
|
||||
sortSubmitting.value = true
|
||||
try {
|
||||
const updates = sortableGroups.value.map((g, index) => ({
|
||||
id: g.id,
|
||||
sort_order: index * 10
|
||||
}))
|
||||
await adminAPI.groups.updateSortOrder(updates)
|
||||
appStore.showSuccess(t('admin.groups.sortOrderUpdated'))
|
||||
closeSortModal()
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdateSortOrder'))
|
||||
console.error('Error updating sort order:', error)
|
||||
} finally {
|
||||
sortSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadCodes"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="showCreateDialog = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.promo.createCode') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Left: Search + Filters -->
|
||||
<div class="flex-1 sm:max-w-64">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -29,13 +13,27 @@
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="filterStatusOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="filterStatusOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
@click="loadCodes"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="showCreateDialog = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.promo.createCode') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,42 @@
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<div class="space-y-3">
|
||||
<!-- Row 1: Actions -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Left: Search + Filters -->
|
||||
<div class="relative w-full sm:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-36">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: All action buttons -->
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
@click="loadProxies"
|
||||
:disabled="loading"
|
||||
@@ -42,41 +75,6 @@
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Search + Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full sm:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-36">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadCodes"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
||||
{{ t('admin.redeem.generateCodes') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.redeem.searchCodes')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Left: Search + Filters -->
|
||||
<div class="flex-1 sm:max-w-64">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.redeem.searchCodes')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.type"
|
||||
:options="filterTypeOptions"
|
||||
@@ -41,9 +25,23 @@
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
<button @click="handleExportCodes" class="btn btn-secondary">
|
||||
{{ t('admin.redeem.exportCsv') }}
|
||||
</button>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
@click="loadCodes"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="handleExportCodes" class="btn btn-secondary">
|
||||
{{ t('admin.redeem.exportCsv') }}
|
||||
</button>
|
||||
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
||||
{{ t('admin.redeem.generateCodes') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<TablePageLayout>
|
||||
<!-- Single Row: Search, Filters, and Actions -->
|
||||
<template #filters>
|
||||
<div class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap-reverse md:items-center md:justify-between md:gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Left: Search + Active Filters -->
|
||||
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3 md:order-1">
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<!-- Search Box -->
|
||||
<div class="relative w-full md:w-64">
|
||||
<Icon
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions and Settings -->
|
||||
<div class="flex w-full items-center justify-between gap-2 md:order-2 md:ml-auto md:max-w-full md:flex-wrap md:justify-end md:gap-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<!-- Mobile: Secondary buttons (icon only) -->
|
||||
<div class="flex items-center gap-2 md:contents">
|
||||
<!-- Refresh Button -->
|
||||
@@ -342,8 +342,11 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-concurrency="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
|
||||
<template #cell-concurrency="{ row }">
|
||||
<UserConcurrencyCell
|
||||
:current="row.current_concurrency ?? 0"
|
||||
:max="row.concurrency"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
@@ -535,6 +538,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
||||
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.vue'
|
||||
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
|
||||
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
|
||||
@@ -56,7 +56,6 @@ interface SummaryRow {
|
||||
total_accounts: number
|
||||
available_accounts: number
|
||||
rate_limited_accounts: number
|
||||
scope_rate_limit_count?: Record<string, number>
|
||||
error_accounts: number
|
||||
// 并发统计
|
||||
total_concurrency: number
|
||||
@@ -122,7 +121,7 @@ const platformRows = computed((): SummaryRow[] => {
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
scope_rate_limit_count: avail.scope_rate_limit_count,
|
||||
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
@@ -162,7 +161,7 @@ const groupRows = computed((): SummaryRow[] => {
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
scope_rate_limit_count: avail.scope_rate_limit_count,
|
||||
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
@@ -329,14 +328,6 @@ function formatDuration(seconds: number): string {
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
function formatScopeName(scope: string): string {
|
||||
const names: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Image'
|
||||
}
|
||||
return names[scope] || scope
|
||||
}
|
||||
|
||||
watch(
|
||||
() => realtimeEnabled.value,
|
||||
@@ -505,18 +496,6 @@ watch(
|
||||
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
|
||||
</span>
|
||||
|
||||
<!-- Scope 限流 (仅 Antigravity) -->
|
||||
<template v-if="row.scope_rate_limit_count && Object.keys(row.scope_rate_limit_count).length > 0">
|
||||
<span
|
||||
v-for="(count, scope) in row.scope_rate_limit_count"
|
||||
:key="scope"
|
||||
class="rounded-full bg-orange-100 px-1.5 py-0.5 font-semibold text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
:title="t('admin.ops.concurrency.scopeRateLimitedTooltip', { scope, count })"
|
||||
>
|
||||
{{ formatScopeName(scope as string) }} {{ count }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 异常账号 -->
|
||||
<span
|
||||
v-if="row.error_accounts > 0"
|
||||
|
||||
Reference in New Issue
Block a user