mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
feat(frontend): show Grok monitoring and Free plan badge
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type Provider = 'openai' | 'anthropic' | 'gemini'
|
||||
export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok'
|
||||
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
|
||||
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
|
||||
export type APIMode = 'chat_completions' | 'responses'
|
||||
|
||||
@@ -109,6 +109,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import type { APIMode, BodyOverrideMode, Provider } from '@/api/admin/channelMonitor'
|
||||
import {
|
||||
API_MODE_RESPONSES,
|
||||
DEFAULT_GROK_MODEL,
|
||||
PROVIDER_GROK,
|
||||
PROVIDER_OPENAI,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
@@ -305,11 +307,12 @@ const bodyPlaceholder = computed(() => {
|
||||
}
|
||||
return '{\n "model": "gpt-4o-mini",\n "instructions": "You are a health check endpoint. Reply briefly.",\n "input": "Reply with exactly: ok",\n "max_output_tokens": 20,\n "stream": false\n}'
|
||||
}
|
||||
if (props.provider === PROVIDER_OPENAI) {
|
||||
if (props.provider === PROVIDER_OPENAI || props.provider === PROVIDER_GROK) {
|
||||
if (props.bodyOverrideMode === 'merge') {
|
||||
return '{\n "max_tokens": 20\n}'
|
||||
}
|
||||
return '{\n "model": "gpt-4o-mini",\n "messages": [{"role":"user","content":"Reply with exactly: ok"}],\n "max_tokens": 20,\n "stream": false\n}'
|
||||
const model = props.provider === PROVIDER_GROK ? DEFAULT_GROK_MODEL : 'gpt-4o-mini'
|
||||
return `{\n "model": "${model}",\n "messages": [{"role":"user","content":"Reply with exactly: ok"}],\n "max_tokens": 20,\n "stream": false\n}`
|
||||
}
|
||||
if (props.bodyOverrideMode === 'merge') {
|
||||
return '{\n "system": "You are Claude Code..."\n}'
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
defineProps<{
|
||||
@@ -94,6 +95,7 @@ const providerFilterOptions = computed(() => [
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
|
||||
])
|
||||
|
||||
const enabledFilterOptions = computed(() => [
|
||||
|
||||
@@ -13,15 +13,16 @@
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.provider') }} <span class="text-red-500">*</span></label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="opt in providerOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
:data-testid="`monitor-provider-${opt.value}`"
|
||||
:aria-pressed="form.provider === opt.value"
|
||||
class="flex items-center justify-center gap-2 rounded-lg border-2 px-3 py-2.5 text-sm font-medium transition-colors"
|
||||
:class="providerPickerClass(opt.value, form.provider === opt.value)"
|
||||
@click="form.provider = opt.value"
|
||||
@click="selectProvider(opt.value)"
|
||||
>
|
||||
<ProviderIcon :provider="opt.value" :size="18" />
|
||||
<span>{{ opt.label }}</span>
|
||||
@@ -50,7 +51,7 @@
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.endpoint') }} <span class="text-red-500">*</span></label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="form.endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
|
||||
<input v-model="form.endpoint" data-testid="monitor-endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
|
||||
<button type="button" @click="useCurrentDomain" class="btn btn-secondary whitespace-nowrap">
|
||||
{{ t('admin.channelMonitor.form.useCurrentDomain') }}
|
||||
</button>
|
||||
@@ -80,6 +81,7 @@
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="form.primary_model"
|
||||
data-testid="monitor-primary-model"
|
||||
type="text"
|
||||
required
|
||||
class="input font-medium"
|
||||
@@ -213,8 +215,11 @@ import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
API_MODE_CHAT_COMPLETIONS,
|
||||
API_MODE_RESPONSES,
|
||||
DEFAULT_GROK_ENDPOINT,
|
||||
DEFAULT_GROK_MODEL,
|
||||
DEFAULT_INTERVAL_SECONDS,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
@@ -396,8 +401,26 @@ const providerOptions = computed<ProviderOption[]>(() => [
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
|
||||
])
|
||||
|
||||
function selectProvider(provider: Provider) {
|
||||
if (form.provider === provider) return
|
||||
const previousProvider = form.provider
|
||||
const clearGrokEndpoint =
|
||||
previousProvider === PROVIDER_GROK && form.endpoint === DEFAULT_GROK_ENDPOINT
|
||||
const clearGrokModel =
|
||||
previousProvider === PROVIDER_GROK && form.primary_model === DEFAULT_GROK_MODEL
|
||||
form.provider = provider
|
||||
if (provider === PROVIDER_GROK) {
|
||||
if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT
|
||||
if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL
|
||||
return
|
||||
}
|
||||
if (clearGrokEndpoint) form.endpoint = ''
|
||||
if (clearGrokModel) form.primary_model = ''
|
||||
}
|
||||
|
||||
// Clear api_key whenever provider changes to avoid cross-provider key mismatch.
|
||||
// Editing mode loads api_key='' via loadFromMonitor and only sets it on user
|
||||
// typing, so clearing on provider change is always a safe no-op until the user
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<!-- provider tabs -->
|
||||
<div class="mb-4 border-b border-gray-200 dark:border-dark-700">
|
||||
<div role="tablist" class="flex gap-1">
|
||||
<div role="tablist" class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="tab in providerTabs"
|
||||
:key="tab.value"
|
||||
@@ -130,7 +130,7 @@
|
||||
{{ t('admin.channelMonitor.form.provider') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="opt in providerTabs"
|
||||
:key="opt.value"
|
||||
@@ -248,6 +248,7 @@ import {
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
API_MODE_CHAT_COMPLETIONS,
|
||||
API_MODE_RESPONSES,
|
||||
} from '@/constants/channelMonitor'
|
||||
@@ -267,6 +268,7 @@ const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
|
||||
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
|
||||
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
|
||||
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
|
||||
{ value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') },
|
||||
])
|
||||
|
||||
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
|
||||
@@ -282,6 +284,7 @@ const countByProvider = computed<Record<Provider, number>>(() => {
|
||||
anthropic: 0,
|
||||
openai: 0,
|
||||
gemini: 0,
|
||||
grok: 0,
|
||||
}
|
||||
for (const t of templates.value) out[t.provider]++
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
transform="scale(.76)"
|
||||
d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"
|
||||
/>
|
||||
<path d="M18.75 14.5l.72 2.53 2.53.72-2.53.72-.72 2.53-.72-2.53-2.53-.72 2.53-.72.72-2.53z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -33,6 +33,17 @@
|
||||
<!-- Row 2: Plan type + Privacy mode (only if either exists) -->
|
||||
<div v-if="planLabel || privacyBadge" class="inline-flex items-center overflow-hidden rounded-md">
|
||||
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1', planBadgeClass]">
|
||||
<GrokFreeIcon
|
||||
v-if="isGrokFreePlan"
|
||||
data-testid="grok-free-plan-icon"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="planIconName"
|
||||
:name="planIconName"
|
||||
size="xs"
|
||||
data-testid="grok-plan-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ planLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
@@ -57,6 +68,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AccountPlatform, AccountType } from '@/types'
|
||||
import GrokFreeIcon from './GrokFreeIcon.vue'
|
||||
import PlatformIcon from './PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
@@ -97,10 +109,13 @@ const typeLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const normalizedPlanType = computed(() =>
|
||||
(props.planType || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
|
||||
)
|
||||
|
||||
const planLabel = computed(() => {
|
||||
if (!props.planType) return ''
|
||||
const lower = props.planType.toLowerCase()
|
||||
switch (lower) {
|
||||
if (!normalizedPlanType.value) return ''
|
||||
switch (normalizedPlanType.value) {
|
||||
case 'plus':
|
||||
return 'Plus'
|
||||
case 'team':
|
||||
@@ -109,7 +124,12 @@ const planLabel = computed(() => {
|
||||
case 'pro':
|
||||
return 'Pro'
|
||||
case 'free':
|
||||
return 'Free'
|
||||
case 'basic':
|
||||
return props.platform === 'grok' ? 'Grok Free' : 'Free'
|
||||
case 'supergrok':
|
||||
return 'SuperGrok'
|
||||
case 'supergrokheavy':
|
||||
return 'SuperGrok Heavy'
|
||||
case 'abnormal':
|
||||
return t('admin.accounts.subscriptionAbnormal')
|
||||
default:
|
||||
@@ -117,6 +137,22 @@ const planLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isGrokFreePlan = computed(() =>
|
||||
props.platform === 'grok' &&
|
||||
(normalizedPlanType.value === 'free' || normalizedPlanType.value === 'basic')
|
||||
)
|
||||
|
||||
const planIconName = computed<'bolt' | null>(() => {
|
||||
if (props.platform !== 'grok') return null
|
||||
if (
|
||||
normalizedPlanType.value === 'supergrok' ||
|
||||
normalizedPlanType.value === 'supergrokheavy'
|
||||
) {
|
||||
return 'bolt'
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const platformClass = computed(() => {
|
||||
if (props.platform === 'anthropic') {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
@@ -150,7 +186,7 @@ const typeClass = computed(() => {
|
||||
})
|
||||
|
||||
const planBadgeClass = computed(() => {
|
||||
if (props.planType && props.planType.toLowerCase() === 'abnormal') {
|
||||
if (normalizedPlanType.value === 'abnormal') {
|
||||
return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
return typeClass.value
|
||||
@@ -159,7 +195,7 @@ const planBadgeClass = computed(() => {
|
||||
// Subscription expiration label (non-free only)
|
||||
const expiresLabel = computed(() => {
|
||||
if (!props.subscriptionExpiresAt || !props.planType) return ''
|
||||
if (props.planType.toLowerCase() === 'free') return ''
|
||||
if (normalizedPlanType.value === 'free' || normalizedPlanType.value === 'basic') return ''
|
||||
try {
|
||||
const d = new Date(props.subscriptionExpiresAt)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import GrokFreeIcon from '../GrokFreeIcon.vue'
|
||||
import PlatformTypeBadge from '../PlatformTypeBadge.vue'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlatformTypeBadge Grok plans', () => {
|
||||
it('renders FREE and BASIC as Grok Free with a lightweight plan icon', async () => {
|
||||
const wrapper = mount(PlatformTypeBadge, {
|
||||
props: {
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
planType: 'BASIC',
|
||||
subscriptionExpiresAt: '2027-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Grok Free')
|
||||
expect(wrapper.findComponent(GrokFreeIcon).exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="grok-free-plan-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('2027-01-01')
|
||||
|
||||
await wrapper.setProps({ planType: 'FREE' })
|
||||
expect(wrapper.text()).toContain('Grok Free')
|
||||
expect(wrapper.findComponent(GrokFreeIcon).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps SuperGrok labels compatible and marks paid Grok plans', async () => {
|
||||
const wrapper = mount(PlatformTypeBadge, {
|
||||
props: {
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
planType: 'SuperGrok Heavy',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('SuperGrok Heavy')
|
||||
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="grok-free-plan-icon"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ platform: 'openai', planType: 'free' })
|
||||
expect(wrapper.text()).toContain('Free')
|
||||
expect(wrapper.text()).not.toContain('Grok Free')
|
||||
expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a dedicated 12px currentColor Grok mark with a Free sparkle', () => {
|
||||
const wrapper = mount(GrokFreeIcon)
|
||||
|
||||
expect(wrapper.element.tagName.toLowerCase()).toBe('svg')
|
||||
expect(wrapper.attributes('fill')).toBe('currentColor')
|
||||
expect(wrapper.classes()).toEqual(expect.arrayContaining(['h-3', 'w-3']))
|
||||
expect(wrapper.findAll('path')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,7 @@ const PROVIDER_TINT: Record<string, string> = {
|
||||
openai: 'text-emerald-600 dark:text-emerald-300',
|
||||
anthropic: 'text-orange-600 dark:text-orange-300',
|
||||
gemini: 'text-sky-600 dark:text-sky-300',
|
||||
grok: 'text-zinc-700 dark:text-zinc-200',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -51,6 +51,11 @@ const PROVIDER_ICONS: Record<Provider, IconData> = {
|
||||
'M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z',
|
||||
],
|
||||
},
|
||||
grok: {
|
||||
paths: [
|
||||
'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
STATUS_OPERATIONAL,
|
||||
STATUS_DEGRADED,
|
||||
STATUS_FAILED,
|
||||
@@ -57,7 +58,12 @@ export function useChannelMonitorFormat() {
|
||||
}
|
||||
|
||||
function providerLabel(p: Provider | string): string {
|
||||
if (p === PROVIDER_OPENAI || p === PROVIDER_ANTHROPIC || p === PROVIDER_GEMINI) {
|
||||
if (
|
||||
p === PROVIDER_OPENAI ||
|
||||
p === PROVIDER_ANTHROPIC ||
|
||||
p === PROVIDER_GEMINI ||
|
||||
p === PROVIDER_GROK
|
||||
) {
|
||||
return t(`monitorCommon.providers.${p}`)
|
||||
}
|
||||
return p || '-'
|
||||
@@ -71,6 +77,8 @@ export function useChannelMonitorFormat() {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/15 dark:text-orange-300'
|
||||
case PROVIDER_GEMINI:
|
||||
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
|
||||
case PROVIDER_GROK:
|
||||
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-500/15 dark:text-zinc-300'
|
||||
default:
|
||||
return NEUTRAL_BADGE
|
||||
}
|
||||
@@ -95,6 +103,10 @@ export function useChannelMonitorFormat() {
|
||||
return active
|
||||
? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300 dark:border-sky-400'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-sky-300 hover:text-sky-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-sky-500/50'
|
||||
case PROVIDER_GROK:
|
||||
return active
|
||||
? 'border-zinc-500 bg-zinc-50 text-zinc-800 dark:bg-zinc-500/15 dark:text-zinc-200 dark:border-zinc-400'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-zinc-400 hover:text-zinc-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-zinc-500/50'
|
||||
default:
|
||||
return active
|
||||
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
|
||||
@@ -166,6 +178,8 @@ export function providerGradient(provider: string): string {
|
||||
return 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-500/10 dark:to-amber-500/20'
|
||||
case PROVIDER_GEMINI:
|
||||
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
|
||||
case PROVIDER_GROK:
|
||||
return 'bg-gradient-to-br from-zinc-50 to-neutral-200 dark:from-zinc-500/10 dark:to-neutral-500/20'
|
||||
default:
|
||||
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import type { APIMode, Provider, MonitorStatus } from '@/api/admin/channelMonito
|
||||
export const PROVIDER_OPENAI: Provider = 'openai'
|
||||
export const PROVIDER_ANTHROPIC: Provider = 'anthropic'
|
||||
export const PROVIDER_GEMINI: Provider = 'gemini'
|
||||
export const PROVIDER_GROK: Provider = 'grok'
|
||||
|
||||
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
|
||||
export const DEFAULT_GROK_MODEL = 'grok-4.5'
|
||||
|
||||
export const API_MODE_CHAT_COMPLETIONS: APIMode = 'chat_completions'
|
||||
export const API_MODE_RESPONSES: APIMode = 'responses'
|
||||
@@ -20,6 +24,7 @@ export const PROVIDERS: readonly Provider[] = [
|
||||
PROVIDER_OPENAI,
|
||||
PROVIDER_ANTHROPIC,
|
||||
PROVIDER_GEMINI,
|
||||
PROVIDER_GROK,
|
||||
]
|
||||
|
||||
export const API_MODES: readonly APIMode[] = [
|
||||
|
||||
@@ -399,7 +399,8 @@ export default {
|
||||
providers: {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
gemini: 'Gemini',
|
||||
grok: 'Grok'
|
||||
},
|
||||
extraModelsHeader: 'Extra Models',
|
||||
extraModelsEmpty: 'No extra models',
|
||||
|
||||
@@ -404,7 +404,8 @@ export default {
|
||||
providers: {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
gemini: 'Gemini',
|
||||
grok: 'Grok'
|
||||
},
|
||||
extraModelsHeader: '附加模型',
|
||||
extraModelsEmpty: '无附加模型',
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type"
|
||||
:plan-type="row.credentials?.plan_type || row.parent_plan_type"
|
||||
:plan-type="getAccountPlanType(row)"
|
||||
:privacy-mode="row.extra?.privacy_mode || row.parent_privacy_mode"
|
||||
:subscription-expires-at="row.credentials?.subscription_expires_at || row.parent_subscription_expires_at" />
|
||||
<span
|
||||
@@ -1146,6 +1146,27 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Fresh billing/quota snapshots are authoritative. Imported credential tiers
|
||||
// can be stale, so they remain fallbacks together with legacy plan_type fields.
|
||||
function getAccountPlanType(row: any): string | undefined {
|
||||
if (!row) return undefined
|
||||
if (row.platform === 'grok') {
|
||||
const extra = (row.extra || {}) as Record<string, any>
|
||||
const billing = extra.grok_billing_snapshot as Record<string, any> | undefined
|
||||
const quota = extra.grok_quota_snapshot as Record<string, any> | undefined
|
||||
return (
|
||||
billing?.plan ||
|
||||
quota?.subscription_tier ||
|
||||
row.credentials?.subscription_tier ||
|
||||
extra.subscription_tier ||
|
||||
row.credentials?.plan_type ||
|
||||
row.parent_plan_type ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
return row.credentials?.plan_type || row.parent_plan_type || undefined
|
||||
}
|
||||
|
||||
// Antigravity 订阅等级辅助函数
|
||||
function getAntigravityTierFromRow(row: any): string | null {
|
||||
if (row.platform !== 'antigravity') return null
|
||||
|
||||
@@ -253,4 +253,79 @@ describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes fresh Grok billing and quota snapshots before stale credential fallbacks', async () => {
|
||||
const grokAccounts = [
|
||||
{
|
||||
id: 201,
|
||||
name: 'oauth-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { subscription_tier: 'FREE', plan_type: 'legacy' },
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'SuperGrok' },
|
||||
subscription_tier: 'BASIC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
name: 'billing-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: {},
|
||||
extra: {
|
||||
grok_billing_snapshot: { plan: 'SuperGrok Heavy' },
|
||||
subscription_tier: 'BASIC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 203,
|
||||
name: 'quota-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { subscription_tier: 'FREE' },
|
||||
extra: {
|
||||
grok_quota_snapshot: { subscription_tier: 'SuperGrok' },
|
||||
subscription_tier: 'BASIC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 204,
|
||||
name: 'extra-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { plan_type: 'SuperGrok' },
|
||||
extra: { subscription_tier: 'BASIC' },
|
||||
},
|
||||
{
|
||||
id: 205,
|
||||
name: 'legacy-tier',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: { plan_type: 'SuperGrok' },
|
||||
},
|
||||
]
|
||||
|
||||
listAccounts.mockResolvedValue({
|
||||
items: grokAccounts,
|
||||
total: grokAccounts.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
})
|
||||
|
||||
const wrapper = mountViewWithRow()
|
||||
await flushPromises()
|
||||
|
||||
const badges = wrapper.findAllComponents(PlatformTypeBadge)
|
||||
expect(badges.map((badge) => badge.props('planType'))).toEqual([
|
||||
'SuperGrok',
|
||||
'SuperGrok Heavy',
|
||||
'SuperGrok',
|
||||
'BASIC',
|
||||
'SuperGrok',
|
||||
])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
|
||||
import {
|
||||
DEFAULT_GROK_ENDPOINT,
|
||||
DEFAULT_GROK_MODEL,
|
||||
PROVIDERS,
|
||||
PROVIDER_GROK,
|
||||
} from '@/constants/channelMonitor'
|
||||
|
||||
const { listTemplates } = vi.hoisted(() => ({
|
||||
listTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
channelMonitor: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
channelMonitorTemplate: {
|
||||
list: listTemplates,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/keys', () => ({
|
||||
keysAPI: { list: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/api/groups', () => ({
|
||||
userGroupsAPI: { getUserGroupRates: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
cachedPublicSettings: null,
|
||||
showError: vi.fn(),
|
||||
showSuccess: 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({
|
||||
props: { show: { type: Boolean, default: false } },
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return mount(MonitorFormDialog, {
|
||||
props: { show: true, monitor: null },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
Toggle: true,
|
||||
Select: true,
|
||||
ModelTagInput: true,
|
||||
MonitorKeyPickerDialog: true,
|
||||
MonitorAdvancedRequestConfig: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('channel monitor Grok provider', () => {
|
||||
beforeEach(() => {
|
||||
listTemplates.mockReset().mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
it('offers Grok in the responsive provider grid and prefills its official defaults', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
expect(PROVIDERS).toContain(PROVIDER_GROK)
|
||||
const providerButtons = wrapper.findAll('[data-testid^="monitor-provider-"]')
|
||||
expect(providerButtons).toHaveLength(4)
|
||||
expect(providerButtons[0].element.parentElement?.className).toContain('grid-cols-2')
|
||||
expect(providerButtons[0].element.parentElement?.className).toContain('sm:grid-cols-4')
|
||||
|
||||
const grokButton = wrapper.get('[data-testid="monitor-provider-grok"]')
|
||||
expect(grokButton.find('svg').exists()).toBe(true)
|
||||
expect(grokButton.text()).toContain('monitorCommon.providers.grok')
|
||||
await grokButton.trigger('click')
|
||||
expect(grokButton.classes().join(' ')).toContain('zinc')
|
||||
|
||||
const endpoint = wrapper.get('[data-testid="monitor-endpoint"]')
|
||||
const model = wrapper.get('[data-testid="monitor-primary-model"]')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe(DEFAULT_GROK_ENDPOINT)
|
||||
expect((model.element as HTMLInputElement).value).toBe(DEFAULT_GROK_MODEL)
|
||||
|
||||
await wrapper.get('[data-testid="monitor-provider-anthropic"]').trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('')
|
||||
expect((model.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
await grokButton.trigger('click')
|
||||
await endpoint.setValue('https://gateway.example.com')
|
||||
await model.setValue('grok-custom')
|
||||
await wrapper.get('[data-testid="monitor-provider-openai"]').trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('https://gateway.example.com')
|
||||
expect((model.element as HTMLInputElement).value).toBe('grok-custom')
|
||||
})
|
||||
|
||||
it('prefills only empty Grok fields and preserves existing provider values', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
const endpoint = wrapper.get('[data-testid="monitor-endpoint"]')
|
||||
const model = wrapper.get('[data-testid="monitor-primary-model"]')
|
||||
const grokButton = wrapper.get('[data-testid="monitor-provider-grok"]')
|
||||
const anthropicButton = wrapper.get('[data-testid="monitor-provider-anthropic"]')
|
||||
|
||||
await endpoint.setValue('https://gateway.example.com')
|
||||
await grokButton.trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('https://gateway.example.com')
|
||||
expect((model.element as HTMLInputElement).value).toBe(DEFAULT_GROK_MODEL)
|
||||
|
||||
await anthropicButton.trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe('https://gateway.example.com')
|
||||
expect((model.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
await endpoint.setValue('')
|
||||
await model.setValue('grok-custom')
|
||||
await grokButton.trigger('click')
|
||||
expect((endpoint.element as HTMLInputElement).value).toBe(DEFAULT_GROK_ENDPOINT)
|
||||
expect((model.element as HTMLInputElement).value).toBe('grok-custom')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user