mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-10 09:01:22 +00:00
merge: sync contrib/routed-codex-model-catalog with upstream v0.1.182
Keep catalog membership on schedulable accounts and intersect advertised capabilities across all active group members that map an alias. Preserve upstream plugin, service-tier, and models-list body-limit changes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
311 files changed
+19415
-1286
No files matched your search
@@ -2266,6 +2266,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth' && !isSparkShadow"
|
||||
class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
data-testid="auto-reset-credit-settings"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.autoResetCredit.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.autoResetCredit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="auto-reset-credit-enabled"
|
||||
@click="autoResetCreditEnabled = !autoResetCreditEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
autoResetCreditEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
autoResetCreditEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.autoResetCredit.threshold5h') }}</label>
|
||||
<input
|
||||
v-model.number="autoResetCredit5hThreshold"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
:disabled="!autoResetCreditEnabled"
|
||||
data-testid="auto-reset-credit-5h-threshold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.autoResetCredit.threshold7d') }}</label>
|
||||
<input
|
||||
v-model.number="autoResetCredit7dThreshold"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
:disabled="!autoResetCreditEnabled"
|
||||
data-testid="auto-reset-credit-7d-threshold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.autoResetCredit.thresholdHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM 等) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
|
||||
@@ -3098,6 +3158,9 @@ const autoPause5hThreshold = ref<number | null>(null)
|
||||
const autoPause7dThreshold = ref<number | null>(null)
|
||||
const autoPause5hDisabled = ref(false)
|
||||
const autoPause7dDisabled = ref(false)
|
||||
const autoResetCreditEnabled = ref(false)
|
||||
const autoResetCredit5hThreshold = ref(100)
|
||||
const autoResetCredit7dThreshold = ref(100)
|
||||
const upstreamBillingAutoProbeEnabled = ref(false)
|
||||
const upstreamBillingRateSyncEnabled = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
@@ -3626,6 +3689,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
|
||||
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
|
||||
autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true
|
||||
autoResetCreditEnabled.value = extra?.auto_reset_credit_enabled === true
|
||||
autoResetCredit5hThreshold.value =
|
||||
typeof extra?.auto_reset_credit_5h_threshold === 'number' ? extra.auto_reset_credit_5h_threshold * 100 : 100
|
||||
autoResetCredit7dThreshold.value =
|
||||
typeof extra?.auto_reset_credit_7d_threshold === 'number' ? extra.auto_reset_credit_7d_threshold * 100 : 100
|
||||
upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true
|
||||
upstreamBillingRateSyncEnabled.value =
|
||||
upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true
|
||||
@@ -4522,6 +4590,13 @@ const handleSubmit = async () => {
|
||||
appStore.showError(t('admin.accounts.pleaseSelectStatus'))
|
||||
return
|
||||
}
|
||||
if (autoResetCreditEnabled.value) {
|
||||
const thresholds = [autoResetCredit5hThreshold.value, autoResetCredit7dThreshold.value]
|
||||
if (thresholds.some((value) => !Number.isFinite(value) || value < 0.1 || value > 100)) {
|
||||
appStore.showError(t('admin.accounts.autoResetCredit.thresholdInvalid'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, unknown> = { ...form }
|
||||
try {
|
||||
@@ -5086,6 +5161,13 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
delete newExtra.auto_pause_7d_disabled
|
||||
}
|
||||
if (props.account.type === 'oauth' && !isSparkShadow.value) {
|
||||
newExtra.auto_reset_credit_enabled = autoResetCreditEnabled.value
|
||||
newExtra.auto_reset_credit_5h_threshold = autoResetCredit5hThreshold.value / 100
|
||||
newExtra.auto_reset_credit_7d_threshold = autoResetCredit7dThreshold.value / 100
|
||||
}
|
||||
// 运行态只允许后端服务更新,账号编辑不得回写旧状态。
|
||||
delete newExtra.codex_auto_reset_credit_state
|
||||
|
||||
delete newExtra.codex_image_generation_bridge_enabled
|
||||
switch (codexImageToolMode.value) {
|
||||
|
||||
@@ -63,6 +63,32 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="autoResetState"
|
||||
class="flex flex-wrap items-center gap-1 text-[10px]"
|
||||
data-testid="auto-reset-credit-state"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded px-1.5 py-0.5 font-medium"
|
||||
:class="autoResetStateClass"
|
||||
>
|
||||
{{ autoResetStateLabel }}
|
||||
<span v-if="autoResetState.trigger_window" class="ml-1 tabular-nums">
|
||||
{{ autoResetState.trigger_window }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="autoResetState.checked_at" class="text-gray-500 dark:text-gray-400">
|
||||
{{ formatResetCreditExpiry(autoResetState.checked_at, 'short') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="autoResetState.error_code"
|
||||
class="max-w-full truncate text-red-600 dark:text-red-400"
|
||||
:title="autoResetState.error_code"
|
||||
>
|
||||
{{ autoResetState.error_code }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="primaryResetCreditExpiry" class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
@@ -172,6 +198,42 @@ const resetWarning = ref<string | null>(null)
|
||||
const showResetConfirm = ref(false)
|
||||
const showResetCreditDetails = ref(false)
|
||||
|
||||
type AutoResetCreditState = NonNullable<NonNullable<Account['extra']>['codex_auto_reset_credit_state']>
|
||||
const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed'])
|
||||
const autoResetState = computed<AutoResetCreditState | null>(() => {
|
||||
if (props.account.extra?.auto_reset_credit_enabled !== true) return null
|
||||
const state = props.account.extra?.codex_auto_reset_credit_state
|
||||
if (!state || typeof state !== 'object' || !validAutoResetStatuses.has(String(state.status))) return null
|
||||
return state
|
||||
})
|
||||
const autoResetStateLabel = computed(() => {
|
||||
if (!autoResetState.value?.status) return ''
|
||||
const keyByStatus: Record<string, string> = {
|
||||
checking: 'checking',
|
||||
available: 'available',
|
||||
resetting: 'resetting',
|
||||
success: 'success',
|
||||
no_credit: 'noCredit',
|
||||
failed: 'failed'
|
||||
}
|
||||
return t(`admin.accounts.openaiQuotaReset.autoStatus.${keyByStatus[autoResetState.value.status]}`)
|
||||
})
|
||||
const autoResetStateClass = computed(() => {
|
||||
switch (autoResetState.value?.status) {
|
||||
case 'available':
|
||||
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
case 'success':
|
||||
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
case 'no_credit':
|
||||
case 'failed':
|
||||
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
case 'resetting':
|
||||
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-dark-800 dark:text-gray-300'
|
||||
}
|
||||
})
|
||||
|
||||
// Rehydrate the card from the persisted snapshot. Credits that already expired
|
||||
// are dropped and the count is clamped to what remains: the snapshot has no
|
||||
// freshness signal, so an unfiltered read would offer to consume credits that no
|
||||
|
||||
@@ -290,6 +290,18 @@ function buildOpenAISetupTokenAccount() {
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildOpenAIOAuthParentAccount() {
|
||||
return {
|
||||
...buildAccount(),
|
||||
id: 7,
|
||||
name: 'OpenAI OAuth Parent',
|
||||
type: 'oauth',
|
||||
parent_account_id: null,
|
||||
credentials: { access_token: 'oauth-token' },
|
||||
extra: {}
|
||||
} as any
|
||||
}
|
||||
|
||||
function mountModal(account = buildAccount()) {
|
||||
return mount(EditAccountModal, {
|
||||
props: {
|
||||
@@ -1351,3 +1363,63 @@ describe('EditAccountModal', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EditAccountModal OpenAI 自动使用重置卡', () => {
|
||||
beforeEach(() => {
|
||||
authIsSimpleMode.value = true
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
|
||||
})
|
||||
|
||||
it('仅对 OpenAI OAuth 母账号显示,默认关闭且阈值为 100/100', () => {
|
||||
const parent = mountModal(buildOpenAIOAuthParentAccount())
|
||||
expect(parent.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(true)
|
||||
expect((parent.get('[data-testid="auto-reset-credit-5h-threshold"]').element as HTMLInputElement).value).toBe('100')
|
||||
expect((parent.get('[data-testid="auto-reset-credit-7d-threshold"]').element as HTMLInputElement).value).toBe('100')
|
||||
expect(parent.get('[data-testid="auto-reset-credit-5h-threshold"]').attributes('disabled')).toBeDefined()
|
||||
parent.unmount()
|
||||
|
||||
for (const account of [buildAccount(), buildOpenAISetupTokenAccount(), buildOpenAISparkShadowAccount()]) {
|
||||
const wrapper = mountModal(account)
|
||||
expect(wrapper.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('独立保存两个阈值,并禁止把运行态回写到管理请求', async () => {
|
||||
const account = buildOpenAIOAuthParentAccount()
|
||||
account.extra = {
|
||||
codex_auto_reset_credit_state: {
|
||||
status: 'success',
|
||||
trigger_window: '5h',
|
||||
available_count: 1
|
||||
}
|
||||
}
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click')
|
||||
await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('75.5')
|
||||
await wrapper.get('[data-testid="auto-reset-credit-7d-threshold"]').setValue('92')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
const extra = updateAccountMock.mock.calls[0]?.[1]?.extra
|
||||
expect(extra).toMatchObject({
|
||||
auto_reset_credit_enabled: true,
|
||||
auto_reset_credit_5h_threshold: 0.755,
|
||||
auto_reset_credit_7d_threshold: 0.92
|
||||
})
|
||||
expect(extra).not.toHaveProperty('codex_auto_reset_credit_state')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('开启后拒绝超出 0.1–100 范围的任一阈值', async () => {
|
||||
const wrapper = mountModal(buildOpenAIOAuthParentAccount())
|
||||
await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click')
|
||||
await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('0')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
expect(updateAccountMock).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -344,3 +344,45 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenAIQuotaResetCell 自动用卡运行态', () => {
|
||||
it.each([
|
||||
['checking', 'checking'],
|
||||
['available', 'available'],
|
||||
['resetting', 'resetting'],
|
||||
['success', 'success'],
|
||||
['no_credit', 'noCredit'],
|
||||
['failed', 'failed'],
|
||||
] as const)('展示 %s 状态且不需要暴露卡标识', (status, labelKey) => {
|
||||
const account = makeAccount({
|
||||
extra: {
|
||||
auto_reset_credit_enabled: true,
|
||||
codex_auto_reset_credit_state: {
|
||||
status,
|
||||
trigger_window: '5h',
|
||||
available_count: 1,
|
||||
checked_at: '2099-07-03T04:05:06Z',
|
||||
error_code: status === 'failed' ? 'RESET_FAILED' : undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
const state = wrapper.get('[data-testid="auto-reset-credit-state"]')
|
||||
expect(state.text()).toContain(`admin.accounts.openaiQuotaReset.autoStatus.${labelKey}`)
|
||||
expect(state.text()).toContain('5h')
|
||||
expect(state.text()).not.toContain('credit_id')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('开关关闭时不显示历史运行态', () => {
|
||||
const account = makeAccount({
|
||||
extra: {
|
||||
auto_reset_credit_enabled: false,
|
||||
codex_auto_reset_credit_state: { status: 'success', available_count: 1 },
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
expect(wrapper.find('[data-testid="auto-reset-credit-state"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,40 @@
|
||||
<template>
|
||||
<section class="mt-3 border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 flex-1 sm:max-w-sm">
|
||||
<div class="min-w-0 flex-1 sm:max-w-2xl">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.timePricing') }}
|
||||
</label>
|
||||
<label class="mt-2 block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.timezone') }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="modelValue.timezone"
|
||||
:options="timezoneOptions"
|
||||
:aria-label="t('admin.channels.form.timezone')"
|
||||
searchable
|
||||
creatable
|
||||
class="mt-1 w-full"
|
||||
@update:model-value="updateTimezone"
|
||||
/>
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div class="min-w-0">
|
||||
<label class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.timezone') }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="modelValue.timezone"
|
||||
:options="timezoneOptions"
|
||||
:aria-label="t('admin.channels.form.timezone')"
|
||||
data-testid="time-pricing-timezone"
|
||||
searchable
|
||||
creatable
|
||||
class="mt-1 w-full"
|
||||
@update:model-value="updateTimezone"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<label class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.timePricingDayScope') }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="modelValue.weekdays_only"
|
||||
:options="dayScopeOptions"
|
||||
:aria-label="t('admin.channels.form.timePricingDayScope')"
|
||||
data-testid="time-pricing-day-scope"
|
||||
class="mt-1 w-full"
|
||||
@update:model-value="updateDayScope"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -99,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -122,10 +140,19 @@ const timezoneOptions = COMMON_TIMEZONES.map(value => {
|
||||
return { value, label: offset ? `${value} (${offset})` : value }
|
||||
})
|
||||
|
||||
const dayScopeOptions = computed(() => [
|
||||
{ value: false, label: t('admin.channels.form.timePricingEveryDay') },
|
||||
{ value: true, label: t('admin.channels.form.timePricingWeekdaysOnly') },
|
||||
])
|
||||
|
||||
function updateTimezone(value: string | number | boolean | null) {
|
||||
emit('update:modelValue', { ...props.modelValue, timezone: String(value ?? '') })
|
||||
}
|
||||
|
||||
function updateDayScope(value: string | number | boolean | null) {
|
||||
emit('update:modelValue', { ...props.modelValue, weekdays_only: value === true })
|
||||
}
|
||||
|
||||
function normalizeClockTime(value: string): string {
|
||||
const normalized = value.replace(/:/g, ':')
|
||||
return normalized === '24:00:00' ? '00:00:00' : normalized
|
||||
|
||||
@@ -12,7 +12,7 @@ const SelectStub = {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<button type="button" data-testid="timezone-select" />',
|
||||
template: '<button type="button" v-bind="$attrs" />',
|
||||
}
|
||||
|
||||
describe('TimePricingSection', () => {
|
||||
@@ -20,6 +20,28 @@ describe('TimePricingSection', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('offers every-day and weekdays-only scopes', async () => {
|
||||
const value = createDefaultTimePricingForm()
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
const selects = wrapper.findAllComponents(SelectStub)
|
||||
expect(selects).toHaveLength(2)
|
||||
expect(selects[1].props('modelValue')).toBe(false)
|
||||
expect(selects[1].props('options')).toEqual([
|
||||
{ value: false, label: 'admin.channels.form.timePricingEveryDay' },
|
||||
{ value: true, label: 'admin.channels.form.timePricingWeekdaysOnly' },
|
||||
])
|
||||
|
||||
selects[1].vm.$emit('update:modelValue', true)
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
...value,
|
||||
weekdays_only: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a neutral period immutably', async () => {
|
||||
const value = createDefaultTimePricingForm()
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
@@ -31,6 +53,7 @@ describe('TimePricingSection', () => {
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekdays_only: false,
|
||||
periods: [{ start_time: '', end_time: '', multiplier: '1.00' }],
|
||||
})
|
||||
expect(value.periods).toHaveLength(0)
|
||||
|
||||
@@ -146,15 +146,24 @@ describe('validateIntervals', () => {
|
||||
describe('time pricing', () => {
|
||||
it('uses a disabled Shanghai default', () => {
|
||||
const form = createDefaultTimePricingForm()
|
||||
expect(form).toEqual({ timezone: 'Asia/Shanghai', periods: [] })
|
||||
expect(form).toEqual({ timezone: 'Asia/Shanghai', periods: [], weekdays_only: false })
|
||||
expect(formTimePricingToAPI(form)).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips and formats multiplier', () => {
|
||||
const form = apiTimePricingToForm({
|
||||
it('defaults missing API day scope to every day', () => {
|
||||
expect(apiTimePricingToForm({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00', end_time: '12:00', multiplier: 2 }],
|
||||
}).weekdays_only).toBe(false)
|
||||
})
|
||||
|
||||
it('round-trips day scope and formats multiplier', () => {
|
||||
const form = apiTimePricingToForm({
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekdays_only: true,
|
||||
periods: [{ start_time: '09:00', end_time: '12:00', multiplier: 2 }],
|
||||
})
|
||||
expect(form.weekdays_only).toBe(true)
|
||||
expect(form.periods[0]).toEqual({
|
||||
start_time: '09:00:00',
|
||||
end_time: '12:00:00',
|
||||
@@ -162,6 +171,7 @@ describe('time pricing', () => {
|
||||
})
|
||||
expect(formTimePricingToAPI(form)).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekdays_only: true,
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface TimePricingPeriodFormEntry {
|
||||
|
||||
export interface TimePricingFormEntry {
|
||||
timezone: string
|
||||
weekdays_only: boolean
|
||||
periods: TimePricingPeriodFormEntry[]
|
||||
}
|
||||
|
||||
@@ -66,13 +67,14 @@ export const COMMON_TIMEZONES = [
|
||||
]
|
||||
|
||||
export function createDefaultTimePricingForm(): TimePricingFormEntry {
|
||||
return { timezone: DEFAULT_TIME_PRICING_TIMEZONE, periods: [] }
|
||||
return { timezone: DEFAULT_TIME_PRICING_TIMEZONE, weekdays_only: false, periods: [] }
|
||||
}
|
||||
|
||||
export function apiTimePricingToForm(value: ChannelTimePricing | null | undefined): TimePricingFormEntry {
|
||||
if (!value) return createDefaultTimePricingForm()
|
||||
return {
|
||||
timezone: value.timezone || DEFAULT_TIME_PRICING_TIMEZONE,
|
||||
weekdays_only: value.weekdays_only === true,
|
||||
periods: (value.periods || []).map(period => ({
|
||||
start_time: LEGACY_CLOCK_TIME.test(period.start_time) ? `${period.start_time}:00` : period.start_time,
|
||||
end_time: LEGACY_CLOCK_TIME.test(period.end_time) ? `${period.end_time}:00` : period.end_time,
|
||||
@@ -86,6 +88,7 @@ export function formTimePricingToAPI(value: TimePricingFormEntry | null | undefi
|
||||
const timezone = typeof value.timezone === 'string' ? value.timezone.trim() : ''
|
||||
return {
|
||||
timezone,
|
||||
weekdays_only: value.weekdays_only === true,
|
||||
periods: value.periods.map(period => ({
|
||||
start_time: period.start_time,
|
||||
end_time: period.end_time,
|
||||
|
||||
@@ -43,7 +43,16 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" class="input" />
|
||||
<input
|
||||
v-model.number="form.concurrency"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.form.concurrencyPlaceholder')"
|
||||
data-test="concurrency-input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.users.form.concurrencyHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
|
||||
@@ -132,8 +141,9 @@ const handleUpdateUser = async () => {
|
||||
appStore.showError(t('admin.users.emailRequired'))
|
||||
return
|
||||
}
|
||||
if (form.concurrency < 1) {
|
||||
appStore.showError(t('admin.users.concurrencyMin'))
|
||||
// 0 = 不限制,与网关 (AcquireUserSlot: maxConcurrency <= 0) 和批量改限额一致
|
||||
if (!Number.isInteger(form.concurrency) || form.concurrency < 0) {
|
||||
appStore.showError(t('admin.users.concurrencyNonNegative'))
|
||||
return
|
||||
}
|
||||
const userId = props.user.id
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import UserEditModal from '../UserEditModal.vue'
|
||||
|
||||
const { update, updateUserAttributeValues, showSuccess, showError } = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
updateUserAttributeValues: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
users: { update },
|
||||
userAttributes: { updateUserAttributeValues }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ showSuccess, showError })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({ copyToClipboard: vi.fn() })
|
||||
}))
|
||||
|
||||
// useStepUp pulls in the API client, which needs the real i18n instance.
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
})
|
||||
}))
|
||||
|
||||
const mountModal = (concurrency: number) => mount(UserEditModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user: { id: 7, email: 'user@example.test', username: 'user', notes: '', role: 'user', concurrency, rpm_limit: 0 } as never
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
props: ['show', 'title'],
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Select: true,
|
||||
Icon: true,
|
||||
UserAttributeForm: true,
|
||||
TotpStepUpDialog: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('UserEditModal concurrency', () => {
|
||||
beforeEach(() => {
|
||||
update.mockReset()
|
||||
updateUserAttributeValues.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showError.mockReset()
|
||||
update.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// Regression coverage for issue #5977: the gateway treats concurrency <= 0 as
|
||||
// unlimited (AcquireUserSlot) and both the batch limits endpoint and the bulk
|
||||
// edit modal accept 0, so this dialog must not be the only place that rejects
|
||||
// it — doing so blocked every other edit on such a user.
|
||||
it('saves an unlimited (0) concurrency instead of blocking the whole form', async () => {
|
||||
const wrapper = mountModal(0)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).not.toHaveBeenCalled()
|
||||
expect(update).toHaveBeenCalledWith(7, expect.objectContaining({ concurrency: 0 }))
|
||||
expect(wrapper.emitted('success')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('still rejects a negative concurrency', async () => {
|
||||
const wrapper = mountModal(3)
|
||||
|
||||
await wrapper.get('[data-test="concurrency-input"]').setValue('-1')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.users.concurrencyNonNegative')
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -193,6 +193,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||
@@ -472,6 +473,10 @@ const ServerIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const PluginIcon = {
|
||||
render: () => h(Icon, { name: 'cube' })
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -685,6 +690,7 @@ const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
||||
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
|
||||
const flagPluginManagement = makeSidebarFlag(FeatureFlags.pluginManagement)
|
||||
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
||||
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
||||
const flagBatchImageAccess = () => canUseBatchImage.value
|
||||
@@ -769,6 +775,7 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
},
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/plugins', label: t('nav.plugins'), icon: PluginIcon, featureFlag: flagPluginManagement },
|
||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||||
{{ peakNote }}
|
||||
</p>
|
||||
<p
|
||||
v-if="longContextNote"
|
||||
class="mt-1.5 flex items-center gap-1 text-xs text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
<Icon name="infoCircle" size="xs" class="h-3 w-3" />
|
||||
{{ longContextNote }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 模型价格表:整行(含 hover 底色/分区底色)顶到卡片边缘,左右留白由表格首列/末列的 padding 提供 -->
|
||||
@@ -54,6 +61,8 @@
|
||||
:user-rate-multiplier="group.user_rate_multiplier ?? null"
|
||||
:image-rate-independent="group.image_rate_independent"
|
||||
:image-rate-multiplier="group.image_rate_multiplier"
|
||||
:peak-window="peakWindow"
|
||||
:peak-rate-multiplier="group.peak_rate_multiplier"
|
||||
/>
|
||||
<p v-else class="px-5 py-4 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('modelPlaza.detail.noModels') }}
|
||||
@@ -81,15 +90,32 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const peakNote = computed(() => {
|
||||
/** 高峰窗口描述(含倍率与服务器时区标注);分组未启用高峰为空串。 */
|
||||
const peakWindow = computed(() => {
|
||||
if (!hasPeakRate(props.group)) return ''
|
||||
const window = formatPeakRateWindow(
|
||||
return formatPeakRateWindow(
|
||||
props.group,
|
||||
serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset)
|
||||
)
|
||||
})
|
||||
|
||||
const peakNote = computed(() => {
|
||||
if (!peakWindow.value) return ''
|
||||
return t('modelPlaza.detail.peakNote', {
|
||||
window,
|
||||
window: peakWindow.value,
|
||||
multiplier: props.group.peak_rate_multiplier
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 分组关闭了长上下文阶梯、但组内有模型官方带阶梯时提示:实付列只展示基础档,
|
||||
* 官方阶梯仅供参考。字段缺失(旧后端)不提示。
|
||||
*/
|
||||
const longContextNote = computed(() => {
|
||||
if (props.group.long_context_pricing_enabled !== false) return ''
|
||||
const hasOfficialLadder = props.group.models.some(
|
||||
(m) => (m.official_pricing?.intervals?.length ?? 0) > 1
|
||||
)
|
||||
return hasOfficialLadder ? t('modelPlaza.detail.longContextDisabledNote') : ''
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="plaza-pricing-table overflow-x-auto" :style="accentStyle">
|
||||
<table class="w-full min-w-[860px] table-fixed border-collapse text-sm tabular-nums">
|
||||
<table class="w-full min-w-[1000px] table-fixed border-collapse text-sm tabular-nums">
|
||||
<colgroup>
|
||||
<col class="w-[22%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[25%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[14%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[8%]" />
|
||||
<col class="w-[14%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[8%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr
|
||||
@@ -58,14 +58,25 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="m in sortedModels"
|
||||
:key="`${m.platform}:${m.name}`"
|
||||
v-for="{ model: m, period, key } in rows"
|
||||
:key="key"
|
||||
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
|
||||
>
|
||||
<!-- 模型名 + 非 token 计费模式徽章 -->
|
||||
<!-- 模型名 + 非 token 计费模式徽章;分时时段行额外标注时段 -->
|
||||
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
|
||||
<!-- 时段徽章紧跟模型名,其余徽章排在后面,空间不足时先换行的是它们 -->
|
||||
<span
|
||||
v-if="period"
|
||||
class="inline-flex items-center whitespace-nowrap rounded-md bg-gray-100 px-1 py-0.5 font-mono text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
|
||||
:title="timePricingRowHint(m)"
|
||||
>
|
||||
<span v-if="m.time_pricing?.weekdays_only" class="mr-1 font-sans">{{
|
||||
t('modelPlaza.table.timePricingWeekdays')
|
||||
}}</span>
|
||||
{{ formatTimeWindow(period) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="platform && m.platform !== platform"
|
||||
:class="[
|
||||
@@ -81,10 +92,17 @@
|
||||
>
|
||||
{{ billingModeLabel(m) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="m.long_context_basis === 'marginal'"
|
||||
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
|
||||
:title="t('modelPlaza.table.tierHintMarginal')"
|
||||
>
|
||||
{{ t('modelPlaza.table.marginalBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- token 计费:输入 / 输出(阶梯内联)/ 缓存(写/读) -->
|
||||
<!-- token 计费:输入 / 输出 / 缓存(写/读),有阶梯时每档一行;档位标签只放输入列,其余列按行对齐 -->
|
||||
<template v-if="billingMode(m) === BILLING_MODE_TOKEN">
|
||||
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
|
||||
<template v-if="tokenIntervals(m).length">
|
||||
@@ -93,11 +111,11 @@
|
||||
:key="idx"
|
||||
class="whitespace-nowrap text-xs leading-5"
|
||||
>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
|
||||
{{ paidPerMillion(iv.input_price) }}
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500" :title="tierHint(m)">{{ tierLabel(iv) }}</span>
|
||||
{{ paidPerMillion(iv.input_price, period) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.input_price) }}</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.input_price, period) }}</template>
|
||||
</td>
|
||||
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
|
||||
<template v-if="tokenIntervals(m).length">
|
||||
@@ -105,25 +123,41 @@
|
||||
v-for="(iv, idx) in tokenIntervals(m)"
|
||||
:key="idx"
|
||||
class="whitespace-nowrap text-xs leading-5"
|
||||
:title="tierHint(m)"
|
||||
>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
|
||||
{{ paidPerMillion(iv.output_price) }}
|
||||
{{ paidPerMillion(iv.output_price, period) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.output_price) }}</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.output_price, period) }}</template>
|
||||
</td>
|
||||
<td class="pz-cell px-3 py-2.5 align-middle">
|
||||
<template v-if="hasTierCachePricing(tokenIntervals(m))">
|
||||
<div
|
||||
v-for="(iv, idx) in tokenIntervals(m)"
|
||||
:key="idx"
|
||||
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200"
|
||||
:title="tierHint(m)"
|
||||
>
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
|
||||
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ paidPerMillion(iv.cache_write_price, period) }}
|
||||
<span class="ml-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
|
||||
{{ paidPerMillion(iv.cache_read_price, period) }}
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="hasCachePricing(m)"
|
||||
v-else-if="hasCachePricing(m)"
|
||||
class="space-y-0.5 font-mono text-xs text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<div>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
|
||||
{{ paidPerMillion(m.pricing?.cache_write_price) }}
|
||||
{{ paidPerMillion(m.pricing?.cache_write_price, period) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
|
||||
{{ paidPerMillion(m.pricing?.cache_read_price) }}
|
||||
{{ paidPerMillion(m.pricing?.cache_read_price, period) }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
@@ -157,18 +191,54 @@
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<!-- 官方价格(LiteLLM 参考价,不乘倍率) -->
|
||||
<!-- 官方价格(参考价,不乘倍率;官方有阶梯时每档一行) -->
|
||||
<td
|
||||
class="border-l border-gray-100 px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:border-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
{{ official(m.official_pricing?.input_price) }}
|
||||
<template v-if="officialIntervals(m).length">
|
||||
<div
|
||||
v-for="(iv, idx) in officialIntervals(m)"
|
||||
:key="idx"
|
||||
class="whitespace-nowrap leading-5"
|
||||
>
|
||||
<span class="mr-1 font-sans text-gray-400 dark:text-dark-500" :title="t('modelPlaza.table.tierHint')">{{ tierLabel(iv) }}</span>
|
||||
{{ official(iv.input_price) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ official(m.official_pricing?.input_price) }}</template>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ official(m.official_pricing?.output_price) }}
|
||||
<template v-if="officialIntervals(m).length">
|
||||
<div
|
||||
v-for="(iv, idx) in officialIntervals(m)"
|
||||
:key="idx"
|
||||
class="whitespace-nowrap leading-5"
|
||||
:title="t('modelPlaza.table.tierHint')"
|
||||
>
|
||||
{{ official(iv.output_price) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ official(m.official_pricing?.output_price) }}</template>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 align-middle">
|
||||
<template v-if="hasTierCachePricing(officialIntervals(m))">
|
||||
<div
|
||||
v-for="(iv, idx) in officialIntervals(m)"
|
||||
:key="idx"
|
||||
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400"
|
||||
:title="t('modelPlaza.table.tierHint')"
|
||||
>
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
|
||||
<span class="font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ official(iv.cache_write_price) }}
|
||||
<span class="ml-1 font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
|
||||
{{ official(iv.cache_read_price) }}
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="m.official_pricing && hasOfficialCache(m.official_pricing)"
|
||||
v-else-if="m.official_pricing && hasOfficialCache(m.official_pricing)"
|
||||
class="space-y-0.5 font-mono text-xs text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
<div>
|
||||
@@ -187,12 +257,18 @@
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</td>
|
||||
|
||||
<!-- 折扣倍率(生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
|
||||
<!-- 折扣倍率(分时时段行展示 生效倍率×时段倍率;生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
|
||||
<td
|
||||
class="border-l border-gray-100 py-2.5 pl-3 pr-5 text-right align-middle font-mono text-xs dark:border-dark-700/60"
|
||||
>
|
||||
<span
|
||||
v-if="usesIndependentImageRate(m)"
|
||||
v-if="period"
|
||||
class="font-bold text-primary-600 dark:text-primary-400"
|
||||
:title="t('modelPlaza.table.timePricingRateHint', { rate: effectiveRate, multiplier: period.multiplier })"
|
||||
>{{ periodRate(period) }}x</span
|
||||
>
|
||||
<span
|
||||
v-else-if="usesIndependentImageRate(m)"
|
||||
class="font-bold text-gray-700 dark:text-gray-300"
|
||||
>{{ requestRate(m) }}x</span
|
||||
>
|
||||
@@ -218,7 +294,7 @@ import {
|
||||
BILLING_MODE_IMAGE,
|
||||
type BillingMode
|
||||
} from '@/constants/channel'
|
||||
import type { PlazaModel } from '@/api/modelPlaza'
|
||||
import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza'
|
||||
import type { UserPricingInterval } from '@/api/channels'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -232,6 +308,13 @@ const props = defineProps<{
|
||||
/** 生图独立倍率:true 时图片计费模型的实付倍率取 imageRateMultiplier,不取分组/专属倍率。 */
|
||||
imageRateIndependent?: boolean
|
||||
imageRateMultiplier?: number | null
|
||||
/**
|
||||
* 高峰窗口描述(含倍率与服务器时区标注),空串/缺省 = 分组未启用高峰。
|
||||
* 表格所有价格均为不含高峰因子的口径,该窗口仅用于分时时段行的 tooltip 披露:
|
||||
* 与高峰重叠的部分实付还会再乘高峰倍率。
|
||||
*/
|
||||
peakWindow?: string
|
||||
peakRateMultiplier?: number | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -279,10 +362,35 @@ function billingModeLabel(m: PlazaModel): string {
|
||||
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
|
||||
const MIN_DECIMALS = 2
|
||||
|
||||
/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
|
||||
function paidPerMillion(value: number | null | undefined): string {
|
||||
/** 表格行:每个模型一行标准价;配置了分时倍率的模型再按时段各加一行。 */
|
||||
interface PlazaRow {
|
||||
model: PlazaModel
|
||||
period: PlazaTimePricingPeriod | null
|
||||
key: string
|
||||
}
|
||||
|
||||
const rows = computed<PlazaRow[]>(() =>
|
||||
sortedModels.value.flatMap((m) => {
|
||||
const base: PlazaRow = { model: m, period: null, key: `${m.platform}:${m.name}` }
|
||||
const periodRows = timePeriods(m).map<PlazaRow>((p, idx) => ({
|
||||
model: m,
|
||||
period: p,
|
||||
key: `${m.platform}:${m.name}:${idx}`
|
||||
}))
|
||||
return [base, ...periodRows]
|
||||
})
|
||||
)
|
||||
|
||||
/** 时段行的生效倍率 = 生效倍率 × 时段倍率(去掉浮点噪声)。 */
|
||||
function periodRate(period: PlazaTimePricingPeriod): number {
|
||||
return Math.round(effectiveRate.value * period.multiplier * 1000) / 1000
|
||||
}
|
||||
|
||||
/** 实付价 = 渠道单价 × 生效倍率(时段行再乘时段倍率),按 $/1M token 展示。 */
|
||||
function paidPerMillion(value: number | null | undefined, period: PlazaTimePricingPeriod | null = null): string {
|
||||
if (value == null) return '-'
|
||||
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
|
||||
const rate = period ? periodRate(period) : effectiveRate.value
|
||||
return formatScaled(value * rate, PER_MILLION, MIN_DECIMALS)
|
||||
}
|
||||
|
||||
/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */
|
||||
@@ -322,23 +430,76 @@ function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boole
|
||||
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
|
||||
}
|
||||
|
||||
/** token 模式的阶梯定价(内联进输入/输出列)。 */
|
||||
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
|
||||
return m.pricing?.intervals ?? []
|
||||
/** 分时倍率时段(后端只给出倍率 ≠ 1 的时段,已升序)。 */
|
||||
function timePeriods(m: PlazaModel): PlazaTimePricingPeriod[] {
|
||||
return m.time_pricing?.periods ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 时段行 tooltip:仅工作日生效的配置换用带周末回落说明的文案;
|
||||
* 分组启用高峰倍率时追加披露——本行价格不含高峰因子,与高峰窗口重叠的部分实付再乘高峰倍率。
|
||||
*/
|
||||
function timePricingRowHint(m: PlazaModel): string {
|
||||
const key = m.time_pricing?.weekdays_only
|
||||
? 'modelPlaza.table.timePricingRowHintWeekdays'
|
||||
: 'modelPlaza.table.timePricingRowHint'
|
||||
let hint = t(key, { timezone: m.time_pricing?.timezone })
|
||||
if (props.peakWindow) {
|
||||
hint += t('modelPlaza.table.timePricingRowHintPeak', {
|
||||
window: props.peakWindow,
|
||||
multiplier: props.peakRateMultiplier ?? 1
|
||||
})
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
/** “00:30–08:30”;整分钟的 HH:mm:ss 省略秒。 */
|
||||
function formatTimeWindow(p: PlazaTimePricingPeriod): string {
|
||||
const clock = (v: string) => v.replace(/^(\d{2}:\d{2}):00$/, '$1')
|
||||
return `${clock(p.start_time)}–${clock(p.end_time)}`
|
||||
}
|
||||
|
||||
/** 上下文档位按下限升序展示(后端已升序,此处兜底)。 */
|
||||
function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[] {
|
||||
return [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
|
||||
}
|
||||
|
||||
/** token 模式的阶梯定价(内联进输入/输出/缓存列)。 */
|
||||
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
|
||||
return sortByContext(m.pricing?.intervals ?? [])
|
||||
}
|
||||
|
||||
/** 官方阶梯(后端按目录规则合成,不受分组开关影响)。 */
|
||||
function officialIntervals(m: PlazaModel): UserPricingInterval[] {
|
||||
return sortByContext(m.official_pricing?.intervals ?? [])
|
||||
}
|
||||
|
||||
/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
|
||||
function hasTierCachePricing(intervals: UserPricingInterval[]): boolean {
|
||||
return intervals.some((iv) => iv.cache_write_price != null || iv.cache_read_price != null)
|
||||
}
|
||||
|
||||
/** 档位说明:整单按档计价,或(平台旧规则)仅超出部分按档计价。 */
|
||||
function tierHint(m: PlazaModel): string {
|
||||
return m.long_context_basis === 'marginal'
|
||||
? t('modelPlaza.table.tierHintMarginal')
|
||||
: t('modelPlaza.table.tierHint')
|
||||
}
|
||||
|
||||
|
||||
/** 按次/按图模式的阶梯定价(仅保留配了按次价的档位)。 */
|
||||
function requestIntervals(m: PlazaModel): UserPricingInterval[] {
|
||||
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
|
||||
}
|
||||
|
||||
/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 200K–1M)。 */
|
||||
/**
|
||||
* 档位标签:优先后端/管理员给出的 tier_label,否则按区间生成统一形态——
|
||||
* 有上限为「≤上限」,末档为「>下限」;档位升序排列,相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
|
||||
*/
|
||||
function tierLabel(iv: UserPricingInterval): string {
|
||||
if (iv.tier_label) return iv.tier_label
|
||||
const { min_tokens: min, max_tokens: max } = iv
|
||||
if (max == null) return `>${formatTokenCount(min)}`
|
||||
if (min === 0) return `≤${formatTokenCount(max)}`
|
||||
return `${formatTokenCount(min)}–${formatTokenCount(max)}`
|
||||
return max == null ? `>${formatTokenCount(min)}` : `≤${formatTokenCount(max)}`
|
||||
}
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PlazaGroupSection from '../PlazaGroupSection.vue'
|
||||
import PlazaModelPricingTable from '../PlazaModelPricingTable.vue'
|
||||
import type { ModelPlazaGroup, PlazaModel } from '@/api/modelPlaza'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ cachedPublicSettings: null })
|
||||
}))
|
||||
|
||||
function ladderModel(tiers: number): PlazaModel {
|
||||
const intervals = Array.from({ length: tiers }, (_, i) => ({
|
||||
min_tokens: i * 272000,
|
||||
max_tokens: i === tiers - 1 ? null : (i + 1) * 272000,
|
||||
tier_label: '',
|
||||
input_price: 5e-6,
|
||||
output_price: 3e-5,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
per_request_price: null
|
||||
}))
|
||||
return {
|
||||
name: 'gpt-5.6-sol',
|
||||
platform: 'openai',
|
||||
pricing: {
|
||||
billing_mode: 'token',
|
||||
input_price: 5e-6,
|
||||
output_price: 3e-5,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
},
|
||||
official_pricing: {
|
||||
input_price: 5e-6,
|
||||
output_price: 3e-5,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
intervals
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function group(overrides: Partial<ModelPlazaGroup> = {}): ModelPlazaGroup {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'g',
|
||||
description: '',
|
||||
platform: 'openai',
|
||||
subscription_type: 'standard',
|
||||
rate_multiplier: 1,
|
||||
peak_rate_enabled: false,
|
||||
peak_start: '',
|
||||
peak_end: '',
|
||||
peak_rate_multiplier: 1,
|
||||
is_exclusive: false,
|
||||
image_rate_independent: false,
|
||||
image_rate_multiplier: 1,
|
||||
long_context_pricing_enabled: true,
|
||||
models: [ladderModel(2)],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountSection(g: ModelPlazaGroup) {
|
||||
return mount(PlazaGroupSection, {
|
||||
props: { group: g },
|
||||
global: {
|
||||
stubs: {
|
||||
GroupBadge: true,
|
||||
Icon: true,
|
||||
PlazaModelPricingTable: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const NOTE = 'modelPlaza.detail.longContextDisabledNote'
|
||||
|
||||
describe('PlazaGroupSection 长上下文说明', () => {
|
||||
it('分组关闭阶梯且组内有官方阶梯模型时显示说明', () => {
|
||||
const wrapper = mountSection(group({ long_context_pricing_enabled: false }))
|
||||
expect(wrapper.text()).toContain(NOTE)
|
||||
})
|
||||
|
||||
it('分组开启阶梯时不显示', () => {
|
||||
const wrapper = mountSection(group({ long_context_pricing_enabled: true }))
|
||||
expect(wrapper.text()).not.toContain(NOTE)
|
||||
})
|
||||
|
||||
it('分组关闭但没有官方阶梯模型时不显示', () => {
|
||||
const wrapper = mountSection(
|
||||
group({ long_context_pricing_enabled: false, models: [ladderModel(1)] })
|
||||
)
|
||||
expect(wrapper.text()).not.toContain(NOTE)
|
||||
})
|
||||
|
||||
it('旧后端缺少开关字段时不显示', () => {
|
||||
const g = group()
|
||||
delete (g as Partial<ModelPlazaGroup>).long_context_pricing_enabled
|
||||
const wrapper = mountSection(g)
|
||||
expect(wrapper.text()).not.toContain(NOTE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlazaGroupSection 高峰配置传递', () => {
|
||||
it('分组启用高峰时把窗口描述与倍率传给价格表', () => {
|
||||
const wrapper = mountSection(
|
||||
group({
|
||||
subscription_type: 'subscription',
|
||||
peak_rate_enabled: true,
|
||||
peak_start: '14:00',
|
||||
peak_end: '18:00',
|
||||
peak_rate_multiplier: 1.5
|
||||
})
|
||||
)
|
||||
const table = wrapper.findComponent(PlazaModelPricingTable)
|
||||
// appStore mock 无 server_utc_offset,窗口描述不带时区标注
|
||||
expect(table.props('peakWindow')).toBe('14:00-18:00 ×1.5')
|
||||
expect(table.props('peakRateMultiplier')).toBe(1.5)
|
||||
})
|
||||
|
||||
it('分组未启用高峰时窗口描述为空串', () => {
|
||||
const wrapper = mountSection(group())
|
||||
expect(wrapper.findComponent(PlazaModelPricingTable).props('peakWindow')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,12 @@ function mountTable(
|
||||
models: PlazaModel[],
|
||||
rateMultiplier: number,
|
||||
userRateMultiplier?: number | null,
|
||||
extraProps?: { imageRateIndependent?: boolean; imageRateMultiplier?: number | null }
|
||||
extraProps?: {
|
||||
imageRateIndependent?: boolean
|
||||
imageRateMultiplier?: number | null
|
||||
peakWindow?: string
|
||||
peakRateMultiplier?: number | null
|
||||
}
|
||||
) {
|
||||
return mount(PlazaModelPricingTable, {
|
||||
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null, ...extraProps }
|
||||
@@ -405,3 +410,225 @@ describe('PlazaModelPricingTable', () => {
|
||||
expect(wrapper.text()).toContain('OpenAI')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlazaModelPricingTable 长上下文阶梯', () => {
|
||||
function ladderIntervals() {
|
||||
return [
|
||||
{
|
||||
min_tokens: 0,
|
||||
max_tokens: 272000,
|
||||
tier_label: '≤272K',
|
||||
input_price: 5e-6,
|
||||
output_price: 3e-5,
|
||||
cache_write_price: 6.25e-6,
|
||||
cache_read_price: 5e-7,
|
||||
per_request_price: null
|
||||
},
|
||||
{
|
||||
min_tokens: 272000,
|
||||
max_tokens: null,
|
||||
tier_label: '>272K',
|
||||
input_price: 1e-5,
|
||||
output_price: 4.5e-5,
|
||||
cache_write_price: 1.25e-5,
|
||||
cache_read_price: 1e-6,
|
||||
per_request_price: null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function ladderModel(overrides: Partial<PlazaModel> = {}): PlazaModel {
|
||||
return tokenModel({
|
||||
name: 'gpt-5.6-sol',
|
||||
platform: 'openai',
|
||||
pricing: {
|
||||
billing_mode: 'token',
|
||||
input_price: 5e-6,
|
||||
output_price: 3e-5,
|
||||
cache_write_price: 6.25e-6,
|
||||
cache_read_price: 5e-7,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: ladderIntervals()
|
||||
},
|
||||
official_pricing: {
|
||||
input_price: 5e-6,
|
||||
output_price: 3e-5,
|
||||
cache_write_price: 6.25e-6,
|
||||
cache_read_price: 5e-7,
|
||||
intervals: ladderIntervals()
|
||||
},
|
||||
long_context_basis: 'whole_request',
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
it('实付缓存列按档分行并乘倍率,每档一行与输入/输出列对齐;档位标签只在输入列', () => {
|
||||
const wrapper = mountTable([ladderModel()], 0.5)
|
||||
const cells = wrapper.findAll('tbody td')
|
||||
const cacheCell = cells[3]
|
||||
const rows = cacheCell.findAll('.leading-5')
|
||||
expect(rows).toHaveLength(2)
|
||||
// 写 6.25 × 0.5 / 读 0.5 × 0.5;高档 12.5 × 0.5 / 1 × 0.5
|
||||
expect(rows[0].text()).toContain('modelPlaza.table.cacheWriteShort')
|
||||
expect(rows[0].text()).toContain('$3.125')
|
||||
expect(rows[0].text()).toContain('$0.25')
|
||||
expect(rows[1].text()).toContain('$6.25')
|
||||
expect(rows[1].text()).toContain('$0.50')
|
||||
// 输入列带标签,输出/缓存列只按行对齐不重复标签
|
||||
expect(cells[1].text()).toContain('≤272K')
|
||||
expect(cells[1].text()).toContain('>272K')
|
||||
expect(cells[2].text()).not.toContain('272K')
|
||||
expect(cacheCell.text()).not.toContain('272K')
|
||||
expect(cells[1].findAll('.leading-5')).toHaveLength(2)
|
||||
expect(cells[2].findAll('.leading-5')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('官方三列按 official_pricing.intervals 分档且不乘倍率,不内联 1h', () => {
|
||||
const wrapper = mountTable([ladderModel()], 0.5)
|
||||
const cells = wrapper.findAll('tbody td')
|
||||
expect(cells[4].text()).toContain('≤272K')
|
||||
expect(cells[4].text()).toContain('$5.00')
|
||||
expect(cells[4].text()).toContain('>272K')
|
||||
expect(cells[4].text()).toContain('$10.00')
|
||||
expect(cells[5].text()).toContain('$30.00')
|
||||
expect(cells[5].text()).toContain('$45.00')
|
||||
expect(cells[6].text()).toContain('$6.25')
|
||||
expect(cells[6].text()).toContain('$12.50')
|
||||
expect(cells[6].text()).toContain('$1.00')
|
||||
expect(cells[6].text()).not.toContain('(1h')
|
||||
})
|
||||
|
||||
it('整单计价的档位标签带 tooltip;边际计价在模型名旁加徽章并换用边际说明', () => {
|
||||
const whole = mountTable([ladderModel()], 1)
|
||||
const wholeLabels = whole.findAll('tbody td span[title="modelPlaza.table.tierHint"]')
|
||||
expect(wholeLabels.length).toBeGreaterThan(0)
|
||||
expect(whole.text()).not.toContain('modelPlaza.table.marginalBadge')
|
||||
|
||||
const marginal = mountTable([ladderModel({ long_context_basis: 'marginal' })], 1)
|
||||
const marginalLabels = marginal.findAll('tbody td span[title="modelPlaza.table.tierHintMarginal"]')
|
||||
expect(marginalLabels.length).toBeGreaterThan(0)
|
||||
expect(marginal.findAll('tbody td')[0].text()).toContain('modelPlaza.table.marginalBadge')
|
||||
})
|
||||
|
||||
it('无标签的多档按区间生成统一形态(≤上限 / >下限),并按下限升序展示', () => {
|
||||
const model = ladderModel({
|
||||
pricing: {
|
||||
...ladderModel().pricing!,
|
||||
// 故意乱序:展示必须按上下文从低到高
|
||||
intervals: [
|
||||
{ ...ladderIntervals()[1], min_tokens: 1000000, tier_label: '' },
|
||||
{ ...ladderIntervals()[0], min_tokens: 100000, max_tokens: 200000, tier_label: '' },
|
||||
{ ...ladderIntervals()[0], max_tokens: 100000, tier_label: '' },
|
||||
{ ...ladderIntervals()[1], min_tokens: 200000, max_tokens: 1000000, tier_label: '' }
|
||||
]
|
||||
}
|
||||
})
|
||||
const rows = mountTable([model], 1).findAll('tbody td')[1].findAll('.leading-5')
|
||||
expect(rows.map((r) => r.text().split(/\s+/)[0])).toEqual(['≤100K', '≤200K', '≤1M', '>1M'])
|
||||
})
|
||||
|
||||
it('官方无 intervals 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {
|
||||
const wrapper = mountTable([tokenModel()], 1)
|
||||
const cells = wrapper.findAll('tbody td')
|
||||
expect(cells[3].text()).toContain('modelPlaza.table.cacheWrite')
|
||||
expect(cells[3].text()).toContain('modelPlaza.table.cacheRead')
|
||||
expect(cells[3].findAll('.leading-5')).toHaveLength(0)
|
||||
expect(cells[6].text()).toContain('(1h')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlazaModelPricingTable 分时计价', () => {
|
||||
function timePricedModel() {
|
||||
return tokenModel({
|
||||
name: 'deepseek-chat',
|
||||
platform: 'deepseek',
|
||||
time_pricing: {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [
|
||||
{ start_time: '00:30', end_time: '08:30:00', multiplier: 0.5 },
|
||||
{ start_time: '18:00', end_time: '22:00', multiplier: 1.2 }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('有分时倍率的模型展开为标准行 + 每时段一行,时段行价格按倍率折算且倍率列显示生效倍率', () => {
|
||||
const wrapper = mountTable([timePricedModel()], 0.8)
|
||||
const trs = wrapper.findAll('tbody tr')
|
||||
expect(trs).toHaveLength(3)
|
||||
|
||||
// 标准行:输入 3 × 0.8
|
||||
const baseCells = trs[0].findAll('td')
|
||||
expect(baseCells[0].text()).toBe('deepseek-chat')
|
||||
expect(baseCells[1].text()).toContain('$2.40')
|
||||
expect(baseCells[7].text()).toContain('0.8x')
|
||||
|
||||
// 夜间时段行:输入 3 × 0.8 × 0.5,倍率 0.4x,标注时段不含时区
|
||||
const nightCells = trs[1].findAll('td')
|
||||
expect(nightCells[0].text()).toContain('deepseek-chat')
|
||||
expect(nightCells[0].text()).toContain('00:30–08:30')
|
||||
expect(nightCells[0].text()).not.toContain('Asia/Shanghai')
|
||||
// 时区只放在 tooltip 里(i18n mock 不做插值,这里只断言挂了说明)
|
||||
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
|
||||
expect(nightCells[1].text()).toContain('$1.20')
|
||||
expect(nightCells[2].text()).toContain('$6.00')
|
||||
expect(nightCells[3].text()).toContain('$1.50')
|
||||
expect(nightCells[7].text()).toContain('0.4x')
|
||||
|
||||
// 晚高峰行:3 × 0.8 × 1.2 = 2.88,倍率 0.96x
|
||||
const peakCells = trs[2].findAll('td')
|
||||
expect(peakCells[0].text()).toContain('18:00–22:00')
|
||||
expect(peakCells[1].text()).toContain('$2.88')
|
||||
expect(peakCells[7].text()).toContain('0.96x')
|
||||
|
||||
// 官方列不受时段影响
|
||||
expect(nightCells[4].text()).toContain('$3.00')
|
||||
})
|
||||
|
||||
it('仅工作日生效时时段行带工作日前缀,tooltip 换用周末回落文案', () => {
|
||||
const model = timePricedModel()
|
||||
model.time_pricing!.weekdays_only = true
|
||||
const wrapper = mountTable([model], 1)
|
||||
const trs = wrapper.findAll('tbody tr')
|
||||
expect(trs).toHaveLength(3)
|
||||
|
||||
const nightCells = trs[1].findAll('td')
|
||||
expect(nightCells[0].text()).toContain('modelPlaza.table.timePricingWeekdays')
|
||||
expect(nightCells[0].text()).toContain('00:30–08:30')
|
||||
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHintWeekdays"]').exists()).toBe(true)
|
||||
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('每日生效(无 weekdays_only)不渲染工作日前缀', () => {
|
||||
const wrapper = mountTable([timePricedModel()], 1)
|
||||
expect(wrapper.find('tbody').text()).not.toContain('modelPlaza.table.timePricingWeekdays')
|
||||
expect(wrapper.find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('分组启用高峰倍率时时段行 tooltip 追加高峰披露,价格与倍率列保持不含高峰的口径', () => {
|
||||
const wrapper = mountTable([timePricedModel()], 0.8, null, {
|
||||
peakWindow: '14:00-18:00 ×1.5 (UTC+08:00)',
|
||||
peakRateMultiplier: 1.5
|
||||
})
|
||||
const nightCells = wrapper.findAll('tbody tr')[1].findAll('td')
|
||||
const title = nightCells[0].find('[title*="modelPlaza.table.timePricingRowHint"]').attributes('title')
|
||||
expect(title).toContain('modelPlaza.table.timePricingRowHintPeak')
|
||||
// 行内数字仍是 基础倍率 × 时段倍率(0.8 × 0.5),高峰只进披露不进价格
|
||||
expect(nightCells[1].text()).toContain('$1.20')
|
||||
expect(nightCells[7].text()).toContain('0.4x')
|
||||
})
|
||||
|
||||
it('分组未启用高峰(peakWindow 缺省)时 tooltip 不含高峰披露', () => {
|
||||
const wrapper = mountTable([timePricedModel()], 1)
|
||||
const badge = wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]')
|
||||
expect(badge.attributes('title')).not.toContain('modelPlaza.table.timePricingRowHintPeak')
|
||||
})
|
||||
|
||||
it('无分时倍率时只有一行,不渲染时段标注', () => {
|
||||
const wrapper = mountTable([tokenModel()], 1)
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
||||
expect(wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user