fix: 高峰倍率全链路透传、计费术语修正与边界处理

- 高峰倍率信息透传至可用渠道、支付计划、结算信息等 API,前端
  GroupBadge / GroupOptionItem / SubscriptionPlanCard / PaymentView /
  SubscriptionsView 统一展示高峰时段与倍率标签
- 修正计费术语:"文本倍率" → "token 倍率",明确高峰倍率同时作用于
  token 计费的图片 token,图片按次计费不受高峰影响
- 允许高峰倍率 multiplier=0,支持高峰时段免费策略
- 切换分组类型为 standard 时自动清除高峰倍率配置
- 长上下文计费与标准计费路径改用内部实现,移除冗余中间调用
- 前端高峰倍率相关控件文案改为 i18n
- 新增多组高峰倍率相关单元测试
This commit is contained in:
xueshiji
2026-07-01 17:46:12 +08:00
parent ebd83dc0f9
commit 1034f576d7
32 changed files with 686 additions and 145 deletions
@@ -32,6 +32,10 @@
:platform="key.group.platform"
:subscription-type="key.group.subscription_type"
:rate-multiplier="key.group.rate_multiplier"
:peak-rate-enabled="key.group.peak_rate_enabled"
:peak-start="key.group.peak_start"
:peak-end="key.group.peak_end"
:peak-rate-multiplier="key.group.peak_rate_multiplier"
/>
<span v-else class="text-gray-400 italic">{{ t('admin.users.none') }}</span>
<svg v-if="updatingKeyIds.has(key.id)" class="h-3 w-3 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
@@ -88,6 +92,10 @@
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:peak-rate-enabled="group.peak_rate_enabled"
:peak-start="group.peak_start"
:peak-end="group.peak_end"
:peak-rate-multiplier="group.peak_rate_multiplier"
:description="group.description"
:selected="selectedKeyForGroup?.group_id === group.id"
/>
@@ -85,16 +85,28 @@
<Icon name="shield" size="xs" class="h-3 w-3" />
{{ t('availableChannels.exclusive') }}
</span>
<GroupBadge
<div
v-for="g in exclusiveGroups(section)"
:key="`ex-${g.id}`"
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
class="inline-flex flex-wrap items-center gap-1"
>
<GroupBadge
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
<span
v-if="hasPeakRate(g)"
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
:title="peakRateTitle(g)"
>
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakRateLabel(g) }}
</span>
</div>
</div>
<div
v-if="publicGroups(section).length > 0"
@@ -107,16 +119,28 @@
<Icon name="globe" size="xs" class="h-3 w-3" />
{{ t('availableChannels.public') }}
</span>
<GroupBadge
<div
v-for="g in publicGroups(section)"
:key="`pub-${g.id}`"
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
class="inline-flex flex-wrap items-center gap-1"
>
<GroupBadge
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
<span
v-if="hasPeakRate(g)"
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
:title="peakRateTitle(g)"
>
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakRateLabel(g) }}
</span>
</div>
</div>
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
</div>
@@ -186,4 +210,16 @@ function exclusiveGroups(section: UserChannelPlatformSection): UserAvailableGrou
function publicGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
return section.groups.filter((g) => !g.is_exclusive)
}
function hasPeakRate(group: UserAvailableGroup): boolean {
return Boolean(group.peak_rate_enabled && group.peak_start && group.peak_end)
}
function peakRateLabel(group: UserAvailableGroup): string {
return `${group.peak_start}-${group.peak_end} ${group.peak_rate_multiplier}x`
}
function peakRateTitle(group: UserAvailableGroup): string {
return `高峰倍率:${group.peak_start}-${group.peak_end} ${group.peak_rate_multiplier}x;token 计费的图片 token 同样适用,图片按次计费不受高峰影响`
}
</script>
@@ -20,6 +20,9 @@
{{ labelText }}
</template>
</span>
<span v-if="hasPeakRate" :class="peakRateClass" :title="peakRateTitle">
{{ peakRateText }}
</span>
</span>
</template>
@@ -35,6 +38,10 @@ interface Props {
subscriptionType?: SubscriptionType
rateMultiplier?: number
userRateMultiplier?: number | null // 用户专属倍率
peakRateEnabled?: boolean
peakStart?: string
peakEnd?: string
peakRateMultiplier?: number
showRate?: boolean
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
/**
@@ -50,6 +57,7 @@ const props = withDefaults(defineProps<Props>(), {
showRate: true,
daysRemaining: null,
userRateMultiplier: null,
peakRateEnabled: false,
alwaysShowRate: false
})
@@ -67,6 +75,18 @@ const hasCustomRate = computed(() => {
)
})
const hasPeakRate = computed(() => {
return Boolean(props.showRate && props.peakRateEnabled && props.peakStart && props.peakEnd)
})
const peakRateText = computed(() => {
return `${props.peakStart}-${props.peakEnd} ×${props.peakRateMultiplier ?? 1}`
})
const peakRateTitle = computed(() => {
return `高峰倍率:${peakRateText.value}`
})
// 是否显示右侧标签
const showLabel = computed(() => {
if (!props.showRate) return false
@@ -127,6 +147,10 @@ const labelClass = computed(() => {
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
const peakRateClass = computed(() => {
return 'px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
})
// Badge color based on platform and subscription type
const badgeClass = computed(() => {
if (props.platform === 'anthropic') {
@@ -24,16 +24,25 @@
<!-- Right: rate pill + checkmark (vertically centered to first row) -->
<div class="flex shrink-0 items-center gap-2 pt-0.5">
<!-- Rate pill (platform color) -->
<span v-if="rateMultiplier !== undefined" :class="['inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold', ratePillClass]">
<template v-if="hasCustomRate">
<span class="mr-1 line-through opacity-50">{{ rateMultiplier }}x</span>
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x {{ t('admin.groups.rateLabel') }}
</template>
</span>
<div class="flex shrink-0 flex-col items-end gap-1">
<!-- Rate pill (platform color) -->
<span v-if="rateMultiplier !== undefined" :class="['inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold', ratePillClass]">
<template v-if="hasCustomRate">
<span class="mr-1 line-through opacity-50">{{ rateMultiplier }}x</span>
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x {{ t('admin.groups.rateLabel') }}
</template>
</span>
<span
v-if="hasPeakRate"
class="inline-flex items-center whitespace-nowrap rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
:title="peakRateTitle"
>
{{ peakRateText }}
</span>
</div>
<!-- Checkmark -->
<svg
v-if="showCheckmark && selected"
@@ -63,6 +72,10 @@ interface Props {
subscriptionType?: SubscriptionType
rateMultiplier?: number
userRateMultiplier?: number | null
peakRateEnabled?: boolean
peakStart?: string
peakEnd?: string
peakRateMultiplier?: number
description?: string | null
selected?: boolean
showCheckmark?: boolean
@@ -72,7 +85,8 @@ const props = withDefaults(defineProps<Props>(), {
subscriptionType: 'standard',
selected: false,
showCheckmark: true,
userRateMultiplier: null
userRateMultiplier: null,
peakRateEnabled: false
})
// Whether user has a custom rate different from default
@@ -85,6 +99,18 @@ const hasCustomRate = computed(() => {
)
})
const hasPeakRate = computed(() => {
return Boolean(props.peakRateEnabled && props.peakStart && props.peakEnd)
})
const peakRateText = computed(() => {
return `${props.peakStart}-${props.peakEnd} ×${props.peakRateMultiplier ?? 1}`
})
const peakRateTitle = computed(() => {
return `高峰倍率:${peakRateText.value}`
})
// Rate pill color matches platform badge color
const ratePillClass = computed(() => {
switch (props.platform) {
@@ -43,6 +43,10 @@
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
</div>
<div v-if="hasPeakRate" class="col-span-2 flex items-center justify-between gap-2">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.peakRate') }}</span>
<span class="text-right font-medium text-amber-700 dark:text-amber-300">{{ peakRateDisplay }}</span>
</div>
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
@@ -140,6 +144,14 @@ const rateDisplay = computed(() => {
return `×${Number(rate.toPrecision(10))}`
})
const hasPeakRate = computed(() => {
return Boolean(props.plan.peak_rate_enabled && props.plan.peak_start && props.plan.peak_end)
})
const peakRateDisplay = computed(() => {
return `${props.plan.peak_start}-${props.plan.peak_end} ×${props.plan.peak_rate_multiplier ?? 1}`
})
const MODEL_SCOPE_LABELS: Record<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',