Merge pull request #3577 from heathermhuang/codex/fix-english-ui-cjk

fix: remove Chinese strings from English UI
This commit is contained in:
Wesley Liddick
2026-07-01 11:35:04 +08:00
committed by GitHub
24 changed files with 521 additions and 219 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')
}}</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')
}}</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')
@@ -4,32 +4,32 @@
<!-- Token mode: context range + prices ($/MTok) -->
<template v-if="mode === 'token'">
<div class="w-20">
<label class="text-xs text-gray-400">Min</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
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">{{ t('admin.channels.form.maxTokens') }} <span class="text-gray-300">{{ t('admin.channels.form.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') }} <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') }} <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.cacheWritePriceShort') }} <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.cacheReadPriceShort') }} <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,23 +39,23 @@
<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') : t('admin.channels.form.tierLabel') }}
</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' : ''" />
</div>
<div class="w-20">
<label class="text-xs text-gray-400">Min</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
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">{{ t('admin.channels.form.maxTokens') }} <span class="text-gray-300">{{ t('admin.channels.form.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') }} <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') }}
</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') }}
</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') }} <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')"
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') }}
</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') }}
<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') }}</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')" />
</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') }}</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')" />
</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') }}</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')" />
</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') }}</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')" />
</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') }}</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')" />
</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') }}
<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') }}
</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') }}
<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')" />
</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') }}
</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') }}
</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') }}
</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') }}
<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')" />
</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') }}
</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') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -254,9 +254,9 @@ const emit = defineEmits<{
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: 'token', label: t('admin.channels.billingMode.token') },
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest') },
{ value: 'image', label: t('admin.channels.billingMode.image') }
])
const billingModeLabel = computed(() => {
@@ -16,6 +16,10 @@ function makeInterval(over: Partial<IntervalFormEntry>): IntervalFormEntry {
}
}
function t(key: string, params?: Record<string, unknown>): string {
return `${key}${params ? ` ${JSON.stringify(params)}` : ''}`
}
describe('validateIntervals', () => {
describe('token mode', () => {
it('rejects unbounded interval that is not last', () => {
@@ -23,7 +27,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', t)).toContain('unboundedLast')
})
it('accepts unbounded interval at the end', () => {
@@ -31,7 +35,7 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: 200000, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: null, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toBeNull()
expect(validateIntervals(intervals, 'token', t)).toBeNull()
})
it('rejects overlapping intervals', () => {
@@ -39,15 +43,15 @@ 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', t)).toContain('overlap')
})
it('defaults mode to token when omitted', () => {
it('rejects unbounded interval in token mode', () => {
const intervals: IntervalFormEntry[] = [
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, 'token', t)).toContain('unboundedLast')
})
})
@@ -58,22 +62,22 @@ describe('validateIntervals', () => {
makeInterval({ tier_label: '2K', per_request_price: 0.06 }),
makeInterval({ tier_label: '4K', per_request_price: 0.08 }),
]
expect(validateIntervals(intervals, 'image')).toBeNull()
expect(validateIntervals(intervals, 'per_request')).toBeNull()
expect(validateIntervals(intervals, 'image', t)).toBeNull()
expect(validateIntervals(intervals, 'per_request', t)).toBeNull()
})
it('still rejects negative prices', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', per_request_price: -1 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/不能为负数/)
expect(validateIntervals(intervals, 'image', t)).toContain('negativePrice')
})
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', t)).toContain('maxGreaterThanMin')
})
})
})
+61 -19
View File
@@ -1,5 +1,7 @@
import type { BillingMode, PricingInterval } from '@/api/admin/channels'
type TranslateFn = (key: string, params?: Record<string, unknown>) => string
export interface IntervalFormEntry {
min_tokens: number
max_tokens: number | null
@@ -124,7 +126,8 @@ export function findModelConflict(models: string[]): [string, string] | null {
*/
export function validateIntervals(
intervals: IntervalFormEntry[],
mode: BillingMode = 'token',
mode: BillingMode,
t: TranslateFn,
): string | null {
if (!intervals || intervals.length === 0) return null
@@ -132,58 +135,97 @@ export function validateIntervals(
const sorted = [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
for (let i = 0; i < sorted.length; i++) {
const err = validateSingleInterval(sorted[i], i)
const err = validateSingleInterval(sorted[i], i, t)
if (err) return err
}
// per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验
if (mode !== 'token') return null
return checkIntervalOverlap(sorted)
return checkIntervalOverlap(sorted, t)
}
function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null {
function intervalValidationMessage(
t: TranslateFn,
key: string,
params: Record<string, unknown>,
): string {
return t(`admin.channels.intervalValidation.${key}`, params)
}
function intervalPriceLabel(t: TranslateFn, key: string): string {
return t(`admin.channels.intervalValidation.price.${key}`)
}
function validateSingleInterval(iv: IntervalFormEntry, idx: number, t: TranslateFn): string | null {
const index = idx + 1
if (iv.min_tokens < 0) {
return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数`
return intervalValidationMessage(
t,
'negativeMin',
{ index, value: iv.min_tokens },
)
}
if (iv.max_tokens != null) {
if (iv.max_tokens <= 0) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于 0`
return intervalValidationMessage(
t,
'maxPositive',
{ index, value: iv.max_tokens },
)
}
if (iv.max_tokens <= iv.min_tokens) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于最小 token 数 (${iv.min_tokens})`
return intervalValidationMessage(
t,
'maxGreaterThanMin',
{ index, max: iv.max_tokens, min: iv.min_tokens },
)
}
}
return validateIntervalPrices(iv, idx)
return validateIntervalPrices(iv, idx, t)
}
function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
function validateIntervalPrices(iv: IntervalFormEntry, idx: number, t: TranslateFn): string | null {
const index = idx + 1
const prices: [string, number | string | null][] = [
['输入价格', iv.input_price],
['输出价格', iv.output_price],
['缓存写入价格', iv.cache_write_price],
['缓存读取价格', iv.cache_read_price],
['单次价格', iv.per_request_price],
['inputPrice', iv.input_price],
['outputPrice', iv.output_price],
['cacheWritePrice', iv.cache_write_price],
['cacheReadPrice', iv.cache_read_price],
['perRequestPrice', iv.per_request_price],
]
for (const [name, val] of prices) {
for (const [key, val] of prices) {
if (val != null && val !== '' && Number(val) < 0) {
return `区间 #${idx + 1}: ${name}不能为负数`
const field = intervalPriceLabel(t, key)
return intervalValidationMessage(
t,
'negativePrice',
{ index, field },
)
}
}
return null
}
function checkIntervalOverlap(sorted: IntervalFormEntry[]): string | null {
function checkIntervalOverlap(sorted: IntervalFormEntry[], t: TranslateFn): 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 intervalValidationMessage(
t,
'unboundedLast',
{ index: i + 1 },
)
}
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 intervalValidationMessage(
t,
'overlap',
{ previousIndex: i, currentIndex: i + 1, previousMax: prevMax, currentMin: 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