Merge branch 'main' into test

This commit is contained in:
yangjianbo
2026-02-12 23:43:47 +08:00
63 changed files with 2018 additions and 282 deletions

View File

@@ -53,4 +53,18 @@ export async function exchangeCode(
return data
}
export default { generateAuthUrl, exchangeCode }
export async function refreshAntigravityToken(
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo> {
const payload: Record<string, any> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<AntigravityTokenInfo>(
'/admin/antigravity/oauth/refresh-token',
payload
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }

View File

@@ -21,6 +21,7 @@ export interface ErrorPassthroughRule {
response_code: number | null
passthrough_body: boolean
custom_message: string | null
skip_monitoring: boolean
description: string | null
created_at: string
updated_at: string
@@ -41,6 +42,7 @@ export interface CreateRuleRequest {
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
skip_monitoring?: boolean
description?: string | null
}
@@ -59,6 +61,7 @@ export interface UpdateRuleRequest {
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
skip_monitoring?: boolean
description?: string | null
}

View File

@@ -1744,12 +1744,12 @@
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@validate-refresh-token="handleOpenAIValidateRT"
@validate-refresh-token="handleValidateRefreshToken"
/>
</div>
@@ -2948,6 +2948,14 @@ const handleGenerateUrl = async () => {
}
}
const handleValidateRefreshToken = (rt: string) => {
if (form.platform === 'openai') {
handleOpenAIValidateRT(rt)
} else if (form.platform === 'antigravity') {
handleAntigravityValidateRT(rt)
}
}
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
@@ -3165,6 +3173,95 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
}
// Antigravity 手动 RT 批量验证和创建
const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
if (!refreshTokenInput.trim()) return
// Parse multiple refresh tokens (one per line)
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt)
if (refreshTokens.length === 0) {
antigravityOAuth.error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return
}
antigravityOAuth.loading.value = true
antigravityOAuth.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < refreshTokens.length; i++) {
try {
const tokenInfo = await antigravityOAuth.validateRefreshToken(
refreshTokens[i],
form.proxy_id
)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${antigravityOAuth.error.value || 'Validation failed'}`)
antigravityOAuth.error.value = ''
continue
}
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
// Generate account name with index for batch
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
await adminAPI.accounts.create({
name: accountName,
notes: form.notes,
platform: 'antigravity',
type: 'oauth',
credentials,
extra: {},
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
successCount++
} catch (error: any) {
failedCount++
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
errors.push(`#${i + 1}: ${errMsg}`)
}
}
// Show results
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
refreshTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
antigravityOAuth.error.value = errors.join('\n')
emit('created')
} else {
antigravityOAuth.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} finally {
antigravityOAuth.loading.value = false
}
}
// Gemini OAuth 授权码兑换
const handleGeminiExchange = async (authCode: string) => {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return

View File

@@ -45,19 +45,19 @@
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.refreshTokenAuth')
t(getOAuthKey('refreshTokenAuth'))
}}</span>
</label>
</div>
</div>
<!-- Refresh Token Input (OpenAI only) -->
<!-- Refresh Token Input (OpenAI / Antigravity) -->
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }}
{{ t(getOAuthKey('refreshTokenDesc')) }}
</p>
<!-- Refresh Token Input -->
@@ -78,7 +78,7 @@
v-model="refreshTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')"
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
></textarea>
<p
v-if="parsedRefreshTokenCount > 1"
@@ -128,8 +128,8 @@
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t('admin.accounts.oauth.openai.validating')
: t('admin.accounts.oauth.openai.validateAndCreate')
? t(getOAuthKey('validating'))
: t(getOAuthKey('validateAndCreate'))
}}
</button>
</div>

View File

@@ -148,6 +148,16 @@
{{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }}
</span>
</div>
<div v-if="rule.skip_monitoring" class="flex items-center gap-1">
<Icon
name="checkCircle"
size="xs"
class="text-yellow-500"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.skipMonitoring') }}
</span>
</div>
</div>
</td>
<td class="px-3 py-2">
@@ -366,6 +376,19 @@
</div>
</div>
<!-- Skip Monitoring -->
<div class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.skip_monitoring"
class="h-3.5 w-3.5 rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.skipMonitoring') }}
</span>
</div>
<p class="input-hint text-xs -mt-3">{{ t('admin.errorPassthrough.form.skipMonitoringHint') }}</p>
<!-- Enabled -->
<div class="flex items-center gap-1.5">
<input
@@ -453,6 +476,7 @@ const form = reactive({
response_code: null as number | null,
passthrough_body: true,
custom_message: null as string | null,
skip_monitoring: false,
description: null as string | null
})
@@ -497,6 +521,7 @@ const resetForm = () => {
form.response_code = null
form.passthrough_body = true
form.custom_message = null
form.skip_monitoring = false
form.description = null
errorCodesInput.value = ''
keywordsInput.value = ''
@@ -520,6 +545,7 @@ const handleEdit = (rule: ErrorPassthroughRule) => {
form.response_code = rule.response_code
form.passthrough_body = rule.passthrough_body
form.custom_message = rule.custom_message
form.skip_monitoring = rule.skip_monitoring
form.description = rule.description
errorCodesInput.value = rule.error_codes.join(', ')
keywordsInput.value = rule.keywords.join('\n')
@@ -575,6 +601,7 @@ const handleSubmit = async () => {
response_code: form.passthrough_code ? null : form.response_code,
passthrough_body: form.passthrough_body,
custom_message: form.passthrough_body ? null : form.custom_message,
skip_monitoring: form.skip_monitoring,
description: form.description?.trim() || null
}

View File

@@ -21,5 +21,5 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }])
</script>

View File

@@ -29,17 +29,19 @@
<!-- Logo/Brand -->
<div class="mb-8 text-center">
<!-- Custom Logo or Default Logo -->
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
<template v-if="settingsLoaded">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
</template>
</div>
<!-- Card Container -->
@@ -61,25 +63,21 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getPublicSettings } from '@/api/auth'
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores'
import { sanitizeUrl } from '@/utils/url'
const siteName = ref('Sub2API')
const siteLogo = ref('')
const siteSubtitle = ref('Subscription to API Conversion Platform')
const appStore = useAppStore()
const siteName = computed(() => appStore.siteName || 'Sub2API')
const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true }))
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Subscription to API Conversion Platform')
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
const currentYear = computed(() => new Date().getFullYear())
onMounted(async () => {
try {
const settings = await getPublicSettings()
siteName.value = settings.site_name || 'Sub2API'
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
} catch (error) {
console.error('Failed to load public settings:', error)
}
onMounted(() => {
appStore.fetchPublicSettings()
})
</script>

View File

@@ -83,6 +83,35 @@ export function useAntigravityOAuth() {
}
}
const validateRefreshToken = async (
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo | null> => {
if (!refreshToken.trim()) {
error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.antigravity.refreshAntigravityToken(
refreshToken.trim(),
proxyId
)
return tokenInfo as AntigravityTokenInfo
} catch (err: any) {
error.value =
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToValidateRT')
// Don't show global error toast for batch validation to avoid spamming
// appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
let expiresAt: string | undefined
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
resetState,
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
buildCredentials
}
}

View File

@@ -841,7 +841,7 @@ export default {
createUser: 'Create User',
editUser: 'Edit User',
deleteUser: 'Delete User',
searchUsers: 'Search users...',
searchUsers: 'Search by email, username, notes, or API key...',
allRoles: 'All Roles',
allStatus: 'All Status',
admin: 'Admin',
@@ -1798,13 +1798,20 @@ export default {
authCode: 'Authorization URL or Code',
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
missingExchangeParams: 'Missing code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Antigravity auth code'
}
},
// Gemini specific (platform-wide)
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
missingExchangeParams: 'Missing code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Antigravity auth code',
// Refresh Token auth
refreshTokenAuth: 'Manual RT',
refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
failedToValidateRT: 'Failed to validate Refresh Token'
}
}, // Gemini specific (platform-wide)
gemini: {
helpButton: 'Help',
helpDialog: {
@@ -2153,7 +2160,7 @@ export default {
title: 'Redeem Code Management',
description: 'Generate and manage redeem codes',
generateCodes: 'Generate Codes',
searchCodes: 'Search codes...',
searchCodes: 'Search codes or email...',
allTypes: 'All Types',
allStatus: 'All Status',
balance: 'Balance',
@@ -3399,6 +3406,7 @@ export default {
custom: 'Custom',
code: 'Code',
body: 'Body',
skipMonitoring: 'Skip Monitoring',
// Columns
columns: {
@@ -3443,6 +3451,8 @@ export default {
passthroughBody: 'Passthrough upstream error message',
customMessage: 'Custom error message',
customMessagePlaceholder: 'Error message to return to client...',
skipMonitoring: 'Skip monitoring',
skipMonitoringHint: 'When enabled, errors matching this rule will not be recorded in ops monitoring',
enabled: 'Enable this rule'
},

View File

@@ -865,8 +865,8 @@ export default {
editUser: '编辑用户',
deleteUser: '删除用户',
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
searchPlaceholder: '搜索用户邮箱用户名备注、支持模糊查询...',
searchUsers: '搜索用户邮箱用户名备注、支持模糊查询',
searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...',
searchUsers: '邮箱/用户名/备注/API Key 模糊搜索',
roleFilter: '角色筛选',
allRoles: '全部角色',
allStatus: '全部状态',
@@ -1936,7 +1936,15 @@ export default {
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Antigravity 授权码兑换失败'
failedToExchangeCode: 'Antigravity 授权码兑换失败',
// Refresh Token auth
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 Antigravity Refresh Token支持批量输入每行一个系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
failedToValidateRT: '验证 Refresh Token 失败'
}
},
// Gemini specific (platform-wide)
@@ -2315,7 +2323,7 @@ export default {
allStatus: '全部状态',
unused: '未使用',
used: '已使用',
searchCodes: '搜索兑换码...',
searchCodes: '搜索兑换码或邮箱...',
exportCsv: '导出 CSV',
deleteAllUnused: '删除全部未使用',
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
@@ -3572,6 +3580,7 @@ export default {
custom: '自定义',
code: '状态码',
body: '消息体',
skipMonitoring: '跳过监控',
// Columns
columns: {
@@ -3616,6 +3625,8 @@ export default {
passthroughBody: '透传上游错误信息',
customMessage: '自定义错误信息',
customMessagePlaceholder: '返回给客户端的错误信息...',
skipMonitoring: '跳过运维监控记录',
skipMonitoringHint: '开启后,匹配此规则的错误不会被记录到运维监控中',
enabled: '启用此规则'
},

View File

@@ -6,6 +6,7 @@
*/
type SanitizeOptions = {
allowRelative?: boolean
allowDataUrl?: boolean
}
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
@@ -18,6 +19,11 @@ export function sanitizeUrl(value: string, options: SanitizeOptions = {}): strin
return trimmed
}
// 允许 data:image/ 开头的 data URL仅限图片类型
if (options.allowDataUrl && trimmed.startsWith('data:image/')) {
return trimmed
}
// 只接受绝对 URL不使用 base URL 来避免相对路径被解析为当前域名
// 检查是否以 http:// 或 https:// 开头
if (!trimmed.match(/^https?:\/\//i)) {

View File

@@ -117,9 +117,9 @@
</span>
</template>
<template #cell-used_by="{ value }">
<template #cell-used_by="{ value, row }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? t('admin.redeem.userPrefix', { id: value }) : '-' }}
{{ row.user?.email || (value ? t('admin.redeem.userPrefix', { id: value }) : '-') }}
</span>
</template>