merge: 合并 main 分支到 test,解决 config 和 modelWhitelist 冲突
- config.go: 保留 Sora 配置,合入 SubscriptionCache 配置 - useModelWhitelist.ts: 同时保留 soraModels 和 antigravityModels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
70
frontend/src/__tests__/integration/data-import.spec.ts
Normal file
70
frontend/src/__tests__/integration/data-import.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
|
||||
|
||||
const showError = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
importData: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ImportDataModal', () => {
|
||||
beforeEach(() => {
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('未选择文件时提示错误', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
|
||||
})
|
||||
|
||||
it('无效 JSON 时提示解析失败', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
|
||||
})
|
||||
})
|
||||
70
frontend/src/__tests__/integration/proxy-data-import.spec.ts
Normal file
70
frontend/src/__tests__/integration/proxy-data-import.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
|
||||
|
||||
const showError = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
proxies: {
|
||||
importData: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('Proxy ImportDataModal', () => {
|
||||
beforeEach(() => {
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('未选择文件时提示错误', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportSelectFile')
|
||||
})
|
||||
|
||||
it('无效 JSON 时提示解析失败', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,9 @@ import type {
|
||||
WindowStats,
|
||||
ClaudeModel,
|
||||
AccountUsageStatsResponse,
|
||||
TempUnschedulableStatus
|
||||
TempUnschedulableStatus,
|
||||
AdminDataPayload,
|
||||
AdminDataImportResult
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -347,6 +349,55 @@ export async function syncFromCrs(params: {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exportData(options?: {
|
||||
ids?: number[]
|
||||
filters?: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
}
|
||||
includeProxies?: boolean
|
||||
}): Promise<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { platform, type, status, search } = options.filters
|
||||
if (platform) params.platform = platform
|
||||
if (type) params.type = type
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
}
|
||||
if (options?.includeProxies === false) {
|
||||
params.include_proxies = 'false'
|
||||
}
|
||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/accounts/data', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function importData(payload: {
|
||||
data: AdminDataPayload
|
||||
skip_default_group_bind?: boolean
|
||||
}): Promise<AdminDataImportResult> {
|
||||
const { data } = await apiClient.post<AdminDataImportResult>('/admin/accounts/data', {
|
||||
data: payload.data,
|
||||
skip_default_group_bind: payload.skip_default_group_bind
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Antigravity default model mapping from backend
|
||||
* @returns Default model mapping (from -> to)
|
||||
*/
|
||||
export async function getAntigravityDefaultModelMapping(): Promise<Record<string, string>> {
|
||||
const { data } = await apiClient.get<Record<string, string>>(
|
||||
'/admin/accounts/antigravity/default-model-mapping'
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
getById,
|
||||
@@ -370,7 +421,10 @@ export const accountsAPI = {
|
||||
batchCreate,
|
||||
batchUpdateCredentials,
|
||||
bulkUpdate,
|
||||
syncFromCrs
|
||||
syncFromCrs,
|
||||
exportData,
|
||||
importData,
|
||||
getAntigravityDefaultModelMapping
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
|
||||
@@ -337,6 +337,22 @@ export interface OpsConcurrencyStatsResponse {
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface UserConcurrencyInfo {
|
||||
user_id: number
|
||||
user_email: string
|
||||
username: string
|
||||
current_in_use: number
|
||||
max_capacity: number
|
||||
load_percentage: number
|
||||
waiting_in_queue: number
|
||||
}
|
||||
|
||||
export interface OpsUserConcurrencyStatsResponse {
|
||||
enabled: boolean
|
||||
user: Record<string, UserConcurrencyInfo>
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export async function getConcurrencyStats(platform?: string, groupId?: number | null): Promise<OpsConcurrencyStatsResponse> {
|
||||
const params: Record<string, any> = {}
|
||||
if (platform) {
|
||||
@@ -350,6 +366,11 @@ export async function getConcurrencyStats(platform?: string, groupId?: number |
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserConcurrencyStats(): Promise<OpsUserConcurrencyStatsResponse> {
|
||||
const { data } = await apiClient.get<OpsUserConcurrencyStatsResponse>('/admin/ops/user-concurrency')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface PlatformAvailability {
|
||||
platform: string
|
||||
total_accounts: number
|
||||
@@ -1171,6 +1192,7 @@ export const opsAPI = {
|
||||
getErrorTrend,
|
||||
getErrorDistribution,
|
||||
getConcurrencyStats,
|
||||
getUserConcurrencyStats,
|
||||
getAccountAvailabilityStats,
|
||||
getRealtimeTrafficSummary,
|
||||
subscribeQPS,
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
ProxyAccountSummary,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse
|
||||
PaginatedResponse,
|
||||
AdminDataPayload,
|
||||
AdminDataImportResult
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -208,6 +210,34 @@ export async function batchDelete(ids: number[]): Promise<{
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exportData(options?: {
|
||||
ids?: number[]
|
||||
filters?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
}
|
||||
}): Promise<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { protocol, status, search } = options.filters
|
||||
if (protocol) params.protocol = protocol
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
}
|
||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function importData(payload: {
|
||||
data: AdminDataPayload
|
||||
}): Promise<AdminDataImportResult> {
|
||||
const { data } = await apiClient.post<AdminDataImportResult>('/admin/proxies/data', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -221,7 +251,9 @@ export const proxiesAPI = {
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
batchDelete
|
||||
batchDelete,
|
||||
exportData,
|
||||
importData
|
||||
}
|
||||
|
||||
export default proxiesAPI
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
@@ -89,6 +90,26 @@
|
||||
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">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
{{ formatScopeName(item.model) }}
|
||||
</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.modelRateLimitedUntil', { model: formatScopeName(item.model), 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>
|
||||
@@ -149,11 +170,28 @@ const activeScopeRateLimits = computed(() => {
|
||||
.map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
|
||||
})
|
||||
|
||||
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
|
||||
const activeModelRateLimits = computed(() => {
|
||||
const modelLimits = (props.account.extra as Record<string, unknown> | undefined)?.model_rate_limits as
|
||||
| Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
| undefined
|
||||
if (!modelLimits) return []
|
||||
const now = new Date()
|
||||
return Object.entries(modelLimits)
|
||||
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
|
||||
.map(([model, info]) => ({ model, reset_at: info.rate_limit_reset_at }))
|
||||
})
|
||||
|
||||
const formatScopeName = (scope: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
claude_sonnet: 'Claude Sonnet',
|
||||
claude_opus: 'Claude Opus',
|
||||
claude_haiku: 'Claude Haiku',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Image'
|
||||
gemini_image: 'Image',
|
||||
gemini_flash: 'Gemini Flash',
|
||||
gemini_pro: 'Gemini Pro'
|
||||
}
|
||||
return names[scope] || scope
|
||||
}
|
||||
|
||||
@@ -925,9 +925,23 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
|
||||
if (enableModelRestriction.value) {
|
||||
const modelMapping = buildModelMappingObject()
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
credentialsChanged = true
|
||||
|
||||
// 统一使用 model_mapping 字段
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
if (allowedModels.value.length > 0) {
|
||||
// 白名单模式:将模型转换为 model_mapping 格式(key=value)
|
||||
const mapping: Record<string, string> = {}
|
||||
for (const m of allowedModels.value) {
|
||||
mapping[m] = m
|
||||
}
|
||||
credentials.model_mapping = mapping
|
||||
credentialsChanged = true
|
||||
}
|
||||
} else {
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
credentialsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.createAccount')"
|
||||
width="normal"
|
||||
width="wide"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step Indicator for OAuth accounts -->
|
||||
@@ -698,6 +698,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
|
||||
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
|
||||
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mapping Mode Only (no toggle for Antigravity) -->
|
||||
<div>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
type="text"
|
||||
:class="[
|
||||
'input flex-1',
|
||||
!isValidWildcardPattern(mapping.from) ? 'border-red-500 dark:border-red-500' : ''
|
||||
]"
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
type="text"
|
||||
:class="[
|
||||
'input flex-1',
|
||||
mapping.to.includes('*') ? 'border-red-500 dark:border-red-500' : ''
|
||||
]"
|
||||
:placeholder="t('admin.accounts.actualModel')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeAntigravityModelMapping(index)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 校验错误提示 -->
|
||||
<p v-if="!isValidWildcardPattern(mapping.from)" class="text-xs text-red-500">
|
||||
{{ t('admin.accounts.wildcardOnlyAtEnd') }}
|
||||
</p>
|
||||
<p v-if="mapping.to.includes('*')" class="text-xs text-red-500">
|
||||
{{ t('admin.accounts.targetNoWildcard') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addAntigravityModelMapping"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in antigravityPresetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addAntigravityPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method (only for Anthropic OAuth-based type) -->
|
||||
<div v-if="form.platform === 'anthropic' && isOAuthFlow">
|
||||
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
|
||||
@@ -1909,7 +2000,15 @@
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { claudeModels, getPresetMappingsByPlatform, getModelsByPlatform, commonErrorCodes, buildModelMappingObject } from '@/composables/useModelWhitelist'
|
||||
import {
|
||||
claudeModels,
|
||||
getPresetMappingsByPlatform,
|
||||
getModelsByPlatform,
|
||||
commonErrorCodes,
|
||||
buildModelMappingObject,
|
||||
fetchAntigravityDefaultMappings,
|
||||
isValidWildcardPattern
|
||||
} from '@/composables/useModelWhitelist'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import {
|
||||
@@ -2049,6 +2148,10 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
|
||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
||||
const upstreamBaseUrl = ref('') // For upstream type: base URL
|
||||
const upstreamApiKey = ref('') // For upstream type: API key
|
||||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const antigravityWhitelistModels = ref<string[]>([])
|
||||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||
@@ -2191,6 +2294,18 @@ watch(
|
||||
if (newVal) {
|
||||
// Modal opened - fill related models
|
||||
allowedModels.value = [...getModelsByPlatform(form.platform)]
|
||||
// Antigravity: 默认使用映射模式并填充默认映射
|
||||
if (form.platform === 'antigravity') {
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
fetchAntigravityDefaultMappings().then(mappings => {
|
||||
antigravityModelMappings.value = [...mappings]
|
||||
})
|
||||
antigravityWhitelistModels.value = []
|
||||
} else {
|
||||
antigravityWhitelistModels.value = []
|
||||
antigravityModelMappings.value = []
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
@@ -2229,15 +2344,24 @@ watch(
|
||||
// Clear model-related settings
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
// Antigravity: 默认使用映射模式并填充默认映射
|
||||
if (newPlatform === 'antigravity') {
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
fetchAntigravityDefaultMappings().then(mappings => {
|
||||
antigravityModelMappings.value = [...mappings]
|
||||
})
|
||||
antigravityWhitelistModels.value = []
|
||||
accountCategory.value = 'oauth-based'
|
||||
antigravityAccountType.value = 'oauth'
|
||||
} else {
|
||||
antigravityWhitelistModels.value = []
|
||||
antigravityModelMappings.value = []
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
}
|
||||
// Reset Anthropic-specific settings when switching to other platforms
|
||||
if (newPlatform !== 'anthropic') {
|
||||
interceptWarmupRequests.value = false
|
||||
}
|
||||
// Antigravity: reset to OAuth by default, but allow upstream selection
|
||||
if (newPlatform === 'antigravity') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
antigravityAccountType.value = 'oauth'
|
||||
}
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
@@ -2281,6 +2405,15 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[antigravityModelRestrictionMode, () => form.platform],
|
||||
([, platform]) => {
|
||||
if (platform !== 'antigravity') return
|
||||
// Antigravity 默认不做限制:白名单留空表示允许所有(包含未来新增模型)。
|
||||
// 如果需要快速填充常用模型,可在组件内点“填充相关模型”。
|
||||
}
|
||||
)
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
@@ -2298,6 +2431,22 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
modelMappings.value.push({ from, to })
|
||||
}
|
||||
|
||||
const addAntigravityModelMapping = () => {
|
||||
antigravityModelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const removeAntigravityModelMapping = (index: number) => {
|
||||
antigravityModelMappings.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addAntigravityPresetMapping = (from: string, to: string) => {
|
||||
if (antigravityModelMappings.value.some((m) => m.from === from)) {
|
||||
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||||
return
|
||||
}
|
||||
antigravityModelMappings.value.push({ from, to })
|
||||
}
|
||||
|
||||
// Error code toggle helper
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
@@ -2455,6 +2604,12 @@ const resetForm = () => {
|
||||
modelMappings.value = []
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = [...claudeModels] // Default fill related models
|
||||
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
antigravityWhitelistModels.value = []
|
||||
fetchAntigravityDefaultMappings().then(mappings => {
|
||||
antigravityModelMappings.value = [...mappings]
|
||||
})
|
||||
customErrorCodesEnabled.value = false
|
||||
selectedErrorCodes.value = []
|
||||
customErrorCodeInput.value = null
|
||||
@@ -2569,12 +2724,24 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Build upstream credentials (and optional model restriction)
|
||||
const credentials: Record<string, unknown> = {
|
||||
base_url: upstreamBaseUrl.value.trim(),
|
||||
api_key: upstreamApiKey.value.trim()
|
||||
}
|
||||
|
||||
// Antigravity 只使用映射模式
|
||||
const antigravityModelMapping = buildModelMappingObject(
|
||||
'mapping',
|
||||
[],
|
||||
antigravityModelMappings.value
|
||||
)
|
||||
if (antigravityModelMapping) {
|
||||
credentials.model_mapping = antigravityModelMapping
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const credentials: Record<string, unknown> = {
|
||||
base_url: upstreamBaseUrl.value.trim(),
|
||||
api_key: upstreamApiKey.value.trim()
|
||||
}
|
||||
await createAccountAndFinish(form.platform, 'upstream', credentials)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
||||
@@ -2845,11 +3012,20 @@ const handleAntigravityExchange = async (authCode: string) => {
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||
await createAccountAndFinish('antigravity', 'oauth', credentials, extra)
|
||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||
// Antigravity 只使用映射模式
|
||||
const antigravityModelMapping = buildModelMappingObject(
|
||||
'mapping',
|
||||
[],
|
||||
antigravityModelMappings.value
|
||||
)
|
||||
if (antigravityModelMapping) {
|
||||
credentials.model_mapping = antigravityModelMapping
|
||||
}
|
||||
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||
await createAccountAndFinish('antigravity', 'oauth', credentials, extra)
|
||||
} catch (error: any) {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(antigravityOAuth.error.value)
|
||||
|
||||
@@ -364,6 +364,96 @@
|
||||
</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">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mapping Mode Only (no toggle for Antigravity) -->
|
||||
<div>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">{{ t('admin.accounts.mapRequestModels') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
type="text"
|
||||
:class="[
|
||||
'input flex-1',
|
||||
!isValidWildcardPattern(mapping.from) ? 'border-red-500 dark:border-red-500' : '',
|
||||
mapping.to.includes('*') ? '' : ''
|
||||
]"
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
type="text"
|
||||
:class="[
|
||||
'input flex-1',
|
||||
mapping.to.includes('*') ? 'border-red-500 dark:border-red-500' : ''
|
||||
]"
|
||||
:placeholder="t('admin.accounts.actualModel')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeAntigravityModelMapping(index)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 校验错误提示 -->
|
||||
<p v-if="!isValidWildcardPattern(mapping.from)" class="text-xs text-red-500">
|
||||
{{ t('admin.accounts.wildcardOnlyAtEnd') }}
|
||||
</p>
|
||||
<p v-if="mapping.to.includes('*')" class="text-xs text-red-500">
|
||||
{{ t('admin.accounts.targetNoWildcard') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addAntigravityModelMapping"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in antigravityPresetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addAntigravityPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temp Unschedulable Rules -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -907,7 +997,8 @@ import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/forma
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
buildModelMappingObject
|
||||
buildModelMappingObject,
|
||||
isValidWildcardPattern
|
||||
} from '@/composables/useModelWhitelist'
|
||||
|
||||
interface Props {
|
||||
@@ -935,6 +1026,8 @@ const baseUrlHint = computed(() => {
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||||
|
||||
// Model mapping type
|
||||
interface ModelMapping {
|
||||
from: string
|
||||
@@ -961,6 +1054,9 @@ const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const antigravityWhitelistModels = ref<string[]>([])
|
||||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
|
||||
@@ -1066,6 +1162,38 @@ watch(
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
|
||||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||||
if (newAccount.platform === 'antigravity') {
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
|
||||
// Antigravity 始终使用映射模式
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
antigravityWhitelistModels.value = []
|
||||
|
||||
// 从 model_mapping 读取映射配置
|
||||
const rawAgMapping = credentials?.model_mapping as Record<string, string> | undefined
|
||||
if (rawAgMapping && typeof rawAgMapping === 'object') {
|
||||
const entries = Object.entries(rawAgMapping)
|
||||
// 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表
|
||||
antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to }))
|
||||
} else {
|
||||
// 兼容旧数据:从 model_whitelist 读取,转换为映射格式
|
||||
const rawWhitelist = credentials?.model_whitelist
|
||||
if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) {
|
||||
antigravityModelMappings.value = rawWhitelist
|
||||
.map((v) => String(v).trim())
|
||||
.filter((v) => v.length > 0)
|
||||
.map((m) => ({ from: m, to: m }))
|
||||
} else {
|
||||
antigravityModelMappings.value = []
|
||||
}
|
||||
}
|
||||
} else {
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
antigravityWhitelistModels.value = []
|
||||
antigravityModelMappings.value = []
|
||||
}
|
||||
|
||||
// Load quota control settings (Anthropic OAuth/SetupToken only)
|
||||
loadQuotaControlSettings(newAccount)
|
||||
|
||||
@@ -1154,6 +1282,23 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
modelMappings.value.push({ from, to })
|
||||
}
|
||||
|
||||
const addAntigravityModelMapping = () => {
|
||||
antigravityModelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const removeAntigravityModelMapping = (index: number) => {
|
||||
antigravityModelMappings.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addAntigravityPresetMapping = (from: string, to: string) => {
|
||||
const exists = antigravityModelMappings.value.some((m) => m.from === from)
|
||||
if (exists) {
|
||||
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||||
return
|
||||
}
|
||||
antigravityModelMappings.value.push({ from, to })
|
||||
}
|
||||
|
||||
// Error code toggle helper
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
@@ -1458,6 +1603,30 @@ const handleSubmit = async () => {
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
|
||||
// Antigravity 只支持映射模式
|
||||
if (props.account.platform === 'antigravity') {
|
||||
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
|
||||
((props.account.credentials as Record<string, unknown>) || {})
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
|
||||
// 移除旧字段
|
||||
delete newCredentials.model_whitelist
|
||||
delete newCredentials.model_mapping
|
||||
|
||||
// 只使用映射模式
|
||||
const antigravityModelMapping = buildModelMappingObject(
|
||||
'mapping',
|
||||
[],
|
||||
antigravityModelMappings.value
|
||||
)
|
||||
if (antigravityModelMapping) {
|
||||
newCredentials.model_mapping = antigravityModelMapping
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
// For antigravity accounts, handle mixed_scheduling in extra
|
||||
if (props.account.platform === 'antigravity') {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
</button>
|
||||
<slot name="after"></slot>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<slot name="beforeCreate"></slot>
|
||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||
<slot name="afterCreate"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
187
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
187
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.dataImportTitle')"
|
||||
width="normal"
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="import-data-form" class="space-y-4" @submit.prevent="handleImport">
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.dataImportHint') }}
|
||||
</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"
|
||||
>
|
||||
{{ t('admin.accounts.dataImportWarning') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ t('admin.accounts.dataImportResult') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.accounts.dataImportResultSummary', result) }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorItems.length" class="mt-2">
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.dataImportErrors') }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} — {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="import-data-form"
|
||||
:disabled="importing"
|
||||
>
|
||||
{{ importing ? t('admin.accounts.dataImporting') : t('admin.accounts.dataImportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
file.value = null
|
||||
result.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
file.value = target.files?.[0] || null
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
data: dataPayload,
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
|
||||
result.value = res
|
||||
|
||||
const msgParams: Record<string, unknown> = {
|
||||
account_created: res.account_created,
|
||||
account_failed: res.account_failed,
|
||||
proxy_created: res.proxy_created,
|
||||
proxy_reused: res.proxy_reused,
|
||||
proxy_failed: res.proxy_failed,
|
||||
}
|
||||
if (res.account_failed > 0 || res.proxy_failed > 0) {
|
||||
appStore.showError(t('admin.accounts.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.accounts.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
183
frontend/src/components/admin/proxy/ImportDataModal.vue
Normal file
183
frontend/src/components/admin/proxy/ImportDataModal.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.proxies.dataImportTitle')"
|
||||
width="normal"
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="import-proxy-data-form" class="space-y-4" @submit.prevent="handleImport">
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.proxies.dataImportHint') }}
|
||||
</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"
|
||||
>
|
||||
{{ t('admin.proxies.dataImportWarning') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.proxies.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ t('admin.proxies.dataImportResult') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.proxies.dataImportResultSummary', result) }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorItems.length" class="mt-2">
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.proxies.dataImportErrors') }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} — {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="import-proxy-data-form"
|
||||
:disabled="importing"
|
||||
>
|
||||
{{ importing ? t('admin.proxies.dataImporting') : t('admin.proxies.dataImportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
file.value = null
|
||||
result.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
file.value = target.files?.[0] || null
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
result.value = res
|
||||
|
||||
const msgParams: Record<string, unknown> = {
|
||||
proxy_created: res.proxy_created,
|
||||
proxy_reused: res.proxy_reused,
|
||||
proxy_failed: res.proxy_failed
|
||||
}
|
||||
|
||||
if (res.proxy_failed > 0) {
|
||||
appStore.showError(t('admin.proxies.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.proxies.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.proxies.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.proxies.dataImportFailed'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -154,6 +154,9 @@
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('refresh')" class="btn btn-secondary">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
@@ -194,6 +197,7 @@ const emit = defineEmits([
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'refresh',
|
||||
'reset',
|
||||
'export',
|
||||
'cleanup'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -491,7 +491,7 @@ async function checkServiceAndReload() {
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch('/api/health', {
|
||||
const response = await fetch('/health', {
|
||||
method: 'GET',
|
||||
cache: 'no-cache'
|
||||
})
|
||||
|
||||
@@ -493,7 +493,7 @@ function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
|
||||
// config.toml content
|
||||
const configContent = `model_provider = "sub2api"
|
||||
model = "gpt-5.2-codex"
|
||||
model = "gpt-5.3-codex"
|
||||
model_reasoning_effort = "high"
|
||||
network_access = "enabled"
|
||||
disable_response_storage = true
|
||||
|
||||
@@ -55,16 +55,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card relative overflow-hidden p-4">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
|
||||
<div class="h-48">
|
||||
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<TokenUsageTrend :trend-data="trend" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,7 +66,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import type { TrendDataPoint, ModelStat } from '@/types'
|
||||
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
|
||||
@@ -93,28 +85,6 @@ const modelData = computed(() => !props.models?.length ? null : {
|
||||
}]
|
||||
})
|
||||
|
||||
const trendData = computed(() => !props.trend?.length ? null : {
|
||||
labels: props.trend.map((d: TrendDataPoint) => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.input'),
|
||||
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: t('dashboard.output'),
|
||||
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const doughnutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -127,25 +97,4 @@ const doughnutOptions = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lineOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true, position: 'top' as const },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value: any) => formatTokens(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -69,6 +69,29 @@ const soraModels = [
|
||||
'prompt-enhance-long-10s', 'prompt-enhance-long-15s', 'prompt-enhance-long-20s'
|
||||
]
|
||||
|
||||
// Antigravity 官方支持的模型(精确匹配)
|
||||
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
|
||||
const antigravityModels = [
|
||||
// Claude 4.5+ 系列
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking',
|
||||
// Gemini 2.5 系列
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.5-flash-thinking',
|
||||
'gemini-2.5-pro',
|
||||
// Gemini 3 系列
|
||||
'gemini-3-flash',
|
||||
'gemini-3-pro-high',
|
||||
'gemini-3-pro-low',
|
||||
'gemini-3-pro-image',
|
||||
// 其他
|
||||
'gpt-oss-120b-medium',
|
||||
'tab_flash_lite_preview'
|
||||
]
|
||||
|
||||
// 智谱 GLM
|
||||
const zhipuModels = [
|
||||
'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520',
|
||||
@@ -254,6 +277,41 @@ const geminiPresetMappings = [
|
||||
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
|
||||
]
|
||||
|
||||
// Antigravity 预设映射(支持通配符)
|
||||
const antigravityPresetMappings = [
|
||||
// Claude 通配符映射
|
||||
{ label: 'Claude→Sonnet', from: 'claude-*', to: 'claude-sonnet-4-5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Sonnet→Sonnet', from: 'claude-sonnet-*', to: 'claude-sonnet-4-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus→Opus', from: 'claude-opus-*', to: 'claude-opus-4-6-thinking', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Haiku→Sonnet', from: 'claude-haiku-*', to: 'claude-sonnet-4-5', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
// Gemini 通配符映射
|
||||
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
// 精确映射
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Opus 4.6-thinking', from: 'claude-opus-4-6-thinking', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||
]
|
||||
|
||||
// Antigravity 默认映射(从后端 API 获取,与 constants.go 保持一致)
|
||||
// 使用 fetchAntigravityDefaultMappings() 异步获取
|
||||
import { getAntigravityDefaultModelMapping } from '@/api/admin/accounts'
|
||||
|
||||
let _antigravityDefaultMappingsCache: { from: string; to: string }[] | null = null
|
||||
|
||||
export async function fetchAntigravityDefaultMappings(): Promise<{ from: string; to: string }[]> {
|
||||
if (_antigravityDefaultMappingsCache !== null) {
|
||||
return _antigravityDefaultMappingsCache
|
||||
}
|
||||
try {
|
||||
const mapping = await getAntigravityDefaultModelMapping()
|
||||
_antigravityDefaultMappingsCache = Object.entries(mapping).map(([from, to]) => ({ from, to }))
|
||||
} catch (e) {
|
||||
console.warn('[fetchAntigravityDefaultMappings] API failed, using empty fallback', e)
|
||||
_antigravityDefaultMappingsCache = []
|
||||
}
|
||||
return _antigravityDefaultMappingsCache
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 常用错误码
|
||||
// =====================
|
||||
@@ -280,6 +338,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
case 'claude': return claudeModels
|
||||
case 'gemini': return geminiModels
|
||||
case 'sora': return soraModels
|
||||
case 'antigravity': return antigravityModels
|
||||
case 'zhipu': return zhipuModels
|
||||
case 'qwen': return qwenModels
|
||||
case 'deepseek': return deepseekModels
|
||||
@@ -304,6 +363,7 @@ export function getPresetMappingsByPlatform(platform: string) {
|
||||
if (platform === 'openai') return openaiPresetMappings
|
||||
if (platform === 'gemini') return geminiPresetMappings
|
||||
if (platform === 'sora') return soraPresetMappings
|
||||
if (platform === 'antigravity') return antigravityPresetMappings
|
||||
return anthropicPresetMappings
|
||||
}
|
||||
|
||||
@@ -311,6 +371,15 @@ export function getPresetMappingsByPlatform(platform: string) {
|
||||
// 构建模型映射对象(用于 API)
|
||||
// =====================
|
||||
|
||||
// isValidWildcardPattern 校验通配符格式:* 只能放在末尾
|
||||
// 导出供表单组件使用实时校验
|
||||
export function isValidWildcardPattern(pattern: string): boolean {
|
||||
const starIndex = pattern.indexOf('*')
|
||||
if (starIndex === -1) return true // 无通配符,有效
|
||||
// * 必须在末尾,且只能有一个
|
||||
return starIndex === pattern.length - 1 && pattern.lastIndexOf('*') === starIndex
|
||||
}
|
||||
|
||||
export function buildModelMappingObject(
|
||||
mode: 'whitelist' | 'mapping',
|
||||
allowedModels: string[],
|
||||
@@ -320,13 +389,29 @@ export function buildModelMappingObject(
|
||||
|
||||
if (mode === 'whitelist') {
|
||||
for (const model of allowedModels) {
|
||||
mapping[model] = model
|
||||
// whitelist 模式的本意是"精确模型列表",如果用户输入了通配符(如 claude-*),
|
||||
// 写入 model_mapping 会导致 GetMappedModel() 把真实模型映射成 "claude-*",从而转发失败。
|
||||
// 因此这里跳过包含通配符的条目。
|
||||
if (!model.includes('*')) {
|
||||
mapping[model] = model
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const m of modelMappings) {
|
||||
const from = m.from.trim()
|
||||
const to = m.to.trim()
|
||||
if (from && to) mapping[from] = to
|
||||
if (!from || !to) continue
|
||||
// 校验通配符格式:* 只能放在末尾
|
||||
if (!isValidWildcardPattern(from)) {
|
||||
console.warn(`[buildModelMappingObject] 无效的通配符格式,跳过: ${from}`)
|
||||
continue
|
||||
}
|
||||
// to 不允许包含通配符
|
||||
if (to.includes('*')) {
|
||||
console.warn(`[buildModelMappingObject] 目标模型不能包含通配符,跳过: ${from} -> ${to}`)
|
||||
continue
|
||||
}
|
||||
mapping[from] = to
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,25 +10,88 @@ export default {
|
||||
login: 'Login',
|
||||
getStarted: 'Get Started',
|
||||
goToDashboard: 'Go to Dashboard',
|
||||
// User-focused value proposition
|
||||
heroSubtitle: 'One Key, All AI Models',
|
||||
heroDescription: 'No need to manage multiple subscriptions. Access Claude, GPT, Gemini and more with a single API key',
|
||||
tags: {
|
||||
subscriptionToApi: 'Subscription to API',
|
||||
stickySession: 'Sticky Session',
|
||||
realtimeBilling: 'Real-time Billing'
|
||||
stickySession: 'Session Persistence',
|
||||
realtimeBilling: 'Pay As You Go'
|
||||
},
|
||||
// Pain points section
|
||||
painPoints: {
|
||||
title: 'Sound Familiar?',
|
||||
items: {
|
||||
expensive: {
|
||||
title: 'High Subscription Costs',
|
||||
desc: 'Paying for multiple AI subscriptions that add up every month'
|
||||
},
|
||||
complex: {
|
||||
title: 'Account Chaos',
|
||||
desc: 'Managing scattered accounts and API keys across different platforms'
|
||||
},
|
||||
unstable: {
|
||||
title: 'Service Interruptions',
|
||||
desc: 'Single accounts hitting rate limits and disrupting your workflow'
|
||||
},
|
||||
noControl: {
|
||||
title: 'No Usage Control',
|
||||
desc: "Can't track where your money goes or limit team member usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
// Solutions section
|
||||
solutions: {
|
||||
title: 'We Solve These Problems',
|
||||
subtitle: 'Three simple steps to stress-free AI access'
|
||||
},
|
||||
features: {
|
||||
unifiedGateway: 'Unified API Gateway',
|
||||
unifiedGatewayDesc:
|
||||
'Convert Claude subscriptions to API endpoints. Access AI capabilities through standard /v1/messages interface.',
|
||||
multiAccount: 'Multi-Account Pool',
|
||||
multiAccountDesc:
|
||||
'Manage multiple upstream accounts with smart load balancing. Support OAuth and API Key authentication.',
|
||||
balanceQuota: 'Balance & Quota',
|
||||
balanceQuotaDesc:
|
||||
'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.'
|
||||
unifiedGateway: 'One-Click Access',
|
||||
unifiedGatewayDesc: 'Get a single API key to call all connected AI models. No separate applications needed.',
|
||||
multiAccount: 'Always Reliable',
|
||||
multiAccountDesc: 'Smart routing across multiple upstream accounts with automatic failover. Say goodbye to errors.',
|
||||
balanceQuota: 'Pay What You Use',
|
||||
balanceQuotaDesc: 'Usage-based billing with quota limits. Full visibility into team consumption.'
|
||||
},
|
||||
// Comparison section
|
||||
comparison: {
|
||||
title: 'Why Choose Us?',
|
||||
headers: {
|
||||
feature: 'Comparison',
|
||||
official: 'Official Subscriptions',
|
||||
us: 'Our Platform'
|
||||
},
|
||||
items: {
|
||||
pricing: {
|
||||
feature: 'Pricing',
|
||||
official: 'Fixed monthly fee, pay even if unused',
|
||||
us: 'Pay only for what you use'
|
||||
},
|
||||
models: {
|
||||
feature: 'Model Selection',
|
||||
official: 'Single provider only',
|
||||
us: 'Switch between models freely'
|
||||
},
|
||||
management: {
|
||||
feature: 'Account Management',
|
||||
official: 'Manage each service separately',
|
||||
us: 'Unified key, one dashboard'
|
||||
},
|
||||
stability: {
|
||||
feature: 'Stability',
|
||||
official: 'Single account rate limits',
|
||||
us: 'Multi-account pool, auto-failover'
|
||||
},
|
||||
control: {
|
||||
feature: 'Usage Control',
|
||||
official: 'Not available',
|
||||
us: 'Quotas & detailed analytics'
|
||||
}
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
title: 'Supported Providers',
|
||||
description: 'Unified API interface for AI services',
|
||||
title: 'Supported AI Models',
|
||||
description: 'One API, Multiple Choices',
|
||||
supported: 'Supported',
|
||||
soon: 'Soon',
|
||||
claude: 'Claude',
|
||||
@@ -36,6 +99,12 @@ export default {
|
||||
antigravity: 'Antigravity',
|
||||
more: 'More'
|
||||
},
|
||||
// CTA section
|
||||
cta: {
|
||||
title: 'Ready to Get Started?',
|
||||
description: 'Sign up now and get free trial credits to experience seamless AI access',
|
||||
button: 'Sign Up Free'
|
||||
},
|
||||
footer: {
|
||||
allRightsReserved: 'All rights reserved.'
|
||||
}
|
||||
@@ -165,6 +234,7 @@ export default {
|
||||
selectedCount: '({count} selected)',
|
||||
refresh: 'Refresh',
|
||||
settings: 'Settings',
|
||||
chooseFile: 'Choose File',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
unknown: 'Unknown',
|
||||
@@ -1207,6 +1277,28 @@ export default {
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
dataExport: 'Export',
|
||||
dataExportSelected: 'Export Selected',
|
||||
dataExportIncludeProxies: 'Include proxies linked to the exported accounts',
|
||||
dataImport: 'Import',
|
||||
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
|
||||
dataExportConfirm: 'Confirm Export',
|
||||
dataExported: 'Data exported successfully',
|
||||
dataExportFailed: 'Failed to export data',
|
||||
dataImportTitle: 'Import Data',
|
||||
dataImportHint: 'Upload the exported JSON file to import accounts and proxies.',
|
||||
dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure existing data does not conflict.',
|
||||
dataImportFile: 'Data file',
|
||||
dataImportButton: 'Start Import',
|
||||
dataImporting: 'Importing...',
|
||||
dataImportSelectFile: 'Please select a data file',
|
||||
dataImportParseFailed: 'Failed to parse data file',
|
||||
dataImportFailed: 'Data import failed',
|
||||
dataImportResult: 'Import Result',
|
||||
dataImportResultSummary: 'Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}',
|
||||
dataImportErrors: 'Error Details',
|
||||
dataImportSuccess: 'Import completed: accounts {account_created}, failed {account_failed}',
|
||||
dataImportCompletedWithErrors: 'Import completed with errors: account failed {account_failed}, proxy failed {proxy_failed}',
|
||||
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||
syncFromCrsDesc:
|
||||
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
|
||||
@@ -1275,6 +1367,7 @@ export default {
|
||||
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'
|
||||
},
|
||||
@@ -1439,6 +1532,8 @@ export default {
|
||||
actualModel: 'Actual model',
|
||||
addMapping: 'Add Mapping',
|
||||
mappingExists: 'Mapping for {model} already exists',
|
||||
wildcardOnlyAtEnd: 'Wildcard * can only be at the end',
|
||||
targetNoWildcard: 'Target model cannot contain wildcard *',
|
||||
searchModels: 'Search models...',
|
||||
noMatchingModels: 'No matching models',
|
||||
fillRelatedModels: 'Fill related models',
|
||||
@@ -1906,6 +2001,27 @@ export default {
|
||||
createProxy: 'Create Proxy',
|
||||
editProxy: 'Edit Proxy',
|
||||
deleteProxy: 'Delete Proxy',
|
||||
dataImport: 'Import',
|
||||
dataExportSelected: 'Export Selected',
|
||||
dataImportTitle: 'Import Proxies',
|
||||
dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.',
|
||||
dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.',
|
||||
dataImportFile: 'Data File',
|
||||
dataImportButton: 'Start Import',
|
||||
dataImporting: 'Importing...',
|
||||
dataImportSelectFile: 'Please select a data file',
|
||||
dataImportParseFailed: 'Failed to parse data',
|
||||
dataImportFailed: 'Failed to import data',
|
||||
dataImportResult: 'Import Result',
|
||||
dataImportResultSummary: 'Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}',
|
||||
dataImportErrors: 'Failure Details',
|
||||
dataImportSuccess: 'Import completed: created {proxy_created}, reused {proxy_reused}',
|
||||
dataImportCompletedWithErrors: 'Import completed with errors: failed {proxy_failed}',
|
||||
dataExport: 'Export',
|
||||
dataExportConfirmMessage: 'The exported data contains sensitive proxy information. Store it securely.',
|
||||
dataExportConfirm: 'Confirm Export',
|
||||
dataExported: 'Data exported successfully',
|
||||
dataExportFailed: 'Failed to export data',
|
||||
searchProxies: 'Search proxies...',
|
||||
allProtocols: 'All Protocols',
|
||||
allStatus: 'All Status',
|
||||
@@ -2941,6 +3057,10 @@ export default {
|
||||
byPlatform: 'By Platform',
|
||||
byGroup: 'By Group',
|
||||
byAccount: 'By Account',
|
||||
byUser: 'By User',
|
||||
showByUserTooltip: 'Switch to user view to see concurrency usage per user',
|
||||
switchToUser: 'Switch to user view',
|
||||
switchToPlatform: 'Switch to platform view',
|
||||
totalRows: '{count} rows',
|
||||
disabledHint: 'Realtime monitoring is disabled in settings.',
|
||||
empty: 'No data',
|
||||
|
||||
@@ -8,24 +8,90 @@ export default {
|
||||
switchToDark: '切换到深色模式',
|
||||
dashboard: '控制台',
|
||||
login: '登录',
|
||||
getStarted: '开始使用',
|
||||
getStarted: '立即开始',
|
||||
goToDashboard: '进入控制台',
|
||||
// 新增:面向用户的价值主张
|
||||
heroSubtitle: '一个密钥,畅用多个 AI 模型',
|
||||
heroDescription: '无需管理多个订阅账号,一站式接入 Claude、GPT、Gemini 等主流 AI 服务',
|
||||
tags: {
|
||||
subscriptionToApi: '订阅转 API',
|
||||
stickySession: '粘性会话',
|
||||
realtimeBilling: '实时计费'
|
||||
stickySession: '会话保持',
|
||||
realtimeBilling: '按量计费'
|
||||
},
|
||||
// 用户痛点区块
|
||||
painPoints: {
|
||||
title: '你是否也遇到这些问题?',
|
||||
items: {
|
||||
expensive: {
|
||||
title: '订阅费用高',
|
||||
desc: '每个 AI 服务都要单独订阅,每月支出越来越多'
|
||||
},
|
||||
complex: {
|
||||
title: '多账号难管理',
|
||||
desc: '不同平台的账号、密钥分散各处,管理起来很麻烦'
|
||||
},
|
||||
unstable: {
|
||||
title: '服务不稳定',
|
||||
desc: '单一账号容易触发限制,影响正常使用'
|
||||
},
|
||||
noControl: {
|
||||
title: '用量无法控制',
|
||||
desc: '不知道钱花在哪了,也无法限制团队成员的使用'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 解决方案区块
|
||||
solutions: {
|
||||
title: '我们帮你解决',
|
||||
subtitle: '简单三步,开始省心使用 AI'
|
||||
},
|
||||
features: {
|
||||
unifiedGateway: '统一 API 网关',
|
||||
unifiedGatewayDesc: '将 Claude 订阅转换为 API 接口,通过标准 /v1/messages 接口访问 AI 能力。',
|
||||
multiAccount: '多账号池',
|
||||
multiAccountDesc: '智能负载均衡管理多个上游账号,支持 OAuth 和 API Key 认证。',
|
||||
balanceQuota: '余额与配额',
|
||||
balanceQuotaDesc: '基于 Token 的精确计费和用量追踪,支持配额管理和兑换码充值。'
|
||||
unifiedGateway: '一键接入',
|
||||
unifiedGatewayDesc: '获取一个 API 密钥,即可调用所有已接入的 AI 模型,无需分别申请。',
|
||||
multiAccount: '稳定可靠',
|
||||
multiAccountDesc: '智能调度多个上游账号,自动切换和负载均衡,告别频繁报错。',
|
||||
balanceQuota: '用多少付多少',
|
||||
balanceQuotaDesc: '按实际使用量计费,支持设置配额上限,团队用量一目了然。'
|
||||
},
|
||||
// 优势对比
|
||||
comparison: {
|
||||
title: '为什么选择我们?',
|
||||
headers: {
|
||||
feature: '对比项',
|
||||
official: '官方订阅',
|
||||
us: '本平台'
|
||||
},
|
||||
items: {
|
||||
pricing: {
|
||||
feature: '付费方式',
|
||||
official: '固定月费,用不完也付',
|
||||
us: '按量付费,用多少付多少'
|
||||
},
|
||||
models: {
|
||||
feature: '模型选择',
|
||||
official: '单一服务商',
|
||||
us: '多模型随意切换'
|
||||
},
|
||||
management: {
|
||||
feature: '账号管理',
|
||||
official: '每个服务单独管理',
|
||||
us: '统一密钥,一站管理'
|
||||
},
|
||||
stability: {
|
||||
feature: '服务稳定性',
|
||||
official: '单账号易触发限制',
|
||||
us: '多账号池,自动切换'
|
||||
},
|
||||
control: {
|
||||
feature: '用量控制',
|
||||
official: '无法限制',
|
||||
us: '可设配额、查明细'
|
||||
}
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
title: '支持的服务商',
|
||||
description: 'AI 服务的统一 API 接口',
|
||||
title: '已支持的 AI 模型',
|
||||
description: '一个 API,多种选择',
|
||||
supported: '已支持',
|
||||
soon: '即将推出',
|
||||
claude: 'Claude',
|
||||
@@ -33,6 +99,12 @@ export default {
|
||||
antigravity: 'Antigravity',
|
||||
more: '更多'
|
||||
},
|
||||
// CTA 区块
|
||||
cta: {
|
||||
title: '准备好开始了吗?',
|
||||
description: '注册即可获得免费试用额度,体验一站式 AI 服务',
|
||||
button: '免费注册'
|
||||
},
|
||||
footer: {
|
||||
allRightsReserved: '保留所有权利。'
|
||||
}
|
||||
@@ -162,6 +234,7 @@ export default {
|
||||
selectedCount: '(已选 {count} 个)',
|
||||
refresh: '刷新',
|
||||
settings: '设置',
|
||||
chooseFile: '选择文件',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
unknown: '未知',
|
||||
@@ -1292,6 +1365,28 @@ export default {
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
dataExport: '导出',
|
||||
dataExportSelected: '导出选中',
|
||||
dataExportIncludeProxies: '导出代理(导出账号关联的代理)',
|
||||
dataImport: '导入',
|
||||
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
|
||||
dataExportConfirm: '确认导出',
|
||||
dataExported: '数据导出成功',
|
||||
dataExportFailed: '数据导出失败',
|
||||
dataImportTitle: '导入数据',
|
||||
dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。',
|
||||
dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。',
|
||||
dataImportFile: '数据文件',
|
||||
dataImportButton: '开始导入',
|
||||
dataImporting: '导入中...',
|
||||
dataImportSelectFile: '请选择数据文件',
|
||||
dataImportParseFailed: '数据解析失败',
|
||||
dataImportFailed: '数据导入失败',
|
||||
dataImportResult: '导入结果',
|
||||
dataImportResultSummary: '代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}',
|
||||
dataImportErrors: '失败详情',
|
||||
dataImportSuccess: '导入完成:账号 {account_created},失败 {account_failed}',
|
||||
dataImportCompletedWithErrors: '导入完成但有错误:账号失败 {account_failed},代理失败 {proxy_failed}',
|
||||
syncFromCrsTitle: '从 CRS 同步账号',
|
||||
syncFromCrsDesc:
|
||||
'将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。',
|
||||
@@ -1408,6 +1503,7 @@ export default {
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
},
|
||||
@@ -1584,6 +1680,8 @@ export default {
|
||||
actualModel: '实际模型',
|
||||
addMapping: '添加映射',
|
||||
mappingExists: '模型 {model} 的映射已存在',
|
||||
wildcardOnlyAtEnd: '通配符 * 只能放在末尾',
|
||||
targetNoWildcard: '目标模型不能包含通配符 *',
|
||||
searchModels: '搜索模型...',
|
||||
noMatchingModels: '没有匹配的模型',
|
||||
fillRelatedModels: '填入相关模型',
|
||||
@@ -2015,6 +2113,27 @@ export default {
|
||||
deleteProxy: '删除代理',
|
||||
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
||||
testProxy: '测试代理',
|
||||
dataImport: '导入',
|
||||
dataExportSelected: '导出选中',
|
||||
dataImportTitle: '导入代理',
|
||||
dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。',
|
||||
dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。',
|
||||
dataImportFile: '数据文件',
|
||||
dataImportButton: '开始导入',
|
||||
dataImporting: '导入中...',
|
||||
dataImportSelectFile: '请选择数据文件',
|
||||
dataImportParseFailed: '数据解析失败',
|
||||
dataImportFailed: '数据导入失败',
|
||||
dataImportResult: '导入结果',
|
||||
dataImportResultSummary: '创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}',
|
||||
dataImportErrors: '失败详情',
|
||||
dataImportSuccess: '导入完成:创建 {proxy_created},复用 {proxy_reused}',
|
||||
dataImportCompletedWithErrors: '导入完成但有错误:失败 {proxy_failed}',
|
||||
dataExport: '导出',
|
||||
dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。',
|
||||
dataExportConfirm: '确认导出',
|
||||
dataExported: '数据导出成功',
|
||||
dataExportFailed: '数据导出失败',
|
||||
columns: {
|
||||
name: '名称',
|
||||
protocol: '协议',
|
||||
@@ -3111,6 +3230,10 @@ export default {
|
||||
byPlatform: '按平台',
|
||||
byGroup: '按分组',
|
||||
byAccount: '按账号',
|
||||
byUser: '按用户',
|
||||
showByUserTooltip: '切换用户视图,显示每个用户的并发使用情况',
|
||||
switchToUser: '切换到用户视图',
|
||||
switchToPlatform: '切换回平台视图',
|
||||
totalRows: '共 {count} 项',
|
||||
disabledHint: '已在设置中关闭实时监控。',
|
||||
empty: '暂无数据',
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
@apply rounded-lg px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
@apply rounded-xl px-4 py-2 text-sm;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply rounded-2xl px-6 py-3 text-base;
|
||||
}
|
||||
|
||||
@@ -574,7 +574,10 @@ export interface Account {
|
||||
platform: AccountPlatform
|
||||
type: AccountType
|
||||
credentials?: Record<string, unknown>
|
||||
extra?: CodexUsageSnapshot & Record<string, unknown> // Extra fields including Codex usage
|
||||
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
|
||||
extra?: (CodexUsageSnapshot & {
|
||||
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
} & Record<string, unknown>)
|
||||
proxy_id: number | null
|
||||
concurrency: number
|
||||
current_concurrency?: number // Real-time concurrency count from Redis
|
||||
@@ -742,6 +745,56 @@ export interface UpdateProxyRequest {
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
export interface AdminDataPayload {
|
||||
type?: string
|
||||
version?: number
|
||||
exported_at: string
|
||||
proxies: AdminDataProxy[]
|
||||
accounts: AdminDataAccount[]
|
||||
}
|
||||
|
||||
export interface AdminDataProxy {
|
||||
proxy_key: string
|
||||
name: string
|
||||
protocol: ProxyProtocol
|
||||
host: string
|
||||
port: number
|
||||
username?: string | null
|
||||
password?: string | null
|
||||
status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
export interface AdminDataAccount {
|
||||
name: string
|
||||
notes?: string | null
|
||||
platform: AccountPlatform
|
||||
type: AccountType
|
||||
credentials: Record<string, unknown>
|
||||
extra?: Record<string, unknown>
|
||||
proxy_key?: string | null
|
||||
concurrency: number
|
||||
priority: number
|
||||
rate_multiplier?: number | null
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
}
|
||||
|
||||
export interface AdminDataImportError {
|
||||
kind: 'proxy' | 'account'
|
||||
name?: string
|
||||
proxy_key?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AdminDataImportResult {
|
||||
proxy_created: number
|
||||
proxy_reused: number
|
||||
proxy_failed: number
|
||||
account_created: number
|
||||
account_failed: number
|
||||
errors?: AdminDataImportError[]
|
||||
}
|
||||
|
||||
// ==================== Usage & Redeem Types ====================
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||
|
||||
@@ -204,7 +204,7 @@ export function formatReasoningEffort(effort: string | null | undefined): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间(只显示时分)
|
||||
* 格式化时间(显示时分秒)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
@@ -212,6 +212,7 @@ export function formatTime(date: string | Date | null | undefined): string {
|
||||
return formatDate(date, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,16 +16,6 @@
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
<template #before>
|
||||
<button
|
||||
@click="showErrorPassthrough = true"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.errorPassthrough.title')"
|
||||
>
|
||||
<Icon name="shield" size="md" class="mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #after>
|
||||
<!-- Auto Refresh Dropdown -->
|
||||
<div class="relative" ref="autoRefreshDropdownRef">
|
||||
@@ -72,6 +62,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Passthrough Rules -->
|
||||
<button
|
||||
@click="showErrorPassthrough = true"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.errorPassthrough.title')"
|
||||
>
|
||||
<Icon name="shield" size="md" class="mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@@ -106,6 +106,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #beforeCreate>
|
||||
<button @click="showImportData = true" class="btn btn-secondary">
|
||||
{{ t('admin.accounts.dataImport') }}
|
||||
</button>
|
||||
<button @click="openExportDataDialog" class="btn btn-secondary">
|
||||
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
|
||||
</button>
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
</template>
|
||||
@@ -120,6 +128,15 @@
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
>
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
:checked="allVisibleSelected"
|
||||
@click.stop
|
||||
@change="toggleSelectAllVisible($event)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-select="{ row }">
|
||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</template>
|
||||
@@ -228,9 +245,16 @@
|
||||
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
|
||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
||||
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" v-model="includeProxyOnExport" />
|
||||
<span>{{ t('admin.accounts.dataExportIncludeProxies') }}</span>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -253,6 +277,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions.
|
||||
import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue'
|
||||
import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue'
|
||||
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
|
||||
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
|
||||
import ReAuthAccountModal from '@/components/admin/account/ReAuthAccountModal.vue'
|
||||
import AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
|
||||
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
|
||||
@@ -277,6 +302,9 @@ const selIds = ref<number[]>([])
|
||||
const showCreate = ref(false)
|
||||
const showEdit = ref(false)
|
||||
const showSync = ref(false)
|
||||
const showImportData = ref(false)
|
||||
const showExportDataDialog = ref(false)
|
||||
const includeProxyOnExport = ref(true)
|
||||
const showBulkEdit = ref(false)
|
||||
const showTempUnsched = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
@@ -292,6 +320,7 @@ const testingAcc = ref<Account | null>(null)
|
||||
const statsAcc = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
|
||||
const exportingData = ref(false)
|
||||
|
||||
// Column settings
|
||||
const showColumnDropdown = ref(false)
|
||||
@@ -418,12 +447,15 @@ const isAnyModalOpen = computed(() => {
|
||||
showCreate.value ||
|
||||
showEdit.value ||
|
||||
showSync.value ||
|
||||
showImportData.value ||
|
||||
showExportDataDialog.value ||
|
||||
showBulkEdit.value ||
|
||||
showTempUnsched.value ||
|
||||
showDeleteDialog.value ||
|
||||
showReAuth.value ||
|
||||
showTest.value ||
|
||||
showStats.value
|
||||
showStats.value ||
|
||||
showErrorPassthrough.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -542,6 +574,21 @@ const openMenu = (a: Account, e: MouseEvent) => {
|
||||
menu.show = true
|
||||
}
|
||||
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
|
||||
const allVisibleSelected = computed(() => {
|
||||
if (accounts.value.length === 0) return false
|
||||
return accounts.value.every(account => selIds.value.includes(account.id))
|
||||
})
|
||||
const toggleSelectAllVisible = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.checked) {
|
||||
const next = new Set(selIds.value)
|
||||
accounts.value.forEach(account => next.add(account.id))
|
||||
selIds.value = Array.from(next)
|
||||
return
|
||||
}
|
||||
const visibleIds = new Set(accounts.value.map(account => account.id))
|
||||
selIds.value = selIds.value.filter(id => !visibleIds.has(id))
|
||||
}
|
||||
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
|
||||
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
||||
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
|
||||
@@ -646,6 +693,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
}
|
||||
}
|
||||
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
|
||||
const handleDataImported = () => { showImportData.value = false; reload() }
|
||||
const formatExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
const pad2 = (value: number) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
|
||||
}
|
||||
const openExportDataDialog = () => {
|
||||
includeProxyOnExport.value = true
|
||||
showExportDataDialog.value = true
|
||||
}
|
||||
const handleExportData = async () => {
|
||||
if (exportingData.value) return
|
||||
exportingData.value = true
|
||||
try {
|
||||
const dataPayload = await adminAPI.accounts.exportData(
|
||||
selIds.value.length > 0
|
||||
? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
|
||||
: {
|
||||
includeProxies: includeProxyOnExport.value,
|
||||
filters: {
|
||||
platform: params.platform,
|
||||
type: params.type,
|
||||
status: params.status,
|
||||
search: params.search
|
||||
}
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
const filename = `sub2api-account-${timestamp}.json`
|
||||
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
appStore.showSuccess(t('admin.accounts.dataExported'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.accounts.dataExportFailed'))
|
||||
} finally {
|
||||
exportingData.value = false
|
||||
showExportDataDialog.value = false
|
||||
}
|
||||
}
|
||||
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
|
||||
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
|
||||
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }
|
||||
|
||||
@@ -2,47 +2,9 @@
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<!-- Search -->
|
||||
<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>
|
||||
|
||||
<!-- Filters -->
|
||||
<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>
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<div class="space-y-3">
|
||||
<!-- Row 1: Actions -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
@click="loadProxies"
|
||||
:disabled="loading"
|
||||
@@ -69,11 +31,52 @@
|
||||
<Icon name="trash" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.batchDeleteAction') }}
|
||||
</button>
|
||||
<button @click="showImportData = true" class="btn btn-secondary">
|
||||
{{ t('admin.proxies.dataImport') }}
|
||||
</button>
|
||||
<button @click="showExportDataDialog = true" class="btn btn-secondary">
|
||||
{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ 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>
|
||||
|
||||
@@ -606,6 +609,21 @@
|
||||
@confirm="confirmBatchDelete"
|
||||
@cancel="showBatchDeleteDialog = false"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
:show="showExportDataDialog"
|
||||
:title="t('admin.proxies.dataExport')"
|
||||
:message="t('admin.proxies.dataExportConfirmMessage')"
|
||||
:confirm-text="t('admin.proxies.dataExportConfirm')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
@confirm="handleExportData"
|
||||
@cancel="showExportDataDialog = false"
|
||||
/>
|
||||
|
||||
<ImportDataModal
|
||||
:show="showImportData"
|
||||
@close="showImportData = false"
|
||||
@imported="handleDataImported"
|
||||
/>
|
||||
|
||||
<!-- Proxy Accounts Dialog -->
|
||||
<BaseDialog
|
||||
@@ -668,6 +686,7 @@ import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
@@ -731,10 +750,13 @@ const pagination = reactive({
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showImportData = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showBatchDeleteDialog = ref(false)
|
||||
const showExportDataDialog = ref(false)
|
||||
const showAccountsModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const exportingData = ref(false)
|
||||
const testingProxyIds = ref<Set<number>>(new Set())
|
||||
const batchTesting = ref(false)
|
||||
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||
@@ -888,6 +910,11 @@ const closeCreateModal = () => {
|
||||
batchParseResult.proxies = []
|
||||
}
|
||||
|
||||
const handleDataImported = () => {
|
||||
showImportData.value = false
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
|
||||
const parseProxyUrl = (
|
||||
line: string
|
||||
@@ -1228,6 +1255,45 @@ const handleBatchTest = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
const pad2 = (value: number) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
|
||||
}
|
||||
|
||||
const handleExportData = async () => {
|
||||
if (exportingData.value) return
|
||||
exportingData.value = true
|
||||
try {
|
||||
const dataPayload = await adminAPI.proxies.exportData(
|
||||
selectedCount.value > 0
|
||||
? { ids: Array.from(selectedProxyIds.value) }
|
||||
: {
|
||||
filters: {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
const filename = `sub2api-proxy-${timestamp}.json`
|
||||
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
appStore.showSuccess(t('admin.proxies.dataExported'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.proxies.dataExportFailed'))
|
||||
} finally {
|
||||
exportingData.value = false
|
||||
showExportDataDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (proxy: Proxy) => {
|
||||
if ((proxy.account_count || 0) > 0) {
|
||||
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
||||
<UsageTable :data="usageLogs" :loading="loading" />
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
@@ -83,6 +83,7 @@ const loadChartData = async () => {
|
||||
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
|
||||
}
|
||||
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
|
||||
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops'
|
||||
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse, type OpsUserConcurrencyStatsResponse } from '@/api/admin/ops'
|
||||
|
||||
interface Props {
|
||||
platformFilter?: string
|
||||
@@ -20,6 +20,10 @@ const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
|
||||
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
|
||||
const userConcurrency = ref<OpsUserConcurrencyStatsResponse | null>(null)
|
||||
|
||||
// 用户视图开关
|
||||
const showByUser = ref(false)
|
||||
|
||||
const realtimeEnabled = computed(() => {
|
||||
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
|
||||
@@ -30,7 +34,10 @@ function safeNumber(n: unknown): number {
|
||||
}
|
||||
|
||||
// 计算显示维度
|
||||
const displayDimension = computed<'platform' | 'group' | 'account'>(() => {
|
||||
const displayDimension = computed<'platform' | 'group' | 'account' | 'user'>(() => {
|
||||
if (showByUser.value) {
|
||||
return 'user'
|
||||
}
|
||||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||||
return 'account'
|
||||
}
|
||||
@@ -81,6 +88,18 @@ interface AccountRow {
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
// 用户行数据
|
||||
interface UserRow {
|
||||
key: string
|
||||
user_id: number
|
||||
user_email: string
|
||||
username: string
|
||||
current_in_use: number
|
||||
max_capacity: number
|
||||
waiting_in_queue: number
|
||||
load_percentage: number
|
||||
}
|
||||
|
||||
// 平台维度汇总
|
||||
const platformRows = computed((): SummaryRow[] => {
|
||||
const concStats = concurrency.value?.platform || {}
|
||||
@@ -205,14 +224,37 @@ const accountRows = computed((): AccountRow[] => {
|
||||
})
|
||||
})
|
||||
|
||||
// 用户维度详细
|
||||
const userRows = computed((): UserRow[] => {
|
||||
const userStats = userConcurrency.value?.user || {}
|
||||
|
||||
return Object.keys(userStats)
|
||||
.map(uid => {
|
||||
const u = userStats[uid] || {}
|
||||
return {
|
||||
key: uid,
|
||||
user_id: safeNumber(u.user_id),
|
||||
user_email: u.user_email || `User ${uid}`,
|
||||
username: u.username || '',
|
||||
current_in_use: safeNumber(u.current_in_use),
|
||||
max_capacity: safeNumber(u.max_capacity),
|
||||
waiting_in_queue: safeNumber(u.waiting_in_queue),
|
||||
load_percentage: safeNumber(u.load_percentage)
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.current_in_use - a.current_in_use || b.load_percentage - a.load_percentage)
|
||||
})
|
||||
|
||||
// 根据维度选择数据
|
||||
const displayRows = computed(() => {
|
||||
if (displayDimension.value === 'user') return userRows.value
|
||||
if (displayDimension.value === 'account') return accountRows.value
|
||||
if (displayDimension.value === 'group') return groupRows.value
|
||||
return platformRows.value
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (displayDimension.value === 'user') return t('admin.ops.concurrency.byUser')
|
||||
if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount')
|
||||
if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup')
|
||||
return t('admin.ops.concurrency.byPlatform')
|
||||
@@ -222,12 +264,19 @@ async function loadData() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [concData, availData] = await Promise.all([
|
||||
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
|
||||
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
|
||||
])
|
||||
concurrency.value = concData
|
||||
availability.value = availData
|
||||
if (showByUser.value) {
|
||||
// 用户视图模式只加载用户并发数据
|
||||
const userData = await opsAPI.getUserConcurrencyStats()
|
||||
userConcurrency.value = userData
|
||||
} else {
|
||||
// 常规模式加载账号/平台/分组数据
|
||||
const [concData, availData] = await Promise.all([
|
||||
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
|
||||
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
|
||||
])
|
||||
concurrency.value = concData
|
||||
availability.value = availData
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[OpsConcurrencyCard] Failed to load data', err)
|
||||
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
|
||||
@@ -245,6 +294,14 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// 切换用户视图时重新加载数据
|
||||
watch(
|
||||
() => showByUser.value,
|
||||
() => {
|
||||
loadData()
|
||||
}
|
||||
)
|
||||
|
||||
function getLoadBarClass(loadPct: number): string {
|
||||
if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600'
|
||||
@@ -302,16 +359,32 @@ watch(
|
||||
</svg>
|
||||
{{ t('admin.ops.concurrency.title') }}
|
||||
</h3>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="loadData"
|
||||
>
|
||||
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 用户视图切换按钮 -->
|
||||
<button
|
||||
class="flex items-center justify-center rounded-lg px-2 py-1 transition-colors"
|
||||
:class="showByUser
|
||||
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300'"
|
||||
:title="showByUser ? t('admin.ops.concurrency.switchToPlatform') : t('admin.ops.concurrency.switchToUser')"
|
||||
@click="showByUser = !showByUser"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 刷新按钮 -->
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="loadData"
|
||||
>
|
||||
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
@@ -344,8 +417,41 @@ watch(
|
||||
{{ t('admin.ops.concurrency.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- 用户视图 -->
|
||||
<div v-else-if="displayDimension === 'user'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-for="row in (displayRows as UserRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-2.5 dark:bg-dark-900">
|
||||
<!-- 用户信息和并发 -->
|
||||
<div class="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.username || row.user_email">
|
||||
{{ row.username || row.user_email }}
|
||||
</span>
|
||||
<span v-if="row.username" class="shrink-0 truncate text-[10px] text-gray-400 dark:text-gray-500" :title="row.user_email">
|
||||
{{ row.user_email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2 text-[10px]">
|
||||
<span class="font-mono font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
|
||||
<span :class="['font-bold', getLoadTextClass(row.load_percentage)]"> {{ Math.round(row.load_percentage) }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div class="h-full rounded-full transition-all duration-300" :class="getLoadBarClass(row.load_percentage)" :style="getLoadBarStyle(row.load_percentage)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 等待队列 -->
|
||||
<div v-if="row.waiting_in_queue > 0" class="mt-1.5 flex justify-end">
|
||||
<span class="rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总视图(平台/分组) -->
|
||||
<div v-else-if="displayDimension !== 'account'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-else-if="displayDimension === 'platform' || displayDimension === 'group'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<!-- 标题行 -->
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
|
||||
Reference in New Issue
Block a user