mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
Merge remote-tracking branch 'origin/main' into fix/gemini-cli-v1internal
# Conflicts: # apps/aether-gateway/src/ai_serving/transport.rs # apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs # apps/aether-gateway/src/handlers/shared/catalog.rs # crates/aether-admin/src/provider/quota.rs # crates/aether-model-fetch/src/strategy.rs # crates/aether-provider-pool/src/lib.rs # crates/aether-provider-pool/src/service.rs # crates/aether-provider-transport/src/provider_types.rs # frontend/src/features/providers/components/ProviderDetailDrawer.vue # frontend/src/utils/__tests__/providerKeyQuota.spec.ts # frontend/src/utils/providerKeyQuota.ts # frontend/src/views/admin/PoolManagement.vue
This commit is contained in:
@@ -11,6 +11,9 @@ const authStoreMock = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const routerPushMock = vi.hoisted(() => vi.fn())
|
||||
const routeMock = vi.hoisted(() => ({
|
||||
query: {},
|
||||
}))
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
@@ -27,6 +30,7 @@ const oauthApiMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeMock,
|
||||
useRouter: () => ({
|
||||
push: routerPushMock,
|
||||
}),
|
||||
@@ -157,6 +161,7 @@ beforeEach(() => {
|
||||
authStoreMock.error = ''
|
||||
authStoreMock.canAccessAdmin = false
|
||||
authStoreMock.login.mockReset()
|
||||
routeMock.query = {}
|
||||
routerPushMock.mockReset()
|
||||
toastMocks.success.mockReset()
|
||||
toastMocks.warning.mockReset()
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
]"
|
||||
@click="switchMode('oauth')"
|
||||
>
|
||||
{{ isKiroProvider ? '设备授权' : '获取授权' }}
|
||||
{{ isDeviceBrowserProvider ? (isWindsurfProvider ? '浏览器登录' : '设备授权') : '获取授权' }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
@@ -93,8 +93,132 @@
|
||||
class="space-y-4 transition-opacity duration-150"
|
||||
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<!-- Windsurf: 浏览器 session/poll 授权 -->
|
||||
<template v-if="isWindsurfProvider">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<button
|
||||
v-for="opt in ([
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'google', label: 'Google' },
|
||||
{ key: 'github', label: 'GitHub' },
|
||||
] as const)"
|
||||
:key="opt.key"
|
||||
class="h-8 text-xs font-medium rounded-md border transition-colors"
|
||||
:class="device.auth_type === opt.key
|
||||
? 'border-primary bg-primary/5 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/20'"
|
||||
@click="selectWindsurfLoginOption(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="device.status === 'error' || device.status === 'expired'"
|
||||
class="rounded-xl border border-destructive/20 bg-destructive/5 p-5"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center space-y-3">
|
||||
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle class="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ device.error || '请重试' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="resetDevice"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="device.starting && !device.session_id"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备登录...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往登录</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="device.starting || device.completing || !device.verification_uri_complete"
|
||||
@click="openDeviceVerificationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="device.starting || device.completing || !device.verification_uri_complete"
|
||||
@click="copyToClipboard(device.verification_uri_complete)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!device.session_id"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="device.starting"
|
||||
@click="startDeviceAuth"
|
||||
>
|
||||
开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL 或 token</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
v-model="device.callback_url"
|
||||
:disabled="device.completing"
|
||||
:placeholder="deviceCallbackPlaceholder"
|
||||
class="min-h-[150px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="device.session_id && device.status === 'pending'"
|
||||
class="pl-6 flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
|
||||
<span>会话剩余 {{ deviceCountdownFormatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Kiro: 设备授权模式 -->
|
||||
<template v-if="isKiroProvider">
|
||||
<template v-else-if="isKiroProvider">
|
||||
<div class="space-y-3">
|
||||
<!-- 授权类型切换 -->
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
@@ -198,7 +322,7 @@
|
||||
<Textarea
|
||||
v-model="device.callback_url"
|
||||
:disabled="device.completing"
|
||||
:placeholder="kiroSocialCallbackPlaceholder"
|
||||
:placeholder="deviceCallbackPlaceholder"
|
||||
class="h-full min-h-0 overflow-y-auto text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -457,7 +581,70 @@
|
||||
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
|
||||
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<div
|
||||
v-if="isWindsurfProvider"
|
||||
class="grid grid-cols-2 gap-1.5 rounded-lg border border-border p-0.5 bg-muted/30"
|
||||
>
|
||||
<button
|
||||
v-for="method in ([
|
||||
{ key: 'email_password', label: '邮箱密码' },
|
||||
{ key: 'token_json', label: 'Token / JSON' },
|
||||
] as const)"
|
||||
:key="method.key"
|
||||
class="h-8 text-xs font-medium rounded-md transition-colors"
|
||||
:class="windsurfImportMethod === method.key
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="importing"
|
||||
@click="setWindsurfImportMethod(method.key)"
|
||||
>
|
||||
{{ method.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isWindsurfEmailPasswordImport"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">邮箱</label>
|
||||
<input
|
||||
v-model="windsurfEmail"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
:disabled="importing"
|
||||
placeholder="[email protected]"
|
||||
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">密码</label>
|
||||
<input
|
||||
v-model="windsurfPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:disabled="importing"
|
||||
placeholder="Windsurf 密码"
|
||||
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-muted-foreground">名称(可选)</label>
|
||||
<input
|
||||
v-model="windsurfAccountName"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="importing"
|
||||
placeholder="未填写时使用邮箱"
|
||||
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JsonImportInput
|
||||
v-else
|
||||
v-model="importText"
|
||||
:disabled="importing"
|
||||
:reset-key="importInputResetKey"
|
||||
@@ -472,7 +659,7 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="importTask"
|
||||
v-if="importTask && !isWindsurfEmailPasswordImport"
|
||||
class="rounded-xl border border-border bg-muted/20 p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
@@ -527,15 +714,15 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth' && showAuthorizationMode && !isKiroProvider"
|
||||
v-if="mode === 'oauth' && showAuthorizationMode && !isDeviceBrowserProvider"
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth' && isKiroSocialManualCallbackMode"
|
||||
:disabled="!canCompleteKiroSocialDeviceAuth"
|
||||
v-if="mode === 'oauth' && isManualDeviceCallbackMode"
|
||||
:disabled="!canCompleteDeviceAuth"
|
||||
@click="completeDeviceAuth"
|
||||
>
|
||||
{{ device.completing ? '验证中...' : '验证' }}
|
||||
@@ -545,7 +732,7 @@
|
||||
:disabled="!canImport"
|
||||
@click="handleImport"
|
||||
>
|
||||
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : importButtonLabel }}
|
||||
{{ importButtonText }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -577,6 +764,7 @@ import {
|
||||
getBatchImportOAuthTaskStatus,
|
||||
startDeviceAuthorize,
|
||||
pollDeviceAuthorize,
|
||||
normalizeBatchImportCredentials,
|
||||
getAwsRegions,
|
||||
} from '@/api/endpoints'
|
||||
import type {
|
||||
@@ -649,6 +837,7 @@ function getSelectedNodeLabel(): string {
|
||||
// 模式
|
||||
type DialogMode = 'oauth' | 'import'
|
||||
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
|
||||
type WindsurfImportMethod = 'email_password' | 'token_json'
|
||||
|
||||
// OAuth 状态
|
||||
interface OAuthState {
|
||||
@@ -678,7 +867,8 @@ let oauthInitRequestId = 0
|
||||
let oauthCompleteRequestId = 0
|
||||
|
||||
// 设备授权状态
|
||||
type DeviceAuthType = 'google' | 'github' | 'builder_id' | 'identity_center'
|
||||
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center'
|
||||
type WindsurfLoginOption = 'default' | 'google' | 'github'
|
||||
|
||||
interface DeviceAuthState {
|
||||
auth_type: DeviceAuthType
|
||||
@@ -736,11 +926,17 @@ const importInputResetKey = ref(0)
|
||||
const importTask = ref<OAuthBatchImportTaskStatusResponse | null>(null)
|
||||
let importPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const importPolling = ref(false)
|
||||
const windsurfImportMethod = ref<WindsurfImportMethod>('email_password')
|
||||
const windsurfEmail = ref('')
|
||||
const windsurfPassword = ref('')
|
||||
const windsurfAccountName = ref('')
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
|
||||
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
|
||||
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
|
||||
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
|
||||
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
|
||||
const showAuthorizationMode = computed(() => !isGrokProvider.value)
|
||||
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
|
||||
|
||||
@@ -752,14 +948,20 @@ const isKiroSocialManualCallbackMode = computed(() =>
|
||||
isKiroProvider.value && isSocialDeviceAuth.value
|
||||
)
|
||||
|
||||
const isKiroSocialManualCallbackPending = computed(() =>
|
||||
isKiroSocialManualCallbackMode.value
|
||||
const isManualDeviceCallbackMode = computed(() =>
|
||||
isKiroSocialManualCallbackMode.value || isWindsurfProvider.value
|
||||
)
|
||||
|
||||
const isManualDeviceCallbackPending = computed(() =>
|
||||
isManualDeviceCallbackMode.value
|
||||
&& device.value.session_id.length > 0
|
||||
&& device.value.status === 'pending'
|
||||
)
|
||||
|
||||
const kiroSocialCallbackPlaceholder = computed(() =>
|
||||
`http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
|
||||
const deviceCallbackPlaceholder = computed(() =>
|
||||
isWindsurfProvider.value
|
||||
? `粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权`
|
||||
: `http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
|
||||
)
|
||||
|
||||
const deviceCountdownFormatted = computed(() => {
|
||||
@@ -779,13 +981,18 @@ const canCompleteOAuth = computed(() => {
|
||||
return !oauthBusy.value
|
||||
})
|
||||
|
||||
const canCompleteKiroSocialDeviceAuth = computed(() => {
|
||||
if (!isKiroSocialManualCallbackPending.value) return false
|
||||
const canCompleteDeviceAuth = computed(() => {
|
||||
if (!isManualDeviceCallbackPending.value) return false
|
||||
if (!device.value.callback_url.trim()) return false
|
||||
return !device.value.starting && !device.value.completing
|
||||
})
|
||||
|
||||
const canImport = computed(() => {
|
||||
if (isWindsurfEmailPasswordImport.value) {
|
||||
return windsurfEmail.value.trim().length > 0
|
||||
&& windsurfPassword.value.trim().length > 0
|
||||
&& !importing.value
|
||||
}
|
||||
return importText.value.trim().length > 0 && !importing.value
|
||||
})
|
||||
|
||||
@@ -800,7 +1007,9 @@ const importDropHint = computed(() => (
|
||||
const importManualPlaceholder = computed(() => (
|
||||
isGrokProvider.value
|
||||
? '粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON'
|
||||
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
|
||||
: isWindsurfProvider.value
|
||||
? '粘贴 show-auth-token Token、API key 或 JSON 内容'
|
||||
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
|
||||
))
|
||||
const importManualDescription = computed(() => (
|
||||
isGrokProvider.value
|
||||
@@ -814,6 +1023,18 @@ const importFileToggleText = computed(() => (
|
||||
isGrokProvider.value ? '或选择 Grok Token 文件导入' : '或选择 JSON 文件导入'
|
||||
))
|
||||
const providerCredentialActionLabel = computed(() => (isGrokProvider.value ? '导入' : '授权'))
|
||||
const isWindsurfEmailPasswordImport = computed(() =>
|
||||
isWindsurfProvider.value && windsurfImportMethod.value === 'email_password'
|
||||
)
|
||||
|
||||
const importButtonText = computed(() => {
|
||||
if (importing.value) {
|
||||
return importTask.value && !isWindsurfEmailPasswordImport.value
|
||||
? `导入中 ${importTask.value.progress_percent}%`
|
||||
: '导入中...'
|
||||
}
|
||||
return isWindsurfEmailPasswordImport.value ? '登录并导入' : importButtonLabel.value
|
||||
})
|
||||
|
||||
function stopImportPolling() {
|
||||
if (importPollTimer) {
|
||||
@@ -957,6 +1178,7 @@ function resetDeviceRuntimeState() {
|
||||
}
|
||||
|
||||
function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
|
||||
if (!isKiroProvider.value) return false
|
||||
if (device.value.starting) {
|
||||
return !isSocialDeviceAuth.value
|
||||
}
|
||||
@@ -967,6 +1189,14 @@ function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function selectWindsurfLoginOption(loginOption: WindsurfLoginOption) {
|
||||
if (!isWindsurfProvider.value) return
|
||||
if (device.value.auth_type === loginOption && device.value.session_id && device.value.status === 'pending') return
|
||||
deviceAuthRequestId += 1
|
||||
resetDeviceRuntimeState()
|
||||
device.value.auth_type = loginOption
|
||||
}
|
||||
|
||||
function selectDeviceAuthType(authType: DeviceAuthType) {
|
||||
if (device.value.auth_type === authType) return
|
||||
if (isKiroDeviceAuthOptionDisabled(authType)) return
|
||||
@@ -985,11 +1215,11 @@ function resetDevice() {
|
||||
totp.stop()
|
||||
const { auth_type, start_url, region, totp_secret } = device.value
|
||||
device.value = createInitialDeviceState()
|
||||
device.value.auth_type = auth_type
|
||||
device.value.auth_type = isWindsurfProvider.value ? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default') : auth_type
|
||||
device.value.start_url = start_url
|
||||
device.value.region = region
|
||||
device.value.totp_secret = totp_secret
|
||||
if (device.value.auth_type === 'google' || device.value.auth_type === 'github') {
|
||||
if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
}
|
||||
}
|
||||
@@ -1003,10 +1233,17 @@ function resetForm() {
|
||||
stopDevicePolling()
|
||||
totp.stop()
|
||||
device.value = createInitialDeviceState()
|
||||
if (isWindsurfProvider.value) {
|
||||
device.value.auth_type = 'default'
|
||||
}
|
||||
importText.value = ''
|
||||
importing.value = false
|
||||
importTask.value = null
|
||||
importInputResetKey.value += 1
|
||||
windsurfImportMethod.value = 'email_password'
|
||||
windsurfEmail.value = ''
|
||||
windsurfPassword.value = ''
|
||||
windsurfAccountName.value = ''
|
||||
proxyPopoverOpen.value = false
|
||||
selectedProxyNodeId.value = ''
|
||||
mode.value = defaultMode.value
|
||||
@@ -1046,7 +1283,7 @@ function openAuthorizationUrl() {
|
||||
async function initOAuth() {
|
||||
if (!props.providerId) return
|
||||
if (!showAuthorizationMode.value) return
|
||||
if (isKiroProvider.value) return
|
||||
if (isDeviceBrowserProvider.value) return
|
||||
if (oauth.value.starting) return
|
||||
|
||||
const requestId = ++oauthInitRequestId
|
||||
@@ -1095,35 +1332,12 @@ async function handleCompleteOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为批量导入格式
|
||||
function isBatchImport(text: string): boolean {
|
||||
const trimmed = text.trim()
|
||||
// JSON 数组(含单元素数组)
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return Array.isArray(parsed) && parsed.length >= 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 单个 JSON 对象(可能是 pretty-printed 多行)不算批量导入
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return false // 可解析的单个 JSON 对象,走单条导入
|
||||
} catch {
|
||||
// 解析失败:可能是多个 JSON 对象(JSON Lines 格式),继续检查
|
||||
}
|
||||
}
|
||||
// 多行文本(纯 Token 一行一个)
|
||||
const lines = trimmed.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
|
||||
return lines.length > 1
|
||||
}
|
||||
|
||||
function parseImportText(text: string): {
|
||||
api_key?: string
|
||||
token?: string
|
||||
refresh_token?: string
|
||||
access_token?: string
|
||||
password?: string
|
||||
expires_at?: number
|
||||
name?: string
|
||||
email?: string
|
||||
@@ -1154,6 +1368,35 @@ function parseImportText(text: string): {
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindsurfProvider.value) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const apiKey = normalizeStringField(obj.api_key) ?? normalizeStringField(obj.apiKey)
|
||||
const token = normalizeStringField(obj.token) ?? normalizeStringField(obj.auth_token) ?? normalizeStringField(obj.authToken)
|
||||
const refreshToken = normalizeStringField(obj.refresh_token) ?? normalizeStringField(obj.refreshToken)
|
||||
const accessToken = normalizeStringField(obj.access_token) ?? normalizeStringField(obj.accessToken)
|
||||
const email = normalizeStringField(obj.email)
|
||||
const password = normalizeStringField(obj.password)
|
||||
if (apiKey || token || refreshToken || accessToken || (email && password)) {
|
||||
return {
|
||||
api_key: apiKey,
|
||||
token,
|
||||
refresh_token: refreshToken,
|
||||
access_token: accessToken,
|
||||
email,
|
||||
password,
|
||||
name: normalizeStringField(obj.name) ?? email,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: treat as token copied from show-auth-token.
|
||||
}
|
||||
return { token: trimmed }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
@@ -1316,8 +1559,47 @@ function handleImportInputError(payload: { message: string; title?: string }) {
|
||||
showError(payload.message, payload.title)
|
||||
}
|
||||
|
||||
function setWindsurfImportMethod(method: WindsurfImportMethod) {
|
||||
if (!isWindsurfProvider.value || importing.value) return
|
||||
windsurfImportMethod.value = method
|
||||
importTask.value = null
|
||||
}
|
||||
|
||||
async function handleWindsurfEmailPasswordImport() {
|
||||
if (!props.providerId) return
|
||||
|
||||
const email = windsurfEmail.value.trim()
|
||||
const password = windsurfPassword.value.trim()
|
||||
if (!email || !password) {
|
||||
showError('请输入邮箱和密码', '格式错误')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await importProviderRefreshToken(props.providerId, {
|
||||
email,
|
||||
password,
|
||||
name: windsurfAccountName.value.trim() || email,
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
success(getOAuthSuccessMessage('导入', result))
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = parseApiError(err, '导入失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canImport.value || !props.providerId) return
|
||||
if (isWindsurfEmailPasswordImport.value) {
|
||||
await handleWindsurfEmailPasswordImport()
|
||||
return
|
||||
}
|
||||
|
||||
const inputText = importText.value.trim()
|
||||
if (!inputText) {
|
||||
@@ -1325,13 +1607,19 @@ async function handleImport() {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedCredentials = normalizeBatchImportCredentials(inputText)
|
||||
if (!normalizedCredentials.ok) {
|
||||
showError(normalizedCredentials.message, '格式错误')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
let keepImporting = false
|
||||
try {
|
||||
const proxyNodeId = selectedProxyNodeId.value || undefined
|
||||
// Kiro 的单条 JSON 凭据也必须走 batch-import 路径,后端需要完整 auth_config。
|
||||
if (isKiroProvider.value || isBatchImport(inputText)) {
|
||||
const task = await startBatchImportOAuthTask(props.providerId, inputText, proxyNodeId)
|
||||
if (isKiroProvider.value || normalizedCredentials.isBatch) {
|
||||
const task = await startBatchImportOAuthTask(props.providerId, normalizedCredentials.credentials, proxyNodeId)
|
||||
importTask.value = {
|
||||
task_id: task.task_id,
|
||||
provider_id: props.providerId,
|
||||
@@ -1356,7 +1644,7 @@ async function handleImport() {
|
||||
scheduleImportPoll(task.task_id, 400)
|
||||
} else {
|
||||
// 单条导入
|
||||
const parsed = parseImportText(inputText)
|
||||
const parsed = parseImportText(normalizedCredentials.credentials)
|
||||
if (!parsed) {
|
||||
showError('无法解析输入内容,请检查格式', '格式错误')
|
||||
return
|
||||
@@ -1413,12 +1701,18 @@ async function startDeviceAuth() {
|
||||
device.value.starting = true
|
||||
device.value.error = ''
|
||||
try {
|
||||
const isWindsurf = isWindsurfProvider.value
|
||||
const isBuilderID = requestedAuthType === 'builder_id'
|
||||
const isSocial = requestedAuthType === 'google' || requestedAuthType === 'github'
|
||||
const windsurfLoginOption: WindsurfLoginOption = isSocial ? requestedAuthType : 'default'
|
||||
const authTypeForRequest = isWindsurf
|
||||
? 'browser'
|
||||
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
|
||||
const resp = await startDeviceAuthorize(props.providerId, {
|
||||
auth_type: requestedAuthType,
|
||||
start_url: isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined)),
|
||||
region: isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined),
|
||||
auth_type: authTypeForRequest,
|
||||
login_option: isWindsurf ? windsurfLoginOption : undefined,
|
||||
start_url: isWindsurf ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
|
||||
region: isWindsurf ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
|
||||
@@ -1428,7 +1722,7 @@ async function startDeviceAuth() {
|
||||
device.value.verification_uri_complete = resp.verification_uri_complete
|
||||
device.value.expires_at = Date.now() + resp.expires_in * 1000
|
||||
device.value.interval = resp.interval || 5
|
||||
device.value.callback_required = resp.callback_required === true || isSocial
|
||||
device.value.callback_required = resp.callback_required === true || isSocial || isWindsurf
|
||||
device.value.status = 'pending'
|
||||
startCountdown()
|
||||
if (!device.value.callback_required) {
|
||||
@@ -1464,7 +1758,7 @@ function scheduleDevicePoll() {
|
||||
}
|
||||
|
||||
async function completeDeviceAuth() {
|
||||
if (device.value.completing || !canCompleteKiroSocialDeviceAuth.value) return
|
||||
if (device.value.completing || !canCompleteDeviceAuth.value) return
|
||||
device.value.completing = true
|
||||
try {
|
||||
await pollDevice(true)
|
||||
@@ -1473,13 +1767,39 @@ async function completeDeviceAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindsurfSubmittedCredential(value: string): { callback_url?: string, token?: string } {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return {}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { callback_url: trimmed }
|
||||
}
|
||||
|
||||
const query = trimmed.replace(/^[?#&]+/, '')
|
||||
const params = new URLSearchParams(query)
|
||||
const hasTokenParam = ['token', 'auth_token', 'access_token'].some(key => params.has(key))
|
||||
const hasStateParam = params.has('state')
|
||||
if (hasTokenParam && hasStateParam) {
|
||||
return { callback_url: `https://windsurf.com/show-auth-token?${query}` }
|
||||
}
|
||||
if (hasTokenParam) {
|
||||
return { token: params.get('token') || params.get('auth_token') || params.get('access_token') || trimmed }
|
||||
}
|
||||
|
||||
return { token: trimmed }
|
||||
}
|
||||
|
||||
async function pollDevice(withCallback = false) {
|
||||
if (!props.providerId || !device.value.session_id || device.value.status !== 'pending') return
|
||||
|
||||
try {
|
||||
const submittedCredential = withCallback ? device.value.callback_url.trim() : ''
|
||||
const windsurfSubmitted = isWindsurfProvider.value
|
||||
? normalizeWindsurfSubmittedCredential(submittedCredential)
|
||||
: {}
|
||||
const result = await pollDeviceAuthorize(props.providerId, {
|
||||
session_id: device.value.session_id,
|
||||
callback_url: withCallback ? device.value.callback_url.trim() : undefined,
|
||||
callback_url: withCallback ? (windsurfSubmitted.callback_url || (!isWindsurfProvider.value ? submittedCredential : undefined)) : undefined,
|
||||
token: withCallback ? windsurfSubmitted.token : undefined,
|
||||
})
|
||||
|
||||
switch (result.status) {
|
||||
@@ -1535,7 +1855,9 @@ watch(() => props.open, (newOpen) => {
|
||||
if (!showAuthorizationMode.value) {
|
||||
return
|
||||
}
|
||||
if (isKiroProvider.value) {
|
||||
if (isWindsurfProvider.value) {
|
||||
device.value.auth_type = 'default'
|
||||
} else if (isKiroProvider.value) {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
} else {
|
||||
initOAuth()
|
||||
@@ -1552,7 +1874,11 @@ watch(
|
||||
mode.value = 'import'
|
||||
return
|
||||
}
|
||||
if (props.open && isKiroProvider.value && mode.value === 'oauth') {
|
||||
if (props.open && isWindsurfProvider.value && mode.value === 'oauth') {
|
||||
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
|
||||
? device.value.auth_type
|
||||
: 'default'
|
||||
} else if (props.open && isKiroProvider.value && mode.value === 'oauth') {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -239,6 +239,47 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">
|
||||
额度提醒
|
||||
</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
余额低于阈值时通过通知服务发送提醒
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="quotaAlert.enabled" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="quotaAlert.enabled"
|
||||
class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label>提醒阈值</Label>
|
||||
<Input
|
||||
v-model.number="quotaAlert.threshold_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>获取频率(秒)</Label>
|
||||
<Input
|
||||
v-model.number="quotaAlert.fetch_interval_seconds"
|
||||
type="number"
|
||||
min="30"
|
||||
max="86400"
|
||||
step="1"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -305,6 +346,7 @@ import {
|
||||
getProviderOpsConfig,
|
||||
deleteProviderOpsConfig,
|
||||
type ArchitectureInfo,
|
||||
type QuotaAlertConfig,
|
||||
} from '@/api/providerOps'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -375,6 +417,12 @@ const architecturesLoaded = ref(false)
|
||||
const selectedArchitectureId = ref('new_api')
|
||||
const selectedAuthType = ref('')
|
||||
const formData = ref<Record<string, unknown>>({})
|
||||
const quotaAlert = ref<QuotaAlertConfig>({
|
||||
enabled: false,
|
||||
threshold_amount: 0,
|
||||
fetch_interval_seconds: 30,
|
||||
})
|
||||
const savedQuotaAlertSignature = ref(quotaAlertSignature(quotaAlert.value))
|
||||
|
||||
// 当前架构支持的认证方式
|
||||
const currentAuthTypes = computed(() => {
|
||||
@@ -421,8 +469,15 @@ const canVerify = computed(() => {
|
||||
})
|
||||
|
||||
// 保存按钮是否可用:验证成功且表单未变动
|
||||
const quotaAlertChanged = computed(() => {
|
||||
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return verifyStatus.value === 'success' && !formChanged.value
|
||||
return (
|
||||
(verifyStatus.value === 'success' && !formChanged.value)
|
||||
|| (hasExistingConfig.value && quotaAlertChanged.value && !formChanged.value)
|
||||
)
|
||||
})
|
||||
|
||||
// 字段分组
|
||||
@@ -495,6 +550,11 @@ function formatQuota(quota: number): string {
|
||||
return quota.toLocaleString()
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : null
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
const schema = currentSchema.value
|
||||
if (!schema) return
|
||||
@@ -565,8 +625,10 @@ async function handleVerify() {
|
||||
const displayName = result.data?.display_name || result.data?.username
|
||||
const extra = result.data?.extra
|
||||
let balanceText = `余额: ${formatQuota(quota)}`
|
||||
if (extra && extra.balance !== undefined && extra.points !== undefined) {
|
||||
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
|
||||
const extraBalance = finiteNumber(extra?.balance)
|
||||
const extraPoints = finiteNumber(extra?.points)
|
||||
if (extraBalance !== null && extraPoints !== null) {
|
||||
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
|
||||
}
|
||||
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
|
||||
}
|
||||
@@ -624,8 +686,10 @@ async function handleSave() {
|
||||
formData.value,
|
||||
props.providerWebsite,
|
||||
)
|
||||
request.quota_alert = normalizedQuotaAlert()
|
||||
const result = await saveProviderOpsConfig(props.providerId, request)
|
||||
if (result.success) {
|
||||
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
|
||||
showSuccess(result.message || '配置已保存', '保存成功')
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
@@ -660,6 +724,7 @@ async function handleClear() {
|
||||
formChanged.value = false
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
loadQuotaAlert(null)
|
||||
resetFormData()
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
@@ -673,18 +738,27 @@ async function handleClear() {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stringOrDefault(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.trim() ? value : fallback
|
||||
}
|
||||
|
||||
function loadFromConfig(config: Record<string, unknown>) {
|
||||
if (!config?.connector) return
|
||||
const connector = isRecord(config.connector) ? config.connector : null
|
||||
if (!connector) return
|
||||
|
||||
hasExistingConfig.value = true
|
||||
|
||||
// 根据已保存的 architecture_id 选择对应架构
|
||||
const architectureId = config.architecture_id || 'new_api'
|
||||
const architectureId = stringOrDefault(config.architecture_id, 'new_api')
|
||||
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
|
||||
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
||||
|
||||
// 从已保存的 connector auth_type 恢复认证方式选择
|
||||
const savedAuthType = config.connector?.auth_type
|
||||
const savedAuthType = stringOrDefault(connector.auth_type, '')
|
||||
const authTypes = currentAuthTypes.value
|
||||
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
|
||||
selectedAuthType.value = savedAuthType
|
||||
@@ -694,7 +768,10 @@ function loadFromConfig(config: Record<string, unknown>) {
|
||||
|
||||
const schema = currentSchema.value
|
||||
if (schema) {
|
||||
const parsedData = parseConfigFromSchema(schema, config)
|
||||
const parsedData = parseConfigFromSchema(schema, {
|
||||
...config,
|
||||
connector,
|
||||
})
|
||||
|
||||
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
||||
sensitivePlaceholders.value = {}
|
||||
@@ -707,6 +784,46 @@ function loadFromConfig(config: Record<string, unknown>) {
|
||||
|
||||
formData.value = parsedData
|
||||
}
|
||||
loadQuotaAlert(config.quota_alert)
|
||||
}
|
||||
|
||||
function defaultQuotaAlert(): QuotaAlertConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
threshold_amount: 0,
|
||||
fetch_interval_seconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuotaAlert(value: unknown): QuotaAlertConfig {
|
||||
if (!value || typeof value !== 'object') return defaultQuotaAlert()
|
||||
const item = value as Record<string, unknown>
|
||||
const threshold = Number(item.threshold_amount)
|
||||
const interval = Number(item.fetch_interval_seconds)
|
||||
return {
|
||||
enabled: item.enabled === true,
|
||||
threshold_amount: Number.isFinite(threshold) && threshold >= 0 ? threshold : 0,
|
||||
fetch_interval_seconds: Number.isFinite(interval) && interval >= 30 ? Math.min(Math.floor(interval), 86400) : 30,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedQuotaAlert(): QuotaAlertConfig {
|
||||
return normalizeQuotaAlert(quotaAlert.value)
|
||||
}
|
||||
|
||||
function quotaAlertSignature(value: QuotaAlertConfig): string {
|
||||
const normalized = normalizeQuotaAlert(value)
|
||||
return JSON.stringify([
|
||||
normalized.enabled,
|
||||
normalized.threshold_amount,
|
||||
normalized.fetch_interval_seconds,
|
||||
])
|
||||
}
|
||||
|
||||
function loadQuotaAlert(value: unknown) {
|
||||
const normalized = normalizeQuotaAlert(value)
|
||||
quotaAlert.value = normalized
|
||||
savedQuotaAlertSignature.value = quotaAlertSignature(normalized)
|
||||
}
|
||||
|
||||
/** 确保架构列表已加载 */
|
||||
@@ -747,11 +864,13 @@ watch(
|
||||
architecture_id: config.architecture_id,
|
||||
base_url: config.base_url,
|
||||
connector: config.connector,
|
||||
quota_alert: config.quota_alert,
|
||||
}
|
||||
loadFromConfig(configData)
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
loadQuotaAlert(null)
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
resetFormData()
|
||||
@@ -759,6 +878,7 @@ watch(
|
||||
} catch {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
loadQuotaAlert(null)
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
resetFormData()
|
||||
@@ -768,6 +888,7 @@ watch(
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
loadQuotaAlert(null)
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
resetFormData()
|
||||
|
||||
@@ -968,6 +968,138 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Windsurf 上游额度信息 -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
|
||||
class="mt-2 p-2 rounded-md"
|
||||
:class="isWindsurfUnavailableKey(key) ? 'bg-destructive/10 border border-destructive/30' : (isWindsurfExhaustedKey(key) ? 'bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/50' : 'bg-muted/30')"
|
||||
>
|
||||
<div
|
||||
v-if="isWindsurfUnavailableKey(key)"
|
||||
class="flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ShieldX class="w-4 h-4 shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[11px] font-medium">
|
||||
账号不可用
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.last_error"
|
||||
class="text-[10px] text-destructive/80 truncate"
|
||||
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
|
||||
>
|
||||
{{ getWindsurfQuotaDisplay(key)?.last_error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="isWindsurfExhaustedKey(key)"
|
||||
class="mb-2 flex items-center gap-2 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<ShieldX class="w-4 h-4 shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[11px] font-medium">
|
||||
{{ getWindsurfQuotaStatusLabel(key) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.last_error"
|
||||
class="text-[10px] text-amber-700/80 dark:text-amber-300/80 truncate"
|
||||
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
|
||||
>
|
||||
{{ getWindsurfQuotaDisplay(key)?.last_error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<RefreshCw
|
||||
v-if="refreshingQuota"
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="getWindsurfQuotaDisplay(key)?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatKiroUpdatedAt(getWindsurfQuotaDisplay(key)?.updated_at || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-if="getWindsurfQuotaDisplay(key)?.daily_remaining_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">日额度</span>
|
||||
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)">
|
||||
{{ (getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)"
|
||||
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.daily_reset_at"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.daily_reset_at || 0) }}重置
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getWindsurfQuotaDisplay(key)?.weekly_remaining_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">周额度</span>
|
||||
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)">
|
||||
{{ (getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)"
|
||||
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.weekly_reset_at"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.weekly_reset_at || 0) }}重置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasWindsurfPromptQuota(key) || hasWindsurfFlexQuota(key)"
|
||||
class="mt-2 flex items-center gap-3 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span v-if="hasWindsurfPromptQuota(key)">
|
||||
Prompt {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_used || 0) }} /
|
||||
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_limit || 0) }}
|
||||
</span>
|
||||
<span v-if="hasWindsurfFlexQuota(key)">
|
||||
Flex {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_used || 0) }} /
|
||||
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_limit || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasWindsurfModelCount(key) || hasWindsurfModelPreview(key)"
|
||||
class="mt-2 flex items-center justify-between gap-2 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span>
|
||||
模型 {{ getWindsurfQuotaDisplay(key)?.allowed_models_count ?? getWindsurfQuotaDisplay(key)?.models?.length }} 个
|
||||
</span>
|
||||
<span
|
||||
v-if="getWindsurfModelPreview(key)"
|
||||
class="truncate"
|
||||
:title="getWindsurfModelPreview(key) || ''"
|
||||
>
|
||||
{{ getWindsurfModelPreview(key) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- ChatGPT Web 上游额度信息(生图配额) -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
|
||||
@@ -1362,6 +1494,7 @@ import type {
|
||||
ChatGPTWebUpstreamMetadata,
|
||||
GrokUpstreamMetadata,
|
||||
KiroUpstreamMetadata,
|
||||
WindsurfUpstreamMetadata,
|
||||
QuotaStatusSnapshot,
|
||||
QuotaWindowSnapshot,
|
||||
} from '@/api/endpoints/types'
|
||||
@@ -2014,7 +2147,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// Codex / Gemini CLI / Antigravity / Kiro / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
// Codex / Gemini CLI / Antigravity / Kiro / Windsurf / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
||||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||||
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
||||
@@ -2033,7 +2166,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
|
||||
|
||||
function getQuotaSnapshotForProvider(
|
||||
key: EndpointAPIKey,
|
||||
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
|
||||
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
|
||||
): QuotaStatusSnapshot | null {
|
||||
const quota = key.status_snapshot?.quota
|
||||
if (!quota) return null
|
||||
@@ -2292,11 +2425,160 @@ function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
|
||||
type WindsurfQuotaDisplay = WindsurfUpstreamMetadata & {
|
||||
daily_used_percent?: number
|
||||
weekly_used_percent?: number
|
||||
}
|
||||
|
||||
function getWindsurfQuotaDisplay(key: EndpointAPIKey): WindsurfQuotaDisplay | null {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||||
const upstream = key.upstream_metadata?.windsurf
|
||||
if (!quota && !upstream) return null
|
||||
|
||||
const display: WindsurfQuotaDisplay = {}
|
||||
const updatedAt = getQuotaSnapshotUpdatedAt(quota) ?? upstream?.updated_at
|
||||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||||
if (quota?.plan_type) display.plan_name = quota.plan_type
|
||||
else if (upstream?.plan_name) display.plan_name = upstream.plan_name
|
||||
if (quota?.reason) display.last_error = quota.reason
|
||||
else if (upstream?.last_error) display.last_error = upstream.last_error
|
||||
if (typeof quota?.allowed_models_count === 'number') display.allowed_models_count = quota.allowed_models_count
|
||||
else if (typeof upstream?.allowed_models_count === 'number') display.allowed_models_count = upstream.allowed_models_count
|
||||
if (quota?.rate_limit) display.rate_limit = quota.rate_limit
|
||||
else if (upstream?.rate_limit) display.rate_limit = upstream.rate_limit
|
||||
if (Array.isArray(upstream?.models)) display.models = upstream.models
|
||||
|
||||
const dailyWindow = getQuotaWindow(quota, 'daily')
|
||||
const dailyRemaining = getQuotaWindowRemainingPercent(dailyWindow)
|
||||
const dailyUsed = getQuotaWindowUsedPercent(dailyWindow)
|
||||
if (dailyRemaining !== undefined) display.daily_remaining_percent = dailyRemaining
|
||||
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_remaining_percent = upstream.daily_remaining_percent
|
||||
if (dailyUsed !== undefined) display.daily_used_percent = dailyUsed
|
||||
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_used_percent = Math.max(100 - upstream.daily_remaining_percent, 0)
|
||||
const dailyResetAt = getQuotaWindowResetAt(dailyWindow)
|
||||
if (dailyResetAt !== undefined) display.daily_reset_at = dailyResetAt
|
||||
else if (typeof upstream?.daily_reset_at === 'number') display.daily_reset_at = upstream.daily_reset_at
|
||||
|
||||
const weeklyWindow = getQuotaWindow(quota, 'weekly')
|
||||
const weeklyRemaining = getQuotaWindowRemainingPercent(weeklyWindow)
|
||||
const weeklyUsed = getQuotaWindowUsedPercent(weeklyWindow)
|
||||
if (weeklyRemaining !== undefined) display.weekly_remaining_percent = weeklyRemaining
|
||||
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_remaining_percent = upstream.weekly_remaining_percent
|
||||
if (weeklyUsed !== undefined) display.weekly_used_percent = weeklyUsed
|
||||
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_used_percent = Math.max(100 - upstream.weekly_remaining_percent, 0)
|
||||
const weeklyResetAt = getQuotaWindowResetAt(weeklyWindow)
|
||||
if (weeklyResetAt !== undefined) display.weekly_reset_at = weeklyResetAt
|
||||
else if (typeof upstream?.weekly_reset_at === 'number') display.weekly_reset_at = upstream.weekly_reset_at
|
||||
|
||||
const promptWindow = getQuotaWindow(quota, 'prompt')
|
||||
if (typeof promptWindow?.used_value === 'number') display.prompt_used = promptWindow.used_value
|
||||
else if (typeof upstream?.prompt_used === 'number') display.prompt_used = upstream.prompt_used
|
||||
if (typeof promptWindow?.limit_value === 'number') display.prompt_limit = promptWindow.limit_value
|
||||
else if (typeof upstream?.prompt_limit === 'number') display.prompt_limit = upstream.prompt_limit
|
||||
if (typeof promptWindow?.remaining_value === 'number') display.prompt_remaining = promptWindow.remaining_value
|
||||
else if (typeof upstream?.prompt_remaining === 'number') display.prompt_remaining = upstream.prompt_remaining
|
||||
|
||||
const flexWindow = getQuotaWindow(quota, 'flex')
|
||||
if (typeof flexWindow?.used_value === 'number') display.flex_used = flexWindow.used_value
|
||||
else if (typeof upstream?.flex_used === 'number') display.flex_used = upstream.flex_used
|
||||
if (typeof flexWindow?.limit_value === 'number') display.flex_limit = flexWindow.limit_value
|
||||
else if (typeof upstream?.flex_limit === 'number') display.flex_limit = upstream.flex_limit
|
||||
if (typeof flexWindow?.remaining_value === 'number') display.flex_remaining = flexWindow.remaining_value
|
||||
else if (typeof upstream?.flex_remaining === 'number') display.flex_remaining = upstream.flex_remaining
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
|
||||
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const grok = getGrokQuotaDisplay(key)
|
||||
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
|
||||
}
|
||||
|
||||
function hasWindsurfQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const windsurf = getWindsurfQuotaDisplay(key)
|
||||
return !!windsurf && (
|
||||
windsurf.daily_remaining_percent !== undefined
|
||||
|| windsurf.weekly_remaining_percent !== undefined
|
||||
|| windsurf.prompt_limit !== undefined
|
||||
|| windsurf.flex_limit !== undefined
|
||||
|| windsurf.allowed_models_count !== undefined
|
||||
|| windsurf.rate_limit !== undefined
|
||||
|| !!windsurf.last_error
|
||||
|| (Array.isArray(windsurf.models) && windsurf.models.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function isWindsurfUnavailableKey(key: EndpointAPIKey): boolean {
|
||||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||||
return code === 'banned' || code === 'forbidden' || code === 'quarantined'
|
||||
}
|
||||
|
||||
function getPositiveQuotaNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function windsurfCooldownHasPositiveReset(key: EndpointAPIKey): boolean {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||||
const rateLimit = quota?.rate_limit
|
||||
if (rateLimit && typeof rateLimit === 'object') {
|
||||
const retryAfterMs =
|
||||
getPositiveQuotaNumber(rateLimit.retry_after_ms)
|
||||
?? getPositiveQuotaNumber(rateLimit.retryAfterMs)
|
||||
if (retryAfterMs !== undefined) return true
|
||||
}
|
||||
|
||||
const rateLimitWindow = getQuotaWindow(quota, 'rate_limit')
|
||||
return (
|
||||
getPositiveQuotaNumber(rateLimitWindow?.reset_seconds) !== undefined
|
||||
|| getPositiveQuotaNumber(rateLimitWindow?.reset_at) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function isWindsurfExhaustedKey(key: EndpointAPIKey): boolean {
|
||||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||||
if (code === 'cooldown') return windsurfCooldownHasPositiveReset(key)
|
||||
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit'
|
||||
}
|
||||
|
||||
function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||||
const label = quota?.label?.trim()
|
||||
if (label) return label
|
||||
const code = String(quota?.code || '').trim().toLowerCase()
|
||||
if (code === 'cooldown') return '冷却中'
|
||||
return code === 'rate_limited' || code === 'rate_limit' ? '速率受限' : '额度耗尽'
|
||||
}
|
||||
|
||||
function getWindsurfModelPreview(key: EndpointAPIKey): string | null {
|
||||
const models = getWindsurfQuotaDisplay(key)?.models
|
||||
if (!Array.isArray(models) || models.length === 0) return null
|
||||
return models
|
||||
.slice(0, 3)
|
||||
.map(model => (model.label || model.model_uid || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' / ') || null
|
||||
}
|
||||
|
||||
function hasFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function hasWindsurfPromptQuota(key: EndpointAPIKey): boolean {
|
||||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.prompt_limit)
|
||||
}
|
||||
|
||||
function hasWindsurfFlexQuota(key: EndpointAPIKey): boolean {
|
||||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.flex_limit)
|
||||
}
|
||||
|
||||
function hasWindsurfModelCount(key: EndpointAPIKey): boolean {
|
||||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.allowed_models_count)
|
||||
}
|
||||
|
||||
function hasWindsurfModelPreview(key: EndpointAPIKey): boolean {
|
||||
return !!getWindsurfModelPreview(key)
|
||||
}
|
||||
|
||||
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
|
||||
image_quota_remaining_percent?: number
|
||||
image_quota_used_percent?: number
|
||||
@@ -2509,7 +2791,7 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Gemini CLI / Antigravity / Kiro / ChatGPT Web)
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Gemini CLI / Antigravity / Kiro / Windsurf / ChatGPT Web)
|
||||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||||
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||||
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||||
@@ -2608,6 +2890,28 @@ function shouldAutoRefreshGrokQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldAutoRefreshWindsurfQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'windsurf') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
for (const { key } of allKeys.value) {
|
||||
if (!key.is_active) continue
|
||||
|
||||
if (isTokenExpiringSoon(key, now)) return true
|
||||
|
||||
if (!hasWindsurfQuotaDisplayData(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const updatedAt = getWindsurfQuotaDisplay(key)?.updated_at
|
||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldAutoRefreshChatGPTWebQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'chatgpt_web') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -2707,14 +3011,14 @@ function applyQuotaResults(
|
||||
return applied
|
||||
}
|
||||
|
||||
// 通用的自动刷新配额函数
|
||||
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
||||
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
|
||||
const providerId = props.providerId
|
||||
if (!providerId) return
|
||||
if (refreshingQuota.value) return
|
||||
|
||||
const providerType = provider.value?.provider_type
|
||||
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
|
||||
// 检查是否需要刷新
|
||||
let shouldRefresh = false
|
||||
@@ -2728,6 +3032,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
||||
shouldRefresh = shouldAutoRefreshKiroQuota()
|
||||
} else if (providerType === 'grok') {
|
||||
shouldRefresh = shouldAutoRefreshGrokQuota()
|
||||
} else if (providerType === 'windsurf') {
|
||||
shouldRefresh = shouldAutoRefreshWindsurfQuota()
|
||||
} else if (providerType === 'chatgpt_web') {
|
||||
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
||||
}
|
||||
@@ -2745,6 +3051,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
|
||||
} else if (providerType === 'grok') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
|
||||
} else if (providerType === 'windsurf') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
|
||||
} else if (providerType === 'chatgpt_web') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
<SelectItem value="kiro">
|
||||
Kiro
|
||||
</SelectItem>
|
||||
<SelectItem value="windsurf">
|
||||
Windsurf
|
||||
</SelectItem>
|
||||
<SelectItem value="antigravity">
|
||||
Antigravity
|
||||
</SelectItem>
|
||||
@@ -96,6 +99,9 @@
|
||||
<SelectItem value="kiro">
|
||||
Kiro
|
||||
</SelectItem>
|
||||
<SelectItem value="windsurf">
|
||||
Windsurf
|
||||
</SelectItem>
|
||||
<SelectItem value="antigravity">
|
||||
Antigravity
|
||||
</SelectItem>
|
||||
@@ -342,6 +348,7 @@ import {
|
||||
createProvider,
|
||||
normalizePoolAdvancedConfig,
|
||||
updateProvider,
|
||||
type ProviderType,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -377,7 +384,7 @@ const defaultPriority = computed(() => {
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
name: '',
|
||||
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok',
|
||||
provider_type: 'custom' as ProviderType,
|
||||
description: '',
|
||||
website: '',
|
||||
// 计费配置
|
||||
|
||||
+11
-2
@@ -14,7 +14,16 @@ const endpointMocks = vi.hoisted(() => ({
|
||||
getAwsRegions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints', () => endpointMocks)
|
||||
vi.mock('@/api/endpoints', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/endpoints/provider_oauth')>(
|
||||
'@/api/endpoints/provider_oauth',
|
||||
)
|
||||
|
||||
return {
|
||||
...endpointMocks,
|
||||
normalizeBatchImportCredentials: actual.normalizeBatchImportCredentials,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
@@ -358,7 +367,7 @@ describe('OAuthAccountDialog Grok import', () => {
|
||||
|
||||
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
|
||||
'provider-1',
|
||||
'sso-1\nsso-2',
|
||||
'["sso-1","sso-2"]',
|
||||
undefined,
|
||||
)
|
||||
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeBatchImportCredentials } from '@/api/endpoints/provider_oauth'
|
||||
import { isKeyManagedProviderType, isOAuthAccountProviderType } from '../providerTypeUtils'
|
||||
|
||||
describe('providerTypeUtils', () => {
|
||||
@@ -14,4 +15,47 @@ describe('providerTypeUtils', () => {
|
||||
expect(isOAuthAccountProviderType('GROK')).toBe(true)
|
||||
expect(isKeyManagedProviderType('grok')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats Windsurf as an OAuth account provider', () => {
|
||||
expect(isOAuthAccountProviderType('windsurf')).toBe(true)
|
||||
expect(isOAuthAccountProviderType('Windsurf')).toBe(true)
|
||||
expect(isKeyManagedProviderType('windsurf')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBatchImportCredentials', () => {
|
||||
it('converts JSON Lines objects into a JSON array payload', () => {
|
||||
const result = normalizeBatchImportCredentials([
|
||||
'{"refresh_token":"rt-1","email":"[email protected]"}',
|
||||
'{"token":"token-2","email":"[email protected]"}',
|
||||
].join('\n'))
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
isBatch: true,
|
||||
credentials: JSON.stringify([
|
||||
{ refresh_token: 'rt-1', email: '[email protected]' },
|
||||
{ token: 'token-2', email: '[email protected]' },
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed JSON Lines instead of treating them as raw tokens', () => {
|
||||
const result = normalizeBatchImportCredentials('{"refresh_token":"rt-1"}\n{"refresh_token":')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.message).toContain('第 2 行')
|
||||
}
|
||||
})
|
||||
|
||||
it('converts multiple raw token lines into a JSON array payload', () => {
|
||||
const result = normalizeBatchImportCredentials('token-a\n# comment\ntoken-b')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
isBatch: true,
|
||||
credentials: JSON.stringify(['token-a', 'token-b']),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
|
||||
'antigravity',
|
||||
'kiro',
|
||||
'grok',
|
||||
'windsurf',
|
||||
])
|
||||
|
||||
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>
|
||||
|
||||
@@ -418,6 +418,25 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function mergePositiveDurationMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
): number | null | undefined {
|
||||
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
|
||||
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
|
||||
|
||||
if (existingIsPositive && nextIsPositive) {
|
||||
return Math.max(existingValue, nextValue)
|
||||
}
|
||||
if (existingIsPositive) {
|
||||
return existingValue
|
||||
}
|
||||
if (nextIsPositive) {
|
||||
return nextValue
|
||||
}
|
||||
return existingValue ?? nextValue
|
||||
}
|
||||
|
||||
function mergeRecordStatus(
|
||||
current: UsageRecord[],
|
||||
next: UsageRecord[]
|
||||
@@ -513,8 +532,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
||||
cost: existing.cost || record.cost,
|
||||
actual_cost: existing.actual_cost ?? record.actual_cost,
|
||||
response_time_ms: existing.response_time_ms ?? record.response_time_ms,
|
||||
first_byte_time_ms: existing.first_byte_time_ms ?? record.first_byte_time_ms,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
is_stream: upstreamIsStream,
|
||||
upstream_is_stream: upstreamIsStream,
|
||||
client_requested_stream: clientRequestedStream,
|
||||
|
||||
@@ -91,6 +91,27 @@ describe('request failure notice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not present HTTP 200 as the cause of stream terminal failures', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
status_code: 200,
|
||||
status: 'failed',
|
||||
error_message: 'This content was flagged for possible cybersecurity risk',
|
||||
failure_summary: {
|
||||
source: 'client_response',
|
||||
status_code: 200,
|
||||
type: 'stream_terminal_error',
|
||||
message: 'This content was flagged for possible cybersecurity risk',
|
||||
},
|
||||
}))
|
||||
|
||||
expect(notice).toEqual({
|
||||
title: '执行失败原因',
|
||||
message: 'This content was flagged for possible cybersecurity risk',
|
||||
isSchedulingFailure: false,
|
||||
meta: ['stream_terminal_error', 'client_response'],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a stale notice when the refreshed detail has no error fields', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
status_code: 200,
|
||||
|
||||
@@ -51,15 +51,15 @@ describe('usage status helpers', () => {
|
||||
expect(isUsageRecordSuccessful(record)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats explicit failed status with a 2xx status code as successful for display', () => {
|
||||
it('treats explicit failed status as authoritative over a 2xx transport code', () => {
|
||||
const record = buildUsageRecord({
|
||||
status: 'failed',
|
||||
status_code: 200,
|
||||
error_message: 'stale failure flag'
|
||||
error_message: 'stream terminal error'
|
||||
})
|
||||
|
||||
expect(isUsageRecordFailed(record)).toBe(false)
|
||||
expect(isUsageRecordSuccessful(record)).toBe(true)
|
||||
expect(isUsageRecordFailed(record)).toBe(true)
|
||||
expect(isUsageRecordSuccessful(record)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes request status strings before mapping timeline status', () => {
|
||||
|
||||
@@ -18,7 +18,9 @@ function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): Re
|
||||
}
|
||||
|
||||
function formatHttpStatus(statusCode: number | null | undefined): string | null {
|
||||
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
|
||||
return typeof statusCode === 'number' && (statusCode < 200 || statusCode >= 300)
|
||||
? `HTTP ${statusCode}`
|
||||
: null
|
||||
}
|
||||
|
||||
function uniqueMeta(values: Array<string | null | undefined>): string[] {
|
||||
|
||||
@@ -189,7 +189,7 @@ export function isUsageRecordFailed(record: UsageFailureSignal & Pick<UsageRecor
|
||||
return false
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return !hasTerminalSuccessStatusCode(record)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (hasTerminalSuccessStatusCode(record)) {
|
||||
@@ -208,7 +208,7 @@ export function isUsageRecordSuccessful(record: UsageFailureSignal & Pick<UsageR
|
||||
return true
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return hasTerminalSuccessStatusCode(record)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="mb-3 text-xs font-semibold text-muted-foreground">
|
||||
功能权限
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">敏感信息保护</Label>
|
||||
<Switch v-model="form.chat_pii_redaction_enabled" />
|
||||
@@ -225,6 +228,15 @@
|
||||
:disabled="!form.chat_pii_redaction_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">通知推送服务</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
允许用户配置自己的第三方推送渠道
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="form.notification_push_service_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -271,6 +283,8 @@ import { log } from '@/utils/logger'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
mergeNotificationPushServiceFeatureSettings,
|
||||
readNotificationPushServiceFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
import {
|
||||
@@ -323,6 +337,7 @@ const form = ref({
|
||||
group_ids: [] as string[],
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
notification_push_service_enabled: false,
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => (props.groups || []).map((group) => ({
|
||||
@@ -348,6 +363,7 @@ function resetForm() {
|
||||
group_ids: [],
|
||||
chat_pii_redaction_enabled: false,
|
||||
chat_pii_redaction_placeholder_notice: true,
|
||||
notification_push_service_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +371,7 @@ function loadUserData() {
|
||||
if (!props.user) return
|
||||
formNonce.value = createFieldNonce()
|
||||
const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings)
|
||||
const notificationPushFeature = readNotificationPushServiceFeatureSettings(props.user.feature_settings)
|
||||
// 创建数组副本,避免与 props 数据共享引用
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
@@ -368,6 +385,7 @@ function loadUserData() {
|
||||
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
|
||||
chat_pii_redaction_enabled: redactionFeature.enabled,
|
||||
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
||||
notification_push_service_enabled: notificationPushFeature.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,10 +460,7 @@ async function handleSubmit() {
|
||||
unlimited: form.value.unlimited,
|
||||
role: form.value.role,
|
||||
group_ids: [...form.value.group_ids],
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
|
||||
enabled: form.value.chat_pii_redaction_enabled,
|
||||
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
|
||||
}),
|
||||
feature_settings: buildFeatureSettingsPayload(),
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.user?.id) {
|
||||
@@ -472,6 +487,16 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildFeatureSettingsPayload(): Record<string, unknown> | null {
|
||||
const withRedaction = mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
|
||||
enabled: form.value.chat_pii_redaction_enabled,
|
||||
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
|
||||
})
|
||||
return mergeNotificationPushServiceFeatureSettings(withRedaction, {
|
||||
enabled: form.value.notification_push_service_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// 设置保存状态(供父组件调用)
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
|
||||
@@ -128,8 +128,25 @@
|
||||
<div class="space-y-4 border-t border-border/60 pt-5">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 pb-2 border-b border-border/60">
|
||||
<span class="text-sm font-medium">组权限</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
多个组与用户额外限制取交集
|
||||
<span class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
组权限叠加,Key 可再收窄
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/70 bg-muted/40 text-muted-foreground outline-none transition-colors hover:border-primary/50 hover:text-primary focus-visible:border-primary/60 focus-visible:text-primary"
|
||||
:title="groupPolicyHelpText"
|
||||
aria-label="查看组权限合并规则"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-72 text-xs leading-5">
|
||||
{{ groupPolicyHelpText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +264,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BadgeCheck, ChevronRight, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { BadgeCheck, ChevronRight, Info, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -255,6 +272,10 @@ import {
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
@@ -302,6 +323,8 @@ const USER_OPTIONS_CACHE_TTL_MS = 30 * 1000
|
||||
let dialogUsersLoadedAt = 0
|
||||
let dialogUsersLoadedVersion = -1
|
||||
|
||||
const groupPolicyHelpText = '模型、供应商和端点会在多个用户组之间叠加授权;unrestricted 仍表示不限制,deny_all 只是不授予额外权限。速率限制按付费档位取更高额度,0 表示不限速;用户/API Key 自身限制仍会收窄最终权限。'
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
allowed_providers_mode: 'unrestricted' as ListPolicyMode,
|
||||
|
||||
Reference in New Issue
Block a user