fix: localize English UI strings

This commit is contained in:
Heatherm Huang
2026-06-30 20:05:23 +08:00
parent d3acd8e96e
commit 6e54776509
24 changed files with 322 additions and 196 deletions
@@ -110,7 +110,7 @@
<span class="text-xs text-gray-500 dark:text-gray-400">
{{
geminiOAuthType === 'google_one'
? '个人账号'
? t('admin.accounts.gemini.oauthType.googleOneDesc')
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInDesc')
: t('admin.accounts.gemini.oauthType.customDesc')
@@ -9,27 +9,27 @@
type="number" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="w-20">
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(含)</span></label>
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(inclusive)</span></label>
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', 'Input') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', 'Output') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存W') }} <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', 'Cache W') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存R') }} <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', 'Cache R') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
@@ -39,7 +39,7 @@
<template v-else>
<div class="w-24">
<label class="text-xs text-gray-400">
{{ mode === 'image' ? t('admin.channels.form.resolution', '分辨率') : t('admin.channels.form.tierLabel', '层级') }}
{{ mode === 'image' ? t('admin.channels.form.resolution', 'Resolution') : t('admin.channels.form.tierLabel', 'Tier') }}
</label>
<input :value="interval.tier_label" @input="emitField('tier_label', ($event.target as HTMLInputElement).value)"
type="text" class="input mt-0.5 text-xs" :placeholder="mode === 'image' ? '1K / 2K / 4K' : ''" />
@@ -50,12 +50,12 @@
type="number" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="w-20">
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(含)</span></label>
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(inclusive)</span></label>
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', '单次价格') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', 'Price per Request') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$</span></label>
<input :value="interval.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
@@ -34,7 +34,7 @@
v-if="entry.models.length === 0"
class="text-xs italic text-gray-400"
>
{{ t('admin.channels.form.noModels', '未添加模型') }}
{{ t('admin.channels.form.noModels', 'No models added') }}
</span>
</div>
@@ -48,7 +48,7 @@
<!-- Expanded: show the label "Pricing Entry" or similar -->
<div v-else class="flex-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.pricingEntry', '定价配置') }}
{{ t('admin.channels.form.pricingEntry', 'Pricing Entry') }}
</div>
<!-- Remove button (always visible, stop propagation) -->
@@ -71,19 +71,19 @@
<div class="mt-3 flex items-start gap-2">
<div class="flex-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.models', '模型列表') }} <span class="text-red-500">*</span>
{{ t('admin.channels.form.models', 'Models') }} <span class="text-red-500">*</span>
</label>
<ModelTagInput
:models="entry.models"
:platform="props.platform"
@update:models="onModelsUpdate($event)"
:placeholder="t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *')"
:placeholder="t('admin.channels.form.modelsPlaceholder', 'Type a model name and press Enter. Wildcard * is supported.')"
class="mt-1"
/>
</div>
<div class="w-40">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.billingMode', '计费模式') }}
{{ t('admin.channels.form.billingMode', 'Billing Mode') }}
</label>
<Select
:modelValue="entry.billing_mode"
@@ -98,34 +98,34 @@
<div v-if="entry.billing_mode === 'token'">
<!-- Default prices (fallback when no interval matches) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultPrices', '默认价格(未命中区间时使用)') }}
{{ t('admin.channels.form.defaultPrices', 'Default prices (fallback when no interval matches)') }}
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
</label>
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', 'Input') }}</label>
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', 'Output') }}</label>
<input :value="entry.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存写入') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', 'Cache Write') }}</label>
<input :value="entry.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存读取') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', 'Cache Read') }}</label>
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice', '图片输出') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice', 'Image Output') }}</label>
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
</div>
@@ -133,11 +133,11 @@
<div class="mt-3">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.intervals', '上下文区间定价(可选)') }}
{{ t('admin.channels.form.intervals', 'Context intervals (optional)') }}
<span class="ml-1 font-normal text-gray-400">(min, max]</span>
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addInterval', '添加区间') }}
+ {{ t('admin.channels.form.addInterval', 'Add interval') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -157,21 +157,21 @@
<div v-else-if="entry.billing_mode === 'per_request'">
<!-- Default per-request price -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultPerRequestPrice', '默认单次价格(未命中层级时使用)') }}
{{ t('admin.channels.form.defaultPerRequestPrice', 'Default per-request price (fallback when no tier matches)') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
<input :value="entry.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
<!-- Tiers -->
<div class="mt-3 flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.requestTiers', '按次计费层级') }}
{{ t('admin.channels.form.requestTiers', 'Request Tiers') }}
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', '添加层级') }}
+ {{ t('admin.channels.form.addTier', 'Add tier') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -185,7 +185,7 @@
/>
</div>
<div v-else class="mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500">
{{ t('admin.channels.form.noTiersYet', '暂无层级,点击添加配置按次计费价格') }}
{{ t('admin.channels.form.noTiersYet', 'No tiers yet. Click add to configure per-request pricing.') }}
</div>
</div>
@@ -193,21 +193,21 @@
<div v-else-if="entry.billing_mode === 'image'">
<!-- Default image price (per-request, same as per_request mode) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultImagePrice', '默认图片价格(未命中层级时使用)') }}
{{ t('admin.channels.form.defaultImagePrice', 'Default image price (fallback when no tier matches)') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
<input :value="entry.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', 'Default')" />
</div>
<!-- Image tiers -->
<div class="mt-3 flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.imageTiers', '图片计费层级(按次)') }}
{{ t('admin.channels.form.imageTiers', 'Image tiers (per request)') }}
</label>
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', '添加层级') }}
+ {{ t('admin.channels.form.addTier', 'Add tier') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -255,8 +255,8 @@ const collapsed = ref(props.entry.models.length > 0)
const billingModeOptions = computed(() => [
{ value: 'token', label: 'Token' },
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', '按次') },
{ value: 'image', label: t('admin.channels.billingMode.image', '图片(按次)') }
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', 'Per Request') },
{ value: 'image', label: t('admin.channels.billingMode.image', 'Image (per request)') }
])
const billingModeLabel = computed(() => {
@@ -23,7 +23,7 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toMatch(/无上限/)
expect(validateIntervals(intervals, 'token')).toMatch(/unbounded interval/)
})
it('accepts unbounded interval at the end', () => {
@@ -39,7 +39,7 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: 250000, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toMatch(/重叠/)
expect(validateIntervals(intervals, 'token')).toMatch(/overlap/)
})
it('defaults mode to token when omitted', () => {
@@ -47,7 +47,7 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 100, max_tokens: 200, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals)).toMatch(/无上限/)
expect(validateIntervals(intervals)).toMatch(/unbounded interval/)
})
})
@@ -66,14 +66,14 @@ describe('validateIntervals', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', per_request_price: -1 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/不能为负数/)
expect(validateIntervals(intervals, 'image')).toMatch(/cannot be negative/)
})
it('still rejects max <= min on a single tier', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', min_tokens: 100, max_tokens: 50, per_request_price: 0.04 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/必须大于/)
expect(validateIntervals(intervals, 'image')).toMatch(/must be greater/)
})
})
})
+11 -11
View File
@@ -143,14 +143,14 @@ export function validateIntervals(
function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null {
if (iv.min_tokens < 0) {
return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数`
return `Interval #${idx + 1}: minimum token count (${iv.min_tokens}) cannot be negative`
}
if (iv.max_tokens != null) {
if (iv.max_tokens <= 0) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于 0`
return `Interval #${idx + 1}: maximum token count (${iv.max_tokens}) must be greater than 0`
}
if (iv.max_tokens <= iv.min_tokens) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于最小 token 数 (${iv.min_tokens})`
return `Interval #${idx + 1}: maximum token count (${iv.max_tokens}) must be greater than minimum token count (${iv.min_tokens})`
}
}
return validateIntervalPrices(iv, idx)
@@ -158,15 +158,15 @@ function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | nu
function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
const prices: [string, number | string | null][] = [
['输入价格', iv.input_price],
['输出价格', iv.output_price],
['缓存写入价格', iv.cache_write_price],
['缓存读取价格', iv.cache_read_price],
['单次价格', iv.per_request_price],
['input price', iv.input_price],
['output price', iv.output_price],
['cache write price', iv.cache_write_price],
['cache read price', iv.cache_read_price],
['per-request price', iv.per_request_price],
]
for (const [name, val] of prices) {
if (val != null && val !== '' && Number(val) < 0) {
return `区间 #${idx + 1}: ${name}不能为负数`
return `Interval #${idx + 1}: ${name} cannot be negative`
}
}
return null
@@ -176,14 +176,14 @@ function checkIntervalOverlap(sorted: IntervalFormEntry[]): string | null {
for (let i = 0; i < sorted.length; i++) {
// 无上限区间必须是最后一个
if (sorted[i].max_tokens == null && i < sorted.length - 1) {
return `区间 #${i + 1}: 无上限区间(最大 token 数为空)只能是最后一个`
return `Interval #${i + 1}: an unbounded interval (empty maximum token count) must be last`
}
if (i === 0) continue
const prev = sorted[i - 1]
// (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
if (prev.max_tokens == null || prev.max_tokens > sorted[i].min_tokens) {
const prevMax = prev.max_tokens == null ? '∞' : String(prev.max_tokens)
return `区间 #${i} 和 #${i + 1} 重叠:前一个区间上界 (${prevMax}) 大于当前区间下界 (${sorted[i].min_tokens})`
return `Intervals #${i} and #${i + 1} overlap: previous upper bound (${prevMax}) is greater than current lower bound (${sorted[i].min_tokens})`
}
}
return null