mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix: localize English UI strings
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -303,16 +303,16 @@ const antigravityPresetMappings = [
|
||||
{ label: '3-Pro-Preview→3.1-Pro-High', from: 'gemini-3-pro-preview', to: 'gemini-3.1-pro-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: '3-Pro-High→3.1-Pro-High', from: 'gemini-3-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3-Pro-Low→3.1-Pro-Low', from: 'gemini-3-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: '3.1-Pro-High透传', from: 'gemini-3.1-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3.1-Pro-Low透传', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: '3.1-Pro-High passthrough', from: 'gemini-3.1-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '3.1-Pro-Low passthrough', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
// Gemini 通配符映射
|
||||
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '2.5-Flash-Image透传', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3.1-Flash-Image透传', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '2.5-Flash-Image passthrough', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3.1-Flash-Image passthrough', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3-Pro-Image→3.1', from: 'gemini-3-pro-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: '3-Flash passthrough', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: '2.5-Flash-Lite passthrough', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
// 精确映射
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
@@ -483,12 +483,12 @@ export function buildModelMappingObject(
|
||||
if (!from || !to) continue
|
||||
// 校验通配符格式:* 只能放在末尾
|
||||
if (!isValidWildcardPattern(from)) {
|
||||
console.warn(`[buildModelMappingObject] 无效的通配符格式,跳过: ${from}`)
|
||||
console.warn(`[buildModelMappingObject] Invalid wildcard pattern, skipped: ${from}`)
|
||||
continue
|
||||
}
|
||||
// to 不允许包含通配符
|
||||
if (to.includes('*')) {
|
||||
console.warn(`[buildModelMappingObject] 目标模型不能包含通配符,跳过: ${from} -> ${to}`)
|
||||
console.warn(`[buildModelMappingObject] Target model cannot contain a wildcard, skipped: ${from} -> ${to}`)
|
||||
continue
|
||||
}
|
||||
mapping[from] = to
|
||||
|
||||
@@ -369,7 +369,24 @@ export default {
|
||||
updatedAt: 'Updated: {date}',
|
||||
empty: 'No content',
|
||||
loginAgreement: 'Login Agreement',
|
||||
adminCompliance: 'Deployment and Operation Compliance Commitment'
|
||||
adminCompliance: 'Deployment and Operation Compliance Commitment',
|
||||
loginAgreementPrompt: {
|
||||
checkboxPrefix: 'I have read and agree to ',
|
||||
documentSeparator: ', ',
|
||||
noticeTitle: 'Accept the latest terms before continuing.',
|
||||
noticeDescription: 'Account/password login and quick sign-in stay disabled until you accept.',
|
||||
viewTerms: 'View terms',
|
||||
dialogTitle: 'Terms Update Notice',
|
||||
dialogDescription: 'Our service terms were updated on {date}. Please read and accept the following terms before continuing.',
|
||||
recently: 'recently',
|
||||
relatedDocuments: 'Related documents',
|
||||
reject: 'Reject',
|
||||
accept: 'Accept and continue',
|
||||
loginRejectedWarning: 'Account/password login and quick sign-in are disabled until you accept the latest terms.',
|
||||
loginRequiredWarning: 'Please read and accept the latest terms before logging in.',
|
||||
registerRejectedWarning: 'Registration and quick sign-in are disabled until you accept the latest terms.',
|
||||
registerRequiredWarning: 'Please read and accept the latest terms before registering.'
|
||||
}
|
||||
},
|
||||
|
||||
// Navigation
|
||||
@@ -587,6 +604,7 @@ export default {
|
||||
},
|
||||
linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback',
|
||||
dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback',
|
||||
dingtalkProviderName: 'DingTalk',
|
||||
oidcCallbackPageTitle: 'OIDC Sign-In Callback',
|
||||
oauthCallbackPageTitle: 'OAuth Callback',
|
||||
wechatProviderName: 'WeChat',
|
||||
@@ -2151,6 +2169,15 @@ export default {
|
||||
accountsUnit: '',
|
||||
rateAndAccounts: '{rate}x rate · {count} accounts',
|
||||
accountsCount: '{count} accounts',
|
||||
rateLabel: 'rate',
|
||||
accountFilters: {
|
||||
title: 'Account Filter Controls',
|
||||
oauthOnly: 'Only allow OAuth accounts',
|
||||
oauthOnlyEnabled: 'Enabled — API Key accounts will be excluded',
|
||||
privacySetOnly: 'Only allow accounts with privacy protection set',
|
||||
privacySetOnlyEnabled: 'Enabled — accounts with unset Privacy will be excluded',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
form: {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
@@ -2256,7 +2283,10 @@ export default {
|
||||
title: 'Custom /v1/models Model List',
|
||||
hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.',
|
||||
loading: 'Loading model list...',
|
||||
empty: 'No displayable models'
|
||||
empty: 'No displayable models',
|
||||
selectedSummary: 'Selected {selected} / {total}',
|
||||
selectAll: 'Select all',
|
||||
invertSelection: 'Invert'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code Client Restriction',
|
||||
@@ -2631,6 +2661,7 @@ export default {
|
||||
queueSize: 'Async Queue Size',
|
||||
blockStatus: 'Block HTTP Status',
|
||||
blockMessage: 'Custom Block Message',
|
||||
defaultBlockMessage: 'Content audit matched a risk rule. Please adjust your input and try again.',
|
||||
emailOnHit: 'Email on Hit',
|
||||
emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.',
|
||||
autoBan: 'Auto Ban User',
|
||||
@@ -3987,6 +4018,11 @@ export default {
|
||||
builtInTitle: 'Built-in OAuth (Gemini CLI / Code Assist)',
|
||||
builtInDesc: 'Uses Google built-in client ID. No admin configuration required.',
|
||||
builtInRequirement: 'Requires a GCP project and Project ID.',
|
||||
googleOneDesc: 'Personal account with Google One subscription quota',
|
||||
codeAssistDesc: 'Enterprise-grade, requires a GCP project',
|
||||
codeAssistRequirement: 'Requires an active GCP project with billing enabled',
|
||||
showAdvanced: 'Show advanced options (custom OAuth Client)',
|
||||
hideAdvanced: 'Hide advanced options (custom OAuth Client)',
|
||||
gcpProjectLink: 'Create project',
|
||||
customTitle: 'Custom OAuth (AI Studio OAuth)',
|
||||
customDesc: 'Uses admin-configured OAuth client for org management.',
|
||||
@@ -3994,6 +4030,9 @@ export default {
|
||||
badges: {
|
||||
recommended: 'Recommended',
|
||||
highConcurrency: 'High concurrency',
|
||||
individuals: 'Recommended for individuals',
|
||||
noGcp: 'No GCP required',
|
||||
enterprise: 'Enterprise users',
|
||||
noAdmin: 'No admin setup',
|
||||
orgManaged: 'Org managed',
|
||||
adminRequired: 'Admin required'
|
||||
@@ -4013,6 +4052,7 @@ export default {
|
||||
},
|
||||
links: {
|
||||
countryCheck: 'Check country association',
|
||||
countryChange: 'Change country association',
|
||||
geminiWebActivation: 'Activate Gemini Web',
|
||||
gcpProject: 'Open GCP Console'
|
||||
}
|
||||
@@ -4757,6 +4797,7 @@ export default {
|
||||
noData: 'No data.',
|
||||
loadingText: 'loading',
|
||||
ready: 'ready',
|
||||
autoRefreshRemaining: 'Remaining {seconds}s',
|
||||
requestsTotal: 'Requests (total)',
|
||||
slaScope: 'SLA scope:',
|
||||
tokens: 'Tokens',
|
||||
@@ -6778,6 +6819,10 @@ export default {
|
||||
notFoundDesc: 'This custom page does not exist or has been removed.',
|
||||
notConfiguredTitle: 'Page URL not configured',
|
||||
notConfiguredDesc: 'The URL for this custom page has not been properly configured.',
|
||||
tableOfContents: 'Contents',
|
||||
copyCode: 'Copy',
|
||||
copiedCode: 'Copied',
|
||||
copyCodeFailed: 'Failed'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
|
||||
@@ -369,7 +369,24 @@ export default {
|
||||
updatedAt: '更新日期:{date}',
|
||||
empty: '暂无正文内容',
|
||||
loginAgreement: '登录条款',
|
||||
adminCompliance: '部署与运营合规承诺'
|
||||
adminCompliance: '部署与运营合规承诺',
|
||||
loginAgreementPrompt: {
|
||||
checkboxPrefix: '我已阅读并同意',
|
||||
documentSeparator: '、',
|
||||
noticeTitle: '继续登录前需要先同意最新条款。',
|
||||
noticeDescription: '未同意前,账号密码输入和快捷登录会保持禁用。',
|
||||
viewTerms: '查看条款',
|
||||
dialogTitle: '条款更新通知',
|
||||
dialogDescription: '我们的服务条款已于 {date} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。',
|
||||
recently: '近期',
|
||||
relatedDocuments: '相关文档',
|
||||
reject: '拒绝',
|
||||
accept: '同意并继续',
|
||||
loginRejectedWarning: '未同意最新条款前,无法输入账号密码或使用快捷登录。',
|
||||
loginRequiredWarning: '请先阅读并同意最新条款后再登录。',
|
||||
registerRejectedWarning: '未同意最新条款前,无法注册或使用快捷登录。',
|
||||
registerRequiredWarning: '请先阅读并同意最新条款后再注册。'
|
||||
}
|
||||
},
|
||||
|
||||
// Navigation
|
||||
@@ -585,6 +602,7 @@ export default {
|
||||
},
|
||||
linuxdoCallbackPageTitle: 'LinuxDo 登录回调',
|
||||
dingtalkCallbackPageTitle: '钉钉登录回调',
|
||||
dingtalkProviderName: '钉钉',
|
||||
oidcCallbackPageTitle: 'OIDC 登录回调',
|
||||
oauthCallbackPageTitle: 'OAuth 回调',
|
||||
wechatProviderName: '微信',
|
||||
@@ -2267,6 +2285,15 @@ export default {
|
||||
public: '公开',
|
||||
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
|
||||
accountsCount: '{count} 个账号',
|
||||
rateLabel: '倍率',
|
||||
accountFilters: {
|
||||
title: '账号过滤控制',
|
||||
oauthOnly: '仅允许 OAuth 账号',
|
||||
oauthOnlyEnabled: '已启用 — 排除 API Key 类型账号',
|
||||
privacySetOnly: '仅允许隐私保护已设置的账号',
|
||||
privacySetOnlyEnabled: '已启用 — Privacy 未设置的账号将被排除',
|
||||
disabled: '未启用'
|
||||
},
|
||||
enterGroupName: '请输入分组名称',
|
||||
optionalDescription: '可选描述',
|
||||
platformHint: '选择此分组关联的平台',
|
||||
@@ -2338,7 +2365,10 @@ export default {
|
||||
title: '自定义 /v1/models 模型列表',
|
||||
hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。',
|
||||
loading: '正在加载模型列表...',
|
||||
empty: '暂无可展示模型'
|
||||
empty: '暂无可展示模型',
|
||||
selectedSummary: '已选 {selected} / {total}',
|
||||
selectAll: '全选',
|
||||
invertSelection: '反选'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 客户端限制',
|
||||
@@ -2707,6 +2737,7 @@ export default {
|
||||
queueSize: '异步队列大小',
|
||||
blockStatus: '拦截 HTTP 状态码',
|
||||
blockMessage: '自定义拦截提示',
|
||||
defaultBlockMessage: '内容审计命中风险规则,请调整输入后重试',
|
||||
emailOnHit: '命中后发送邮件',
|
||||
emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
|
||||
autoBan: '自动封禁用户',
|
||||
@@ -4139,6 +4170,11 @@ export default {
|
||||
builtInTitle: '内置授权(Gemini CLI / Code Assist)',
|
||||
builtInDesc: '使用 Google 内置客户端 ID,无需管理员配置。',
|
||||
builtInRequirement: '需要 GCP 项目并填写 Project ID。',
|
||||
googleOneDesc: '个人账号,享受 Google One 订阅配额',
|
||||
codeAssistDesc: '企业级,需要 GCP 项目',
|
||||
codeAssistRequirement: '需要激活 GCP 项目并绑定信用卡',
|
||||
showAdvanced: '显示高级选项(自建 OAuth Client)',
|
||||
hideAdvanced: '隐藏高级选项(自建 OAuth Client)',
|
||||
gcpProjectLink: '创建项目',
|
||||
customTitle: '自定义授权(AI Studio OAuth)',
|
||||
customDesc: '使用管理员预设的 OAuth 客户端,适合组织管理。',
|
||||
@@ -4146,6 +4182,9 @@ export default {
|
||||
badges: {
|
||||
recommended: '推荐',
|
||||
highConcurrency: '高并发',
|
||||
individuals: '推荐个人用户',
|
||||
noGcp: '无需 GCP',
|
||||
enterprise: '企业用户',
|
||||
noAdmin: '无需管理员配置',
|
||||
orgManaged: '组织管理',
|
||||
adminRequired: '需要管理员'
|
||||
@@ -4165,6 +4204,7 @@ export default {
|
||||
},
|
||||
links: {
|
||||
countryCheck: '检查归属地',
|
||||
countryChange: '修改归属地',
|
||||
geminiWebActivation: '激活 Gemini Web',
|
||||
gcpProject: '打开 GCP 控制台'
|
||||
}
|
||||
@@ -4911,6 +4951,7 @@ export default {
|
||||
noData: '暂无数据',
|
||||
loadingText: '加载中...',
|
||||
ready: '就绪',
|
||||
autoRefreshRemaining: '剩余 {seconds}s',
|
||||
requestsTotal: '请求(总计)',
|
||||
slaScope: 'SLA 范围:',
|
||||
tokens: 'Token数',
|
||||
@@ -6932,6 +6973,10 @@ export default {
|
||||
notFoundDesc: '该自定义页面不存在或已被删除。',
|
||||
notConfiguredTitle: '页面链接未配置',
|
||||
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
|
||||
tableOfContents: '目录',
|
||||
copyCode: '复制',
|
||||
copiedCode: '已复制',
|
||||
copyCodeFailed: '失败'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
class="channel-tab"
|
||||
:class="activeTab === 'basic' ? 'channel-tab-active' : 'channel-tab-inactive'"
|
||||
>
|
||||
{{ t('admin.channels.form.basicSettings', '基础设置') }}
|
||||
{{ t('admin.channels.form.basicSettings', 'Basic Settings') }}
|
||||
</button>
|
||||
<!-- Platform Tabs (only enabled) -->
|
||||
<button
|
||||
@@ -230,7 +230,7 @@
|
||||
|
||||
<!-- Platform Management -->
|
||||
<div class="space-y-3">
|
||||
<label class="input-label mb-0">{{ t('admin.channels.form.platformConfig', '平台配置') }}</label>
|
||||
<label class="input-label mb-0">{{ t('admin.channels.form.platformConfig', 'Platform Configuration') }}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="p in platformOrder"
|
||||
@@ -283,7 +283,7 @@
|
||||
<label class="input-label text-xs">
|
||||
{{ t('admin.channels.form.groups', 'Associated Groups') }} <span class="text-red-500">*</span>
|
||||
<span v-if="section.group_ids.length > 0" class="ml-1 font-normal text-gray-400">
|
||||
({{ t('admin.channels.form.selectedCount', { count: section.group_ids.length }, `已选 ${section.group_ids.length} 个`) }})
|
||||
({{ t('admin.channels.form.selectedCount', { count: section.group_ids.length }, `${section.group_ids.length} selected`) }})
|
||||
</span>
|
||||
</label>
|
||||
<div class="max-h-40 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-900">
|
||||
@@ -1434,14 +1434,14 @@ async function handleSubmit() {
|
||||
for (const section of form.platforms.filter(s => s.enabled)) {
|
||||
if (section.group_ids.length === 0) {
|
||||
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
|
||||
appStore.showError(t('admin.channels.noGroupsSelected', { platform: platformLabel }, `${platformLabel} 平台未选择分组,请至少选择一个分组或禁用该平台`))
|
||||
appStore.showError(t('admin.channels.noGroupsSelected', { platform: platformLabel }, `${platformLabel} has no selected group. Select at least one group or disable this platform.`))
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
for (const entry of section.model_pricing) {
|
||||
if (entry.models.length === 0) {
|
||||
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
|
||||
appStore.showError(t('admin.channels.emptyModelsInPricing', { platform: platformLabel }, `${platformLabel} 平台下有定价条目未添加模型,请添加模型或删除该条目`))
|
||||
appStore.showError(t('admin.channels.emptyModelsInPricing', { platform: platformLabel }, `${platformLabel} has a pricing entry without models. Add models or remove the entry.`))
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
@@ -1460,7 +1460,7 @@ async function handleSubmit() {
|
||||
appStore.showError(
|
||||
t('admin.channels.modelConflict',
|
||||
{ model1: pricingConflict[0], model2: pricingConflict[1] },
|
||||
`模型模式 '${pricingConflict[0]}' 和 '${pricingConflict[1]}' 冲突:匹配范围重叠`)
|
||||
`Model patterns '${pricingConflict[0]}' and '${pricingConflict[1]}' conflict: overlapping match range.`)
|
||||
)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
@@ -1473,7 +1473,7 @@ async function handleSubmit() {
|
||||
appStore.showError(
|
||||
t('admin.channels.mappingConflict',
|
||||
{ model1: mappingConflict[0], model2: mappingConflict[1] },
|
||||
`模型映射源 '${mappingConflict[0]}' 和 '${mappingConflict[1]}' 冲突:匹配范围重叠`)
|
||||
`Mapping source patterns '${mappingConflict[0]}' and '${mappingConflict[1]}' conflict: overlapping match range.`)
|
||||
)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
@@ -1488,7 +1488,7 @@ async function handleSubmit() {
|
||||
if ((entry.billing_mode === 'per_request' || entry.billing_mode === 'image') &&
|
||||
(entry.per_request_price == null || entry.per_request_price === '') &&
|
||||
(!entry.intervals || entry.intervals.length === 0)) {
|
||||
appStore.showError(t('admin.channels.form.perRequestPriceRequired', '按次/图片计费模式必须设置默认价格或至少一个计费层级'))
|
||||
appStore.showError(t('admin.channels.form.perRequestPriceRequired', 'Per-request price or billing tiers required for per-request/image billing mode'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,8 +683,12 @@
|
||||
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
已选 {{ createModelsListSelectedCount }} /
|
||||
{{ createModelsListState.items.length }}
|
||||
{{
|
||||
t("admin.groups.modelsList.selectedSummary", {
|
||||
selected: createModelsListSelectedCount,
|
||||
total: createModelsListState.items.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
@@ -692,14 +696,14 @@
|
||||
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
|
||||
@click="selectAllModelsListItems(createModelsListState)"
|
||||
>
|
||||
全选
|
||||
{{ t("admin.groups.modelsList.selectAll") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
@click="invertModelsListSelection(createModelsListState)"
|
||||
>
|
||||
反选
|
||||
{{ t("admin.groups.modelsList.invertSelection") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1297,20 +1301,20 @@
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
账号过滤控制
|
||||
{{ t("admin.groups.accountFilters.title") }}
|
||||
</h4>
|
||||
|
||||
<!-- require_oauth_only toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400"
|
||||
>仅允许 OAuth 账号</label
|
||||
>{{ t("admin.groups.accountFilters.oauthOnly") }}</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{
|
||||
createForm.require_oauth_only
|
||||
? "已启用 — 排除 API Key 类型账号"
|
||||
: "未启用"
|
||||
? t("admin.groups.accountFilters.oauthOnlyEnabled")
|
||||
: t("admin.groups.accountFilters.disabled")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1341,13 +1345,13 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400"
|
||||
>仅允许隐私保护已设置的账号</label
|
||||
>{{ t("admin.groups.accountFilters.privacySetOnly") }}</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{
|
||||
createForm.require_privacy_set
|
||||
? "已启用 — Privacy 未设置的账号将被排除"
|
||||
: "未启用"
|
||||
? t("admin.groups.accountFilters.privacySetOnlyEnabled")
|
||||
: t("admin.groups.accountFilters.disabled")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1971,8 +1975,12 @@
|
||||
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
已选 {{ editModelsListSelectedCount }} /
|
||||
{{ editModelsListState.items.length }}
|
||||
{{
|
||||
t("admin.groups.modelsList.selectedSummary", {
|
||||
selected: editModelsListSelectedCount,
|
||||
total: editModelsListState.items.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
@@ -1980,14 +1988,14 @@
|
||||
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
|
||||
@click="selectAllModelsListItems(editModelsListState)"
|
||||
>
|
||||
全选
|
||||
{{ t("admin.groups.modelsList.selectAll") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
@click="invertModelsListSelection(editModelsListState)"
|
||||
>
|
||||
反选
|
||||
{{ t("admin.groups.modelsList.invertSelection") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2581,20 +2589,20 @@
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
账号过滤控制
|
||||
{{ t("admin.groups.accountFilters.title") }}
|
||||
</h4>
|
||||
|
||||
<!-- require_oauth_only toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400"
|
||||
>仅允许 OAuth 账号</label
|
||||
>{{ t("admin.groups.accountFilters.oauthOnly") }}</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{
|
||||
editForm.require_oauth_only
|
||||
? "已启用 — 排除 API Key 类型账号"
|
||||
: "未启用"
|
||||
? t("admin.groups.accountFilters.oauthOnlyEnabled")
|
||||
: t("admin.groups.accountFilters.disabled")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -2625,13 +2633,13 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400"
|
||||
>仅允许隐私保护已设置的账号</label
|
||||
>{{ t("admin.groups.accountFilters.privacySetOnly") }}</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{
|
||||
editForm.require_privacy_set
|
||||
? "已启用 — Privacy 未设置的账号将被排除"
|
||||
: "未启用"
|
||||
? t("admin.groups.accountFilters.privacySetOnlyEnabled")
|
||||
: t("admin.groups.accountFilters.disabled")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -3241,7 +3249,7 @@ const copyAccountsGroupOptions = computed(() => {
|
||||
);
|
||||
return eligibleGroups.map((g) => ({
|
||||
value: g.id,
|
||||
label: `${g.name} (${g.account_count || 0} 个账号)`,
|
||||
label: `${g.name} (${t("admin.groups.accountsCount", { count: g.account_count || 0 })})`,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -3256,7 +3264,7 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
|
||||
);
|
||||
return eligibleGroups.map((g) => ({
|
||||
value: g.id,
|
||||
label: `${g.name} (${g.account_count || 0} 个账号)`,
|
||||
label: `${g.name} (${t("admin.groups.accountsCount", { count: g.account_count || 0 })})`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1184,6 +1184,7 @@ const riskThresholdCategories = Object.keys(riskThresholdDefaults)
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const defaultBlockMessage = () => t('admin.riskControl.defaultBlockMessage')
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -1230,7 +1231,7 @@ const configForm = reactive({
|
||||
worker_count: 4,
|
||||
queue_size: 32768,
|
||||
block_status: 403,
|
||||
block_message: '内容审计命中风险规则,请调整输入后重试',
|
||||
block_message: defaultBlockMessage(),
|
||||
email_on_hit: true,
|
||||
auto_ban_enabled: true,
|
||||
cyber_policy_exclude_from_ban_count: false,
|
||||
@@ -1707,7 +1708,7 @@ function applyConfig(config: ContentModerationConfig) {
|
||||
configForm.worker_count = config.worker_count || 4
|
||||
configForm.queue_size = config.queue_size || 32768
|
||||
configForm.block_status = config.block_status || 403
|
||||
configForm.block_message = config.block_message || '内容审计命中风险规则,请调整输入后重试'
|
||||
configForm.block_message = config.block_message || defaultBlockMessage()
|
||||
configForm.email_on_hit = config.email_on_hit ?? true
|
||||
configForm.auto_ban_enabled = config.auto_ban_enabled ?? true
|
||||
configForm.cyber_policy_exclude_from_ban_count = config.cyber_policy_exclude_from_ban_count ?? false
|
||||
@@ -1788,7 +1789,7 @@ async function saveConfig() {
|
||||
worker_count: Number(configForm.worker_count) || 4,
|
||||
queue_size: Number(configForm.queue_size) || 32768,
|
||||
block_status: Number(configForm.block_status) || 403,
|
||||
block_message: configForm.block_message || '内容审计命中风险规则,请调整输入后重试',
|
||||
block_message: configForm.block_message || defaultBlockMessage(),
|
||||
email_on_hit: configForm.email_on_hit,
|
||||
auto_ban_enabled: configForm.auto_ban_enabled,
|
||||
cyber_policy_exclude_from_ban_count: configForm.cyber_policy_exclude_from_ban_count,
|
||||
|
||||
@@ -2537,7 +2537,7 @@
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_display_name_attr_name"
|
||||
type="text"
|
||||
placeholder="钉钉姓名"
|
||||
:placeholder="localText('钉钉姓名', 'DingTalk Name')"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -2583,7 +2583,7 @@
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_corp_email_attr_name"
|
||||
type="text"
|
||||
placeholder="钉钉企业邮箱"
|
||||
:placeholder="localText('钉钉企业邮箱', 'DingTalk Corporate Email')"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -2629,7 +2629,7 @@
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_dept_attr_name"
|
||||
type="text"
|
||||
placeholder="钉钉部门"
|
||||
:placeholder="localText('钉钉部门', 'DingTalk Department')"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -7435,22 +7435,22 @@ function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
|
||||
return [
|
||||
{
|
||||
id: "terms",
|
||||
title: "服务条款",
|
||||
title: localText("服务条款", "Terms of Service"),
|
||||
content_md: "",
|
||||
},
|
||||
{
|
||||
id: "usage-policy",
|
||||
title: "使用政策",
|
||||
title: localText("使用政策", "Usage Policy"),
|
||||
content_md: "",
|
||||
},
|
||||
{
|
||||
id: "supported-regions",
|
||||
title: "支持的国家和地区",
|
||||
title: localText("支持的国家和地区", "Supported Countries and Regions"),
|
||||
content_md: "",
|
||||
},
|
||||
{
|
||||
id: "service-specific-terms",
|
||||
title: "服务特定条款",
|
||||
title: localText("服务特定条款", "Service-Specific Terms"),
|
||||
content_md: "",
|
||||
},
|
||||
];
|
||||
@@ -7989,9 +7989,9 @@ const form = reactive<SettingsForm>({
|
||||
dingtalk_connect_sync_corp_email_attr_key: "dingtalk_email",
|
||||
dingtalk_connect_sync_display_name_attr_key: "dingtalk_name",
|
||||
dingtalk_connect_sync_dept_attr_key: "dingtalk_department",
|
||||
dingtalk_connect_sync_corp_email_attr_name: "钉钉企业邮箱",
|
||||
dingtalk_connect_sync_display_name_attr_name: "钉钉姓名",
|
||||
dingtalk_connect_sync_dept_attr_name: "钉钉部门",
|
||||
dingtalk_connect_sync_corp_email_attr_name: localText("钉钉企业邮箱", "DingTalk Corporate Email"),
|
||||
dingtalk_connect_sync_display_name_attr_name: localText("钉钉姓名", "DingTalk Name"),
|
||||
dingtalk_connect_sync_dept_attr_name: localText("钉钉部门", "DingTalk Department"),
|
||||
wechat_connect_enabled: false,
|
||||
wechat_connect_app_id: "",
|
||||
wechat_connect_app_secret: "",
|
||||
@@ -8148,7 +8148,7 @@ const authSourceDefaultsMeta = computed(() => [
|
||||
},
|
||||
{
|
||||
source: "dingtalk" as AuthSourceType,
|
||||
title: "钉钉",
|
||||
title: t("auth.dingtalkProviderName"),
|
||||
description: localText(
|
||||
"通过钉钉首次注册或首次绑定时应用。",
|
||||
"Applied on first signup or first bind through DingTalk.",
|
||||
|
||||
@@ -889,7 +889,7 @@ function handleToolbarRefresh() {
|
||||
|
||||
<template v-if="props.autoRefreshEnabled && props.autoRefreshCountdown !== undefined">
|
||||
<span>·</span>
|
||||
<span>剩余 {{ props.autoRefreshCountdown }}s</span>
|
||||
<span>{{ t('admin.ops.autoRefreshRemaining', { seconds: props.autoRefreshCountdown }) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ const timeRangeOptions = [
|
||||
]
|
||||
|
||||
const filterLevelOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'debug', label: 'debug' },
|
||||
{ value: 'info', label: 'info' },
|
||||
{ value: 'warn', label: 'warn' },
|
||||
@@ -203,7 +203,7 @@ const fetchLogs = async () => {
|
||||
total.value = res.total || 0
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to fetch logs', err)
|
||||
appStore.showError(err?.response?.data?.detail || '系统日志加载失败')
|
||||
appStore.showError(err?.response?.data?.detail || 'Failed to load system logs')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -246,17 +246,17 @@ const saveRuntimeConfig = async () => {
|
||||
runtimeConfig.caller = saved.caller
|
||||
runtimeConfig.stacktrace_level = saved.stacktrace_level
|
||||
runtimeConfig.retention_days = saved.retention_days
|
||||
appStore.showSuccess('日志运行时配置已生效')
|
||||
appStore.showSuccess('Runtime log configuration is active')
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to save runtime log config', err)
|
||||
appStore.showError(err?.response?.data?.detail || '保存日志配置失败')
|
||||
appStore.showError(err?.response?.data?.detail || 'Failed to save log configuration')
|
||||
} finally {
|
||||
runtimeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetRuntimeConfig = async () => {
|
||||
const ok = window.confirm('确认回滚为启动配置(env/yaml)并立即生效?')
|
||||
const ok = window.confirm('Reset to startup configuration (env/yaml) and apply immediately?')
|
||||
if (!ok) return
|
||||
|
||||
runtimeSaving.value = true
|
||||
@@ -269,18 +269,18 @@ const resetRuntimeConfig = async () => {
|
||||
runtimeConfig.caller = saved.caller
|
||||
runtimeConfig.stacktrace_level = saved.stacktrace_level
|
||||
runtimeConfig.retention_days = saved.retention_days
|
||||
appStore.showSuccess('已回滚到启动日志配置')
|
||||
appStore.showSuccess('Reset to startup log configuration')
|
||||
await fetchHealth()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to reset runtime log config', err)
|
||||
appStore.showError(err?.response?.data?.detail || '回滚日志配置失败')
|
||||
appStore.showError(err?.response?.data?.detail || 'Failed to reset log configuration')
|
||||
} finally {
|
||||
runtimeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupCurrentFilter = async () => {
|
||||
const ok = window.confirm('确认按当前筛选条件清理系统日志?该操作不可撤销。')
|
||||
const ok = window.confirm('Clean up system logs matching the current filters? This cannot be undone.')
|
||||
if (!ok) return
|
||||
try {
|
||||
const payload = {
|
||||
@@ -298,12 +298,12 @@ const cleanupCurrentFilter = async () => {
|
||||
q: filters.q.trim() || undefined
|
||||
}
|
||||
const res = await opsAPI.cleanupSystemLogs(payload)
|
||||
appStore.showSuccess(`清理完成,删除 ${res.deleted || 0} 条日志`)
|
||||
appStore.showSuccess(`Cleanup complete. Deleted ${res.deleted || 0} log entries.`)
|
||||
page.value = 1
|
||||
await Promise.all([fetchLogs(), fetchHealth()])
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to cleanup logs', err)
|
||||
appStore.showError(err?.response?.data?.detail || '清理系统日志失败')
|
||||
appStore.showError(err?.response?.data?.detail || 'Failed to clean up system logs')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,41 +368,41 @@ onMounted(async () => {
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/60">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">系统日志</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">默认按最新时间倒序,支持筛选搜索与按条件清理。</p>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">System Logs</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Newest logs are shown first. Filter, search, and clean up by condition.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">队列 {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">写入 {{ health.written_count }}</span>
|
||||
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">丢弃 {{ health.dropped_count }}</span>
|
||||
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">失败 {{ health.write_failed_count }}</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">Queue {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">Written {{ health.written_count }}</span>
|
||||
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Dropped {{ health.dropped_count }}</span>
|
||||
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">Failed {{ health.write_failed_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800/70">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">运行时日志配置(实时生效)</div>
|
||||
<span v-if="runtimeLoading" class="text-xs text-gray-500">加载中...</span>
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">Runtime Log Configuration (applies immediately)</div>
|
||||
<span v-if="runtimeLoading" class="text-xs text-gray-500">Loading...</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
级别
|
||||
Level
|
||||
<Select v-model="runtimeConfig.level" class="mt-1" :options="runtimeLevelOptions" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
堆栈阈值
|
||||
Stacktrace threshold
|
||||
<Select v-model="runtimeConfig.stacktrace_level" class="mt-1" :options="stacktraceLevelOptions" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
采样初始
|
||||
Sampling initial
|
||||
<input v-model.number="runtimeConfig.sampling_initial" type="number" min="1" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
采样后续
|
||||
Sampling thereafter
|
||||
<input v-model.number="runtimeConfig.sampling_thereafter" type="number" min="1" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
保留天数
|
||||
Retention days
|
||||
<input v-model.number="runtimeConfig.retention_days" type="number" min="1" max="3650" class="input mt-1" />
|
||||
</label>
|
||||
<div class="md:col-span-2 xl:col-span-6">
|
||||
@@ -419,38 +419,38 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
|
||||
{{ runtimeSaving ? '保存中...' : '保存并生效' }}
|
||||
{{ runtimeSaving ? 'Saving...' : 'Save and apply' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
|
||||
回滚默认值
|
||||
Reset defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">最近写入错误:{{ health.last_error }}</p>
|
||||
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">Latest write error: {{ health.last_error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-5">
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
时间范围
|
||||
Time range
|
||||
<Select v-model="filters.time_range" class="mt-1" :options="timeRangeOptions" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
开始时间(可选)
|
||||
Start time (optional)
|
||||
<input v-model="filters.start_time" type="datetime-local" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
结束时间(可选)
|
||||
End time (optional)
|
||||
<input v-model="filters.end_time" type="datetime-local" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
级别
|
||||
Level
|
||||
<Select v-model="filters.level" class="mt-1" :options="filterLevelOptions" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
组件
|
||||
<input v-model="filters.component" type="text" class="input mt-1" placeholder="如 http.access" />
|
||||
Component
|
||||
<input v-model="filters.component" type="text" class="input mt-1" placeholder="e.g. http.access" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
request_id
|
||||
@@ -473,36 +473,36 @@ onMounted(async () => {
|
||||
<input v-model="filters.account_id" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
平台
|
||||
Platform
|
||||
<input v-model="filters.platform" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
模型
|
||||
Model
|
||||
<input v-model="filters.model" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
关键词
|
||||
<input v-model="filters.q" type="text" class="input mt-1" placeholder="消息/request_id" />
|
||||
Keyword
|
||||
<input v-model="filters.q" type="text" class="input mt-1" placeholder="message/request_id" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">查询</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">按当前筛选清理</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">刷新健康指标</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">Search</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">Reset</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">Clean current filters</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">Refresh health</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">加载中...</div>
|
||||
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">暂无系统日志</div>
|
||||
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">Loading...</div>
|
||||
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">No system logs</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<table class="min-w-full table-fixed divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">时间</th>
|
||||
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">级别</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">日志详细信息</th>
|
||||
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">Time</th>
|
||||
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">Level</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">Log Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
|
||||
@@ -295,7 +295,7 @@ const totpTempToken = ref('')
|
||||
const totpCode = ref('')
|
||||
const totpError = ref('')
|
||||
const totpUserEmailMasked = ref('')
|
||||
const providerName = '钉钉'
|
||||
const providerName = t('auth.dingtalkProviderName')
|
||||
|
||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
|
||||
|
||||
@@ -397,7 +397,7 @@ function rejectLoginAgreement(): void {
|
||||
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
|
||||
agreementAccepted.value = false
|
||||
showAgreementModal.value = false
|
||||
appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.loginRejectedWarning'))
|
||||
}
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
@@ -428,7 +428,7 @@ function validateForm(): boolean {
|
||||
let isValid = true
|
||||
|
||||
if (agreementGateActive.value) {
|
||||
appStore.showWarning('请先阅读并同意最新条款后再登录。')
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.loginRequiredWarning'))
|
||||
if (loginAgreementMode.value !== 'checkbox') {
|
||||
showAgreementModal.value = true
|
||||
}
|
||||
|
||||
@@ -567,7 +567,7 @@ function rejectLoginAgreement(): void {
|
||||
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
|
||||
agreementAccepted.value = false
|
||||
showAgreementModal.value = false
|
||||
appStore.showWarning('未同意最新条款前,无法注册或使用快捷登录。')
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.registerRejectedWarning'))
|
||||
}
|
||||
|
||||
// ==================== Promo Code Validation ====================
|
||||
@@ -757,7 +757,7 @@ function validateForm(): boolean {
|
||||
let isValid = true
|
||||
|
||||
if (agreementGateActive.value) {
|
||||
appStore.showWarning('请先阅读并同意最新条款后再注册。')
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.registerRequiredWarning'))
|
||||
if (loginAgreementMode.value !== 'checkbox') {
|
||||
showAgreementModal.value = true
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
class="toc-sidebar"
|
||||
>
|
||||
<div class="toc-header">
|
||||
<span class="toc-title">目录</span>
|
||||
<span class="toc-title">{{ t('customPage.tableOfContents') }}</span>
|
||||
<button class="toc-close-btn" @click="tocVisible = false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
@@ -64,7 +64,7 @@
|
||||
@click="tocVisible = true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||
<span class="ml-1 text-xs">目录</span>
|
||||
<span class="ml-1 text-xs">{{ t('customPage.tableOfContents') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -317,16 +317,16 @@ function injectCopyButtons() {
|
||||
if (pre.querySelector('.copy-btn')) return
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'copy-btn'
|
||||
btn.textContent = '复制'
|
||||
btn.textContent = t('customPage.copyCode')
|
||||
btn.addEventListener('click', async () => {
|
||||
const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? ''
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
btn.textContent = '已复制 ✓'
|
||||
setTimeout(() => { btn.textContent = '复制' }, 2000)
|
||||
btn.textContent = t('customPage.copiedCode')
|
||||
setTimeout(() => { btn.textContent = t('customPage.copyCode') }, 2000)
|
||||
} catch {
|
||||
btn.textContent = '失败'
|
||||
setTimeout(() => { btn.textContent = '复制' }, 2000)
|
||||
btn.textContent = t('customPage.copyCodeFailed')
|
||||
setTimeout(() => { btn.textContent = t('customPage.copyCode') }, 2000)
|
||||
}
|
||||
})
|
||||
pre.style.position = 'relative'
|
||||
|
||||
Reference in New Issue
Block a user