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

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
@@ -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)