mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge upstream/main into fix/api-double-billing
This commit is contained in:
@@ -386,6 +386,7 @@
|
||||
:label="t('admin.accounts.usageWindow.grokRequests')"
|
||||
:utilization="grokRequestQuotaBar.utilization"
|
||||
:resets-at="grokRequestQuotaBar.resetsAt"
|
||||
:remaining-capacity="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
@@ -393,6 +394,7 @@
|
||||
:label="t('admin.accounts.usageWindow.grokTokens')"
|
||||
:utilization="grokTokenQuotaBar.utilization"
|
||||
:resets-at="grokTokenQuotaBar.resetsAt"
|
||||
:remaining-capacity="true"
|
||||
color="emerald"
|
||||
/>
|
||||
<div v-if="grokRetryAfterLabel" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
@@ -1036,9 +1038,9 @@ interface GrokQuotaBarInfo {
|
||||
|
||||
const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => {
|
||||
if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null
|
||||
const used = Math.max(0, quota.limit - quota.remaining)
|
||||
const remaining = Math.min(quota.limit, Math.max(0, quota.remaining))
|
||||
return {
|
||||
utilization: (used / quota.limit) * 100,
|
||||
utilization: (remaining / quota.limit) * 100,
|
||||
resetsAt: quota.reset_at || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Grok - OAuth only) -->
|
||||
<!-- Account Type Selection (Grok) -->
|
||||
<div v-if="form.platform === 'grok'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
|
||||
@@ -381,10 +381,34 @@
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.grokOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grok-account-type-api-key"
|
||||
@click="accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'apikey'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.oauth.grok.oauthOnlyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Gemini) -->
|
||||
@@ -1087,10 +1111,12 @@
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: form.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -1104,10 +1130,12 @@
|
||||
? 'sk-proj-...'
|
||||
: form.platform === 'gemini'
|
||||
? 'AIza...'
|
||||
: 'sk-ant-...'
|
||||
: form.platform === 'grok'
|
||||
? 'xai-...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ apiKeyHint }}</p>
|
||||
<p v-if="apiKeyHint" class="input-hint">{{ apiKeyHint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini API Key tier selection -->
|
||||
@@ -3515,14 +3543,14 @@ const oauthStepTitle = computed(() => {
|
||||
const baseUrlHint = computed(() => {
|
||||
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||
if (form.platform === 'grok') return t('admin.accounts.grok.baseUrlHint')
|
||||
if (form.platform === 'grok') return ''
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
const apiKeyHint = computed(() => {
|
||||
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
|
||||
if (form.platform === 'grok') return t('admin.accounts.grok.apiKeyHint')
|
||||
if (form.platform === 'grok') return ''
|
||||
return t('admin.accounts.apiKeyHint')
|
||||
})
|
||||
|
||||
@@ -4926,7 +4954,9 @@ const handleSubmit = async () => {
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: form.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
|
||||
// Build credentials with optional model mapping
|
||||
const credentials: Record<string, unknown> = {
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: account.platform === 'antigravity'
|
||||
? 'https://cloudcode-pa.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: account.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||||
@@ -63,7 +65,9 @@
|
||||
? 'AIza...'
|
||||
: account.platform === 'antigravity'
|
||||
? 'sk-...'
|
||||
: 'sk-ant-...'
|
||||
: account.platform === 'grok'
|
||||
? 'xai-...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
@@ -2626,6 +2630,7 @@ const baseUrlHint = computed(() => {
|
||||
if (!props.account) return t('admin.accounts.baseUrlHint')
|
||||
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||
if (props.account.platform === 'grok') return ''
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
@@ -3076,6 +3081,7 @@ const tempUnschedPresets = computed(() => [
|
||||
const defaultBaseUrl = computed(() => {
|
||||
if (props.account?.platform === 'openai') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||||
if (props.account?.platform === 'grok') return 'https://api.x.ai/v1'
|
||||
return 'https://api.anthropic.com'
|
||||
})
|
||||
|
||||
@@ -3370,7 +3376,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: newAccount.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
||||
|
||||
// Load model mappings and detect mode
|
||||
@@ -3446,7 +3454,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: newAccount.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
editBaseUrl.value = platformDefaultUrl
|
||||
|
||||
// Load model mappings for OpenAI/Grok OAuth accounts
|
||||
|
||||
@@ -69,6 +69,7 @@ const props = defineProps<{
|
||||
color: 'indigo' | 'emerald' | 'purple' | 'amber'
|
||||
windowStats?: WindowStats | null
|
||||
showNowWhenIdle?: boolean
|
||||
remainingCapacity?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -109,6 +110,14 @@ const labelClass = computed(() => {
|
||||
|
||||
// Progress bar color based on utilization
|
||||
const barClass = computed(() => {
|
||||
if (props.remainingCapacity) {
|
||||
if (props.utilization <= 20) {
|
||||
return 'bg-red-500'
|
||||
} else if (props.utilization <= 50) {
|
||||
return 'bg-amber-500'
|
||||
}
|
||||
return 'bg-green-500'
|
||||
}
|
||||
if (props.utilization >= 100) {
|
||||
return 'bg-red-500'
|
||||
} else if (props.utilization >= 80) {
|
||||
@@ -120,6 +129,14 @@ const barClass = computed(() => {
|
||||
|
||||
// Text color based on utilization
|
||||
const textClass = computed(() => {
|
||||
if (props.remainingCapacity) {
|
||||
if (props.utilization <= 20) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
} else if (props.utilization <= 50) {
|
||||
return 'text-amber-600 dark:text-amber-400'
|
||||
}
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
if (props.utilization >= 100) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
} else if (props.utilization >= 80) {
|
||||
@@ -131,12 +148,16 @@ const textClass = computed(() => {
|
||||
|
||||
// Bar width (capped at 100%)
|
||||
const barWidth = computed(() => {
|
||||
return `${Math.min(props.utilization, 100)}%`
|
||||
return `${Math.min(Math.max(props.utilization, 0), 100)}%`
|
||||
})
|
||||
|
||||
// Display percentage (cap at 999% for readability)
|
||||
const displayPercent = computed(() => {
|
||||
const percent = Math.round(props.utilization)
|
||||
const percent = Math.round(
|
||||
props.remainingCapacity
|
||||
? Math.min(Math.max(props.utilization, 0), 100)
|
||||
: props.utilization
|
||||
)
|
||||
return percent > 999 ? '>999%' : `${percent}%`
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/format', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/utils/format')>('@/utils/format')
|
||||
return {
|
||||
...actual,
|
||||
formatCountdown: () => '1h'
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -43,6 +51,31 @@ function makeAccount(overrides: Partial<Account>): Account {
|
||||
}
|
||||
|
||||
describe('AccountStatusIndicator', () => {
|
||||
it('Grok 账号额度限流时显示自动恢复时间而非临时不可调度', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 5,
|
||||
name: 'grok-free-1',
|
||||
platform: 'grok',
|
||||
rate_limited_at: '2026-07-11T12:00:00Z',
|
||||
rate_limit_reset_at: '2099-07-11T13:00:00Z',
|
||||
temp_unschedulable_until: '2099-07-11T12:30:00Z',
|
||||
temp_unschedulable_reason: 'legacy grok rate limited'
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.badge-warning').text()).toBe('admin.accounts.status.rateLimited')
|
||||
expect(wrapper.text()).toContain('admin.accounts.status.rateLimitedAutoResume')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.status.tempUnschedulable')
|
||||
})
|
||||
|
||||
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
|
||||
@@ -566,7 +566,7 @@ describe('AccountUsageCell', () => {
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
|
||||
})
|
||||
|
||||
it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => {
|
||||
it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_local_usage: {
|
||||
requests: 4,
|
||||
@@ -611,13 +611,55 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('1.2K')
|
||||
expect(wrapper.text()).toContain('A $0.12')
|
||||
expect(wrapper.text()).toContain('U $0.34')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|120|2026-07-09T16:00:00Z')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|2026-07-09T16:00:00Z')
|
||||
|
||||
const badges = wrapper.findAll('span[title]')
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
|
||||
})
|
||||
|
||||
it('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_request_quota: {
|
||||
limit: 100,
|
||||
remaining: 100,
|
||||
reset_at: '2026-07-09T16:00:00Z'
|
||||
},
|
||||
grok_token_quota: {
|
||||
limit: 1000,
|
||||
remaining: 250,
|
||||
reset_at: '2026-07-09T16:00:00Z'
|
||||
},
|
||||
grok_quota_snapshot_state: 'observed'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 4073,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ remainingCapacity }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
|
||||
})
|
||||
|
||||
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/components/account/CreateAccountModal.vue'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('CreateAccountModal Grok account types', () => {
|
||||
it('offers API-key setup alongside OAuth with the official xAI default', () => {
|
||||
expect(source).toContain('data-testid="grok-account-type-api-key"')
|
||||
expect(source).toContain("@click=\"accountCategory = 'apikey'\"")
|
||||
expect(source).toContain("newPlatform === 'grok'")
|
||||
expect(source).toContain("? 'https://api.x.ai/v1'")
|
||||
expect(source).toContain("form.platform === 'grok'")
|
||||
expect(source).toContain("? 'xai-...'")
|
||||
})
|
||||
})
|
||||
@@ -267,6 +267,18 @@ function buildGrokOAuthAccount() {
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildGrokAPIKeyAccount() {
|
||||
return {
|
||||
...buildAccount(),
|
||||
id: 6,
|
||||
name: 'Grok API Key',
|
||||
platform: 'grok',
|
||||
credentials: {},
|
||||
credentials_status: { has_api_key: true },
|
||||
concurrency: 2
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildOpenAISetupTokenAccount() {
|
||||
return {
|
||||
...buildAccount(),
|
||||
@@ -433,6 +445,24 @@ describe('EditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the official xAI base URL when a Grok API-key account omits base_url', async () => {
|
||||
const account = buildGrokAPIKeyAccount()
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
expect((wrapper.get('input[placeholder="https://api.x.ai/v1"]').element as HTMLInputElement).value)
|
||||
.toBe('https://api.x.ai/v1')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.base_url).toBe('https://api.x.ai/v1')
|
||||
})
|
||||
|
||||
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
|
||||
authIsSimpleMode.value = false
|
||||
const account = buildOpenAISparkShadowAccount()
|
||||
|
||||
@@ -96,4 +96,54 @@ describe('UsageProgressBar', () => {
|
||||
expect(wrapper.text()).toContain('usage.resetNow')
|
||||
expect(wrapper.text()).not.toContain('usage.resetPending')
|
||||
})
|
||||
|
||||
it('剩余容量模式在 100% 时显示满格绿色', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: 'Req',
|
||||
utilization: 100,
|
||||
remainingCapacity: true,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('100%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-green-500')
|
||||
})
|
||||
|
||||
it('剩余容量模式在低量和耗尽时缩短并变红', async () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: 'Req',
|
||||
utilization: 15,
|
||||
remainingCapacity: true,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('15%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 15%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
|
||||
|
||||
await wrapper.setProps({ utilization: 0 })
|
||||
|
||||
expect(wrapper.text()).toContain('0%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 0%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
|
||||
})
|
||||
|
||||
it('默认利用率模式仍把超限显示为满格红色', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: '5h',
|
||||
utilization: 120,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('120%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,6 +181,8 @@ const defaultClientTab = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
return 'codex'
|
||||
case 'grok':
|
||||
return 'grok'
|
||||
case 'gemini':
|
||||
return 'gemini'
|
||||
case 'antigravity':
|
||||
@@ -288,6 +290,11 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'grok':
|
||||
return [
|
||||
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
@@ -313,7 +320,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
|
||||
|
||||
const currentTabs = computed(() => {
|
||||
if (!showShellTabs.value) return []
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') {
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws' || activeClientTab.value === 'grok') {
|
||||
return openaiTabs
|
||||
}
|
||||
return shellTabs
|
||||
@@ -330,6 +337,8 @@ const platformDescription = computed(() => {
|
||||
return t('keys.useKeyModal.gemini.description')
|
||||
case 'antigravity':
|
||||
return t('keys.useKeyModal.antigravity.description')
|
||||
case 'grok':
|
||||
return t('keys.useKeyModal.grok.description')
|
||||
default:
|
||||
return t('keys.useKeyModal.description')
|
||||
}
|
||||
@@ -350,6 +359,10 @@ const platformNote = computed(() => {
|
||||
return activeClientTab.value === 'claude'
|
||||
? t('keys.useKeyModal.antigravity.claudeNote')
|
||||
: t('keys.useKeyModal.antigravity.geminiNote')
|
||||
case 'grok':
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.noteWindows')
|
||||
: t('keys.useKeyModal.grok.note')
|
||||
default:
|
||||
return t('keys.useKeyModal.note')
|
||||
}
|
||||
@@ -407,6 +420,8 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
|
||||
]
|
||||
case 'grok':
|
||||
return [generateOpenCodeConfig('grok', apiBase, apiKey)]
|
||||
default:
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
}
|
||||
@@ -428,6 +443,8 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||
}
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
case 'grok':
|
||||
return generateGrokFiles(apiBase, apiKey)
|
||||
default:
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
}
|
||||
@@ -568,6 +585,30 @@ goals = true`
|
||||
]
|
||||
}
|
||||
|
||||
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok'
|
||||
const configContent = `[models]
|
||||
default = "sub2api-grok"
|
||||
web_search = "sub2api-grok"
|
||||
|
||||
[model."sub2api-grok"]
|
||||
model = "grok-4.5"
|
||||
base_url = "${baseUrl}"
|
||||
name = "Grok 4.5 via Sub2API"
|
||||
description = "Grok 4.5 through a Sub2API Grok group"
|
||||
api_key = "${apiKey}"
|
||||
api_backend = "responses"
|
||||
context_window = 1000000
|
||||
supports_backend_search = true`
|
||||
|
||||
return [{
|
||||
path: `${configDir}/config.toml`,
|
||||
content: configContent,
|
||||
hint: t('keys.useKeyModal.grok.configTomlHint')
|
||||
}]
|
||||
}
|
||||
|
||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
@@ -1072,6 +1113,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
}
|
||||
const grokModels = {
|
||||
'grok-4.5': {
|
||||
name: 'Grok 4.5',
|
||||
limit: { context: 1000000, output: 128000 }
|
||||
},
|
||||
'grok-4.3': {
|
||||
name: 'Grok 4.3',
|
||||
limit: { context: 1000000, output: 128000 }
|
||||
},
|
||||
'grok-build-0.1': {
|
||||
name: 'Grok Build 0.1',
|
||||
limit: { context: 256000, output: 128000 }
|
||||
},
|
||||
'grok-composer-2.5-fast': {
|
||||
name: 'Grok Composer 2.5 Fast',
|
||||
limit: { context: 500000, output: 128000 }
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
@@ -1088,6 +1147,10 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
provider[platform].models = antigravityGeminiModels
|
||||
} else if (platform === 'openai') {
|
||||
provider[platform].models = openaiModels
|
||||
} else if (platform === 'grok') {
|
||||
provider[platform].npm = '@ai-sdk/openai'
|
||||
provider[platform].name = 'Grok via Sub2API'
|
||||
provider[platform].models = grokModels
|
||||
}
|
||||
|
||||
const agent =
|
||||
|
||||
@@ -17,6 +17,67 @@ vi.mock('@/composables/useClipboard', () => ({
|
||||
import UseKeyModal from '../UseKeyModal.vue'
|
||||
|
||||
describe('UseKeyModal', () => {
|
||||
it('renders Grok Build and OpenCode setup for Grok groups', async () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const grokTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.grokCli')
|
||||
)
|
||||
expect(grokTab).toBeDefined()
|
||||
|
||||
const grokConfig = wrapper.findAll('pre code')
|
||||
.map((code) => code.text())
|
||||
.find((content) => content.includes('[model."sub2api-grok"]'))
|
||||
expect(grokConfig).toBeDefined()
|
||||
expect(grokConfig).toContain('model = "grok-4.5"')
|
||||
expect(grokConfig).toContain('base_url = "https://example.com/v1"')
|
||||
expect(grokConfig).toContain('api_key = "sk-grok-test"')
|
||||
expect(grokConfig).toContain('api_backend = "responses"')
|
||||
|
||||
const windowsTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'Windows'
|
||||
)
|
||||
expect(windowsTab).toBeDefined()
|
||||
await windowsTab!.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain('%userprofile%\\.grok/config.toml')
|
||||
|
||||
const opencodeTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.opencode')
|
||||
)
|
||||
expect(opencodeTab).toBeDefined()
|
||||
await opencodeTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const parsed = JSON.parse(wrapper.find('pre code').text())
|
||||
expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai')
|
||||
expect(parsed.provider.grok.options).toEqual({
|
||||
baseURL: 'https://example.com/v1',
|
||||
apiKey: 'sk-grok-test'
|
||||
})
|
||||
expect(parsed.provider.grok.models['grok-4.5']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['grok-build-0.1']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['grok-composer-2.5-fast']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders GPT-5.5 and goals feature in OpenAI Codex config', () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
|
||||
@@ -53,3 +53,20 @@ describe('useGrokOAuth.exchangeAuthCode', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGrokOAuth.buildCredentials', () => {
|
||||
it('persists the Grok CLI subscription proxy for OAuth inference', () => {
|
||||
const oauth = useGrokOAuth()
|
||||
|
||||
const credentials = oauth.buildCredentials({
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_at: 1_900_000_000,
|
||||
client_id: 'client-id',
|
||||
scope: 'openid grok-cli:access',
|
||||
email: 'grok@example.com'
|
||||
})
|
||||
|
||||
expect(credentials.base_url).toBe('https://cli-chat-proxy.grok.com/v1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,7 +122,8 @@ export function useGrokOAuth() {
|
||||
scope: tokenInfo.scope,
|
||||
email: tokenInfo.email,
|
||||
subscription_tier: tokenInfo.subscription_tier,
|
||||
entitlement_status: tokenInfo.entitlement_status
|
||||
entitlement_status: tokenInfo.entitlement_status,
|
||||
base_url: 'https://cli-chat-proxy.grok.com/v1'
|
||||
}
|
||||
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
|
||||
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import en from '../locales/en'
|
||||
import zh from '../locales/zh'
|
||||
|
||||
describe('OpenAI Fast/Flex policy locale keys', () => {
|
||||
it('exposes user scope copy at the runtime zh path', () => {
|
||||
expect(zh.admin.settings.openaiFastPolicy).toMatchObject({
|
||||
userIds: '指定用户',
|
||||
userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。',
|
||||
userSearchPlaceholder: '输入用户邮箱搜索',
|
||||
userSearchEmpty: '未找到匹配用户',
|
||||
userDeleted: '(已删除)',
|
||||
userIdFallback: '用户 #{id}',
|
||||
removeUser: '移除用户'
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes user scope copy at the runtime en path', () => {
|
||||
expect(en.admin.settings.openaiFastPolicy).toMatchObject({
|
||||
userIds: 'Specific users',
|
||||
userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.',
|
||||
userSearchPlaceholder: 'Search by user email',
|
||||
userSearchEmpty: 'No matching users found',
|
||||
userDeleted: '(deleted)',
|
||||
userIdFallback: 'User #{id}',
|
||||
removeUser: 'Remove user'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from '@/i18n/locales/en'
|
||||
import zh from '@/i18n/locales/zh'
|
||||
|
||||
function flattenKeys(obj: Record<string, any>, prefix = ''): string[] {
|
||||
const keys: string[] = []
|
||||
@@ -35,4 +36,18 @@ describe('groups locale key completeness', () => {
|
||||
const enKeys = flattenKeys(en)
|
||||
expect(enKeys).toContain('admin.groups.failedToSave')
|
||||
})
|
||||
|
||||
const webSearchPricingKeys = [
|
||||
'admin.groups.webSearchPricing.title',
|
||||
'admin.groups.webSearchPricing.pricePerCall',
|
||||
'admin.groups.webSearchPricing.pricePerCallHint',
|
||||
'admin.groups.webSearchPricing.finalPricePreview',
|
||||
]
|
||||
|
||||
for (const key of webSearchPricingKeys) {
|
||||
it(`en and zh locales both have ${key}`, () => {
|
||||
expect(flattenKeys(en)).toContain(key)
|
||||
expect(flattenKeys(zh)).toContain(key)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -939,6 +939,13 @@ export default {
|
||||
finalPricePreview: 'Final per-second price preview',
|
||||
notConfigured: 'Not configured'
|
||||
},
|
||||
webSearchPricing: {
|
||||
title: 'Codex Web Search Pricing',
|
||||
pricePerCall: 'Price per search call (USD)',
|
||||
pricePerCallHint:
|
||||
'Leave empty to use the default $0.01 per call (official pricing: $10 per 1,000 calls); 0 means free. The group rate multiplier is applied on top.',
|
||||
finalPricePreview: 'Per-call price after current multiplier: {price}'
|
||||
},
|
||||
peakRate: {
|
||||
enable: 'Enable peak rate multiplier',
|
||||
peakStart: 'Peak start',
|
||||
|
||||
@@ -979,11 +979,6 @@ export default {
|
||||
scopeOAuth: 'OAuth only',
|
||||
scopeAPIKey: 'API Key only',
|
||||
scopeBedrock: 'Bedrock only',
|
||||
userIds: 'Specific user IDs',
|
||||
userIdsHint: 'Leave empty to apply to all Sub2API users. Specified users match requests from their API keys and take precedence over global rules.',
|
||||
userIdPlaceholder: 'e.g., 1001',
|
||||
addUserId: 'Add user ID',
|
||||
removeUserId: 'Remove user ID',
|
||||
errorMessage: 'Error message',
|
||||
errorMessagePlaceholder: 'Custom error message when blocked',
|
||||
errorMessageHint: 'Leave empty for default message',
|
||||
@@ -1024,6 +1019,13 @@ export default {
|
||||
scopeOAuth: 'OAuth only',
|
||||
scopeAPIKey: 'API Key only',
|
||||
scopeBedrock: 'Bedrock only',
|
||||
userIds: 'Specific users',
|
||||
userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.',
|
||||
userSearchPlaceholder: 'Search by user email',
|
||||
userSearchEmpty: 'No matching users found',
|
||||
userDeleted: '(deleted)',
|
||||
userIdFallback: 'User #{id}',
|
||||
removeUser: 'Remove user',
|
||||
errorMessage: 'Error message',
|
||||
errorMessagePlaceholder: 'Custom error message when blocked',
|
||||
errorMessageHint: 'Leave empty for the default message.',
|
||||
|
||||
@@ -146,6 +146,7 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
grokCli: 'Grok CLI',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
@@ -160,6 +161,12 @@ export default {
|
||||
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
grok: {
|
||||
description: 'Configure Grok Build or OpenCode to send Responses API traffic through your Sub2API Grok group.',
|
||||
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
|
||||
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select sub2api-grok from /model.',
|
||||
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select sub2api-grok from /model.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
subtitle: 'opencode.json',
|
||||
|
||||
@@ -930,6 +930,13 @@ export default {
|
||||
finalPricePreview: '最终每秒价格预览',
|
||||
notConfigured: '未配置'
|
||||
},
|
||||
webSearchPricing: {
|
||||
title: 'Codex 网页搜索计费',
|
||||
pricePerCall: '搜索单次价格(USD/次)',
|
||||
pricePerCallHint:
|
||||
'留空使用默认价 $0.01/次(官方定价 $10/1000 次);填 0 表示免费。实际扣费会叠加分组费率倍数。',
|
||||
finalPricePreview: '应用当前倍率后的单次价格:{price}'
|
||||
},
|
||||
peakRate: {
|
||||
enable: '启用高峰倍率',
|
||||
peakStart: '高峰开始',
|
||||
|
||||
@@ -974,11 +974,6 @@ export default {
|
||||
scopeOAuth: '仅 OAuth 账号',
|
||||
scopeAPIKey: '仅 API Key 账号',
|
||||
scopeBedrock: '仅 Bedrock 账号',
|
||||
userIds: '指定用户 ID',
|
||||
userIdsHint: '留空表示对全部 Sub2API 用户生效。指定后仅匹配这些用户的 API Key 请求,且优先于全局规则。',
|
||||
userIdPlaceholder: '例如: 1001',
|
||||
addUserId: '添加用户 ID',
|
||||
removeUserId: '移除用户 ID',
|
||||
errorMessage: '错误消息',
|
||||
errorMessagePlaceholder: '拦截时返回的自定义错误消息',
|
||||
errorMessageHint: '留空则使用默认错误消息',
|
||||
@@ -1019,6 +1014,13 @@ export default {
|
||||
scopeOAuth: '仅 OAuth 账号',
|
||||
scopeAPIKey: '仅 API Key 账号',
|
||||
scopeBedrock: '仅 Bedrock 账号',
|
||||
userIds: '指定用户',
|
||||
userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。',
|
||||
userSearchPlaceholder: '输入用户邮箱搜索',
|
||||
userSearchEmpty: '未找到匹配用户',
|
||||
userDeleted: '(已删除)',
|
||||
userIdFallback: '用户 #{id}',
|
||||
removeUser: '移除用户',
|
||||
errorMessage: '错误消息',
|
||||
errorMessagePlaceholder: '拦截时返回的自定义错误消息',
|
||||
errorMessageHint: '留空则使用默认错误消息。',
|
||||
|
||||
@@ -147,6 +147,7 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
grokCli: 'Grok CLI',
|
||||
opencode: 'OpenCode'
|
||||
},
|
||||
antigravity: {
|
||||
@@ -164,6 +165,12 @@ export default {
|
||||
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
grok: {
|
||||
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
|
||||
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。',
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
|
||||
@@ -531,6 +531,8 @@ export interface Group {
|
||||
video_price_480p: number | null
|
||||
video_price_720p: number | null
|
||||
video_price_1080p: number | null
|
||||
// Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
|
||||
web_search_price_per_call: number | null
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: boolean
|
||||
peak_start: string
|
||||
@@ -664,6 +666,7 @@ export interface CreateGroupRequest {
|
||||
video_price_480p?: number | null
|
||||
video_price_720p?: number | null
|
||||
video_price_1080p?: number | null
|
||||
web_search_price_per_call?: number | null
|
||||
peak_rate_enabled?: boolean
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
@@ -711,6 +714,7 @@ export interface UpdateGroupRequest {
|
||||
video_price_480p?: number | null
|
||||
video_price_720p?: number | null
|
||||
video_price_1080p?: number | null
|
||||
web_search_price_per_call?: number | null
|
||||
peak_rate_enabled?: boolean
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
|
||||
@@ -1315,6 +1315,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex 网页搜索按次计费(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="createForm.platform === 'openai'"
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{{ t("admin.groups.webSearchPricing.title") }}
|
||||
</h4>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.webSearchPricing.pricePerCall")
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="createForm.web_search_price_per_call"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
placeholder="0.01"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t("admin.groups.webSearchPricing.pricePerCallHint") }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t("admin.groups.webSearchPricing.finalPricePreview", {
|
||||
price: createWebSearchFinalPricePreview,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="createForm.platform === 'openai'"
|
||||
@@ -2790,6 +2825,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex 网页搜索按次计费(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="editForm.platform === 'openai'"
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{{ t("admin.groups.webSearchPricing.title") }}
|
||||
</h4>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.groups.webSearchPricing.pricePerCall")
|
||||
}}</label>
|
||||
<input
|
||||
v-model.number="editForm.web_search_price_per_call"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
placeholder="0.01"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t("admin.groups.webSearchPricing.pricePerCallHint") }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t("admin.groups.webSearchPricing.finalPricePreview", {
|
||||
price: editWebSearchFinalPricePreview,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="editForm.platform === 'openai'"
|
||||
@@ -3888,6 +3958,8 @@ const createForm = reactive({
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
|
||||
web_search_price_per_call: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: false,
|
||||
peak_start: "",
|
||||
@@ -4233,6 +4305,8 @@ const editForm = reactive({
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
|
||||
web_search_price_per_call: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
peak_rate_enabled: false,
|
||||
peak_start: "",
|
||||
@@ -4390,6 +4464,27 @@ const editVideoFinalPricePreview = computed(() =>
|
||||
buildVideoFinalPricePreview(editForm),
|
||||
);
|
||||
|
||||
// Codex 网页搜索单次默认价(与后端 defaultWebSearchPricePerCall 一致,官方 $10/1000 次)
|
||||
const DEFAULT_WEB_SEARCH_PRICE_PER_CALL = 0.01;
|
||||
|
||||
const buildWebSearchFinalPricePreview = (form: {
|
||||
web_search_price_per_call: number | string | null;
|
||||
rate_multiplier: number | string | null;
|
||||
}) => {
|
||||
const basePrice =
|
||||
parsePreviewPrice(form.web_search_price_per_call) ??
|
||||
DEFAULT_WEB_SEARCH_PRICE_PER_CALL;
|
||||
const multiplier = normalizePreviewNumber(form.rate_multiplier, 1);
|
||||
return formatImagePricePreview(basePrice * multiplier);
|
||||
};
|
||||
|
||||
const createWebSearchFinalPricePreview = computed(() =>
|
||||
buildWebSearchFinalPricePreview(createForm),
|
||||
);
|
||||
const editWebSearchFinalPricePreview = computed(() =>
|
||||
buildWebSearchFinalPricePreview(editForm),
|
||||
);
|
||||
|
||||
const resetDisabledBatchImagePricing = (
|
||||
form: Pick<
|
||||
ImagePricingFormState,
|
||||
@@ -4615,6 +4710,7 @@ const closeCreateModal = () => {
|
||||
createForm.video_price_480p = null;
|
||||
createForm.video_price_720p = null;
|
||||
createForm.video_price_1080p = null;
|
||||
createForm.web_search_price_per_call = null;
|
||||
createForm.peak_rate_enabled = false;
|
||||
createForm.peak_start = "";
|
||||
createForm.peak_end = "";
|
||||
@@ -4726,6 +4822,9 @@ const handleCreateGroup = async () => {
|
||||
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
|
||||
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
|
||||
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
|
||||
requestData.web_search_price_per_call = emptyToNull(
|
||||
requestData.web_search_price_per_call,
|
||||
);
|
||||
requestData.peak_rate_enabled = createForm.peak_rate_enabled;
|
||||
requestData.peak_start = createForm.peak_start;
|
||||
requestData.peak_end = createForm.peak_end;
|
||||
@@ -4779,6 +4878,7 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.video_price_480p = group.video_price_480p;
|
||||
editForm.video_price_720p = group.video_price_720p;
|
||||
editForm.video_price_1080p = group.video_price_1080p;
|
||||
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
|
||||
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
|
||||
editForm.peak_start = group.peak_start ?? "";
|
||||
editForm.peak_end = group.peak_end ?? "";
|
||||
@@ -4836,6 +4936,7 @@ const closeEditModal = () => {
|
||||
editForm.video_price_480p = null;
|
||||
editForm.video_price_720p = null;
|
||||
editForm.video_price_1080p = null;
|
||||
editForm.web_search_price_per_call = null;
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
resetModelsListState(editModelsListState);
|
||||
};
|
||||
@@ -4914,6 +5015,9 @@ const handleUpdateGroup = async () => {
|
||||
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
|
||||
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
|
||||
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
|
||||
payload.web_search_price_per_call = emptyPriceToClear(
|
||||
payload.web_search_price_per_call,
|
||||
);
|
||||
payload.peak_rate_enabled = editForm.peak_rate_enabled;
|
||||
payload.peak_start = editForm.peak_start;
|
||||
payload.peak_end = editForm.peak_end;
|
||||
|
||||
@@ -1199,60 +1199,10 @@
|
||||
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.settings.openaiFastPolicy.userIdsHint") }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(_, userIDIndex) in rule.user_ids || []"
|
||||
:key="userIDIndex"
|
||||
class="mb-1.5 flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model.number="rule.user_ids![userIDIndex]"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-sm flex-1"
|
||||
:placeholder="t('admin.settings.openaiFastPolicy.userIdPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeOpenAIFastPolicyUserID(rule, userIDIndex)"
|
||||
class="shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
:title="t('admin.settings.openaiFastPolicy.removeUserId')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="addOpenAIFastPolicyUserID(rule)"
|
||||
class="mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t("admin.settings.openaiFastPolicy.addUserId") }}
|
||||
</button>
|
||||
<OpenAIFastPolicyUserSelector
|
||||
:model-value="rule.user_ids || []"
|
||||
@update:model-value="rule.user_ids = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Message (only when action=block) -->
|
||||
@@ -7431,6 +7381,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
|
||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
|
||||
import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||
@@ -10226,18 +10177,6 @@ function removeOpenAIFastPolicyRule(index: number) {
|
||||
openaiFastPolicyForm.rules.splice(index, 1);
|
||||
}
|
||||
|
||||
function addOpenAIFastPolicyUserID(rule: OpenAIFastPolicyRule) {
|
||||
if (!rule.user_ids) rule.user_ids = [];
|
||||
rule.user_ids.push(0);
|
||||
}
|
||||
|
||||
function removeOpenAIFastPolicyUserID(
|
||||
rule: OpenAIFastPolicyRule,
|
||||
idx: number,
|
||||
) {
|
||||
rule.user_ids?.splice(idx, 1);
|
||||
}
|
||||
|
||||
function addOpenAIFastPolicyModelPattern(rule: OpenAIFastPolicyRule) {
|
||||
if (!rule.model_whitelist) rule.model_whitelist = [];
|
||||
rule.model_whitelist.push("");
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<div v-if="selectedUserIds.length > 0" class="mb-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="userId in selectedUserIds"
|
||||
:key="userId"
|
||||
class="inline-flex max-w-full items-center gap-1.5 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-200"
|
||||
>
|
||||
<span class="max-w-64 truncate font-medium" :title="selectedUserLabel(userId)">
|
||||
{{ selectedUserLabel(userId) }}
|
||||
</span>
|
||||
<span class="shrink-0 text-gray-400">#{{ userId }}</span>
|
||||
<span
|
||||
v-if="selectedUsers[userId]?.deleted"
|
||||
class="shrink-0 text-gray-400"
|
||||
>
|
||||
{{ t("admin.settings.openaiFastPolicy.userDeleted") }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
:aria-label="t('admin.settings.openaiFastPolicy.removeUser')"
|
||||
:title="t('admin.settings.openaiFastPolicy.removeUser')"
|
||||
@click="removeUser(userId)"
|
||||
>
|
||||
<Icon name="x" size="xs" :stroke-width="2" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<Icon
|
||||
name="search"
|
||||
size="sm"
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="input input-sm w-full pl-9"
|
||||
:placeholder="t('admin.settings.openaiFastPolicy.userSearchPlaceholder')"
|
||||
@input="debounceSearch"
|
||||
@focus="showDropdown = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showDropdown && searchQuery.trim()"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-700"
|
||||
>
|
||||
<div v-if="searchLoading" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="availableResults.length === 0"
|
||||
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t("admin.settings.openaiFastPolicy.userSearchEmpty") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="user in availableResults"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white">
|
||||
{{ user.email }}
|
||||
<span v-if="user.deleted" class="ml-1 text-xs font-normal text-gray-400">
|
||||
{{ t("admin.settings.openaiFastPolicy.userDeleted") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-gray-400">#{{ user.id }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { adminAPI } from "@/api/admin";
|
||||
import type { SimpleUser } from "@/api/admin/usage";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: number[]];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const searchQuery = ref("");
|
||||
const searchResults = ref<SimpleUser[]>([]);
|
||||
const searchLoading = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const selectedUsers = ref<Record<number, SimpleUser>>({});
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let searchSequence = 0;
|
||||
|
||||
const selectedUserIds = computed(() =>
|
||||
Array.from(new Set(props.modelValue.filter((id) => Number.isInteger(id) && id > 0))),
|
||||
);
|
||||
|
||||
const availableResults = computed(() => {
|
||||
const selected = new Set(selectedUserIds.value);
|
||||
return searchResults.value
|
||||
.filter((user) => !selected.has(user.id))
|
||||
.sort((a, b) => Number(a.deleted) - Number(b.deleted));
|
||||
});
|
||||
|
||||
function selectedUserLabel(userId: number): string {
|
||||
return selectedUsers.value[userId]?.email ||
|
||||
t("admin.settings.openaiFastPolicy.userIdFallback", { id: userId });
|
||||
}
|
||||
|
||||
function clearPendingSearch(): void {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
}
|
||||
searchSequence += 1;
|
||||
}
|
||||
|
||||
function debounceSearch(): void {
|
||||
clearPendingSearch();
|
||||
const query = searchQuery.value.trim();
|
||||
showDropdown.value = true;
|
||||
if (!query) {
|
||||
searchResults.value = [];
|
||||
searchLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const sequence = searchSequence;
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchLoading.value = true;
|
||||
try {
|
||||
const results = await adminAPI.usage.searchUsers(query);
|
||||
if (sequence === searchSequence) {
|
||||
searchResults.value = results;
|
||||
}
|
||||
} catch {
|
||||
if (sequence === searchSequence) {
|
||||
searchResults.value = [];
|
||||
}
|
||||
} finally {
|
||||
if (sequence === searchSequence) {
|
||||
searchLoading.value = false;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectUser(user: SimpleUser): void {
|
||||
selectedUsers.value = { ...selectedUsers.value, [user.id]: user };
|
||||
emit("update:modelValue", [...selectedUserIds.value, user.id]);
|
||||
clearPendingSearch();
|
||||
searchQuery.value = "";
|
||||
searchResults.value = [];
|
||||
searchLoading.value = false;
|
||||
showDropdown.value = false;
|
||||
}
|
||||
|
||||
function removeUser(userId: number): void {
|
||||
emit(
|
||||
"update:modelValue",
|
||||
selectedUserIds.value.filter((id) => id !== userId),
|
||||
);
|
||||
}
|
||||
|
||||
async function hydrateSelectedUsers(userIds: number[]): Promise<void> {
|
||||
const missing = userIds.filter((id) => !selectedUsers.value[id]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const users = await Promise.all(
|
||||
missing.map(async (id) => {
|
||||
try {
|
||||
const user = await adminAPI.users.getById(id, true);
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
deleted: Boolean(user.deleted_at),
|
||||
} satisfies SimpleUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const next = { ...selectedUsers.value };
|
||||
for (const user of users) {
|
||||
if (user && props.modelValue.includes(user.id)) {
|
||||
next[user.id] = user;
|
||||
}
|
||||
}
|
||||
selectedUsers.value = next;
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent): void {
|
||||
const target = event.target as Node | null;
|
||||
if (target && !containerRef.value?.contains(target)) {
|
||||
showDropdown.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedUserIds,
|
||||
(userIds) => {
|
||||
void hydrateSelectedUsers(userIds);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearPendingSearch();
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import OpenAIFastPolicyUserSelector from '../OpenAIFastPolicyUserSelector.vue'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.settings.openaiFastPolicy.userDeleted': '(deleted)',
|
||||
'admin.settings.openaiFastPolicy.userIdFallback': 'User #{id}',
|
||||
'admin.settings.openaiFastPolicy.removeUser': 'Remove user',
|
||||
'admin.settings.openaiFastPolicy.userSearchPlaceholder': 'Search users',
|
||||
'admin.settings.openaiFastPolicy.userSearchEmpty': 'No users found',
|
||||
'common.loading': 'Loading',
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const message = messages[key] ?? key
|
||||
return params
|
||||
? Object.entries(params).reduce(
|
||||
(value, [name, replacement]) => value.replace(`{${name}}`, String(replacement)),
|
||||
message,
|
||||
)
|
||||
: message
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSearchUsers = vi.fn()
|
||||
const mockGetUserById = vi.fn()
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
usage: {
|
||||
searchUsers: (...args: unknown[]) => mockSearchUsers(...args),
|
||||
},
|
||||
users: {
|
||||
getById: (...args: unknown[]) => mockGetUserById(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('OpenAIFastPolicyUserSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockSearchUsers.mockReset()
|
||||
mockGetUserById.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('hydrates existing IDs to email labels without changing the saved IDs', async () => {
|
||||
mockGetUserById.mockResolvedValue({
|
||||
id: 7,
|
||||
email: 'existing@example.com',
|
||||
deleted_at: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(OpenAIFastPolicyUserSelector, {
|
||||
props: { modelValue: [7] },
|
||||
global: { stubs: { Icon: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetUserById).toHaveBeenCalledWith(7, true)
|
||||
expect(wrapper.text()).toContain('existing@example.com')
|
||||
expect(wrapper.text()).toContain('#7')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('searches after one character and adds the selected user ID', async () => {
|
||||
mockSearchUsers.mockResolvedValue([
|
||||
{ id: 9, email: 'alice@example.com', deleted: false },
|
||||
])
|
||||
|
||||
const wrapper = mount(OpenAIFastPolicyUserSelector, {
|
||||
props: { modelValue: [] },
|
||||
global: { stubs: { Icon: true } },
|
||||
})
|
||||
const input = wrapper.get('input')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('a')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSearchUsers).toHaveBeenCalledWith('a')
|
||||
const result = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('alice@example.com'),
|
||||
)
|
||||
expect(result).toBeDefined()
|
||||
await result!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[9]]])
|
||||
})
|
||||
|
||||
it('keeps an unresolved saved ID visible and removable', async () => {
|
||||
mockGetUserById.mockRejectedValue(new Error('not found'))
|
||||
|
||||
const wrapper = mount(OpenAIFastPolicyUserSelector, {
|
||||
props: { modelValue: [42] },
|
||||
global: { stubs: { Icon: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('User #42')
|
||||
await wrapper.get('button[aria-label="Remove user"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[]]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user