mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
功能:支持渠道模型分时倍率定价
This commit is contained in:
@@ -87,7 +87,12 @@
|
||||
</label>
|
||||
<Select
|
||||
:modelValue="entry.billing_mode"
|
||||
@update:modelValue="emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
|
||||
@update:modelValue="emit('update', {
|
||||
...entry,
|
||||
billing_mode: $event as BillingMode,
|
||||
intervals: [],
|
||||
time_pricing: { ...entry.time_pricing, periods: [] },
|
||||
})"
|
||||
:options="billingModeOptions"
|
||||
class="mt-1"
|
||||
/>
|
||||
@@ -156,6 +161,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimePricingSection
|
||||
v-if="enableTimePricing"
|
||||
:model-value="entry.time_pricing"
|
||||
@update:model-value="emit('update', { ...entry, time_pricing: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Per-request mode -->
|
||||
@@ -238,6 +249,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import IntervalRow from './IntervalRow.vue'
|
||||
import ModelTagInput from './ModelTagInput.vue'
|
||||
import TimePricingSection from './TimePricingSection.vue'
|
||||
import type { PricingFormEntry, IntervalFormEntry } from './types'
|
||||
import { perTokenToMTok, getPlatformTagClass } from './types'
|
||||
import type { BillingMode } from '@/api/admin/channels'
|
||||
@@ -249,8 +261,10 @@ const props = withDefaults(defineProps<{
|
||||
entry: PricingFormEntry
|
||||
platform?: string
|
||||
hideTokenIntervals?: boolean
|
||||
enableTimePricing?: boolean
|
||||
}>(), {
|
||||
hideTokenIntervals: false,
|
||||
enableTimePricing: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<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">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="self-start text-xs text-primary-600 hover:text-primary-700 sm:self-end sm:pb-2"
|
||||
data-testid="add-time-period"
|
||||
@click="addPeriod"
|
||||
>
|
||||
+ {{ t('admin.channels.form.addTimePeriod') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue.periods.length > 0" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="(period, index) in modelValue.periods"
|
||||
:key="index"
|
||||
class="grid grid-cols-1 gap-2 border-t border-gray-200 pt-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_2rem] sm:items-end dark:border-dark-600"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<label :for="`${inputIdPrefix}-start-${index}`" class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.startTime') }}
|
||||
</label>
|
||||
<input
|
||||
:id="`${inputIdPrefix}-start-${index}`"
|
||||
:value="period.start_time"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="8"
|
||||
placeholder="HH:mm:ss"
|
||||
pattern="[0-9]{2}:[0-9]{2}:[0-9]{2}"
|
||||
autocomplete="off"
|
||||
class="input mt-1 w-full text-sm"
|
||||
@input="updatePeriod(index, 'start_time', normalizeClockTime(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<label :for="`${inputIdPrefix}-end-${index}`" class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.endTime') }}
|
||||
</label>
|
||||
<input
|
||||
:id="`${inputIdPrefix}-end-${index}`"
|
||||
:value="period.end_time"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="8"
|
||||
placeholder="HH:mm:ss"
|
||||
pattern="[0-9]{2}:[0-9]{2}:[0-9]{2}"
|
||||
autocomplete="off"
|
||||
class="input mt-1 w-full text-sm"
|
||||
@input="updatePeriod(index, 'end_time', normalizeClockTime(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<label :for="`${inputIdPrefix}-multiplier-${index}`" class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.multiplier') }}
|
||||
</label>
|
||||
<input
|
||||
:id="`${inputIdPrefix}-multiplier-${index}`"
|
||||
:value="period.multiplier"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
class="input mt-1 w-full text-sm"
|
||||
@input="updatePeriod(index, 'multiplier', ($event.target as HTMLInputElement).value)"
|
||||
@blur="formatMultiplier(index, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:text-red-500"
|
||||
:title="t('admin.channels.form.removeTimePeriod')"
|
||||
:aria-label="t('admin.channels.form.removeTimePeriod')"
|
||||
:data-testid="`remove-time-period-${index}`"
|
||||
@click="removePeriod(index)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import {
|
||||
COMMON_TIMEZONES,
|
||||
formatTimezoneOffset,
|
||||
isValidTimePricingMultiplier,
|
||||
type TimePricingFormEntry,
|
||||
type TimePricingPeriodFormEntry,
|
||||
} from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ modelValue: TimePricingFormEntry }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: TimePricingFormEntry] }>()
|
||||
const inputIdPrefix = `time-pricing-${getCurrentInstance()?.uid}`
|
||||
|
||||
const timezoneOptions = COMMON_TIMEZONES.map(value => {
|
||||
const offset = formatTimezoneOffset(value)
|
||||
return { value, label: offset ? `${value} (${offset})` : value }
|
||||
})
|
||||
|
||||
function updateTimezone(value: string | number | boolean | null) {
|
||||
emit('update:modelValue', { ...props.modelValue, timezone: String(value ?? '') })
|
||||
}
|
||||
|
||||
function normalizeClockTime(value: string): string {
|
||||
const normalized = value.replace(/:/g, ':')
|
||||
return normalized === '24:00:00' ? '00:00:00' : normalized
|
||||
}
|
||||
|
||||
function addPeriod() {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
periods: [
|
||||
...props.modelValue.periods,
|
||||
{ start_time: '', end_time: '', multiplier: '1.00' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function updatePeriod(index: number, field: keyof TimePricingPeriodFormEntry, value: string) {
|
||||
const periods = props.modelValue.periods.map((period, current) =>
|
||||
current === index ? { ...period, [field]: value } : period)
|
||||
emit('update:modelValue', { ...props.modelValue, periods })
|
||||
}
|
||||
|
||||
function formatMultiplier(index: number, value: string) {
|
||||
if (!isValidTimePricingMultiplier(value)) return
|
||||
updatePeriod(index, 'multiplier', Number(value).toFixed(2))
|
||||
}
|
||||
|
||||
function removePeriod(index: number) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
periods: props.modelValue.periods.filter((_period, current) => current !== index),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import PricingEntryCard from '../PricingEntryCard.vue'
|
||||
import type { PricingFormEntry } from '../types'
|
||||
|
||||
vi.mock('vue-i18n', async importOriginal => ({
|
||||
...await importOriginal<typeof import('vue-i18n')>(),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): PricingFormEntry {
|
||||
return {
|
||||
models: [],
|
||||
billing_mode: billingMode,
|
||||
input_price: null,
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: [],
|
||||
time_pricing: {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00', end_time: '12:00', multiplier: '2.00' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PricingEntryCard time pricing visibility', () => {
|
||||
it('is hidden by default', () => {
|
||||
const wrapper = shallowMount(PricingEntryCard, {
|
||||
props: { entry: createEntry() },
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent({ name: 'TimePricingSection' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('is shown for token pricing when explicitly enabled', () => {
|
||||
const wrapper = shallowMount(PricingEntryCard, {
|
||||
props: { entry: createEntry(), enableTimePricing: true },
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent({ name: 'TimePricingSection' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('is hidden for non-token pricing even when explicitly enabled', () => {
|
||||
const wrapper = shallowMount(PricingEntryCard, {
|
||||
props: { entry: createEntry('per_request'), enableTimePricing: true },
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent({ name: 'TimePricingSection' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears time periods when changing billing mode', () => {
|
||||
const entry = createEntry()
|
||||
const wrapper = shallowMount(PricingEntryCard, {
|
||||
props: { entry, enableTimePricing: true },
|
||||
})
|
||||
|
||||
wrapper.findComponent({ name: 'Select' }).vm.$emit('update:modelValue', 'image')
|
||||
|
||||
expect(wrapper.emitted('update')?.[0]?.[0]).toEqual({
|
||||
...entry,
|
||||
billing_mode: 'image',
|
||||
intervals: [],
|
||||
time_pricing: { timezone: 'Asia/Shanghai', periods: [] },
|
||||
})
|
||||
expect(entry.time_pricing.periods).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TimePricingSection from '../TimePricingSection.vue'
|
||||
import { createDefaultTimePricingForm } from '../types'
|
||||
|
||||
vi.mock('vue-i18n', async importOriginal => ({
|
||||
...await importOriginal<typeof import('vue-i18n')>(),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const SelectStub = {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<button type="button" data-testid="timezone-select" />',
|
||||
}
|
||||
|
||||
describe('TimePricingSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('adds a neutral period immutably', async () => {
|
||||
const value = createDefaultTimePricingForm()
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
await wrapper.get('[data-testid="add-time-period"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '', end_time: '', multiplier: '1.00' }],
|
||||
})
|
||||
expect(value.periods).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes without mutating props', async () => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
}
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
const removeButton = wrapper.get('[data-testid="remove-time-period-0"]')
|
||||
expect(removeButton.attributes('title')).toBe('admin.channels.form.removeTimePeriod')
|
||||
expect(removeButton.attributes('aria-label')).toBe('admin.channels.form.removeTimePeriod')
|
||||
await removeButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [],
|
||||
})
|
||||
expect(value.periods).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses locale-independent 24-hour second inputs', () => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
}
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
const timeInputs = wrapper.findAll('input[inputmode="numeric"]')
|
||||
expect(timeInputs).toHaveLength(2)
|
||||
expect(timeInputs.every(input => input.attributes('type') === 'text')).toBe(true)
|
||||
expect(timeInputs.every(input => input.attributes('maxlength') === '8')).toBe(true)
|
||||
expect(timeInputs.every(input => input.attributes('placeholder') === 'HH:mm:ss')).toBe(true)
|
||||
})
|
||||
|
||||
it('updates the timezone and normalizes full-width colons immutably', async () => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
}
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
wrapper.getComponent(SelectStub).vm.$emit('update:modelValue', 'Europe/London')
|
||||
await wrapper.get('input').setValue('10:30:15')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
...value,
|
||||
timezone: 'Europe/London',
|
||||
})
|
||||
expect(wrapper.emitted('update:modelValue')?.[1]?.[0]).toEqual({
|
||||
...value,
|
||||
periods: [{ ...value.periods[0], start_time: '10:30:15' }],
|
||||
})
|
||||
expect(value).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes 24:00:00 to 00:00:00', async () => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
}
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
await wrapper.get('input').setValue('24:00:00')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
...value,
|
||||
periods: [{ ...value.periods[0], start_time: '00:00:00' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('formats only a valid positive multiplier on blur', async () => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2' }],
|
||||
}
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
const multiplier = wrapper.get('input[type="number"]')
|
||||
expect(multiplier.attributes('min')).toBe('0.01')
|
||||
expect(multiplier.attributes('step')).toBe('0.01')
|
||||
await multiplier.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
...value,
|
||||
periods: [{ ...value.periods[0], multiplier: '2.00' }],
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
it.each(['1.234', '1e2', '.5'])('keeps invalid multiplier %s unchanged on blur', async invalid => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: invalid }],
|
||||
}
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
await wrapper.get('input[type="number"]').trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(value.periods[0].multiplier).toBe(invalid)
|
||||
})
|
||||
|
||||
it('uses unique input ids across component instances', () => {
|
||||
const value = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
}
|
||||
const wrapper = mount({
|
||||
components: { TimePricingSection },
|
||||
data: () => ({ first: value, second: value }),
|
||||
template: `
|
||||
<div>
|
||||
<TimePricingSection v-model="first" />
|
||||
<TimePricingSection v-model="second" />
|
||||
</div>
|
||||
`,
|
||||
}, {
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
const ids = wrapper.findAll('input').map(input => input.attributes('id'))
|
||||
expect(ids).toHaveLength(6)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateIntervals, type IntervalFormEntry } from '../types'
|
||||
import {
|
||||
apiTimePricingToForm,
|
||||
createDefaultTimePricingForm,
|
||||
formTimePricingToAPI,
|
||||
validateIntervals,
|
||||
validateTimePricing,
|
||||
type IntervalFormEntry,
|
||||
type TimePricingFormEntry,
|
||||
type TimePricingPeriodFormEntry,
|
||||
} from '../types'
|
||||
|
||||
function makeInterval(over: Partial<IntervalFormEntry>): IntervalFormEntry {
|
||||
return {
|
||||
@@ -81,3 +90,67 @@ describe('validateIntervals', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('time pricing', () => {
|
||||
it('uses a disabled Shanghai default', () => {
|
||||
const form = createDefaultTimePricingForm()
|
||||
expect(form).toEqual({ timezone: 'Asia/Shanghai', periods: [] })
|
||||
expect(formTimePricingToAPI(form)).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips and formats multiplier', () => {
|
||||
const form = apiTimePricingToForm({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00', end_time: '12:00', multiplier: 2 }],
|
||||
})
|
||||
expect(form.periods[0]).toEqual({
|
||||
start_time: '09:00:00',
|
||||
end_time: '12:00:00',
|
||||
multiplier: '2.00',
|
||||
})
|
||||
expect(formTimePricingToAPI(form)).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['separated', [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }, { start_time: '14:00:00', end_time: '18:00:00', multiplier: '2.00' }], null],
|
||||
['adjacent', [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }, { start_time: '12:00:00', end_time: '14:00:00', multiplier: '1.50' }], null],
|
||||
['midnight split', [{ start_time: '22:00:00', end_time: '00:00:00', multiplier: '2.00' }, { start_time: '00:00:00', end_time: '02:00:00', multiplier: '2.00' }], null],
|
||||
['overlap by one second', [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }, { start_time: '11:59:59', end_time: '14:00:00', multiplier: '2.00' }], 'overlap'],
|
||||
['cross midnight', [{ start_time: '22:00:00', end_time: '02:00:00', multiplier: '2.00' }], 'range'],
|
||||
['equal midnight', [{ start_time: '00:00:00', end_time: '00:00:00', multiplier: '2.00' }], 'range'],
|
||||
['missing seconds', [{ start_time: '09:00', end_time: '12:00', multiplier: '2.00' }], 'format'],
|
||||
['zero', [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '0.00' }], 'multiplier'],
|
||||
['three decimals', [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '1.001' }], 'multiplier'],
|
||||
])('%s', (_name, periods, errorKey) => {
|
||||
const result = validateTimePricing({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: periods as TimePricingPeriodFormEntry[],
|
||||
}, t)
|
||||
if (errorKey === null) expect(result).toBeNull()
|
||||
else expect(result).toContain(String(errorKey))
|
||||
})
|
||||
|
||||
it('rejects non-IANA timezone', () => {
|
||||
expect(validateTimePricing({
|
||||
timezone: 'UTC+8',
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
}, t)).toContain('timezone')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['blank', ' '],
|
||||
])('rejects a %s timezone without throwing during conversion', (_name, timezone) => {
|
||||
const form = {
|
||||
timezone,
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: '2.00' }],
|
||||
} as unknown as TimePricingFormEntry
|
||||
|
||||
expect(validateTimePricing(form, t)).toContain('timezone')
|
||||
expect(() => formTimePricingToAPI(form)).not.toThrow()
|
||||
expect(formTimePricingToAPI(form)?.timezone).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BillingMode, PricingInterval } from '@/api/admin/channels'
|
||||
import type { BillingMode, ChannelTimePricing, PricingInterval } from '@/api/admin/channels'
|
||||
|
||||
type TranslateFn = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
@@ -25,6 +25,134 @@ export interface PricingFormEntry {
|
||||
image_output_price: number | string | null
|
||||
per_request_price: number | string | null
|
||||
intervals: IntervalFormEntry[]
|
||||
time_pricing: TimePricingFormEntry
|
||||
}
|
||||
|
||||
export interface TimePricingPeriodFormEntry {
|
||||
start_time: string
|
||||
end_time: string
|
||||
multiplier: number | string
|
||||
}
|
||||
|
||||
export interface TimePricingFormEntry {
|
||||
timezone: string
|
||||
periods: TimePricingPeriodFormEntry[]
|
||||
}
|
||||
|
||||
export const DEFAULT_TIME_PRICING_TIMEZONE = 'Asia/Shanghai'
|
||||
|
||||
const CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
|
||||
const LEGACY_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/
|
||||
const TWO_DECIMAL_MULTIPLIER = /^\d+(?:\.\d{1,2})?$/
|
||||
|
||||
export function isValidTimePricingMultiplier(value: number | string): boolean {
|
||||
const multiplier = String(value)
|
||||
const numericValue = Number(multiplier)
|
||||
return TWO_DECIMAL_MULTIPLIER.test(multiplier) &&
|
||||
Number.isFinite(numericValue) && numericValue > 0
|
||||
}
|
||||
|
||||
export const COMMON_TIMEZONES = [
|
||||
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
|
||||
'Australia/Sydney', 'Europe/London', 'Europe/Paris', 'Europe/Berlin',
|
||||
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||
'America/Toronto', 'America/Sao_Paulo', 'Pacific/Auckland', 'Pacific/Honolulu',
|
||||
]
|
||||
|
||||
export function createDefaultTimePricingForm(): TimePricingFormEntry {
|
||||
return { timezone: DEFAULT_TIME_PRICING_TIMEZONE, periods: [] }
|
||||
}
|
||||
|
||||
export function apiTimePricingToForm(value: ChannelTimePricing | null | undefined): TimePricingFormEntry {
|
||||
if (!value) return createDefaultTimePricingForm()
|
||||
return {
|
||||
timezone: value.timezone || DEFAULT_TIME_PRICING_TIMEZONE,
|
||||
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,
|
||||
multiplier: Number(period.multiplier).toFixed(2),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function formTimePricingToAPI(value: TimePricingFormEntry | null | undefined): ChannelTimePricing | null {
|
||||
if (!value?.periods?.length) return null
|
||||
const timezone = typeof value.timezone === 'string' ? value.timezone.trim() : ''
|
||||
return {
|
||||
timezone,
|
||||
periods: value.periods.map(period => ({
|
||||
start_time: period.start_time,
|
||||
end_time: period.end_time,
|
||||
multiplier: Number(period.multiplier),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function timeToSeconds(time: string, isEnd: boolean): number {
|
||||
if (isEnd && time === '00:00:00') return 24 * 60 * 60
|
||||
const [hours, minutes, seconds] = time.split(':').map(Number)
|
||||
return hours * 60 * 60 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
function timePricingValidationMessage(t: TranslateFn, key: string): string {
|
||||
return t(`admin.channels.timePricingValidation.${key}`)
|
||||
}
|
||||
|
||||
export function validateTimePricing(value: TimePricingFormEntry, t: TranslateFn): string | null {
|
||||
if (!value?.periods?.length) return null
|
||||
|
||||
if (typeof value.timezone !== 'string' || value.timezone.trim() === '') {
|
||||
return timePricingValidationMessage(t, 'timezone')
|
||||
}
|
||||
const timezone = value.timezone.trim()
|
||||
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone })
|
||||
} catch {
|
||||
return timePricingValidationMessage(t, 'timezone')
|
||||
}
|
||||
|
||||
const periods = [] as { start: number, end: number }[]
|
||||
for (const period of value.periods) {
|
||||
if (!CLOCK_TIME.test(period.start_time) || !CLOCK_TIME.test(period.end_time)) {
|
||||
return timePricingValidationMessage(t, 'format')
|
||||
}
|
||||
if (period.start_time === period.end_time) {
|
||||
return timePricingValidationMessage(t, 'range')
|
||||
}
|
||||
|
||||
const start = timeToSeconds(period.start_time, false)
|
||||
const end = timeToSeconds(period.end_time, true)
|
||||
if (start >= end) return timePricingValidationMessage(t, 'range')
|
||||
|
||||
if (!isValidTimePricingMultiplier(period.multiplier)) {
|
||||
return timePricingValidationMessage(t, 'multiplier')
|
||||
}
|
||||
periods.push({ start, end })
|
||||
}
|
||||
|
||||
const sorted = [...periods].sort((a, b) => a.start - b.start)
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].start < sorted[i - 1].end) {
|
||||
return timePricingValidationMessage(t, 'overlap')
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatTimezoneOffset(timezone: string, at = new Date()): string {
|
||||
try {
|
||||
const part = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'shortOffset',
|
||||
}).formatToParts(at).find(item => item.type === 'timeZoneName')?.value
|
||||
if (!part || part === 'GMT') return 'UTC+00:00'
|
||||
const match = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/.exec(part)
|
||||
if (!match) return ''
|
||||
return `UTC${match[1]}${match[2].padStart(2, '0')}:${match[3] || '00'}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 价格转换:后端存 per-token,前端显示 per-MTok ($/1M tokens)
|
||||
|
||||
Reference in New Issue
Block a user