Commit 3b7a5fff authored by 陈曦's avatar 陈曦
Browse files

补充openai、gemini以及流失请求的采集数据以及nfs落库

parent 8519a8eb
Pipeline #82284 failed with stage
in 2 minutes and 21 seconds
/**
* Admin Affiliate API endpoints
* Manage per-user affiliate (邀请返利) configurations:
* exclusive invite codes (overrides aff_code) and exclusive rebate rates.
*/
import { apiClient } from '../client'
import type { PaginatedResponse } from '@/types'
export interface AffiliateAdminEntry {
user_id: number
email: string
username: string
aff_code: string
aff_code_custom: boolean
aff_rebate_rate_percent?: number | null
aff_count: number
}
export interface ListAffiliateUsersParams {
page?: number
page_size?: number
search?: string
}
export interface UpdateAffiliateUserRequest {
aff_code?: string
aff_rebate_rate_percent?: number | null
/** Set true to explicitly clear the per-user rate (sets it to NULL). */
clear_rebate_rate?: boolean
}
export interface BatchSetRateRequest {
user_ids: number[]
aff_rebate_rate_percent?: number | null
/** Set true to clear rates instead of setting. */
clear?: boolean
}
export interface SimpleUser {
id: number
email: string
username: string
}
export async function listUsers(
params: ListAffiliateUsersParams = {},
): Promise<PaginatedResponse<AffiliateAdminEntry>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateAdminEntry>>(
'/admin/affiliates/users',
{
params: {
page: params.page ?? 1,
page_size: params.page_size ?? 20,
search: params.search ?? '',
},
},
)
return data
}
export async function lookupUsers(q: string): Promise<SimpleUser[]> {
const { data } = await apiClient.get<SimpleUser[]>(
'/admin/affiliates/users/lookup',
{ params: { q } },
)
return data
}
export async function updateUserSettings(
userId: number,
payload: UpdateAffiliateUserRequest,
): Promise<{ user_id: number }> {
const { data } = await apiClient.put<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
payload,
)
return data
}
export async function clearUserSettings(
userId: number,
): Promise<{ user_id: number }> {
const { data } = await apiClient.delete<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
)
return data
}
export async function batchSetRate(
payload: BatchSetRateRequest,
): Promise<{ affected: number }> {
const { data } = await apiClient.post<{ affected: number }>(
'/admin/affiliates/users/batch-rate',
payload,
)
return data
}
export const affiliatesAPI = {
listUsers,
lookupUsers,
updateUserSettings,
clearUserSettings,
batchSetRate,
}
export default affiliatesAPI
...@@ -29,6 +29,7 @@ import channelsAPI from './channels' ...@@ -29,6 +29,7 @@ import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor' import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate' import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment' import adminPaymentAPI from './payment'
import affiliatesAPI from './affiliates'
/** /**
* Unified admin API object for convenient access * Unified admin API object for convenient access
...@@ -59,7 +60,8 @@ export const adminAPI = { ...@@ -59,7 +60,8 @@ export const adminAPI = {
channels: channelsAPI, channels: channelsAPI,
channelMonitor: channelMonitorAPI, channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI, channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI payment: adminPaymentAPI,
affiliates: affiliatesAPI
} }
export { export {
...@@ -88,7 +90,8 @@ export { ...@@ -88,7 +90,8 @@ export {
channelsAPI, channelsAPI,
channelMonitorAPI, channelMonitorAPI,
channelMonitorTemplateAPI, channelMonitorTemplateAPI,
adminPaymentAPI adminPaymentAPI,
affiliatesAPI
} }
export default adminAPI export default adminAPI
......
...@@ -308,6 +308,10 @@ export interface SystemSettings { ...@@ -308,6 +308,10 @@ export interface SystemSettings {
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
// Default settings // Default settings
default_balance: number; default_balance: number;
affiliate_rebate_rate: number;
affiliate_rebate_freeze_hours: number;
affiliate_rebate_duration_days: number;
affiliate_rebate_per_invitee_cap: number;
default_concurrency: number; default_concurrency: number;
default_user_rpm_limit: number; default_user_rpm_limit: number;
default_subscriptions: DefaultSubscriptionSetting[]; default_subscriptions: DefaultSubscriptionSetting[];
...@@ -477,6 +481,9 @@ export interface SystemSettings { ...@@ -477,6 +481,9 @@ export interface SystemSettings {
// Available Channels feature switch // Available Channels feature switch
available_channels_enabled: boolean; available_channels_enabled: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
...@@ -489,6 +496,10 @@ export interface UpdateSettingsRequest { ...@@ -489,6 +496,10 @@ export interface UpdateSettingsRequest {
invitation_code_enabled?: boolean; invitation_code_enabled?: boolean;
totp_enabled?: boolean; // TOTP 双因素认证 totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number; default_balance?: number;
affiliate_rebate_rate?: number;
affiliate_rebate_freeze_hours?: number;
affiliate_rebate_duration_days?: number;
affiliate_rebate_per_invitee_cap?: number;
default_concurrency?: number; default_concurrency?: number;
default_user_rpm_limit?: number; default_user_rpm_limit?: number;
default_subscriptions?: DefaultSubscriptionSetting[]; default_subscriptions?: DefaultSubscriptionSetting[];
...@@ -634,6 +645,9 @@ export interface UpdateSettingsRequest { ...@@ -634,6 +645,9 @@ export interface UpdateSettingsRequest {
// Available Channels feature switch // Available Channels feature switch
available_channels_enabled?: boolean; available_channels_enabled?: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
} }
/** /**
......
...@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese ...@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
*/ */
export async function completeLinuxDoOAuthRegistration( export async function completeLinuxDoOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingLinuxDoOAuthAccount(invitationCode, decision) return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
} }
/** /**
...@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration( ...@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration(
*/ */
export async function completeOIDCOAuthRegistration( export async function completeOIDCOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision) return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
} }
export async function completeWeChatOAuthRegistration( export async function completeWeChatOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingWeChatOAuthAccount(invitationCode, decision) return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
} }
async function createPendingOAuthAccount( async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat', provider: 'linuxdo' | 'oidc' | 'wechat',
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>( const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`, `/auth/oauth/${provider}/complete-registration`,
{ {
invitation_code: invitationCode, invitation_code: invitationCode,
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
...serializeOAuthAdoptionDecision(decision) ...serializeOAuthAdoptionDecision(decision)
} }
) )
...@@ -605,23 +611,26 @@ async function createPendingOAuthAccount( ...@@ -605,23 +611,26 @@ async function createPendingOAuthAccount(
export async function createPendingLinuxDoOAuthAccount( export async function createPendingLinuxDoOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision) return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
} }
export async function createPendingOIDCOAuthAccount( export async function createPendingOIDCOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision) return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
} }
export async function createPendingWeChatOAuthAccount( export async function createPendingWeChatOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision) return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
} }
export async function completePendingOAuthBindLogin( export async function completePendingOAuthBindLogin(
......
...@@ -9,7 +9,14 @@ import { ...@@ -9,7 +9,14 @@ import {
prepareOAuthBindAccessTokenCookie, prepareOAuthBindAccessTokenCookie,
type WeChatOAuthPublicSettings, type WeChatOAuthPublicSettings,
} from './auth' } from './auth'
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types' import type {
User,
ChangePasswordRequest,
NotifyEmailEntry,
UserAuthProvider,
UserAffiliateDetail,
AffiliateTransferResponse
} from '@/types'
/** /**
* Get current user profile * Get current user profile
...@@ -168,6 +175,16 @@ export async function startOAuthBinding( ...@@ -168,6 +175,16 @@ export async function startOAuthBinding(
window.location.href = startURL window.location.href = startURL
} }
export async function getAffiliateDetail(): Promise<UserAffiliateDetail> {
const { data } = await apiClient.get<UserAffiliateDetail>('/user/aff')
return data
}
export async function transferAffiliateQuota(): Promise<AffiliateTransferResponse> {
const { data } = await apiClient.post<AffiliateTransferResponse>('/user/aff/transfer')
return data
}
export const userAPI = { export const userAPI = {
getProfile, getProfile,
updateProfile, updateProfile,
...@@ -180,7 +197,9 @@ export const userAPI = { ...@@ -180,7 +197,9 @@ export const userAPI = {
bindEmailIdentity, bindEmailIdentity,
unbindAuthIdentity, unbindAuthIdentity,
buildOAuthBindingStartURL, buildOAuthBindingStartURL,
startOAuthBinding startOAuthBinding,
getAffiliateDetail,
transferAffiliateQuota
} }
export default userAPI export default userAPI
...@@ -55,6 +55,17 @@ ...@@ -55,6 +55,17 @@
/> />
</div> </div>
<div v-if="isOpenAIAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.openai.testMode') }}
</label>
<Select
v-model="testMode"
:options="openAITestModeOptions"
:disabled="status === 'connecting'"
/>
</div>
<div v-if="supportsImageTest" class="space-y-1.5"> <div v-if="supportsImageTest" class="space-y-1.5">
<TextArea <TextArea
v-model="testPrompt" v-model="testPrompt"
...@@ -274,6 +285,12 @@ const testPrompt = ref('') ...@@ -274,6 +285,12 @@ const testPrompt = ref('')
const loadingModels = ref(false) const loadingModels = ref(false)
let abortController: AbortController | null = null let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([]) const generatedImages = ref<PreviewImage[]>([])
const testMode = ref<'default' | 'compact'>('default')
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
const openAITestModeOptions = computed(() => [
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
])
const previewImageUrl = ref('') const previewImageUrl = ref('')
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash'] const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => { const supportsGeminiImageTest = computed(() => {
...@@ -308,6 +325,7 @@ watch( ...@@ -308,6 +325,7 @@ watch(
async (newVal) => { async (newVal) => {
if (newVal && props.account) { if (newVal && props.account) {
testPrompt.value = '' testPrompt.value = ''
testMode.value = 'default'
resetState() resetState()
await loadAvailableModels() await loadAvailableModels()
} else { } else {
...@@ -410,9 +428,10 @@ const startTest = async () => { ...@@ -410,9 +428,10 @@ const startTest = async () => {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
model_id: selectedModelId.value, model_id: selectedModelId.value,
prompt: supportsImageTest.value ? testPrompt.value.trim() : '' prompt: supportsImageTest.value ? testPrompt.value.trim() : '',
}), mode: isOpenAIAccount.value ? testMode.value : 'default'
}),
signal: abortController.signal signal: abortController.signal
}) })
......
...@@ -2449,6 +2449,45 @@ ...@@ -2449,6 +2449,45 @@
</div> </div>
</div> </div>
<!-- OpenAI Compact 能力配置 -->
<div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.compactMode') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.compactModeDesc') }}
</p>
</div>
<div class="w-44">
<Select v-model="openAICompactMode" :options="openAICompactModeOptions" />
</div>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.openai.compactModelMapping') }}</label>
<p class="input-hint">{{ t('admin.accounts.openai.compactModelMappingDesc') }}</p>
<div v-if="openAICompactModelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in openAICompactModelMappings"
:key="getOpenAICompactModelMappingKey(mapping)"
class="flex items-center gap-2"
>
<input v-model="mapping.from" type="text" class="input flex-1" :placeholder="t('admin.accounts.fromModel')" />
<span class="text-gray-400"></span>
<input v-model="mapping.to" type="text" class="input flex-1" :placeholder="t('admin.accounts.toModel')" />
<button type="button" @click="removeOpenAICompactModelMapping(index)" class="text-red-500 hover:text-red-700">
<Icon name="trash" size="sm" />
</button>
</div>
</div>
<button type="button" @click="addOpenAICompactModelMapping" class="btn btn-secondary text-sm">
+ {{ t('admin.accounts.addMapping') }}
</button>
</div>
</div>
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
...@@ -2918,7 +2957,8 @@ import type { ...@@ -2918,7 +2957,8 @@ import type {
AccountPlatform, AccountPlatform,
AccountType, AccountType,
CheckMixedChannelResponse, CheckMixedChannelResponse,
CreateAccountRequest CreateAccountRequest,
OpenAICompactMode
} from '@/types' } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
...@@ -3059,6 +3099,7 @@ const editWeeklyResetDay = ref<number | null>(null) ...@@ -3059,6 +3099,7 @@ const editWeeklyResetDay = ref<number | null>(null)
const editWeeklyResetHour = ref<number | null>(null) const editWeeklyResetHour = ref<number | null>(null)
const editResetTimezone = ref<string | null>(null) const editResetTimezone = ref<string | null>(null)
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const openAICompactModelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3 const DEFAULT_POOL_MODE_RETRY_COUNT = 3
...@@ -3071,6 +3112,7 @@ const customErrorCodeInput = ref<number | null>(null) ...@@ -3071,6 +3112,7 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true) const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
...@@ -3112,10 +3154,16 @@ const bedrockApiKeyValue = ref('') ...@@ -3112,10 +3154,16 @@ const bedrockApiKeyValue = ref('')
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping') const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping') const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule') const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
const openAICompactModeOptions = computed(() => [
{ value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
])
function buildAntigravityExtra(): Record<string, unknown> | undefined { function buildAntigravityExtra(): Record<string, unknown> | undefined {
const extra: Record<string, unknown> = {} const extra: Record<string, unknown> = {}
...@@ -3124,6 +3172,9 @@ function buildAntigravityExtra(): Record<string, unknown> | undefined { ...@@ -3124,6 +3172,9 @@ function buildAntigravityExtra(): Record<string, unknown> | undefined {
return Object.keys(extra).length > 0 ? extra : undefined return Object.keys(extra).length > 0 ? extra : undefined
} }
const buildOpenAICompactModelMapping = () =>
buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
const showMixedChannelWarning = ref(false) const showMixedChannelWarning = ref(false)
const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>( const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(
null null
...@@ -3489,6 +3540,14 @@ const addModelMapping = () => { ...@@ -3489,6 +3540,14 @@ const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' }) modelMappings.value.push({ from: '', to: '' })
} }
const addOpenAICompactModelMapping = () => {
openAICompactModelMappings.value.push({ from: '', to: '' })
}
const removeOpenAICompactModelMapping = (index: number) => {
openAICompactModelMappings.value.splice(index, 1)
}
const removeModelMapping = (index: number) => { const removeModelMapping = (index: number) => {
modelMappings.value.splice(index, 1) modelMappings.value.splice(index, 1)
} }
...@@ -3781,6 +3840,7 @@ const resetForm = () => { ...@@ -3781,6 +3840,7 @@ const resetForm = () => {
editWeeklyResetHour.value = null editWeeklyResetHour.value = null
editResetTimezone.value = null editResetTimezone.value = null
modelMappings.value = [] modelMappings.value = []
openAICompactModelMappings.value = []
modelRestrictionMode.value = 'whitelist' modelRestrictionMode.value = 'whitelist'
allowedModels.value = [...claudeModels] // Default fill related models allowedModels.value = [...claudeModels] // Default fill related models
...@@ -3797,6 +3857,7 @@ const resetForm = () => { ...@@ -3797,6 +3857,7 @@ const resetForm = () => {
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
autoPauseOnExpired.value = true autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
openAICompactMode.value = 'auto'
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
...@@ -3874,6 +3935,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow ...@@ -3874,6 +3935,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
} else { } else {
delete extra.codex_cli_only delete extra.codex_cli_only
} }
if (openAICompactMode.value !== 'auto') {
extra.openai_compact_mode = openAICompactMode.value
} else {
delete extra.openai_compact_mode
}
return Object.keys(extra).length > 0 ? extra : undefined return Object.keys(extra).length > 0 ? extra : undefined
} }
...@@ -4086,6 +4152,12 @@ const handleSubmit = async () => { ...@@ -4086,6 +4152,12 @@ const handleSubmit = async () => {
credentials.model_mapping = modelMapping credentials.model_mapping = modelMapping
} }
} }
if (form.platform === 'openai') {
const compactModelMapping = buildOpenAICompactModelMapping()
if (compactModelMapping) {
credentials.compact_model_mapping = compactModelMapping
}
}
// Add pool mode if enabled // Add pool mode if enabled
if (poolModeEnabled.value) { if (poolModeEnabled.value) {
...@@ -4198,6 +4270,14 @@ const createAccountAndFinish = async ( ...@@ -4198,6 +4270,14 @@ const createAccountAndFinish = async (
finalExtra = quotaExtra finalExtra = quotaExtra
} }
} }
if (platform === 'openai') {
const compactModelMapping = buildOpenAICompactModelMapping()
if (compactModelMapping) {
credentials.compact_model_mapping = compactModelMapping
} else {
delete credentials.compact_model_mapping
}
}
await doCreateAccount({ await doCreateAccount({
name: form.name, name: form.name,
notes: form.notes, notes: form.notes,
...@@ -4252,6 +4332,12 @@ const handleOpenAIExchange = async (authCode: string) => { ...@@ -4252,6 +4332,12 @@ const handleOpenAIExchange = async (authCode: string) => {
credentials.model_mapping = modelMapping credentials.model_mapping = modelMapping
} }
} }
if (shouldCreateOpenAI) {
const compactModelMapping = buildOpenAICompactModelMapping()
if (compactModelMapping) {
credentials.compact_model_mapping = compactModelMapping
}
}
// 应用临时不可调度配置 // 应用临时不可调度配置
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
...@@ -4344,6 +4430,12 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) ...@@ -4344,6 +4430,12 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
credentials.model_mapping = modelMapping credentials.model_mapping = modelMapping
} }
} }
if (shouldCreateOpenAI) {
const compactModelMapping = buildOpenAICompactModelMapping()
if (compactModelMapping) {
credentials.compact_model_mapping = compactModelMapping
}
}
// Generate account name; fallback to email if name is empty (ent schema requires NotEmpty) // Generate account name; fallback to email if name is empty (ent schema requires NotEmpty)
const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account' const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account'
......
...@@ -1306,6 +1306,64 @@ ...@@ -1306,6 +1306,64 @@
</div> </div>
</div> </div>
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.compactMode') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.compactModeDesc') }}
</p>
</div>
<div class="w-44">
<Select v-model="openAICompactMode" :options="openAICompactModeOptions" />
</div>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300">
<span class="font-medium">{{ t(openAICompactStatusKey) }}</span>
<span
v-if="account?.extra?.openai_compact_checked_at"
class="ml-2 text-gray-500 dark:text-gray-400"
>
{{ t('admin.accounts.openai.compactLastChecked') }}:
{{ formatDateTime(new Date(String(account.extra.openai_compact_checked_at))) }}
</span>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.openai.compactModelMapping') }}</label>
<p class="input-hint">{{ t('admin.accounts.openai.compactModelMappingDesc') }}</p>
<div v-if="openAICompactModelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in openAICompactModelMappings"
:key="getOpenAICompactModelMappingKey(mapping)"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.fromModel')"
/>
<span class="text-gray-400"></span>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.toModel')"
/>
<button type="button" @click="removeOpenAICompactModelMapping(index)" class="text-red-500 hover:text-red-700">
<Icon name="trash" size="sm" />
</button>
</div>
</div>
<button type="button" @click="addOpenAICompactModelMapping" class="btn btn-secondary text-sm">
+ {{ t('admin.accounts.addMapping') }}
</button>
</div>
</div>
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
...@@ -1849,7 +1907,7 @@ import { useAppStore } from '@/stores/app' ...@@ -1849,7 +1907,7 @@ import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState' import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types' import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse, OpenAICompactMode } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -1859,7 +1917,7 @@ import GroupSelector from '@/components/common/GroupSelector.vue' ...@@ -1859,7 +1917,7 @@ import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { import {
OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_CTX_POOL,
...@@ -1934,6 +1992,7 @@ const isBedrockAPIKeyMode = computed(() => ...@@ -1934,6 +1992,7 @@ const isBedrockAPIKeyMode = computed(() =>
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey' (props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
) )
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const openAICompactModelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3 const DEFAULT_POOL_MODE_RETRY_COUNT = 3
...@@ -1953,6 +2012,7 @@ const antigravityModelMappings = ref<ModelMapping[]>([]) ...@@ -1953,6 +2012,7 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping') const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping') const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule') const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
...@@ -1992,6 +2052,7 @@ const customBaseUrl = ref('') ...@@ -1992,6 +2052,7 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key) // OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
...@@ -2045,9 +2106,27 @@ const openaiResponsesWebSocketV2Mode = computed({ ...@@ -2045,9 +2106,27 @@ const openaiResponsesWebSocketV2Mode = computed({
const openAIWSModeConcurrencyHintKey = computed(() => const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value) resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
) )
const openAICompactModeOptions = computed(() => [
{ value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
])
const isOpenAIModelRestrictionDisabled = computed(() => const isOpenAIModelRestrictionDisabled = computed(() =>
props.account?.platform === 'openai' && openaiPassthroughEnabled.value props.account?.platform === 'openai' && openaiPassthroughEnabled.value
) )
const openAICompactStatusKey = computed(() => {
const extra = props.account?.extra as Record<string, unknown> | undefined
if (!props.account || props.account.platform !== 'openai') return ''
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
if (mode === 'force_on') return 'admin.accounts.openai.compactSupported'
if (mode === 'force_off') return 'admin.accounts.openai.compactUnsupported'
if (typeof extra?.openai_compact_supported === 'boolean') {
return extra.openai_compact_supported
? 'admin.accounts.openai.compactSupported'
: 'admin.accounts.openai.compactUnsupported'
}
return 'admin.accounts.openai.compactUnknown'
})
// Computed: current preset mappings based on platform // Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
...@@ -2177,6 +2256,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2177,6 +2256,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key) // Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
openAICompactMode.value = 'auto'
openAICompactModelMappings.value = []
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
...@@ -2184,6 +2265,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2184,6 +2265,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default' webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
modeKey: 'openai_oauth_responses_websockets_v2_mode', modeKey: 'openai_oauth_responses_websockets_v2_mode',
enabledKey: 'openai_oauth_responses_websockets_v2_enabled', enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
...@@ -2199,6 +2281,11 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2199,6 +2281,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
if (newAccount.type === 'oauth') { if (newAccount.type === 'oauth') {
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
} }
const credentials = newAccount.credentials as Record<string, unknown> | undefined
const compactMappings = credentials?.compact_model_mapping as Record<string, string> | undefined
if (compactMappings && typeof compactMappings === 'object') {
openAICompactModelMappings.value = Object.entries(compactMappings).map(([from, to]) => ({ from, to }))
}
} }
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
...@@ -2423,6 +2510,15 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2423,6 +2510,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editApiKey.value = '' editApiKey.value = ''
} }
async function loadTLSProfiles() {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
} catch {
tlsFingerprintProfiles.value = []
}
}
watch( watch(
[() => props.show, () => props.account], [() => props.show, () => props.account],
([show, newAccount], [wasShow, previousAccount]) => { ([show, newAccount], [wasShow, previousAccount]) => {
...@@ -2437,15 +2533,6 @@ watch( ...@@ -2437,15 +2533,6 @@ watch(
{ immediate: true } { immediate: true }
) )
const loadTLSProfiles = async () => {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
} catch {
tlsFingerprintProfiles.value = []
}
}
// Model mapping helpers // Model mapping helpers
const addModelMapping = () => { const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' }) modelMappings.value.push({ from: '', to: '' })
...@@ -2468,6 +2555,14 @@ const addAntigravityModelMapping = () => { ...@@ -2468,6 +2555,14 @@ const addAntigravityModelMapping = () => {
antigravityModelMappings.value.push({ from: '', to: '' }) antigravityModelMappings.value.push({ from: '', to: '' })
} }
const addOpenAICompactModelMapping = () => {
openAICompactModelMappings.value.push({ from: '', to: '' })
}
const removeOpenAICompactModelMapping = (index: number) => {
openAICompactModelMappings.value.splice(index, 1)
}
const removeAntigravityModelMapping = (index: number) => { const removeAntigravityModelMapping = (index: number) => {
antigravityModelMappings.value.splice(index, 1) antigravityModelMappings.value.splice(index, 1)
} }
...@@ -2911,6 +3006,14 @@ const handleSubmit = async () => { ...@@ -2911,6 +3006,14 @@ const handleSubmit = async () => {
} else if (currentCredentials.model_mapping) { } else if (currentCredentials.model_mapping) {
newCredentials.model_mapping = currentCredentials.model_mapping newCredentials.model_mapping = currentCredentials.model_mapping
} }
if (props.account.platform === 'openai') {
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
if (compactModelMapping) {
newCredentials.compact_model_mapping = compactModelMapping
} else {
delete newCredentials.compact_model_mapping
}
}
// Add pool mode if enabled // Add pool mode if enabled
if (poolModeEnabled.value) { if (poolModeEnabled.value) {
...@@ -3036,6 +3139,12 @@ const handleSubmit = async () => { ...@@ -3036,6 +3139,12 @@ const handleSubmit = async () => {
// 透传模式保留现有映射 // 透传模式保留现有映射
newCredentials.model_mapping = currentCredentials.model_mapping newCredentials.model_mapping = currentCredentials.model_mapping
} }
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
if (compactModelMapping) {
newCredentials.compact_model_mapping = compactModelMapping
} else {
delete newCredentials.compact_model_mapping
}
updatePayload.credentials = newCredentials updatePayload.credentials = newCredentials
} }
...@@ -3208,6 +3317,11 @@ const handleSubmit = async () => { ...@@ -3208,6 +3317,11 @@ const handleSubmit = async () => {
delete newExtra.openai_passthrough delete newExtra.openai_passthrough
delete newExtra.openai_oauth_passthrough delete newExtra.openai_oauth_passthrough
} }
if (openAICompactMode.value === 'auto') {
delete newExtra.openai_compact_mode
} else {
newExtra.openai_compact_mode = openAICompactMode.value
}
if (props.account.type === 'oauth') { if (props.account.type === 'oauth') {
if (codexCLIOnlyEnabled.value) { if (codexCLIOnlyEnabled.value) {
......
...@@ -122,7 +122,7 @@ describe('AccountStatusIndicator', () => { ...@@ -122,7 +122,7 @@ describe('AccountStatusIndicator', () => {
} }
}) })
expect(wrapper.text()).toContain('account.creditsExhausted') expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
}) })
it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => { it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => {
...@@ -157,6 +157,6 @@ describe('AccountStatusIndicator', () => { ...@@ -157,6 +157,6 @@ describe('AccountStatusIndicator', () => {
expect(wrapper.text()).toContain('CSon45') expect(wrapper.text()).toContain('CSon45')
expect(wrapper.text()).not.toContain('') expect(wrapper.text()).not.toContain('')
// AICredits 积分耗尽状态应显示 // AICredits 积分耗尽状态应显示
expect(wrapper.text()).toContain('account.creditsExhausted') expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
}) })
}) })
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import AccountTestModal from '../AccountTestModal.vue'
const { getAvailableModelsMock } = vi.hoisted(() => ({
getAvailableModelsMock: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
getAvailableModels: getAvailableModelsMock
}
}
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: vi.fn()
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const BaseDialogStub = defineComponent({
name: 'BaseDialog',
props: { show: { type: Boolean, default: false } },
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
})
const SelectStub = defineComponent({
name: 'SelectStub',
props: {
modelValue: { type: [String, Number, Boolean, null], default: '' },
options: { type: Array, default: () => [] },
valueKey: { type: String, default: 'value' },
labelKey: { type: String, default: 'label' }
},
emits: ['update:modelValue'],
template: `
<select
v-bind="$attrs"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option
v-for="option in options"
:key="option[valueKey]"
:value="option[valueKey]"
>
{{ option[labelKey] }}
</option>
</select>
`
})
const TextAreaStub = defineComponent({
name: 'TextArea',
props: {
modelValue: { type: String, default: '' }
},
emits: ['update:modelValue'],
template: `
<textarea
v-bind="$attrs"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
})
function buildAccount() {
return {
id: 1,
name: 'OpenAI OAuth',
platform: 'openai',
type: 'oauth',
status: 'active',
credentials: {},
extra: {},
concurrency: 1,
priority: 1,
proxy_id: null,
auto_pause_on_expired: false
} as any
}
describe('AccountTestModal', () => {
const originalFetch = global.fetch
beforeEach(() => {
getAvailableModelsMock.mockReset()
getAvailableModelsMock.mockResolvedValue([
{ id: 'gpt-5.4', display_name: 'GPT-5.4' }
])
global.fetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined })
})
}
} as any)
localStorage.setItem('auth_token', 'test-token')
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
it('posts compact mode for OpenAI compact probe', async () => {
const wrapper = mount(AccountTestModal, {
props: {
show: true,
account: buildAccount()
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
Select: SelectStub,
TextArea: TextAreaStub,
Icon: true
}
}
})
await flushPromises()
;(wrapper.vm as any).selectedModelId = 'gpt-5.4'
;(wrapper.vm as any).testMode = 'compact'
await (wrapper.vm as any).startTest()
await flushPromises()
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, options] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(options.body)).toMatchObject({
model_id: 'gpt-5.4',
mode: 'compact'
})
})
})
...@@ -26,6 +26,13 @@ vi.mock('@/api/admin', () => ({ ...@@ -26,6 +26,13 @@ vi.mock('@/api/admin', () => ({
accounts: { accounts: {
update: updateAccountMock, update: updateAccountMock,
checkMixedChannelRisk: checkMixedChannelRiskMock checkMixedChannelRisk: checkMixedChannelRiskMock
},
settings: {
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
getSettings: vi.fn().mockResolvedValue({})
},
tlsFingerprintProfiles: {
list: vi.fn().mockResolvedValue([])
} }
} }
})) }))
...@@ -82,6 +89,32 @@ const ModelWhitelistSelectorStub = defineComponent({ ...@@ -82,6 +89,32 @@ const ModelWhitelistSelectorStub = defineComponent({
` `
}) })
const SelectStub = defineComponent({
name: 'SelectStub',
props: {
modelValue: {
type: [String, Number, Boolean, null],
default: ''
},
options: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `
<select
v-bind="$attrs"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
`
})
function buildAccount() { function buildAccount() {
return { return {
id: 1, id: 1,
...@@ -119,7 +152,7 @@ function mountModal(account = buildAccount()) { ...@@ -119,7 +152,7 @@ function mountModal(account = buildAccount()) {
global: { global: {
stubs: { stubs: {
BaseDialog: BaseDialogStub, BaseDialog: BaseDialogStub,
Select: true, Select: SelectStub,
Icon: true, Icon: true,
ProxySelector: true, ProxySelector: true,
GroupSelector: true, GroupSelector: true,
...@@ -156,4 +189,31 @@ describe('EditAccountModal', () => { ...@@ -156,4 +189,31 @@ describe('EditAccountModal', () => {
'gpt-5.2': 'gpt-5.2' 'gpt-5.2': 'gpt-5.2'
}) })
}) })
it('submits OpenAI compact mode and compact-only model mapping', async () => {
const account = buildAccount()
account.extra = {
openai_compact_mode: 'force_on'
}
account.credentials = {
...account.credentials,
compact_model_mapping: {
'gpt-5.4': 'gpt-5.4-openai-compact'
}
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_compact_mode).toBe('force_on')
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.compact_model_mapping).toEqual({
'gpt-5.4': 'gpt-5.4-openai-compact'
})
})
}) })
...@@ -42,9 +42,11 @@ ...@@ -42,9 +42,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true showDivider: true
...@@ -55,6 +57,7 @@ const { t } = useI18n() ...@@ -55,6 +57,7 @@ const { t } = useI18n()
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
......
...@@ -23,9 +23,11 @@ ...@@ -23,9 +23,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
providerName?: string providerName?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
...@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to ...@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
......
...@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router' ...@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveWeChatOAuthStart } from '@/api/auth' import { resolveWeChatOAuthStart } from '@/api/auth'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true, showDivider: true,
...@@ -84,6 +86,7 @@ function startLogin(): void { ...@@ -84,6 +86,7 @@ function startLogin(): void {
return return
} }
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const mode = resolvedStart.value.mode const mode = resolvedStart.value.mode
......
...@@ -645,6 +645,7 @@ const ChevronDownIcon = { ...@@ -645,6 +645,7 @@ const ChevronDownIcon = {
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor) const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment) const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels) const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled const flagAdminPayment = () => adminSettingsStore.paymentEnabled
...@@ -667,6 +668,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { ...@@ -667,6 +668,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment }, { path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment }, { path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }, { path: '/profile', label: t('nav.profile'), icon: UserIcon },
{ path: '/docs', label: t('nav.docs'), icon: BookOpenIcon }, { path: '/docs', label: t('nav.docs'), icon: BookOpenIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({ ...customMenuItemsForUser.value.map((item): NavItem => ({
......
...@@ -33,7 +33,7 @@ function createOrderResult(overrides: Partial<CreateOrderResult> = {}): CreateOr ...@@ -33,7 +33,7 @@ function createOrderResult(overrides: Partial<CreateOrderResult> = {}): CreateOr
} }
describe('getVisibleMethods', () => { describe('getVisibleMethods', () => {
it('filters hidden provider methods and normalizes aliases', () => { it('normalizes provider aliases and keeps stripe as a top-level method', () => {
const visible = getVisibleMethods({ const visible = getVisibleMethods({
alipay_direct: methodLimit({ single_min: 5 }), alipay_direct: methodLimit({ single_min: 5 }),
wxpay: methodLimit({ single_max: 100 }), wxpay: methodLimit({ single_max: 100 }),
...@@ -43,6 +43,7 @@ describe('getVisibleMethods', () => { ...@@ -43,6 +43,7 @@ describe('getVisibleMethods', () => {
expect(visible).toEqual({ expect(visible).toEqual({
alipay: methodLimit({ single_min: 5 }), alipay: methodLimit({ single_min: 5 }),
wxpay: methodLimit({ single_max: 100 }), wxpay: methodLimit({ single_max: 100 }),
stripe: methodLimit({ fee_rate: 3 }),
}) })
}) })
...@@ -76,6 +77,19 @@ describe('decidePaymentLaunch', () => { ...@@ -76,6 +77,19 @@ describe('decidePaymentLaunch', () => {
expect(decision.recovery.outTradeNo).toBe('') expect(decision.recovery.outTradeNo).toBe('')
}) })
it('routes Stripe button click to the full Payment Element without a preselected sub-method', () => {
const decision = decidePaymentLaunch(createOrderResult({
client_secret: 'cs_test',
}), {
visibleMethod: 'stripe',
orderType: 'balance',
isMobile: false,
})
expect(decision.kind).toBe('stripe_route')
expect(decision.stripeMethod).toBeUndefined()
})
it('uses Stripe route flow for mobile WeChat client secret', () => { it('uses Stripe route flow for mobile WeChat client secret', () => {
const decision = decidePaymentLaunch(createOrderResult({ const decision = decidePaymentLaunch(createOrderResult({
client_secret: 'cs_test', client_secret: 'cs_test',
......
...@@ -14,9 +14,10 @@ const VISIBLE_METHOD_ALIASES = { ...@@ -14,9 +14,10 @@ const VISIBLE_METHOD_ALIASES = {
alipay_direct: 'alipay', alipay_direct: 'alipay',
wxpay: 'wxpay', wxpay: 'wxpay',
wxpay_direct: 'wxpay', wxpay_direct: 'wxpay',
stripe: 'stripe',
} as const } as const
export type VisiblePaymentMethod = 'alipay' | 'wxpay' export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe'
export type StripeVisibleMethod = 'alipay' | 'wechat_pay' export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
export type PaymentLaunchKind = export type PaymentLaunchKind =
| 'qr_waiting' | 'qr_waiting'
...@@ -144,7 +145,12 @@ export function decidePaymentLaunch( ...@@ -144,7 +145,12 @@ export function decidePaymentLaunch(
}, context.now) }, context.now)
if (baseState.clientSecret) { if (baseState.clientSecret) {
const stripeMethod: StripeVisibleMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay' // visibleMethod === 'stripe' means the user clicked the dedicated Stripe button
// and should land on the full Payment Element to choose a sub-method themselves.
const isStripeButton = visibleMethod === 'stripe'
const stripeMethod: StripeVisibleMethod | undefined = isStripeButton
? undefined
: visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const kind: PaymentLaunchKind = stripeMethod === 'alipay' && !context.isMobile const kind: PaymentLaunchKind = stripeMethod === 'alipay' && !context.isMobile
? 'stripe_popup' ? 'stripe_popup'
: 'stripe_route' : 'stripe_route'
......
...@@ -4,15 +4,6 @@ ...@@ -4,15 +4,6 @@
// OpenAI // OpenAI
const openaiModels = [ const openaiModels = [
'gpt-3.5-turbo', 'gpt-3.5-turbo-0125', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo-16k',
'gpt-4', 'gpt-4-turbo', 'gpt-4-turbo-preview',
'gpt-4o', 'gpt-4o-2024-08-06', 'gpt-4o-2024-11-20',
'gpt-4o-mini', 'gpt-4o-mini-2024-07-18',
'gpt-4.5-preview',
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
'o1', 'o1-preview', 'o1-mini', 'o1-pro',
'o3', 'o3-mini', 'o3-pro',
'o4-mini',
// GPT-5.2 系列 // GPT-5.2 系列
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest', 'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
...@@ -22,7 +13,6 @@ const openaiModels = [ ...@@ -22,7 +13,6 @@ const openaiModels = [
'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05',
// GPT-5.3 系列 // GPT-5.3 系列
'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.3-codex', 'gpt-5.3-codex-spark',
'chatgpt-4o-latest',
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview', 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
// GPT Image 系列 // GPT Image 系列
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2' 'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
...@@ -32,7 +22,6 @@ const openaiModels = [ ...@@ -32,7 +22,6 @@ const openaiModels = [
export const claudeModels = [ export const claudeModels = [
'claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20240620', 'claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20240620',
'claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022',
'claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307',
'claude-3-7-sonnet-20250219', 'claude-3-7-sonnet-20250219',
'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-sonnet-4-20250514', 'claude-opus-4-20250514',
'claude-opus-4-1-20250805', 'claude-opus-4-1-20250805',
...@@ -40,8 +29,7 @@ export const claudeModels = [ ...@@ -40,8 +29,7 @@ export const claudeModels = [
'claude-opus-4-5-20251101', 'claude-opus-4-5-20251101',
'claude-opus-4-6', 'claude-opus-4-6',
'claude-opus-4-7', 'claude-opus-4-7',
'claude-sonnet-4-6', 'claude-sonnet-4-6'
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
] ]
// Google Gemini // Google Gemini
......
...@@ -353,6 +353,7 @@ export default { ...@@ -353,6 +353,7 @@ export default {
apiKeys: 'API Keys', apiKeys: 'API Keys',
usage: 'Usage', usage: 'Usage',
redeem: 'Redeem', redeem: 'Redeem',
affiliate: 'Affiliate Rebates',
profile: 'Profile', profile: 'Profile',
users: 'Users', users: 'Users',
groups: 'Groups', groups: 'Groups',
...@@ -979,6 +980,53 @@ export default { ...@@ -979,6 +980,53 @@ export default {
} }
}, },
affiliate: {
title: 'Affiliate Rebates',
description: 'Invite new users and convert your rebate quota into account balance',
yourCode: 'Your Affiliate Code',
inviteLink: 'Invite Link',
copyCode: 'Copy Code',
copyLink: 'Copy Link',
codeCopied: 'Affiliate code copied',
linkCopied: 'Invite link copied',
loadFailed: 'Failed to load affiliate data',
transferFailed: 'Failed to transfer affiliate quota',
stats: {
rebateRate: 'My Rebate Rate',
rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota',
frozenQuota: 'Frozen',
frozenQuotaHint: 'Recently earned rebates pending release',
totalQuota: 'Historical Rebate Quota'
},
transfer: {
title: 'Transfer Rebate Quota',
description: 'Move available rebate quota into your account balance',
button: 'Transfer to Balance',
transferring: 'Transferring...',
empty: 'No available rebate quota',
success: '{amount} has been transferred to your balance'
},
invitees: {
title: 'Invited Users',
empty: 'No invited users yet',
columns: {
email: 'Email',
username: 'Username',
rebate: 'Rebate',
joinedAt: 'Joined At'
}
},
tips: {
title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.',
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
}
},
// Redeem // Redeem
redeem: { redeem: {
title: 'Redeem Code', title: 'Redeem Code',
...@@ -2813,6 +2861,22 @@ export default { ...@@ -2813,6 +2861,22 @@ export default {
codexCLIOnly: 'Codex official clients only', codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc: codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
compactMode: 'Compact mode',
compactModeDesc:
'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.',
compactModeAuto: 'Auto',
compactModeForceOn: 'Force On',
compactModeForceOff: 'Force Off',
compactModelMapping: 'Compact-only model mapping',
compactModelMappingDesc:
'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.',
compactSupported: 'Compact supported',
compactUnsupported: 'Compact unsupported',
compactUnknown: 'Compact unknown',
compactLastChecked: 'Last compact probe',
testMode: 'Test mode',
testModeDefault: 'Default request',
testModeCompact: 'Compact probe',
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.', modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
}, },
anthropic: { anthropic: {
...@@ -4728,6 +4792,61 @@ export default { ...@@ -4728,6 +4792,61 @@ export default {
enabled: 'Enable Available Channels', enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.', enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
}, },
affiliate: {
title: 'Affiliate (Invite Rebate)',
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
enabled: 'Enable Affiliate',
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
freezeHours: 'Rebate Freeze Period (hours)',
freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.',
durationDays: 'Rebate Duration (days)',
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
perInviteeCap: 'Per-Invitee Rebate Cap',
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
customUsers: {
title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
addButton: 'Add Custom User',
searchPlaceholder: 'Search by email or username',
batchButton: 'Batch Set Rate ({count} selected)',
empty: 'No users with custom affiliate settings yet',
customBadge: 'custom',
useGlobal: 'use global',
resetTitle: 'Reset Custom Settings',
resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
totalLabel: '{total} total',
col: {
email: 'Email',
username: 'Username',
code: 'Invite Code',
rate: 'Custom Rate',
actions: 'Actions',
},
},
modal: {
addTitle: 'Add Custom User',
editTitle: 'Edit Custom Settings',
userLabel: 'User',
userPlaceholder: 'Search by email or username',
changeUser: 'Change user',
codeLabel: 'Custom Invite Code (optional)',
codePlaceholder: 'e.g. VIP2026',
codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
rateLabel: 'Exclusive Rebate Rate (optional)',
ratePlaceholder: 'e.g. 30',
rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
errorBadRate: 'Please enter a number between 0 and 100',
errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
},
batchModal: {
title: 'Batch Set Rate ({count} users selected)',
hint: 'Apply the same exclusive rebate rate to all selected users.',
placeholder: 'e.g. 30',
clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
},
},
}, },
emailTabDisabledTitle: 'Email Verification Not Enabled', emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
...@@ -4844,6 +4963,9 @@ export default { ...@@ -4844,6 +4963,9 @@ export default {
description: 'Default values for new users', description: 'Default values for new users',
defaultBalance: 'Default Balance', defaultBalance: 'Default Balance',
defaultBalanceHint: 'Initial balance for new users', defaultBalanceHint: 'Initial balance for new users',
affiliateRebateRate: 'Affiliate Rebate Rate',
affiliateRebateRateHint:
'Rebate percentage credited to inviter after recharge (0-100%, e.g. 10 means 10%)',
defaultConcurrency: 'Default Concurrency', defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users', defaultConcurrencyHint: 'Maximum concurrent requests for new users',
defaultUserRpmLimit: 'Default User RPM Limit', defaultUserRpmLimit: 'Default User RPM Limit',
......
...@@ -353,6 +353,7 @@ export default { ...@@ -353,6 +353,7 @@ export default {
apiKeys: 'API 密钥', apiKeys: 'API 密钥',
usage: '使用记录', usage: '使用记录',
redeem: '兑换', redeem: '兑换',
affiliate: '邀请返利',
profile: '个人资料', profile: '个人资料',
users: '用户管理', users: '用户管理',
groups: '分组管理', groups: '分组管理',
...@@ -983,6 +984,53 @@ export default { ...@@ -983,6 +984,53 @@ export default {
} }
}, },
affiliate: {
title: '邀请返利',
description: '邀请新用户注册,并将返利额度转入账户余额',
yourCode: '我的邀请码',
inviteLink: '邀请链接',
copyCode: '复制邀请码',
copyLink: '复制链接',
codeCopied: '邀请码已复制',
linkCopied: '邀请链接已复制',
loadFailed: '加载邀请返利数据失败',
transferFailed: '转入余额失败',
stats: {
rebateRate: '我的返利比例',
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数',
availableQuota: '可转返利额度',
frozenQuota: '冻结中',
frozenQuotaHint: '新产生的返利正在冻结期中',
totalQuota: '历史返利额度'
},
transfer: {
title: '返利额度转余额',
description: '将当前可用返利额度一键转入账户余额',
button: '转入余额',
transferring: '转入中...',
empty: '当前没有可转入额度',
success: '已转入余额:{amount}'
},
invitees: {
title: '已邀请用户',
empty: '暂无邀请记录',
columns: {
email: '邮箱',
username: '用户名',
rebate: '返利明细',
joinedAt: '注册时间'
}
},
tips: {
title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。',
line4: '新产生的返利需要经过冻结期后才能提现。'
}
},
// Redeem // Redeem
redeem: { redeem: {
title: '兑换码', title: '兑换码',
...@@ -2958,6 +3006,22 @@ export default { ...@@ -2958,6 +3006,22 @@ export default {
responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。', responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。',
codexCLIOnly: '仅允许 Codex 官方客户端', codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
compactMode: 'Compact 模式',
compactModeDesc:
'控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。',
compactModeAuto: '自动',
compactModeForceOn: '强制开启',
compactModeForceOff: '强制关闭',
compactModelMapping: 'Compact 专属模型映射',
compactModelMappingDesc:
'仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。',
compactSupported: '支持 Compact',
compactUnsupported: '不支持 Compact',
compactUnknown: 'Compact 未知',
compactLastChecked: '最近探测',
testMode: '测试模式',
testModeDefault: '常规请求',
testModeCompact: 'Compact 探测',
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。', modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
}, },
anthropic: { anthropic: {
...@@ -4891,6 +4955,61 @@ export default { ...@@ -4891,6 +4955,61 @@ export default {
enabled: '启用可用渠道', enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。', enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
}, },
affiliate: {
title: '邀请返利',
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
enabled: '启用邀请返利',
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
freezeHours: '返利冻结期(小时)',
freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。',
durationDays: '返利有效期(天)',
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
perInviteeCap: '单人返利上限',
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
customUsers: {
title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
addButton: '添加专属用户',
searchPlaceholder: '搜索邮箱或用户名',
batchButton: '批量设置比例(已选 {count})',
empty: '暂无专属配置用户',
customBadge: '自定义',
useGlobal: '沿用全局',
resetTitle: '重置该用户的专属配置',
resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)',
totalLabel: '共 {total} 条',
col: {
email: '邮箱',
username: '用户名',
code: '邀请码',
rate: '专属比例',
actions: '操作',
},
},
modal: {
addTitle: '添加专属用户',
editTitle: '编辑专属配置',
userLabel: '用户',
userPlaceholder: '搜索邮箱或用户名',
changeUser: '更换用户',
codeLabel: '专属邀请码(可选)',
codePlaceholder: '例如 VIP2026',
codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。',
rateLabel: '专属返利比例(可选)',
ratePlaceholder: '例如 30',
rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。',
errorBadRate: '请输入 0-100 之间的比例',
errorEmpty: '至少填写一项:专属邀请码或专属返利比例',
},
batchModal: {
title: '批量设置专属比例(已选 {count} 个用户)',
hint: '为所选用户统一设置专属返利比例。',
placeholder: '例如 30',
clearHint: '留空提交将清除所选用户的专属比例。',
},
},
}, },
emailTabDisabledTitle: '邮箱验证未启用', emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
...@@ -5007,6 +5126,8 @@ export default { ...@@ -5007,6 +5126,8 @@ export default {
description: '新用户的默认值', description: '新用户的默认值',
defaultBalance: '默认余额', defaultBalance: '默认余额',
defaultBalanceHint: '新用户的初始余额', defaultBalanceHint: '新用户的初始余额',
affiliateRebateRate: '邀请返利比例',
affiliateRebateRateHint: '充值后返给邀请人的比例(0-100%,例如填写 10 表示返利 10%)',
defaultConcurrency: '默认并发数', defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数', defaultConcurrencyHint: '新用户的最大并发请求数',
defaultUserRpmLimit: '默认用户 RPM 限制', defaultUserRpmLimit: '默认用户 RPM 限制',
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment