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
@@ -560,18 +560,18 @@
Google One
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
个人账号,享受 Google One 订阅配额
{{ t('admin.accounts.gemini.oauthType.googleOneDesc') }}
</span>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
推荐个人用户
{{ t('admin.accounts.gemini.oauthType.badges.individuals') }}
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
无需 GCP
{{ t('admin.accounts.gemini.oauthType.badges.noGcp') }}
</span>
</div>
</div>
@@ -603,10 +603,10 @@
GCP Code Assist
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
企业级,需要 GCP 项目
{{ t('admin.accounts.gemini.oauthType.codeAssistDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
需要激活 GCP 项目并绑定信用卡
{{ t('admin.accounts.gemini.oauthType.codeAssistRequirement') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
@@ -620,12 +620,12 @@
<span
class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
企业用户
{{ t('admin.accounts.gemini.oauthType.badges.enterprise') }}
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
高并发
{{ t('admin.accounts.gemini.oauthType.badges.highConcurrency') }}
</span>
</div>
</div>
@@ -648,7 +648,13 @@
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span>{{ showAdvancedOAuth ? '隐藏' : '显示' }}高级选项(自建 OAuth Client)</span>
<span>
{{
showAdvancedOAuth
? t('admin.accounts.gemini.oauthType.hideAdvanced')
: t('admin.accounts.gemini.oauthType.showAdvanced')
}}
</span>
</button>
</div>
@@ -3072,7 +3078,7 @@
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
修改归属地
{{ t('admin.accounts.gemini.setupGuide.links.countryChange') }}
</a>
<span class="text-gray-400">·</span>
<a
@@ -56,7 +56,7 @@
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.mobileRefreshTokenAuth', '手动输入 Mobile RT')
t('admin.accounts.oauth.openai.mobileRefreshTokenAuth', 'Manual Mobile RT Input')
}}</span>
</label>
<label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2">
@@ -78,7 +78,7 @@
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
t('admin.accounts.oauth.openai.accessTokenAuth', 'Manual AT Input')
}}</span>
</label>
<label v-if="showCodexSessionImportOption" class="flex cursor-pointer items-center gap-2">
@@ -106,7 +106,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')
@@ -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
@@ -19,7 +19,7 @@
font-weight="bold"
fill="white"
text-anchor="middle"
>钉</text>
>D</text>
</svg>
{{ t('auth.dingtalk.signIn') }}
</button>
@@ -17,7 +17,7 @@
for="login-agreement-consent"
class="cursor-pointer text-gray-700 dark:text-dark-200"
>
我已阅读并同意
{{ t('legal.loginAgreementPrompt.checkboxPrefix') }}
</label>
<template v-for="(doc, index) in documents" :key="doc.id || doc.title">
<RouterLink
@@ -28,7 +28,7 @@
>
{{ doc.title }}
</RouterLink>
<span v-if="index < documents.length - 1">、</span>
<span v-if="index < documents.length - 1">{{ t('legal.loginAgreementPrompt.documentSeparator') }}</span>
</template>
</p>
</div>
@@ -42,9 +42,9 @@
<div class="flex items-start gap-3">
<Icon name="shield" size="sm" class="mt-0.5 flex-shrink-0 text-primary-600 dark:text-primary-300" />
<div class="min-w-0 flex-1">
<p class="font-medium">继续登录前需要先同意最新条款。</p>
<p class="font-medium">{{ t('legal.loginAgreementPrompt.noticeTitle') }}</p>
<p class="mt-1 text-primary-700 dark:text-primary-200/80">
未同意前,账号密码输入和快捷登录会保持禁用。
{{ t('legal.loginAgreementPrompt.noticeDescription') }}
</p>
</div>
<button
@@ -52,7 +52,7 @@
class="flex-shrink-0 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-700"
@click="emit('open')"
>
查看条款
{{ t('legal.loginAgreementPrompt.viewTerms') }}
</button>
</div>
</div>
@@ -72,7 +72,7 @@
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold tracking-normal text-gray-950 dark:text-white">
条款更新通知
{{ t('legal.loginAgreementPrompt.dialogTitle') }}
</h2>
<span
v-if="updatedAt"
@@ -82,7 +82,11 @@
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
我们的服务条款已于 {{ updatedAt || '近期' }} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。
{{
t('legal.loginAgreementPrompt.dialogDescription', {
date: updatedAt || t('legal.loginAgreementPrompt.recently'),
})
}}
</p>
</div>
</div>
@@ -90,7 +94,7 @@
<div class="max-h-[58vh] overflow-y-auto px-6 py-5">
<div class="mb-3 flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-gray-900 dark:text-white">相关文档</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('legal.loginAgreementPrompt.relatedDocuments') }}</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RouterLink
@@ -121,14 +125,14 @@
class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-200 dark:hover:bg-dark-700"
@click="emit('reject')"
>
拒绝
{{ t('legal.loginAgreementPrompt.reject') }}
</button>
<button
type="button"
class="rounded-xl bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
@click="emit('accept')"
>
同意并继续
{{ t('legal.loginAgreementPrompt.accept') }}
</button>
</div>
</div>
@@ -140,9 +144,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { LoginAgreementDocument } from '@/types'
const { t } = useI18n()
const props = withDefaults(defineProps<{
accepted: boolean
documents: LoginAgreementDocument[]
@@ -184,10 +191,21 @@ function handleCheckboxChange(event: Event): void {
}
function documentIcon(index: number, title: string): 'document' | 'shield' | 'globe' | 'cog' {
if (title.includes('政策') || title.includes('隐私')) {
const normalizedTitle = title.toLowerCase()
if (
normalizedTitle.includes('policy') ||
normalizedTitle.includes('privacy') ||
title.includes('政策') ||
title.includes('隐私')
) {
return 'shield'
}
if (title.includes('国家') || title.includes('地区')) {
if (
normalizedTitle.includes('country') ||
normalizedTitle.includes('region') ||
title.includes('国家') ||
title.includes('地区')
) {
return 'globe'
}
if (index === 3) {
@@ -31,7 +31,7 @@
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x 倍率
{{ rateMultiplier }}x {{ t('admin.groups.rateLabel') }}
</template>
</span>
<!-- Checkmark -->
@@ -51,9 +51,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue'
import type { SubscriptionType, GroupPlatform } from '@/types'
const { t } = useI18n()
interface Props {
name: string
platform: GroupPlatform