Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
xueshiji
2026-07-01 14:00:51 +08:00
committed by GitHub
71 changed files with 3620 additions and 1931 deletions
+2
View File
@@ -561,6 +561,7 @@ export interface SystemSettings {
claude_oauth_system_prompt_blocks: string;
enable_anthropic_cache_ttl_1h_injection: boolean;
rewrite_message_cache_control: boolean;
enable_client_dateline_normalization: boolean;
antigravity_user_agent_version: string;
openai_codex_user_agent: string;
// codex_cli_only 加固
@@ -811,6 +812,7 @@ export interface UpdateSettingsRequest {
claude_oauth_system_prompt_blocks?: string;
enable_anthropic_cache_ttl_1h_injection?: boolean;
rewrite_message_cache_control?: boolean;
enable_client_dateline_normalization?: boolean;
antigravity_user_agent_version?: string;
openai_codex_user_agent?: string;
// codex_cli_only 加固
+52 -6
View File
@@ -11,6 +11,8 @@ import type {
PaginatedResponse,
TrendDataPoint,
ModelStat,
GroupStat,
UsageRequestType,
UserErrorRequest,
UserErrorRequestDetail,
UserErrorListParams
@@ -57,6 +59,14 @@ export interface TrendParams {
start_date?: string
end_date?: string
granularity?: 'day' | 'hour'
api_key_id?: number
model?: string
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}
export interface TrendResponse {
@@ -91,6 +101,22 @@ export interface ApiKeyDailyUsageResponse {
end_date: string
}
export interface UsageDashboardSnapshotV2Params extends TrendParams {
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
}
export interface UsageDashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
}
/**
* List usage logs with optional filters
* @param page - Page number (default: 1)
@@ -141,10 +167,12 @@ export async function query(
* @returns Usage statistics
*/
export async function getStats(
period: string = 'today',
paramsOrPeriod: (UsageQueryParams & { period?: string; timezone?: string }) | string = 'today',
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = { period }
const params: Record<string, unknown> = typeof paramsOrPeriod === 'string'
? { period: paramsOrPeriod }
: { ...paramsOrPeriod }
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
@@ -251,6 +279,15 @@ export async function getDashboardTrend(params?: TrendParams): Promise<TrendResp
export async function getDashboardModels(params?: {
start_date?: string
end_date?: string
api_key_id?: number
model?: string
model_source?: 'requested'
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
return data
@@ -273,6 +310,16 @@ export async function getMyApiKeyDailyUsage(
return data
}
export async function getDashboardSnapshotV2(
params?: UsageDashboardSnapshotV2Params
): Promise<UsageDashboardSnapshotV2Response> {
const { data } = await apiClient.get<UsageDashboardSnapshotV2Response>(
'/usage/dashboard/snapshot-v2',
{ params }
)
return data
}
export interface BatchApiKeyUsageStats {
api_key_id: number
today_actual_cost: number
@@ -308,11 +355,9 @@ export async function getDashboardApiKeysUsage(
}
export async function listMyErrorRequests(
params: UserErrorListParams,
config: { signal?: AbortSignal } = {}
params: UserErrorListParams
): Promise<PaginatedResponse<UserErrorRequest>> {
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
...config,
params
})
return data
@@ -335,10 +380,11 @@ export const usageAPI = {
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardSnapshotV2,
getDashboardApiKeysUsage,
// Error requests
listMyErrorRequests,
getMyErrorDetail,
getMyErrorDetail
}
export default usageAPI
@@ -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
@@ -68,9 +68,14 @@
${{ (stats?.total_actual_cost || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ (stats?.total_account_cost || 0).toFixed(4) }}</span>
<span> · </span>
<span>{{ t('usage.standardCost') }} ${{ (stats?.total_cost || 0).toFixed(4) }}</span>
<template v-if="showAccountCost && totalAccountCost != null">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ totalAccountCost.toFixed(4) }}</span>
<span> · </span>
</template>
<span>
{{ t('usage.standardCost') }}
<span :class="{ 'line-through': strikeStandardCost }">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</span>
</p>
</div>
</div>
@@ -84,14 +89,30 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
import type { UsageStatsResponse } from '@/types'
import Icon from '@/components/icons/Icon.vue'
defineProps<{ stats: AdminUsageStatsResponse | null }>()
const props = withDefaults(defineProps<{
stats: (AdminUsageStatsResponse | UsageStatsResponse) | null
showAccountCost?: boolean
strikeStandardCost?: boolean
}>(), {
showAccountCost: true,
strikeStandardCost: false,
})
const { t } = useI18n()
const totalAccountCost = computed(() => {
const stats = props.stats as (AdminUsageStatsResponse & { total_account_cost?: number }) | null
return stats?.total_account_cost ?? null
})
const showAccountCost = computed(() => props.showAccountCost)
const strikeStandardCost = computed(() => props.strikeStandardCost)
const formatDuration = (ms: number) =>
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
@@ -68,7 +68,7 @@
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
</div>
<div class="break-all text-gray-700 dark:text-gray-300">
<div v-if="showUpstreamEndpoint" class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.upstream') }}:</span>
<span class="ml-1">{{ row.upstream_endpoint?.trim() || '-' }}</span>
</div>
@@ -163,7 +163,7 @@
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
<div v-if="showAccountBilling && row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
A ${{ accountBilled(row).toFixed(6) }}
</div>
</div>
@@ -380,20 +380,22 @@
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<!-- Account billing (separated from user billing) -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
<template v-if="showAccountBilling">
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
</template>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
@@ -449,19 +451,25 @@ interface Props {
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
showAccountBilling?: boolean
showUpstreamEndpoint?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
defaultSortOrder: 'asc',
showAccountBilling: true,
showUpstreamEndpoint: true
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n()
const showAccountBilling = props.showAccountBilling
const showUpstreamEndpoint = props.showUpstreamEndpoint
// Tooltip state - cost
const tooltipVisible = ref(false)
@@ -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) {
@@ -89,13 +89,14 @@
<tbody>
<template v-for="item in displayEndpointStats" :key="item.endpoint">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown(item.endpoint)"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="enableBreakdown ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && toggleBreakdown(item.endpoint)"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" :title="item.endpoint">
<td class="max-w-[180px] truncate py-1.5 font-medium" :class="enableBreakdown ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'" :title="item.endpoint">
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ item.endpoint }}
</span>
</td>
@@ -159,6 +160,7 @@ const props = withDefaults(
source?: EndpointSource
showMetricToggle?: boolean
showSourceToggle?: boolean
enableBreakdown?: boolean
startDate?: string
endDate?: string
filters?: Record<string, any>
@@ -171,7 +173,8 @@ const props = withDefaults(
metric: 'tokens',
source: 'inbound',
showMetricToggle: false,
showSourceToggle: false
showSourceToggle: false,
enableBreakdown: true
}
)
@@ -45,7 +45,7 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th v-if="showAccountCost" class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
@@ -53,17 +53,17 @@
<template v-for="group in displayGroupStats" :key="group.group_id">
<tr
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="group.group_id > 0 && toggleBreakdown('group', group.group_id)"
:class="enableBreakdown && group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && group.group_id > 0 && toggleBreakdown('group', group.group_id)"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium"
:class="group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:class="enableBreakdown && group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="group.group_name || String(group.group_id)"
>
<span class="inline-flex items-center gap-1">
<svg v-if="group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown && group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ group.group_name || t('admin.dashboard.noGroup') }}
</span>
</td>
@@ -76,7 +76,7 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(group.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
@@ -85,10 +85,11 @@
</tr>
<!-- User breakdown sub-rows -->
<tr v-if="expandedKey === `group-${group.group_id}`">
<td colspan="6" class="p-0">
<td :colspan="distributionColspan" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
:show-account-cost="showAccountCost"
/>
</td>
</tr>
@@ -127,6 +128,8 @@ const props = withDefaults(defineProps<{
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
enableBreakdown?: boolean
showAccountCost?: boolean
startDate?: string
endDate?: string
filters?: Record<string, any>
@@ -134,6 +137,8 @@ const props = withDefaults(defineProps<{
loading: false,
metric: 'tokens',
showMetricToggle: false,
enableBreakdown: true,
showAccountCost: true,
})
const emit = defineEmits<{
@@ -143,6 +148,8 @@ const emit = defineEmits<{
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const showAccountCost = computed(() => props.showAccountCost)
const distributionColspan = computed(() => showAccountCost.value ? 6 : 5)
const toggleBreakdown = async (type: string, id: number | string) => {
const key = `${type}-${id}`
@@ -114,23 +114,25 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th v-if="showAccountCost" class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<template v-for="model in displayModelStats" :key="model.model">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown('model', model.model)"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="enableBreakdown ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && toggleBreakdown('model', model.model)"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
class="max-w-[100px] truncate py-1.5 font-medium"
:class="enableBreakdown ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="model.model"
>
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ model.model }}
</span>
</td>
@@ -143,7 +145,7 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(model.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
@@ -151,10 +153,11 @@
</td>
</tr>
<tr v-if="expandedKey === `model-${model.model}`">
<td colspan="6" class="p-0">
<td :colspan="distributionColspan" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
:show-account-cost="showAccountCost"
/>
</td>
</tr>
@@ -270,6 +273,8 @@ const props = withDefaults(defineProps<{
metric?: DistributionMetric
showSourceToggle?: boolean
showMetricToggle?: boolean
enableBreakdown?: boolean
showAccountCost?: boolean
rankingLoading?: boolean
rankingError?: boolean
startDate?: string
@@ -288,6 +293,8 @@ const props = withDefaults(defineProps<{
metric: 'tokens',
showSourceToggle: false,
showMetricToggle: false,
enableBreakdown: true,
showAccountCost: true,
rankingLoading: false,
rankingError: false
})
@@ -328,6 +335,8 @@ const emit = defineEmits<{
}>()
const enableRankingView = computed(() => props.enableRankingView)
const showAccountCost = computed(() => props.showAccountCost)
const distributionColspan = computed(() => showAccountCost.value ? 6 : 5)
const activeView = ref<'model_distribution' | 'spending_ranking'>('model_distribution')
const chartColors = [
@@ -25,7 +25,7 @@
<td class="py-1 text-right text-green-600 dark:text-green-400">
${{ formatCost(user.actual_cost) }}
</td>
<td class="py-1 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(user.account_cost) }}
</td>
<td class="py-1 pr-1 text-right text-gray-400 dark:text-gray-500">
@@ -38,16 +38,23 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { UserBreakdownItem } from '@/types'
const { t } = useI18n()
defineProps<{
const props = withDefaults(defineProps<{
items: UserBreakdownItem[]
loading?: boolean
}>()
showAccountCost?: boolean
}>(), {
loading: false,
showAccountCost: true,
})
const showAccountCost = computed(() => props.showAccountCost)
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -56,7 +63,8 @@ const formatTokens = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
const formatCost = (value: number | undefined | null): string => {
if (value == null) return '0.0000'
if (value >= 1000) return (value / 1000).toFixed(2) + 'K'
if (value >= 1) return value.toFixed(2)
if (value >= 0.01) return value.toFixed(3)
@@ -10,6 +10,7 @@ const messages: Record<string, string> = {
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.accountCost': 'Account Cost',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
@@ -111,4 +112,22 @@ describe('GroupDistributionChart', () => {
})
expect(label).toBe('group-b: $0.900 (90.0%)')
})
it('can hide account cost for user usage stats without account_cost', () => {
const wrapper = mount(GroupDistributionChart, {
props: {
groupStats,
showAccountCost: false,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
expect(wrapper.text()).not.toContain('Account Cost')
expect(wrapper.findAll('thead th')).toHaveLength(5)
expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5)
})
})
@@ -17,6 +17,7 @@ const messages: Record<string, string> = {
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.accountCost': 'Account Cost',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
@@ -126,6 +127,24 @@ describe('ModelDistributionChart', () => {
expect(label).toBe('model-b: $1.40 (87.5%)')
})
it('can hide account cost for user usage stats without account_cost', () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats,
showAccountCost: false,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
expect(wrapper.text()).not.toContain('Account Cost')
expect(wrapper.findAll('thead th')).toHaveLength(5)
expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5)
})
it('renders Others in the spending ranking table and uses a dedicated chart color', async () => {
const wrapper = mount(ModelDistributionChart, {
props: {
@@ -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
+11 -8
View File
@@ -31,6 +31,7 @@ export const claudeModels = [
'claude-opus-4-7',
'claude-opus-4-8',
'claude-sonnet-4-6',
'claude-sonnet-5',
'claude-fable-5'
]
@@ -243,6 +244,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
const anthropicPresetMappings = [
{ label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'claude-sonnet-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
@@ -303,16 +305,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' },
@@ -328,6 +330,7 @@ const bedrockPresetMappings = [
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'us.anthropic.claude-sonnet-5-v1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.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: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
@@ -483,12 +486,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
+114 -2
View File
@@ -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',
@@ -2412,6 +2442,21 @@ export default {
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.",
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.",
intervalValidation: {
negativeMin: 'Interval #{index}: minimum token count ({value}) cannot be negative',
maxPositive: 'Interval #{index}: maximum token count ({value}) must be greater than 0',
maxGreaterThanMin: 'Interval #{index}: maximum token count ({max}) must be greater than minimum token count ({min})',
negativePrice: 'Interval #{index}: {field} cannot be negative',
unboundedLast: 'Interval #{index}: an unbounded interval (empty maximum token count) must be last',
overlap: 'Intervals #{previousIndex} and #{currentIndex} overlap: previous upper bound ({previousMax}) is greater than current lower bound ({currentMin})',
price: {
inputPrice: 'input price',
outputPrice: 'output price',
cacheWritePrice: 'cache write price',
cacheReadPrice: 'cache read price',
perRequestPrice: 'per-request price'
}
},
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
columns: {
name: 'Name',
@@ -2446,10 +2491,15 @@ export default {
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
cacheWritePriceShort: 'Cache W',
cacheReadPriceShort: 'Cache R',
imageTokenPrice: 'Image Output',
imageOutputPrice: 'Image Output Price',
pricePlaceholder: 'Default',
intervals: 'Context Intervals (optional)',
minTokens: 'Min',
maxTokens: 'Max',
inclusive: '(inclusive)',
addInterval: 'Add Interval',
requestTiers: 'Request Tiers',
imageTiers: 'Image Tiers (Per Request)',
@@ -2631,6 +2681,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',
@@ -3988,6 +4039,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.',
@@ -3995,6 +4051,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'
@@ -4014,6 +4073,7 @@ export default {
},
links: {
countryCheck: 'Check country association',
countryChange: 'Change country association',
geminiWebActivation: 'Activate Gemini Web',
gcpProject: 'Open GCP Console'
}
@@ -4758,6 +4818,52 @@ export default {
noData: 'No data.',
loadingText: 'loading',
ready: 'ready',
autoRefreshRemaining: 'Remaining {seconds}s',
systemLogs: {
title: 'System Logs',
description: 'Newest logs are shown first. Filter, search, and clean up by condition.',
queue: 'Queue',
written: 'Written',
dropped: 'Dropped',
failed: 'Failed',
runtimeConfig: 'Runtime Log Configuration (applies immediately)',
all: 'All',
level: 'Level',
stacktraceThreshold: 'Stacktrace threshold',
samplingInitial: 'Sampling initial',
samplingThereafter: 'Sampling thereafter',
retentionDays: 'Retention days',
caller: 'caller',
sampling: 'sampling',
saveAndApply: 'Save and apply',
resetDefaults: 'Reset defaults',
latestWriteError: 'Latest write error:',
timeRange: 'Time range',
startTime: 'Start time (optional)',
endTime: 'End time (optional)',
component: 'Component',
componentPlaceholder: 'e.g. http.access',
keyId: 'KEY ID',
platform: 'Platform',
model: 'Model',
keyword: 'Keyword',
keywordPlaceholder: 'message/request_id',
search: 'Search',
cleanCurrentFilters: 'Clean current filters',
refreshHealth: 'Refresh health',
empty: 'No system logs',
time: 'Time',
logDetails: 'Log Details',
loadFailed: 'Failed to load system logs',
runtimeConfigActive: 'Runtime log configuration is active',
runtimeConfigSaveFailed: 'Failed to save log configuration',
resetRuntimeConfigConfirm: 'Reset to startup configuration (env/yaml) and apply immediately?',
runtimeConfigReset: 'Reset to startup log configuration',
runtimeConfigResetFailed: 'Failed to reset log configuration',
cleanupConfirm: 'Clean up system logs matching the current filters? This cannot be undone.',
cleanupSuccess: 'Cleanup complete. Deleted {count} log entries.',
cleanupFailed: 'Failed to clean up system logs'
},
requestsTotal: 'Requests (total)',
slaScope: 'SLA scope:',
tokens: 'Tokens',
@@ -5827,6 +5933,8 @@ export default {
anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.',
rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints',
rewriteMessageCacheControlHint: 'Default off: preserve client cache_control on message content blocks. When enabled, client breakpoints are stripped and proxy breakpoints are injected for clients that do not manage caching themselves.',
clientDatelineNormalization: 'Client Dateline Normalization',
clientDatelineNormalizationHint: 'Default on. Rewrites the "Today\'s date is …" sentence in Anthropic OAuth/Setup Token requests back to a canonical ASCII apostrophe and hyphen date format, erasing steganographic fingerprint bits some clients inject when they detect a non-official base URL. Applies to system prompts and <system-reminder> blocks only; API-Key accounts are unaffected.',
antigravityUserAgentVersion: 'Antigravity UA Version',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.',
@@ -6779,6 +6887,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
+114 -2
View File
@@ -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 客户端限制',
@@ -2488,6 +2518,21 @@ export default {
duplicateModels: '模型「{0}」在多个定价条目中重复',
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。",
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。",
intervalValidation: {
negativeMin: '区间 #{index}:最小 token 数({value})不能为负数',
maxPositive: '区间 #{index}:最大 token 数({value})必须大于 0',
maxGreaterThanMin: '区间 #{index}:最大 token 数({max})必须大于最小 token 数({min})',
negativePrice: '区间 #{index}:{field}不能为负数',
unboundedLast: '区间 #{index}:无上限区间(最大 token 数为空)必须放在最后',
overlap: '区间 #{previousIndex} 和 #{currentIndex} 重叠:前一个上界({previousMax})大于当前下界({currentMin})',
price: {
inputPrice: '输入价格',
outputPrice: '输出价格',
cacheWritePrice: '缓存写入价格',
cacheReadPrice: '缓存读取价格',
perRequestPrice: '单次价格'
}
},
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
columns: {
name: '名称',
@@ -2522,10 +2567,15 @@ export default {
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
cacheWritePriceShort: '缓存写',
cacheReadPriceShort: '缓存读',
imageTokenPrice: '图片输出',
imageOutputPrice: '图片输出价格',
pricePlaceholder: '默认',
intervals: '上下文区间定价(可选)',
minTokens: '最小',
maxTokens: '最大',
inclusive: '(含)',
addInterval: '添加区间',
requestTiers: '按次计费层级',
imageTiers: '图片计费层级(按次)',
@@ -2707,6 +2757,7 @@ export default {
queueSize: '异步队列大小',
blockStatus: '拦截 HTTP 状态码',
blockMessage: '自定义拦截提示',
defaultBlockMessage: '内容审计命中风险规则,请调整输入后重试',
emailOnHit: '命中后发送邮件',
emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
autoBan: '自动封禁用户',
@@ -4140,6 +4191,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 客户端,适合组织管理。',
@@ -4147,6 +4203,9 @@ export default {
badges: {
recommended: '推荐',
highConcurrency: '高并发',
individuals: '推荐个人用户',
noGcp: '无需 GCP',
enterprise: '企业用户',
noAdmin: '无需管理员配置',
orgManaged: '组织管理',
adminRequired: '需要管理员'
@@ -4166,6 +4225,7 @@ export default {
},
links: {
countryCheck: '检查归属地',
countryChange: '修改归属地',
geminiWebActivation: '激活 Gemini Web',
gcpProject: '打开 GCP 控制台'
}
@@ -4912,6 +4972,52 @@ export default {
noData: '暂无数据',
loadingText: '加载中...',
ready: '就绪',
autoRefreshRemaining: '剩余 {seconds}s',
systemLogs: {
title: '系统日志',
description: '优先显示最新日志,可按条件筛选、搜索和清理。',
queue: '队列',
written: '已写入',
dropped: '已丢弃',
failed: '写入失败',
runtimeConfig: '运行时日志配置(立即生效)',
all: '全部',
level: '级别',
stacktraceThreshold: '堆栈阈值',
samplingInitial: '采样初始条数',
samplingThereafter: '后续采样间隔',
retentionDays: '保留天数',
caller: '调用方',
sampling: '采样',
saveAndApply: '保存并应用',
resetDefaults: '重置默认值',
latestWriteError: '最近写入错误:',
timeRange: '时间范围',
startTime: '开始时间(可选)',
endTime: '结束时间(可选)',
component: '组件',
componentPlaceholder: '例如 http.access',
keyId: 'KEY ID',
platform: '平台',
model: '模型',
keyword: '关键词',
keywordPlaceholder: 'message/request_id',
search: '搜索',
cleanCurrentFilters: '清理当前筛选结果',
refreshHealth: '刷新健康状态',
empty: '暂无系统日志',
time: '时间',
logDetails: '日志详情',
loadFailed: '加载系统日志失败',
runtimeConfigActive: '运行时日志配置已生效',
runtimeConfigSaveFailed: '保存日志配置失败',
resetRuntimeConfigConfirm: '确定要重置为启动配置(env/yaml)并立即应用吗?',
runtimeConfigReset: '已重置为启动日志配置',
runtimeConfigResetFailed: '重置日志配置失败',
cleanupConfirm: '确定要清理匹配当前筛选条件的系统日志吗?此操作不可撤销。',
cleanupSuccess: '清理完成,已删除 {count} 条日志。',
cleanupFailed: '清理系统日志失败'
},
requestsTotal: '请求(总计)',
slaScope: 'SLA 范围:',
tokens: 'Token数',
@@ -5981,6 +6087,8 @@ export default {
anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。',
rewriteMessageCacheControl: '改写消息缓存断点',
rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。',
clientDatelineNormalization: '客户端 dateline 归一化',
clientDatelineNormalizationHint: '默认开启。将 Anthropic OAuth/Setup Token 请求体中 "Today\'s date is …" 语句里的撇号与日期分隔符还原为 ASCII 撇号 + 短横线 (2026-07-01) 的规范形态,抹除某些客户端在检测到非官方 base URL 时注入的隐写指纹位。仅作用于 system prompt 与 <system-reminder> 块内,API Key 账号不受影响。',
antigravityUserAgentVersion: 'Antigravity UA 版本',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
@@ -6933,6 +7041,10 @@ export default {
notFoundDesc: '该自定义页面不存在或已被删除。',
notConfiguredTitle: '页面链接未配置',
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
tableOfContents: '目录',
copyCode: '复制',
copiedCode: '已复制',
copyCodeFailed: '失败'
},
// Announcements Page
+8 -5
View File
@@ -1303,6 +1303,7 @@ export interface UsageLog {
// User-Agent
user_agent: string | null
ip_address?: string | null
// Cache TTL Override
cache_ttl_overridden: boolean
@@ -1336,9 +1337,6 @@ export interface AdminUsageLog extends UsageLog {
channel_id?: number | null
billing_tier?: string | null
// 用户请求 IP(仅管理员可见)
ip_address?: string | null
// 最小账号信息(仅管理员接口返回)
account?: UsageLogAccountSummary
}
@@ -1481,6 +1479,9 @@ export interface UsageStatsResponse {
total_actual_cost: number // 实际扣除
average_duration_ms: number
models?: Record<string, number>
endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[]
endpoint_paths?: EndpointStat[]
}
// ==================== Trend & Chart Types ====================
@@ -1507,7 +1508,7 @@ export interface ModelStat {
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
account_cost: number // 账号成本
account_cost?: number // 账号成本(仅管理员接口返回)
}
export interface EndpointStat {
@@ -1525,7 +1526,7 @@ export interface GroupStat {
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
account_cost: number // 账号成本
account_cost?: number // 账号成本(仅管理员接口返回)
}
export interface UserBreakdownItem {
@@ -1700,8 +1701,10 @@ export interface UsageQueryParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
start_date?: string
end_date?: string
timezone?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
+9 -11
View File
@@ -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') }}
</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') }}</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 }) }})
</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 }))
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 }))
activeTab.value = section.platform
return
}
@@ -1459,8 +1459,7 @@ async function handleSubmit() {
if (pricingConflict) {
appStore.showError(
t('admin.channels.modelConflict',
{ model1: pricingConflict[0], model2: pricingConflict[1] },
`模型模式 '${pricingConflict[0]}' 和 '${pricingConflict[1]}' 冲突:匹配范围重叠`)
{ model1: pricingConflict[0], model2: pricingConflict[1] })
)
activeTab.value = section.platform
return
@@ -1472,8 +1471,7 @@ async function handleSubmit() {
if (mappingConflict) {
appStore.showError(
t('admin.channels.mappingConflict',
{ model1: mappingConflict[0], model2: mappingConflict[1] },
`模型映射源 '${mappingConflict[0]}' 和 '${mappingConflict[1]}' 冲突:匹配范围重叠`)
{ model1: mappingConflict[0], model2: mappingConflict[1] })
)
activeTab.value = section.platform
return
@@ -1488,7 +1486,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'))
return
}
}
@@ -1498,7 +1496,7 @@ async function handleSubmit() {
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (!entry.intervals || entry.intervals.length === 0) continue
const intervalErr = validateIntervals(entry.intervals, entry.billing_mode)
const intervalErr = validateIntervals(entry.intervals, entry.billing_mode, t)
if (intervalErr) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')
+32 -24
View File
@@ -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>
@@ -1343,20 +1347,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>
@@ -1387,13 +1391,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>
@@ -2017,8 +2021,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
@@ -2026,14 +2034,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>
@@ -2673,20 +2681,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>
@@ -2717,13 +2725,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>
@@ -3333,7 +3341,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 })})`,
}));
});
@@ -3348,7 +3356,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 })})`,
}));
});
+4 -3
View File
@@ -1191,6 +1191,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)
@@ -1237,7 +1238,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,
@@ -1714,7 +1715,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
@@ -1795,7 +1796,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,
+39 -11
View File
@@ -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>
@@ -4368,6 +4368,31 @@
<Toggle v-model="form.rewrite_message_cache_control" />
</div>
<!-- 客户端 dateline 归一化(仅 Anthropic OAuth/SetupToken) -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.clientDatelineNormalization",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.clientDatelineNormalizationHint",
)
}}
</p>
</div>
<Toggle
v-model="form.enable_client_dateline_normalization"
/>
</div>
<!-- Antigravity UA 版本 -->
<div>
<label
@@ -7435,22 +7460,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 +8014,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: "",
@@ -8078,6 +8103,7 @@ const form = reactive<SettingsForm>({
claude_oauth_system_prompt_blocks: defaultClaudeOAuthSystemPromptBlocks,
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
// codex_cli_only 加固
@@ -8148,7 +8174,7 @@ const authSourceDefaultsMeta = computed(() => [
},
{
source: "dingtalk" as AuthSourceType,
title: "钉钉",
title: t("auth.dingtalkProviderName"),
description: localText(
"通过钉钉首次注册或首次绑定时应用。",
"Applied on first signup or first bind through DingTalk.",
@@ -9282,6 +9308,8 @@ async function saveSettings() {
enable_anthropic_cache_ttl_1h_injection:
form.enable_anthropic_cache_ttl_1h_injection,
rewrite_message_cache_control: form.rewrite_message_cache_control,
enable_client_dateline_normalization:
form.enable_client_dateline_normalization,
antigravity_user_agent_version:
form.antigravity_user_agent_version?.trim() || "",
openai_codex_user_agent:
@@ -383,6 +383,7 @@ const baseSettingsResponse = {
claude_oauth_system_prompt_blocks: "",
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
payment_enabled: true,
@@ -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>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import { useAppStore } from '@/stores'
const appStore = useAppStore()
const { t } = useI18n()
const props = withDefaults(defineProps<{
platformFilter?: string
@@ -81,13 +83,13 @@ const timeRangeOptions = [
{ value: '30d', label: '30d' }
]
const filterLevelOptions = [
{ value: '', label: '全部' },
const filterLevelOptions = computed(() => [
{ value: '', label: t('admin.ops.systemLogs.all') },
{ value: 'debug', label: 'debug' },
{ value: 'info', label: 'info' },
{ value: 'warn', label: 'warn' },
{ value: 'error', label: 'error' }
]
])
const levelBadgeClass = (level: string) => {
const v = String(level || '').toLowerCase()
@@ -203,7 +205,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 || t('admin.ops.systemLogs.loadFailed'))
} finally {
loading.value = false
}
@@ -246,17 +248,17 @@ const saveRuntimeConfig = async () => {
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('日志运行时配置已生效')
appStore.showSuccess(t('admin.ops.systemLogs.runtimeConfigActive'))
} 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 || t('admin.ops.systemLogs.runtimeConfigSaveFailed'))
} finally {
runtimeSaving.value = false
}
}
const resetRuntimeConfig = async () => {
const ok = window.confirm('确认回滚为启动配置(env/yaml)并立即生效?')
const ok = window.confirm(t('admin.ops.systemLogs.resetRuntimeConfigConfirm'))
if (!ok) return
runtimeSaving.value = true
@@ -269,18 +271,18 @@ const resetRuntimeConfig = async () => {
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('已回滚到启动日志配置')
appStore.showSuccess(t('admin.ops.systemLogs.runtimeConfigReset'))
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 || t('admin.ops.systemLogs.runtimeConfigResetFailed'))
} finally {
runtimeSaving.value = false
}
}
const cleanupCurrentFilter = async () => {
const ok = window.confirm('确认按当前筛选条件清理系统日志?该操作不可撤销。')
const ok = window.confirm(t('admin.ops.systemLogs.cleanupConfirm'))
if (!ok) return
try {
const payload = {
@@ -298,12 +300,12 @@ const cleanupCurrentFilter = async () => {
q: filters.q.trim() || undefined
}
const res = await opsAPI.cleanupSystemLogs(payload)
appStore.showSuccess(`清理完成,删除 ${res.deleted || 0} 条日志`)
appStore.showSuccess(t('admin.ops.systemLogs.cleanupSuccess', { count: res.deleted || 0 }))
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 || t('admin.ops.systemLogs.cleanupFailed'))
}
}
@@ -368,41 +370,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">{{ t('admin.ops.systemLogs.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.systemLogs.description') }}</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">{{ t('admin.ops.systemLogs.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">{{ t('admin.ops.systemLogs.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">{{ t('admin.ops.systemLogs.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">{{ t('admin.ops.systemLogs.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">{{ t('admin.ops.systemLogs.runtimeConfig') }}</div>
<span v-if="runtimeLoading" class="text-xs text-gray-500">{{ t('common.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">
级别
{{ t('admin.ops.systemLogs.level') }}
<Select v-model="runtimeConfig.level" class="mt-1" :options="runtimeLevelOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
堆栈阈值
{{ t('admin.ops.systemLogs.stacktraceThreshold') }}
<Select v-model="runtimeConfig.stacktrace_level" class="mt-1" :options="stacktraceLevelOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样初始
{{ t('admin.ops.systemLogs.samplingInitial') }}
<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">
采样后续
{{ t('admin.ops.systemLogs.samplingThereafter') }}
<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">
保留天数
{{ t('admin.ops.systemLogs.retentionDays') }}
<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">
@@ -410,47 +412,47 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.caller" type="checkbox" />
caller
{{ t('admin.ops.systemLogs.caller') }}
</label>
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
sampling
{{ t('admin.ops.systemLogs.sampling') }}
</label>
</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 ? t('common.saving') : t('admin.ops.systemLogs.saveAndApply') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
回滚默认值
{{ t('admin.ops.systemLogs.resetDefaults') }}
</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">{{ t('admin.ops.systemLogs.latestWriteError') }} {{ 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">
时间范围
{{ t('admin.ops.systemLogs.timeRange') }}
<Select v-model="filters.time_range" class="mt-1" :options="timeRangeOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
开始时间(可选)
{{ t('admin.ops.systemLogs.startTime') }}
<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">
结束时间(可选)
{{ t('admin.ops.systemLogs.endTime') }}
<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">
级别
{{ t('admin.ops.systemLogs.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" />
{{ t('admin.ops.systemLogs.component') }}
<input v-model="filters.component" type="text" class="input mt-1" :placeholder="t('admin.ops.systemLogs.componentPlaceholder')" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
request_id
@@ -465,7 +467,7 @@ onMounted(async () => {
<input v-model="filters.user_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
KEY ID
{{ t('admin.ops.systemLogs.keyId') }}
<input v-model="filters.api_key_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
@@ -473,36 +475,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">
平台
{{ t('admin.ops.systemLogs.platform') }}
<input v-model="filters.platform" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
模型
{{ t('admin.ops.systemLogs.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" />
{{ t('admin.ops.systemLogs.keyword') }}
<input v-model="filters.q" type="text" class="input mt-1" :placeholder="t('admin.ops.systemLogs.keywordPlaceholder')" />
</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">{{ t('admin.ops.systemLogs.search') }}</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">{{ t('common.reset') }}</button>
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">{{ t('admin.ops.systemLogs.cleanCurrentFilters') }}</button>
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">{{ t('admin.ops.systemLogs.refreshHealth') }}</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">{{ t('common.loading') }}</div>
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.ops.systemLogs.empty') }}</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">{{ t('admin.ops.systemLogs.time') }}</th>
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.level') }}</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.logDetails') }}</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')
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+7 -7
View File
@@ -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'
+6 -20
View File
@@ -275,7 +275,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
@@ -540,10 +540,6 @@ const localeCode = computed(() => {
return undefined
})
interface PaymentAmountFormatOptions {
subscription?: boolean
}
function currencyFractionDigits(currency: string): number {
try {
return new Intl.NumberFormat(undefined, {
@@ -567,22 +563,12 @@ function ceilPaymentAmount(value: number, currency: string): number {
return Math.ceil(value * factor) / factor
}
function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
if (currency !== DEFAULT_PAYMENT_CURRENCY) return value
return roundPaymentAmount(value / balanceRechargeMultiplier.value, currency)
}
function subscriptionPaymentAmount(value: number): number {
return subscriptionPaymentAmountForCurrency(value, selectedCurrency.value)
}
function formatSelectedPaymentAmount(value: number, options: PaymentAmountFormatOptions = {}): string {
const amount = options.subscription ? subscriptionPaymentAmount(value) : value
return formatPaymentAmount(amount, selectedCurrency.value, localeCode.value)
function formatSelectedPaymentAmount(value: number): string {
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
}
function formatSelectedSubscriptionPaymentAmount(value: number): string {
return formatSelectedPaymentAmount(value, { subscription: true })
return formatSelectedPaymentAmount(roundPaymentAmount(value, selectedCurrency.value))
}
const methodOptions = computed<PaymentMethodOption[]>(() =>
@@ -631,7 +617,7 @@ const canSubmit = computed(() =>
const subPaymentAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
return subscriptionPaymentAmount(price)
return roundPaymentAmount(price, selectedCurrency.value)
})
const subFeeAmount = computed(() => {
@@ -645,7 +631,7 @@ const subTotalAmount = computed(() => {
})
function subscriptionTotalAmountForCurrency(value: number, currency: string): number {
const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency)
const paymentAmount = roundPaymentAmount(value, currency)
if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount
const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency)
return roundPaymentAmount(paymentAmount + fee, currency)
File diff suppressed because it is too large Load Diff
@@ -236,28 +236,29 @@ async function mountSubscriptionConfirm(options: Parameters<typeof checkoutInfoW
}
describe('PaymentView subscription confirmation amounts', () => {
it('shows converted CNY pay amount for plan price, original price, and create button', async () => {
it('keeps subscription plan price independent from balance recharge multiplier', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
balance_recharge_multiplier: 4,
},
method: {
currency: 'CNY',
},
plan: {
price: 7.99,
original_price: 9.99,
price: 200,
original_price: 300,
},
})
const text = wrapper.text()
const convertedPrice = formatPaymentAmount(57.07, 'CNY')
const convertedOriginalPrice = formatPaymentAmount(71.36, 'CNY')
const planPrice = formatPaymentAmount(200, 'CNY')
const originalPrice = formatPaymentAmount(300, 'CNY')
const convertedByRechargeMultiplier = formatPaymentAmount(50, 'CNY')
expect(text).toContain(convertedPrice)
expect(text).toContain(convertedOriginalPrice)
expect(text).not.toContain(formatPaymentAmount(7.99, 'CNY'))
expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true)
expect(text).toContain(planPrice)
expect(text).toContain(originalPrice)
expect(text).not.toContain(convertedByRechargeMultiplier)
expect(wrapper.findAll('button').some(button => button.text().includes(planPrice))).toBe(true)
})
it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => {
@@ -293,10 +294,10 @@ describe('PaymentView subscription confirmation amounts', () => {
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
})
it('adds fee rate after CNY multiplier conversion to match backend pay_amount', async () => {
it('adds fee rate to the direct subscription plan price to match backend pay_amount', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
balance_recharge_multiplier: 4,
recharge_fee_rate: 2.5,
},
method: {
@@ -308,11 +309,11 @@ describe('PaymentView subscription confirmation amounts', () => {
})
const text = wrapper.text()
const convertedPrice = formatPaymentAmount(57.07, 'CNY')
const fee = formatPaymentAmount(1.43, 'CNY')
const total = formatPaymentAmount(58.5, 'CNY')
const price = formatPaymentAmount(7.99, 'CNY')
const fee = formatPaymentAmount(0.20, 'CNY')
const total = formatPaymentAmount(8.19, 'CNY')
expect(text).toContain(convertedPrice)
expect(text).toContain(price)
expect(text).toContain(fee)
expect(text).toContain(total)
expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)
@@ -1,13 +1,26 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import UsageView from '../UsageView.vue'
const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, showInfo } = vi.hoisted(() => ({
const {
query,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
list,
getAvailable,
showError,
showWarning,
showSuccess,
showInfo,
} = vi.hoisted(() => ({
query: vi.fn(),
getStatsByDateRange: vi.fn(),
getStats: vi.fn(),
getDashboardModels: vi.fn(),
getDashboardSnapshotV2: vi.fn(),
list: vi.fn(),
getAvailable: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
@@ -15,62 +28,55 @@ const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, s
}))
const messages: Record<string, string> = {
'usage.costDetails': 'Cost Breakdown',
'admin.usage.inputCost': 'Input Cost',
'admin.usage.outputCost': 'Output Cost',
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
'admin.usage.cacheReadCost': 'Cache Read Cost',
'usage.inputTokenPrice': 'Input price',
'usage.outputTokenPrice': 'Output price',
'usage.perMillionTokens': '/ 1M tokens',
'usage.serviceTier': 'Service tier',
'usage.serviceTierPriority': 'Fast',
'usage.serviceTierFlex': 'Flex',
'usage.serviceTierStandard': 'Standard',
'usage.rate': 'Rate',
'usage.original': 'Original',
'usage.billed': 'Billed',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.reasoningEffort': 'Reasoning Effort',
'usage.type': 'Type',
'usage.tokens': 'Tokens',
'usage.cost': 'Cost',
'usage.firstToken': 'First Token',
'usage.duration': 'Duration',
'usage.time': 'Time',
'usage.userAgent': 'User Agent',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.dashboard.timeRange': 'Time range',
'admin.dashboard.granularity': 'Granularity',
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.users.columnSettings': 'Columns',
'admin.usage.group': 'Group',
'admin.usage.billingType': 'Billing type',
'admin.usage.billingMode': 'Billing mode',
'admin.usage.allTypes': 'All types',
'admin.usage.allBillingTypes': 'All billing types',
'admin.usage.billingTypeBalance': 'Balance',
'admin.usage.billingTypeSubscription': 'Subscription',
'admin.usage.allBillingModes': 'All billing modes',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.allGroups': 'All groups',
'admin.usage.allModels': 'All models',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.type': 'Type',
'usage.ws': 'WS',
'usage.stream': 'Stream',
'usage.sync': 'Sync',
'usage.exporting': 'Exporting',
'usage.exportCsv': 'Export CSV',
'usage.failedToLoad': 'Failed to load',
'usage.noDataToExport': 'No data',
'usage.preparingExport': 'Preparing export',
'usage.exportSuccess': 'Export success',
'usage.exportFailed': 'Export failed',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
}
vi.mock('@/api', () => ({
usageAPI: {
query,
getStatsByDateRange,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
},
keysAPI: {
list,
},
userGroupsAPI: {
getAvailable,
},
}))
vi.mock('@/stores/app', () => ({
@@ -87,178 +93,131 @@ vi.mock('vue-i18n', async () => {
}
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const TablePageLayoutStub = {
template: '<div><slot name="actions" /><slot name="filters" /><slot name="table" /><slot /></div>',
}
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
const simpleStub = { template: '<div><slot /></div>' }
const chartStub = { template: '<div />' }
const usageLog = {
id: 1,
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
ip_address: '203.0.113.10',
api_key: { name: 'demo-key' },
billing_mode: 'token',
request_type: 'sync',
stream: false,
}
describe('user UsageView tooltip', () => {
function mountUsageView() {
return mount(UsageView, {
global: {
stubs: {
AppLayout: simpleStub,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
UsageStatsCards: chartStub,
UsageTable: chartStub,
ModelDistributionChart: chartStub,
GroupDistributionChart: chartStub,
EndpointDistributionChart: chartStub,
TokenUsageTrend: chartStub,
},
},
})
}
describe('user UsageView', () => {
beforeEach(() => {
query.mockReset()
getStatsByDateRange.mockReset()
getStats.mockReset()
getDashboardModels.mockReset()
getDashboardSnapshotV2.mockReset()
list.mockReset()
getAvailable.mockReset()
showError.mockReset()
showWarning.mockReset()
showSuccess.mockReset()
showInfo.mockReset()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 20,
left: 20,
right: 120,
bottom: 40,
width: 100,
height: 20,
toJSON: () => ({}),
} as DOMRect)
;(globalThis as any).ResizeObserver = class {
observe() {}
disconnect() {}
}
query.mockResolvedValue({ items: [usageLog], total: 1, pages: 1 })
getStats.mockResolvedValue({
total_requests: 1,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_tokens: 0,
total_tokens: 30,
total_cost: 0.1,
total_actual_cost: 0.08,
average_duration_ms: 12,
endpoints: [],
upstream_endpoints: [],
endpoint_paths: [],
})
getDashboardModels.mockResolvedValue({
models: [{ model: 'gpt-5.4', requests: 1, input_tokens: 10, output_tokens: 20, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 30, cost: 0.1, actual_cost: 0.08 }],
start_date: '2026-03-08',
end_date: '2026-03-08',
})
getDashboardSnapshotV2.mockResolvedValue({
generated_at: '2026-03-08T00:00:00Z',
start_date: '2026-03-08',
end_date: '2026-03-08',
granularity: 'hour',
trend: [],
groups: [],
})
list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }] })
getAvailable.mockResolvedValue([{ id: 1, name: 'default' }])
})
it('shows fast service tier and unit prices in user tooltip', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-1',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 0,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 100,
total_cost: 0.1,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
it('loads logs, stats, model stats, and snapshot on first render', async () => {
mountUsageView()
await flushPromises()
await nextTick()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-1',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
}
setupState.tooltipVisible = true
await nextTick()
const text = wrapper.text()
expect(text).toContain('Service tier')
expect(text).toContain('Fast')
expect(text).toContain('Rate')
expect(text).toContain('1.00x')
expect(text).toContain('Billed')
expect(text).toContain('$0.092883')
expect(text).toContain('$5.0000 / 1M tokens')
expect(text).toContain('$30.0000 / 1M tokens')
expect(query).toHaveBeenCalled()
expect(getStats).toHaveBeenCalled()
expect(getDashboardModels).toHaveBeenCalled()
expect(getDashboardSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({
include_trend: true,
include_model_stats: false,
include_group_stats: true,
}))
expect(list).toHaveBeenCalledWith(1, 100)
expect(getAvailable).toHaveBeenCalled()
})
it('exports csv with input and output unit price columns', async () => {
const exportedLogs = [
{
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 100,
total_cost: 0.1,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
it('exports csv with current filters and without admin-only fields', async () => {
const wrapper = mountUsageView()
await flushPromises()
let exportedBlob: Blob | null = null
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
@@ -268,146 +227,38 @@ describe('user UsageView tooltip', () => {
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
await (wrapper.vm as any).exportToCSV()
expect(exportedBlob).not.toBeNull()
const hasSortedExportQuery = query.mock.calls.some((call) => {
const params = call[0] as Record<string, unknown> | undefined
const config = call[1]
return (
params?.page_size === 100 &&
params?.sort_by === 'created_at' &&
params?.sort_order === 'desc' &&
config === undefined
)
})
expect(hasSortedExportQuery).toBe(true)
expect(query).toHaveBeenCalledWith(expect.objectContaining({
page_size: 100,
sort_by: 'created_at',
sort_order: 'desc',
}))
expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled()
expect(csvContent).toContain('IP Address')
expect(csvContent).toContain('203.0.113.10')
expect(csvContent).toContain('Billed Cost')
expect(csvContent).toContain('Original Cost')
expect(csvContent).not.toContain('Upstream Endpoint')
expect(csvContent).not.toContain('account_cost')
expect(csvContent).not.toContain('account_rate_multiplier')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
it('exports historical image rows with image billing mode derived from image_count', async () => {
const exportedLogs = [
{
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
let exportedBlob: Blob | null = null
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
exportedBlob = blob as Blob
return 'blob:usage-export'
}) as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull()
const csv = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(exportedBlob as Blob)
})
expect(csv).toContain('Billing Mode')
expect(csv).toContain('Image')
expect(csv).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
})
it('does not display a 2K fallback for historical image rows with missing size', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-legacy-missing-image',
...usageLog,
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
@@ -416,125 +267,40 @@ describe('user UsageView tooltip', () => {
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
billing_mode: null,
ip_address: null,
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
it('shows image billing metadata in the user cost tooltip', async () => {
query.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
avg_duration_ms: 0,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
const wrapper = mountUsageView()
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-output-image',
actual_cost: 0.8,
total_cost: 0.8,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
billing_mode: null,
image_count: 2,
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 2 },
}
setupState.tooltipVisible = true
await nextTick()
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('4K')
expect(text).toContain('Size source')
expect(text).toContain('Upstream output')
expect(text).toContain('Input size')
expect(text).toContain('1024x1024')
expect(text).toContain('Output size')
expect(text).toContain('3840x2160')
expect(text).toContain('4K x 2')
await (wrapper.vm as any).exportToCSV()
expect(csvContent).toContain('Billing Mode')
expect(csvContent).toContain('Image')
expect(csvContent).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
})