mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
功能:支持渠道模型分时倍率定价
This commit is contained in:
@@ -21,6 +21,17 @@ export interface PricingInterval {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface ChannelTimePricingPeriod {
|
||||
start_time: string
|
||||
end_time: string
|
||||
multiplier: number
|
||||
}
|
||||
|
||||
export interface ChannelTimePricing {
|
||||
timezone: string
|
||||
periods: ChannelTimePricingPeriod[]
|
||||
}
|
||||
|
||||
export interface ChannelModelPricing {
|
||||
id?: number
|
||||
platform: string
|
||||
@@ -34,6 +45,7 @@ export interface ChannelModelPricing {
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: PricingInterval[]
|
||||
time_pricing: ChannelTimePricing | null
|
||||
}
|
||||
|
||||
export interface AccountStatsPricingRule {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -80,6 +80,13 @@ export default {
|
||||
perRequestPrice: 'per-request price'
|
||||
}
|
||||
},
|
||||
timePricingValidation: {
|
||||
timezone: 'Select a valid IANA time zone',
|
||||
format: 'Start and end times must use HH:mm:ss format',
|
||||
range: 'Start time must be earlier than end time; split ranges across midnight',
|
||||
overlap: 'Time periods must not overlap',
|
||||
multiplier: 'Multiplier must be greater than 0 with at most two decimal places'
|
||||
},
|
||||
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
@@ -122,6 +129,13 @@ export default {
|
||||
imageOutputPrice: 'Image Output Price',
|
||||
pricePlaceholder: 'Default',
|
||||
intervals: 'Context Intervals (optional)',
|
||||
timePricing: 'Time-based pricing (optional)',
|
||||
timezone: 'Time zone',
|
||||
addTimePeriod: 'Add period',
|
||||
startTime: 'Start time',
|
||||
endTime: 'End time',
|
||||
multiplier: 'Multiplier',
|
||||
removeTimePeriod: 'Remove period',
|
||||
minTokens: 'Min',
|
||||
maxTokens: 'Max',
|
||||
inclusive: '(inclusive)',
|
||||
|
||||
@@ -80,6 +80,13 @@ export default {
|
||||
perRequestPrice: '单次价格'
|
||||
}
|
||||
},
|
||||
timePricingValidation: {
|
||||
timezone: '请选择有效的 IANA 时区',
|
||||
format: '开始时间和结束时间必须使用 HH:mm:ss 格式',
|
||||
range: '开始时间必须早于结束时间;跨午夜请拆分为两个时间段',
|
||||
overlap: '时间段不能重叠',
|
||||
multiplier: '倍率必须大于 0,且最多保留两位小数'
|
||||
},
|
||||
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
|
||||
columns: {
|
||||
name: '名称',
|
||||
@@ -122,6 +129,13 @@ export default {
|
||||
imageOutputPrice: '图片输出价格',
|
||||
pricePlaceholder: '默认',
|
||||
intervals: '上下文区间定价(可选)',
|
||||
timePricing: '时间段定价(可选)',
|
||||
timezone: '时区',
|
||||
addTimePeriod: '添加时间段',
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间',
|
||||
multiplier: '倍率',
|
||||
removeTimePeriod: '删除时间段',
|
||||
minTokens: '最小',
|
||||
maxTokens: '最大',
|
||||
inclusive: '(含)',
|
||||
|
||||
@@ -447,6 +447,7 @@
|
||||
:key="idx"
|
||||
:entry="entry"
|
||||
:platform="section.platform"
|
||||
enable-time-pricing
|
||||
@update="updatePricingEntry(sIdx, idx, $event)"
|
||||
@remove="removePricingEntry(sIdx, idx)"
|
||||
/>
|
||||
@@ -632,7 +633,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
|
||||
import type { PricingFormEntry } from '@/components/admin/channel/types'
|
||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
|
||||
import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formTimePricingToAPI, mTokToPerToken, perTokenToMTok, validateIntervals, validateTimePricing } from '@/components/admin/channel/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||
@@ -859,7 +860,8 @@ function addPricingEntry(sectionIdx: number) {
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
intervals: [],
|
||||
time_pricing: createDefaultTimePricingForm()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -892,7 +894,8 @@ async function syncLatestModels(sectionIdx: number) {
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
intervals: [],
|
||||
time_pricing: createDefaultTimePricingForm()
|
||||
})
|
||||
appStore.showSuccess(t('admin.channels.form.syncModelsSuccess', { count: newModels.length }))
|
||||
} catch (error) {
|
||||
@@ -957,7 +960,8 @@ function addRulePricingEntry(sectionIdx: number, ruleIndex: number) {
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
intervals: [],
|
||||
time_pricing: createDefaultTimePricingForm()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1073,7 +1077,8 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
|
||||
image_input_price: mTokToPerToken(p.image_input_price),
|
||||
image_output_price: mTokToPerToken(p.image_output_price),
|
||||
per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null,
|
||||
intervals: formIntervalsToAPI(p.intervals || [])
|
||||
intervals: formIntervalsToAPI(p.intervals || []),
|
||||
time_pricing: null
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -1114,7 +1119,8 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
|
||||
image_input_price: mTokToPerToken(entry.image_input_price),
|
||||
image_output_price: mTokToPerToken(entry.image_output_price),
|
||||
per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null,
|
||||
intervals: formIntervalsToAPI(entry.intervals || [])
|
||||
intervals: formIntervalsToAPI(entry.intervals || []),
|
||||
time_pricing: formTimePricingToAPI(entry.time_pricing)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1211,7 +1217,8 @@ function apiToForm(channel: Channel): PlatformSection[] {
|
||||
image_input_price: perTokenToMTok(p.image_input_price),
|
||||
image_output_price: perTokenToMTok(p.image_output_price),
|
||||
per_request_price: p.per_request_price,
|
||||
intervals: apiIntervalsToForm(p.intervals || [])
|
||||
intervals: apiIntervalsToForm(p.intervals || []),
|
||||
time_pricing: apiTimePricingToForm(p.time_pricing)
|
||||
} as PricingFormEntry))
|
||||
|
||||
// Read web_search_emulation from features_config
|
||||
@@ -1400,7 +1407,8 @@ function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) {
|
||||
image_input_price: perTokenToMTok(p.image_input_price),
|
||||
image_output_price: perTokenToMTok(p.image_output_price),
|
||||
per_request_price: p.per_request_price,
|
||||
intervals: apiIntervalsToForm(p.intervals || [])
|
||||
intervals: apiIntervalsToForm(p.intervals || []),
|
||||
time_pricing: createDefaultTimePricingForm()
|
||||
} as PricingFormEntry))
|
||||
}
|
||||
section.account_stats_pricing_rules.push(formRule)
|
||||
@@ -1523,6 +1531,20 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验时间段定价,并切换到对应平台便于修正
|
||||
for (const section of form.platforms.filter(s => s.enabled)) {
|
||||
for (const entry of section.model_pricing) {
|
||||
const timePricingError = validateTimePricing(entry.time_pricing, t)
|
||||
if (timePricingError) {
|
||||
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
|
||||
const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')
|
||||
appStore.showError(`${platformLabel} - ${modelLabel}: ${timePricingError}`)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
|
||||
|
||||
submitting.value = true
|
||||
|
||||
@@ -4429,6 +4429,7 @@ import PricingEntryCard from "@/components/admin/channel/PricingEntryCard.vue";
|
||||
import type { PricingFormEntry } from "@/components/admin/channel/types";
|
||||
import {
|
||||
apiIntervalsToForm,
|
||||
createDefaultTimePricingForm,
|
||||
formIntervalsToAPI,
|
||||
mTokToPerToken,
|
||||
perTokenToMTok,
|
||||
@@ -4499,6 +4500,7 @@ const emptyGroupPricing = (): PricingFormEntry => ({
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: [],
|
||||
time_pricing: createDefaultTimePricingForm(),
|
||||
});
|
||||
|
||||
const addGroupPricing = (entries: PricingFormEntry[]) =>
|
||||
@@ -4518,6 +4520,7 @@ const groupPricingFromAPI = (
|
||||
image_output_price: perTokenToMTok(entry.image_output_price),
|
||||
per_request_price: entry.per_request_price,
|
||||
intervals: apiIntervalsToForm(entry.intervals || []),
|
||||
time_pricing: createDefaultTimePricingForm(),
|
||||
}));
|
||||
|
||||
const groupPricingToAPI = (
|
||||
@@ -4541,6 +4544,7 @@ const groupPricingToAPI = (
|
||||
entry.billing_mode === "token"
|
||||
? []
|
||||
: formIntervalsToAPI(entry.intervals || []),
|
||||
time_pricing: null,
|
||||
}));
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
Reference in New Issue
Block a user