功能:支持渠道模型分时倍率定价

This commit is contained in:
lyen1688
2026-08-17 19:45:07 +08:00
parent e330c243a8
commit 9f24a55305
40 changed files with 2297 additions and 131 deletions
+12
View File
@@ -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('')
})
})
+129 -1
View File
@@ -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: '(含)',
+30 -8
View File
@@ -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
+4
View File
@@ -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();