mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix: preserve zh locale in i18n cleanup
This commit is contained in:
@@ -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">(inclusive)</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', 'Input') }} <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', 'Output') }} <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', 'Cache 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', 'Cache 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', 'Resolution') : t('admin.channels.form.tierLabel', 'Tier') }}
|
||||
{{ 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">(inclusive)</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', 'Price per Request') }} <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', 'No models added') }}
|
||||
{{ 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', 'Pricing Entry') }}
|
||||
{{ 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', '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', 'Type a model name and press Enter. Wildcard * is supported.')"
|
||||
: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', 'Billing Mode') }}
|
||||
{{ 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', 'Default prices (fallback when no interval matches)') }}
|
||||
{{ 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', 'Input') }}</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', 'Default')" />
|
||||
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', 'Output') }}</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', 'Default')" />
|
||||
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', 'Cache Write') }}</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', 'Default')" />
|
||||
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', 'Cache Read') }}</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', 'Default')" />
|
||||
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', 'Image Output') }}</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', 'Default')" />
|
||||
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', 'Context intervals (optional)') }}
|
||||
{{ 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', 'Add interval') }}
|
||||
+ {{ 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', 'Default per-request price (fallback when no tier matches)') }}
|
||||
{{ 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', 'Default')" />
|
||||
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', 'Request Tiers') }}
|
||||
{{ 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', 'Add tier') }}
|
||||
+ {{ 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', 'No tiers yet. Click add to configure per-request pricing.') }}
|
||||
{{ 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', 'Default image price (fallback when no tier matches)') }}
|
||||
{{ 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', 'Default')" />
|
||||
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', 'Image tiers (per request)') }}
|
||||
{{ 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', 'Add tier') }}
|
||||
+ {{ 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', 'Per Request') },
|
||||
{ value: 'image', label: t('admin.channels.billingMode.image', 'Image (per request)') }
|
||||
{ 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(() => {
|
||||
|
||||
@@ -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
|
||||
@@ -125,6 +127,7 @@ export function findModelConflict(models: string[]): [string, string] | null {
|
||||
export function validateIntervals(
|
||||
intervals: IntervalFormEntry[],
|
||||
mode: BillingMode = 'token',
|
||||
t?: TranslateFn,
|
||||
): string | null {
|
||||
if (!intervals || intervals.length === 0) return null
|
||||
|
||||
@@ -132,58 +135,104 @@ 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 | undefined,
|
||||
key: string,
|
||||
params: Record<string, unknown>,
|
||||
fallback: string,
|
||||
): string {
|
||||
return t ? t(`admin.channels.intervalValidation.${key}`, params) : fallback
|
||||
}
|
||||
|
||||
function intervalPriceLabel(t: TranslateFn | undefined, key: string, fallback: string): string {
|
||||
return t ? t(`admin.channels.intervalValidation.price.${key}`) : fallback
|
||||
}
|
||||
|
||||
function validateSingleInterval(iv: IntervalFormEntry, idx: number, t?: TranslateFn): string | null {
|
||||
const index = idx + 1
|
||||
if (iv.min_tokens < 0) {
|
||||
return `Interval #${idx + 1}: minimum token count (${iv.min_tokens}) cannot be negative`
|
||||
return intervalValidationMessage(
|
||||
t,
|
||||
'negativeMin',
|
||||
{ index, value: iv.min_tokens },
|
||||
`Interval #${index}: minimum token count (${iv.min_tokens}) cannot be negative`,
|
||||
)
|
||||
}
|
||||
if (iv.max_tokens != null) {
|
||||
if (iv.max_tokens <= 0) {
|
||||
return `Interval #${idx + 1}: maximum token count (${iv.max_tokens}) must be greater than 0`
|
||||
return intervalValidationMessage(
|
||||
t,
|
||||
'maxPositive',
|
||||
{ index, value: iv.max_tokens },
|
||||
`Interval #${index}: maximum token count (${iv.max_tokens}) must be greater than 0`,
|
||||
)
|
||||
}
|
||||
if (iv.max_tokens <= iv.min_tokens) {
|
||||
return `Interval #${idx + 1}: maximum token count (${iv.max_tokens}) must be greater than minimum token count (${iv.min_tokens})`
|
||||
return intervalValidationMessage(
|
||||
t,
|
||||
'maxGreaterThanMin',
|
||||
{ index, max: iv.max_tokens, min: iv.min_tokens },
|
||||
`Interval #${index}: maximum token count (${iv.max_tokens}) must be greater than minimum token count (${iv.min_tokens})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return validateIntervalPrices(iv, idx)
|
||||
return validateIntervalPrices(iv, idx, t)
|
||||
}
|
||||
|
||||
function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
|
||||
const prices: [string, number | string | null][] = [
|
||||
['input price', iv.input_price],
|
||||
['output price', iv.output_price],
|
||||
['cache write price', iv.cache_write_price],
|
||||
['cache read price', iv.cache_read_price],
|
||||
['per-request price', iv.per_request_price],
|
||||
function validateIntervalPrices(iv: IntervalFormEntry, idx: number, t?: TranslateFn): string | null {
|
||||
const index = idx + 1
|
||||
const prices: [string, string, number | string | null][] = [
|
||||
['inputPrice', 'input price', iv.input_price],
|
||||
['outputPrice', 'output price', iv.output_price],
|
||||
['cacheWritePrice', 'cache write price', iv.cache_write_price],
|
||||
['cacheReadPrice', 'cache read price', iv.cache_read_price],
|
||||
['perRequestPrice', 'per-request price', iv.per_request_price],
|
||||
]
|
||||
for (const [name, val] of prices) {
|
||||
for (const [key, fallbackName, val] of prices) {
|
||||
if (val != null && val !== '' && Number(val) < 0) {
|
||||
return `Interval #${idx + 1}: ${name} cannot be negative`
|
||||
const field = intervalPriceLabel(t, key, fallbackName)
|
||||
return intervalValidationMessage(
|
||||
t,
|
||||
'negativePrice',
|
||||
{ index, field },
|
||||
`Interval #${index}: ${field} cannot be negative`,
|
||||
)
|
||||
}
|
||||
}
|
||||
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 `Interval #${i + 1}: an unbounded interval (empty maximum token count) must be last`
|
||||
return intervalValidationMessage(
|
||||
t,
|
||||
'unboundedLast',
|
||||
{ index: i + 1 },
|
||||
`Interval #${i + 1}: an unbounded interval (empty maximum token count) must be last`,
|
||||
)
|
||||
}
|
||||
if (i === 0) continue
|
||||
const prev = sorted[i - 1]
|
||||
// (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
|
||||
if (prev.max_tokens == null || prev.max_tokens > sorted[i].min_tokens) {
|
||||
const prevMax = prev.max_tokens == null ? '∞' : String(prev.max_tokens)
|
||||
return `Intervals #${i} and #${i + 1} overlap: previous upper bound (${prevMax}) is greater than current lower bound (${sorted[i].min_tokens})`
|
||||
return intervalValidationMessage(
|
||||
t,
|
||||
'overlap',
|
||||
{ previousIndex: i, currentIndex: i + 1, previousMax: prevMax, currentMin: sorted[i].min_tokens },
|
||||
`Intervals #${i} and #${i + 1} overlap: previous upper bound (${prevMax}) is greater than current lower bound (${sorted[i].min_tokens})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -2442,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',
|
||||
@@ -2476,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)',
|
||||
@@ -4798,6 +4818,51 @@ export default {
|
||||
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',
|
||||
|
||||
@@ -2518,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: '名称',
|
||||
@@ -2552,10 +2567,15 @@ export default {
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
cacheReadPrice: '缓存读取',
|
||||
cacheWritePriceShort: '缓存写',
|
||||
cacheReadPriceShort: '缓存读',
|
||||
imageTokenPrice: '图片输出',
|
||||
imageOutputPrice: '图片输出价格',
|
||||
pricePlaceholder: '默认',
|
||||
intervals: '上下文区间定价(可选)',
|
||||
minTokens: '最小',
|
||||
maxTokens: '最大',
|
||||
inclusive: '(含)',
|
||||
addInterval: '添加区间',
|
||||
requestTiers: '按次计费层级',
|
||||
imageTiers: '图片计费层级(按次)',
|
||||
@@ -4952,6 +4972,51 @@ export default {
|
||||
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数',
|
||||
|
||||
@@ -1498,7 +1498,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')
|
||||
|
||||
@@ -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: 'All' },
|
||||
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 || 'Failed to load system logs')
|
||||
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('Runtime log configuration is active')
|
||||
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 || 'Failed to save log configuration')
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.systemLogs.runtimeConfigSaveFailed'))
|
||||
} finally {
|
||||
runtimeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetRuntimeConfig = async () => {
|
||||
const ok = window.confirm('Reset to startup configuration (env/yaml) and apply immediately?')
|
||||
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('Reset to startup log configuration')
|
||||
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 || 'Failed to reset log configuration')
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.systemLogs.runtimeConfigResetFailed'))
|
||||
} finally {
|
||||
runtimeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupCurrentFilter = async () => {
|
||||
const ok = window.confirm('Clean up system logs matching the current filters? This cannot be undone.')
|
||||
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(`Cleanup complete. Deleted ${res.deleted || 0} log entries.`)
|
||||
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 || 'Failed to clean up system logs')
|
||||
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">System Logs</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Newest logs are shown first. Filter, search, and clean up by condition.</p>
|
||||
<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">Queue {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">Written {{ health.written_count }}</span>
|
||||
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Dropped {{ health.dropped_count }}</span>
|
||||
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">Failed {{ health.write_failed_count }}</span>
|
||||
<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">Runtime Log Configuration (applies immediately)</div>
|
||||
<span v-if="runtimeLoading" class="text-xs text-gray-500">Loading...</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">
|
||||
Level
|
||||
{{ 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">
|
||||
Stacktrace threshold
|
||||
{{ 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">
|
||||
Sampling initial
|
||||
{{ 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">
|
||||
Sampling thereafter
|
||||
{{ 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">
|
||||
Retention days
|
||||
{{ 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 ? 'Saving...' : 'Save and apply' }}
|
||||
{{ runtimeSaving ? t('common.saving') : t('admin.ops.systemLogs.saveAndApply') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
|
||||
Reset defaults
|
||||
{{ 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">Latest write error: {{ 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">
|
||||
Time range
|
||||
{{ 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">
|
||||
Start time (optional)
|
||||
{{ 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">
|
||||
End time (optional)
|
||||
{{ 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">
|
||||
Level
|
||||
{{ 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">
|
||||
Component
|
||||
<input v-model="filters.component" type="text" class="input mt-1" placeholder="e.g. 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">
|
||||
Platform
|
||||
{{ 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">
|
||||
Model
|
||||
{{ 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">
|
||||
Keyword
|
||||
<input v-model="filters.q" type="text" class="input mt-1" placeholder="message/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">Search</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">Reset</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">Clean current filters</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">Refresh health</button>
|
||||
<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">Loading...</div>
|
||||
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">No system logs</div>
|
||||
<div v-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">Time</th>
|
||||
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">Level</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">Log Details</th>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user