Commit c7abfe67 authored by song's avatar song
Browse files

Merge remote-tracking branch 'upstream/main'

parents 4e3476a6 db6f53e2
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
class="input pr-8" class="input pr-8"
:placeholder="t('admin.usage.searchApiKeyPlaceholder')" :placeholder="t('admin.usage.searchApiKeyPlaceholder')"
@input="debounceApiKeySearch" @input="debounceApiKeySearch"
@focus="showApiKeyDropdown = true" @focus="onApiKeyFocus"
/> />
<button <button
v-if="filters.api_key_id" v-if="filters.api_key_id"
...@@ -62,7 +62,7 @@ ...@@ -62,7 +62,7 @@
</button> </button>
<div <div
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)" v-if="showApiKeyDropdown && apiKeyResults.length > 0"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
> >
<button <button
...@@ -85,9 +85,40 @@ ...@@ -85,9 +85,40 @@
</div> </div>
<!-- Account Filter --> <!-- Account Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]"> <div ref="accountSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.account') }}</label> <label class="input-label">{{ t('admin.usage.account') }}</label>
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" /> <input
v-model="accountKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchAccountPlaceholder')"
@input="debounceAccountSearch"
@focus="showAccountDropdown = true"
/>
<button
v-if="filters.account_id"
type="button"
@click="clearAccount"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear account filter"
>
</button>
<div
v-if="showAccountDropdown && (accountResults.length > 0 || accountKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="a in accountResults"
:key="a.id"
type="button"
@click="selectAccount(a)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ a.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ a.id }}</span>
</button>
</div>
</div> </div>
<!-- Stream Type Filter --> <!-- Stream Type Filter -->
...@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue') ...@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue')
const userSearchRef = ref<HTMLElement | null>(null) const userSearchRef = ref<HTMLElement | null>(null)
const apiKeySearchRef = ref<HTMLElement | null>(null) const apiKeySearchRef = ref<HTMLElement | null>(null)
const accountSearchRef = ref<HTMLElement | null>(null)
const userKeyword = ref('') const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([]) const userResults = ref<SimpleUser[]>([])
...@@ -177,9 +209,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([]) ...@@ -177,9 +209,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([])
const showApiKeyDropdown = ref(false) const showApiKeyDropdown = ref(false)
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
interface SimpleAccount {
id: number
name: string
}
const accountKeyword = ref('')
const accountResults = ref<SimpleAccount[]>([])
const showAccountDropdown = ref(false)
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }]) const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }]) const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
const streamTypeOptions = ref<SelectOption[]>([ const streamTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allTypes') }, { value: null, label: t('admin.usage.allTypes') },
...@@ -223,14 +263,10 @@ const debounceUserSearch = () => { ...@@ -223,14 +263,10 @@ const debounceUserSearch = () => {
const debounceApiKeySearch = () => { const debounceApiKeySearch = () => {
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout) if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
apiKeySearchTimeout = setTimeout(async () => { apiKeySearchTimeout = setTimeout(async () => {
if (!apiKeyKeyword.value) {
apiKeyResults.value = []
return
}
try { try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys( apiKeyResults.value = await adminAPI.usage.searchApiKeys(
filters.value.user_id, filters.value.user_id,
apiKeyKeyword.value apiKeyKeyword.value || ''
) )
} catch { } catch {
apiKeyResults.value = [] apiKeyResults.value = []
...@@ -238,11 +274,19 @@ const debounceApiKeySearch = () => { ...@@ -238,11 +274,19 @@ const debounceApiKeySearch = () => {
}, 300) }, 300)
} }
const selectUser = (u: SimpleUser) => { const selectUser = async (u: SimpleUser) => {
userKeyword.value = u.email userKeyword.value = u.email
showUserDropdown.value = false showUserDropdown.value = false
filters.value.user_id = u.id filters.value.user_id = u.id
clearApiKey() clearApiKey()
// Auto-load API keys for this user
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
} catch {
apiKeyResults.value = []
}
emitChange() emitChange()
} }
...@@ -274,15 +318,56 @@ const onClearApiKey = () => { ...@@ -274,15 +318,56 @@ const onClearApiKey = () => {
emitChange() emitChange()
} }
const debounceAccountSearch = () => {
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
accountSearchTimeout = setTimeout(async () => {
if (!accountKeyword.value) {
accountResults.value = []
return
}
try {
const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value })
accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name }))
} catch {
accountResults.value = []
}
}, 300)
}
const selectAccount = (a: SimpleAccount) => {
accountKeyword.value = a.name
showAccountDropdown.value = false
filters.value.account_id = a.id
emitChange()
}
const clearAccount = () => {
accountKeyword.value = ''
accountResults.value = []
showAccountDropdown.value = false
filters.value.account_id = undefined
emitChange()
}
const onApiKeyFocus = () => {
showApiKeyDropdown.value = true
// Trigger search if no results yet
if (apiKeyResults.value.length === 0) {
debounceApiKeySearch()
}
}
const onDocumentClick = (e: MouseEvent) => { const onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node | null const target = e.target as Node | null
if (!target) return if (!target) return
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false
if (!clickedInsideUser) showUserDropdown.value = false if (!clickedInsideUser) showUserDropdown.value = false
if (!clickedInsideApiKey) showApiKeyDropdown.value = false if (!clickedInsideApiKey) showApiKeyDropdown.value = false
if (!clickedInsideAccount) showAccountDropdown.value = false
} }
watch( watch(
...@@ -321,20 +406,27 @@ watch( ...@@ -321,20 +406,27 @@ watch(
} }
) )
watch(
() => filters.value.account_id,
(accountId) => {
if (!accountId) {
accountKeyword.value = ''
accountResults.value = []
}
}
)
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
try { try {
const [gs, ms, as] = await Promise.all([ const [gs, ms] = await Promise.all([
adminAPI.groups.list(1, 1000), adminAPI.groups.list(1, 1000),
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }), adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
adminAPI.accounts.list(1, 1000)
]) ])
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name }))) groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
const uniqueModels = new Set<string>() const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model)) ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
modelOptions.value.push( modelOptions.value.push(
......
...@@ -4,17 +4,34 @@ ...@@ -4,17 +4,34 @@
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
<Icon name="document" size="md" /> <Icon name="document" size="md" />
</div> </div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div> <div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
</div>
</div> </div>
<div class="card p-4 flex items-center gap-3"> <div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div> <div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div> <div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500">
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
</p>
</div>
</div> </div>
<div class="card p-4 flex items-center gap-3"> <div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"> <div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
<Icon name="dollar" size="md" /> <Icon name="dollar" size="md" />
</div> </div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div> <div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
<p class="text-xs text-gray-400">
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
</div>
</div> </div>
<div class="card p-4 flex items-center gap-3"> <div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"> <div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
......
...@@ -44,32 +44,56 @@ ...@@ -44,32 +44,56 @@
<span class="text-gray-400">({{ row.image_size || '2K' }})</span> <span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div> </div>
<!-- Token 请求 --> <!-- Token 请求 -->
<div v-else class="space-y-1 text-sm"> <div v-else class="flex items-center gap-1.5">
<div class="flex items-center gap-2"> <div class="space-y-1 text-sm">
<div class="inline-flex items-center gap-1"> <div class="flex items-center gap-2">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" /> <div class="inline-flex items-center gap-1">
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span> <Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
</div>
</div> </div>
<div class="inline-flex items-center gap-1"> <div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" /> <div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span> <svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
</div>
</div> </div>
</div> </div>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2"> <!-- Token Detail Tooltip -->
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1"> <div
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg> class="group relative"
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span> @mouseenter="showTokenTooltip($event, row)"
</div> @mouseleave="hideTokenTooltip"
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1"> >
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg> <div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span> <Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<template #cell-cost="{ row }"> <template #cell-cost="{ row }">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span> <div class="flex items-center gap-1.5 text-sm">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
</template> </template>
<template #cell-billing_type="{ row }"> <template #cell-billing_type="{ row }">
...@@ -106,6 +130,98 @@ ...@@ -106,6 +130,98 @@
</DataTable> </DataTable>
</div> </div>
</div> </div>
<!-- Token Tooltip Portal -->
<Teleport to="body">
<div
v-if="tokenTooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5">
<div>
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
</div>
</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>
</div>
</Teleport>
<!-- Cost Tooltip Portal -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</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>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -116,12 +232,23 @@ import { useAppStore } from '@/stores/app' ...@@ -116,12 +232,23 @@ import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { UsageLog } from '@/types'
defineProps(['data', 'loading']) defineProps(['data', 'loading'])
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null) const copiedRequestId = ref<string | null>(null)
// Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Tooltip state - token
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null)
const cols = computed(() => [ const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false }, { key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false }, { key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
...@@ -160,4 +287,34 @@ const copyRequestId = async (requestId: string) => { ...@@ -160,4 +287,34 @@ const copyRequestId = async (requestId: string) => {
appStore.showError(t('common.copyFailed')) appStore.showError(t('common.copyFailed'))
} }
} }
// Cost tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
tooltipPosition.value.x = rect.right + 8
tooltipPosition.value.y = rect.top + rect.height / 2
tooltipVisible.value = true
}
const hideTooltip = () => {
tooltipVisible.value = false
tooltipData.value = null
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
tokenTooltipPosition.value.x = rect.right + 8
tokenTooltipPosition.value.y = rect.top + rect.height / 2
tokenTooltipVisible.value = true
}
const hideTokenTooltip = () => {
tokenTooltipVisible.value = false
tokenTooltipData.value = null
}
</script> </script>
...@@ -48,12 +48,12 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a ...@@ -48,12 +48,12 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false) const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } }) watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch {} finally { loading.value = false } } const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch (error) { console.error('Failed to load groups:', error) } finally { loading.value = false } }
const handleSave = async () => { const handleSave = async () => {
if (!props.user) return; submitting.value = true if (!props.user) return; submitting.value = true
try { try {
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value }) await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close') appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch {} finally { submitting.value = false } } catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
} }
</script> </script>
\ No newline at end of file
...@@ -42,6 +42,6 @@ const apiKeys = ref<ApiKey[]>([]); const loading = ref(false) ...@@ -42,6 +42,6 @@ const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
watch(() => props.show, (v) => { if (v && props.user) load() }) watch(() => props.show, (v) => { if (v && props.user) load() })
const load = async () => { const load = async () => {
if (!props.user) return; loading.value = true if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch {} finally { loading.value = false } try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
} }
</script> </script>
\ No newline at end of file
...@@ -51,7 +51,8 @@ const handleBalanceSubmit = async () => { ...@@ -51,7 +51,8 @@ const handleBalanceSubmit = async () => {
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes) await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
appStore.showSuccess(t('common.success')); emit('success'); emit('close') appStore.showSuccess(t('common.success')); emit('success'); emit('close')
} catch (e: any) { } catch (e: any) {
console.error('Failed to update balance:', e)
appStore.showError(e.response?.data?.detail || t('common.error')) appStore.showError(e.response?.data?.detail || t('common.error'))
} finally { submitting.value = false } } finally { submitting.value = false }
} }
</script> </script>
\ No newline at end of file
...@@ -105,10 +105,7 @@ ...@@ -105,10 +105,7 @@
</button> </button>
</div> </div>
<!-- Code Content --> <!-- Code Content -->
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"> <pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-if="file.highlighted" v-html="file.highlighted"></code><code v-else v-text="file.content"></code></pre>
<code v-if="file.highlighted" v-html="file.highlighted"></code>
<code v-else v-text="file.content"></code>
</pre>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p> <p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p> <p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalanceWithCode') }}</p>
</div> </div>
<Icon <Icon
name="chevronRight" name="chevronRight"
......
...@@ -376,6 +376,8 @@ export default { ...@@ -376,6 +376,8 @@ export default {
usage: { usage: {
title: 'Usage Records', title: 'Usage Records',
description: 'View and analyze your API usage history', description: 'View and analyze your API usage history',
costDetails: 'Cost Breakdown',
tokenDetails: 'Token Breakdown',
totalRequests: 'Total Requests', totalRequests: 'Total Requests',
totalTokens: 'Total Tokens', totalTokens: 'Total Tokens',
totalCost: 'Total Cost', totalCost: 'Total Cost',
...@@ -1009,6 +1011,7 @@ export default { ...@@ -1009,6 +1011,7 @@ export default {
groups: 'Groups', groups: 'Groups',
usageWindows: 'Usage Windows', usageWindows: 'Usage Windows',
lastUsed: 'Last Used', lastUsed: 'Last Used',
expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
}, },
tempUnschedulable: { tempUnschedulable: {
...@@ -1150,12 +1153,17 @@ export default { ...@@ -1150,12 +1153,17 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc: interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
expired: 'Expired',
proxy: 'Proxy', proxy: 'Proxy',
noProxy: 'No Proxy', noProxy: 'No Proxy',
concurrency: 'Concurrency', concurrency: 'Concurrency',
priority: 'Priority', priority: 'Priority',
priorityHint: 'Higher priority accounts are used first', priorityHint: 'Lower value accounts are used first',
higherPriorityFirst: 'Higher value means higher priority', expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority',
mixedScheduling: 'Use in /v1/messages', mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
mixedSchedulingTooltip: mixedSchedulingTooltip:
...@@ -1691,6 +1699,7 @@ export default { ...@@ -1691,6 +1699,7 @@ export default {
userFilter: 'User', userFilter: 'User',
searchUserPlaceholder: 'Search user by email...', searchUserPlaceholder: 'Search user by email...',
searchApiKeyPlaceholder: 'Search API key by name...', searchApiKeyPlaceholder: 'Search API key by name...',
searchAccountPlaceholder: 'Search account by name...',
selectedUser: 'Selected', selectedUser: 'Selected',
user: 'User', user: 'User',
account: 'Account', account: 'Account',
...@@ -1984,7 +1993,7 @@ export default { ...@@ -1984,7 +1993,7 @@ export default {
}, },
accountPriority: { accountPriority: {
title: '⚖️ 4. Priority (Optional)', title: '⚖️ 4. Priority (Optional)',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Higher number = higher priority</li><li>System uses high-priority accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to high priority, backup accounts to low priority</p></div>', description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Lower number = higher priority</li><li>System uses low-value accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to lower value, backup accounts to higher value</p></div>',
nextBtn: 'Next' nextBtn: 'Next'
}, },
accountGroups: { accountGroups: {
......
...@@ -373,6 +373,8 @@ export default { ...@@ -373,6 +373,8 @@ export default {
usage: { usage: {
title: '使用记录', title: '使用记录',
description: '查看和分析您的 API 使用历史', description: '查看和分析您的 API 使用历史',
costDetails: '成本明细',
tokenDetails: 'Token 明细',
totalRequests: '总请求数', totalRequests: '总请求数',
totalTokens: '总 Token', totalTokens: '总 Token',
totalCost: '总消费', totalCost: '总消费',
...@@ -857,7 +859,7 @@ export default { ...@@ -857,7 +859,7 @@ export default {
accountsLabel: '指定账号', accountsLabel: '指定账号',
accountsPlaceholder: '选择账号(留空则不限制)', accountsPlaceholder: '选择账号(留空则不限制)',
priorityLabel: '优先级', priorityLabel: '优先级',
priorityHint: '数值越优先级越高,用于账号调度', priorityHint: '数值越优先级越高,用于账号调度',
statusLabel: '状态' statusLabel: '状态'
}, },
exclusiveObj: { exclusiveObj: {
...@@ -1059,6 +1061,7 @@ export default { ...@@ -1059,6 +1061,7 @@ export default {
groups: '分组', groups: '分组',
usageWindows: '用量窗口', usageWindows: '用量窗口',
lastUsed: '最近使用', lastUsed: '最近使用',
expiresAt: '过期时间',
actions: '操作' actions: '操作'
}, },
clearRateLimit: '清除速率限制', clearRateLimit: '清除速率限制',
...@@ -1178,7 +1181,7 @@ export default { ...@@ -1178,7 +1181,7 @@ export default {
credentialsLabel: '凭证', credentialsLabel: '凭证',
credentialsPlaceholder: '请输入 Cookie 或 API Key', credentialsPlaceholder: '请输入 Cookie 或 API Key',
priorityLabel: '优先级', priorityLabel: '优先级',
priorityHint: '数值越优先级越高', priorityHint: '数值越优先级越高',
weightLabel: '权重', weightLabel: '权重',
weightHint: '用于负载均衡的权重值', weightHint: '用于负载均衡的权重值',
statusLabel: '状态' statusLabel: '状态'
...@@ -1284,12 +1287,17 @@ export default { ...@@ -1284,12 +1287,17 @@ export default {
errorCodeExists: '该错误码已被选中', errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求', interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
expired: '已过期',
proxy: '代理', proxy: '代理',
noProxy: '无代理', noProxy: '无代理',
concurrency: '并发数', concurrency: '并发数',
priority: '优先级', priority: '优先级',
priorityHint: '优先级越高的账号优先使用', priorityHint: '优先级越小的账号优先使用',
higherPriorityFirst: '数值越高优先级越高', expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高',
mixedScheduling: '在 /v1/messages 中使用', mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
mixedSchedulingTooltip: mixedSchedulingTooltip:
...@@ -1836,6 +1844,7 @@ export default { ...@@ -1836,6 +1844,7 @@ export default {
userFilter: '用户', userFilter: '用户',
searchUserPlaceholder: '按邮箱搜索用户...', searchUserPlaceholder: '按邮箱搜索用户...',
searchApiKeyPlaceholder: '按名称搜索 API 密钥...', searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
searchAccountPlaceholder: '按名称搜索账号...',
selectedUser: '已选择', selectedUser: '已选择',
user: '用户', user: '用户',
account: '账户', account: '账户',
...@@ -2126,7 +2135,7 @@ export default { ...@@ -2126,7 +2135,7 @@ export default {
}, },
accountPriority: { accountPriority: {
title: '⚖️ 4. 优先级(可选)', title: '⚖️ 4. 优先级(可选)',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>', description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
nextBtn: '下一步' nextBtn: '下一步'
}, },
accountGroups: { accountGroups: {
......
...@@ -401,6 +401,8 @@ export interface Account { ...@@ -401,6 +401,8 @@ export interface Account {
status: 'active' | 'inactive' | 'error' status: 'active' | 'inactive' | 'error'
error_message: string | null error_message: string | null
last_used_at: string | null last_used_at: string | null
expires_at: number | null
auto_pause_on_expired: boolean
created_at: string created_at: string
updated_at: string updated_at: string
proxy?: Proxy proxy?: Proxy
...@@ -491,6 +493,8 @@ export interface CreateAccountRequest { ...@@ -491,6 +493,8 @@ export interface CreateAccountRequest {
concurrency?: number concurrency?: number
priority?: number priority?: number
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
...@@ -506,6 +510,8 @@ export interface UpdateAccountRequest { ...@@ -506,6 +510,8 @@ export interface UpdateAccountRequest {
schedulable?: boolean schedulable?: boolean
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
......
...@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string { ...@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
* 格式化日期 * 格式化日期
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions * @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期字符串 * @returns 格式化后的日期字符串
*/ */
export function formatDate( export function formatDate(
...@@ -108,14 +109,15 @@ export function formatDate( ...@@ -108,14 +109,15 @@ export function formatDate(
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: false hour12: false
} },
localeOverride?: string
): string { ): string {
if (!date) return '' if (!date) return ''
const d = new Date(date) const d = new Date(date)
if (isNaN(d.getTime())) return '' if (isNaN(d.getTime())) return ''
const locale = getLocale() const locale = localeOverride ?? getLocale()
return new Intl.DateTimeFormat(locale, options).format(d) return new Intl.DateTimeFormat(locale, options).format(d)
} }
...@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string { ...@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string {
/** /**
* 格式化日期时间(完整格式) * 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期时间字符串 * @returns 格式化后的日期时间字符串
*/ */
export function formatDateTime(date: string | Date | null | undefined): string { export function formatDateTime(
return formatDate(date) date: string | Date | null | undefined,
options?: Intl.DateTimeFormatOptions,
localeOverride?: string
): string {
return formatDate(date, options, localeOverride)
}
/**
* 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间)
*/
export function formatDateTimeLocalInput(timestampSeconds: number | null): string {
if (!timestampSeconds) return ''
const date = new Date(timestampSeconds * 1000)
if (isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* 解析 datetime-local 控件值为时间戳(秒,使用本地时间)
*/
export function parseDateTimeLocalInput(value: string): number | null {
if (!value) return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return Math.floor(date.getTime() / 1000)
} }
/** /**
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
<AccountTableFilters <AccountTableFilters
v-model:searchQuery="params.search" v-model:searchQuery="params.search"
:filters="params" :filters="params"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="reload" @change="reload"
@update:searchQuery="debouncedReload" @update:searchQuery="debouncedReload"
/> />
...@@ -69,6 +70,25 @@ ...@@ -69,6 +70,25 @@
<template #cell-last_used_at="{ value }"> <template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template> </template>
<template #cell-expires_at="{ row, value }">
<div class="flex flex-col items-start gap-1">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
<span
v-if="isExpired(value)"
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.expired') }}
</span>
<span
v-if="row.auto_pause_on_expired && value"
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
>
{{ t('admin.accounts.autoPauseOnExpired') }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"> <button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
...@@ -127,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue' ...@@ -127,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { formatRelativeTime } from '@/utils/format' import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
const { t } = useI18n() const { t } = useI18n()
...@@ -177,6 +197,7 @@ const cols = computed(() => { ...@@ -177,6 +197,7 @@ const cols = computed(() => {
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, { key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } { key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
) )
...@@ -187,7 +208,7 @@ const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true } ...@@ -187,7 +208,7 @@ const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true } const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true }
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) } const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] } const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch {} } const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() } const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const closeTestModal = () => { showTest.value = false; testingAcc.value = null } const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null } const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
...@@ -195,14 +216,33 @@ const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = nul ...@@ -195,14 +216,33 @@ const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = nul
const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true } const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true }
const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true } const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true }
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true } const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch {} } const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch (error) { console.error('Failed to refresh credentials:', error) } }
const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch {} } const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to reset status:', error) } }
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch {} } const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } }
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true } const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch {} } const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } } const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true } const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch {} } const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
const formatExpiresAt = (value: number | null) => {
if (!value) return '-'
return formatDateTime(
new Date(value * 1000),
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
},
'sv-SE'
)
}
const isExpired = (value: number | null) => {
if (!value) return false
return value * 1000 <= Date.now()
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch {} }) onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
</script> </script>
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage' import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue' import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue' import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
...@@ -55,16 +55,16 @@ const loadLogs = async () => { ...@@ -55,16 +55,16 @@ const loadLogs = async () => {
try { try {
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value }, { signal: c.signal }) const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value }, { signal: c.signal })
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total } if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
} catch {} finally { if(abortController === c) loading.value = false } } catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
} }
const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch {} } const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch (error) { console.error('Failed to load usage stats:', error) } }
const loadChartData = async () => { const loadChartData = async () => {
chartsLoading.value = true chartsLoading.value = true
try { try {
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id } const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id }
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })]) const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || [] trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
} catch {} finally { chartsLoading.value = false } } catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
} }
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() } const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() } const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() }
...@@ -85,14 +85,53 @@ const exportToExcel = async () => { ...@@ -85,14 +85,53 @@ const exportToExcel = async () => {
if (all.length >= total || res.items.length < 100) break; p++ if (all.length >= total || res.items.length < 100) break; p++
} }
if(!c.signal.aborted) { if(!c.signal.aborted) {
const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage') const XLSX = await import('xlsx')
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`) const headers = [
appStore.showSuccess('Export Success') t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('admin.usage.group'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId')
]
const rows = all.map(log => [
log.created_at,
log.user?.email || '',
log.api_key?.name || '',
log.account?.name || '',
log.model,
log.group?.name || '',
log.stream ? t('usage.stream') : t('usage.sync'),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000',
log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000',
log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00',
log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000',
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
log.first_token_ms ?? '',
log.duration_ms,
log.request_id || ''
])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Usage')
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`)
appStore.showSuccess(t('usage.exportSuccess'))
} }
} catch { appStore.showError('Export Failed') } } catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') }
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } } finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
} }
onMounted(() => { loadLogs(); loadStats(); loadChartData() }) onMounted(() => { loadLogs(); loadStats(); loadChartData() })
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort() }) onUnmounted(() => { abortController?.abort(); exportAbortController?.abort() })
</script> </script>
\ No newline at end of file
...@@ -28,9 +28,9 @@ const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]> ...@@ -28,9 +28,9 @@ const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>
const formatLD = (d: Date) => d.toISOString().split('T')[0] const formatLD = (d: Date) => d.toISOString().split('T')[0]
const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day') const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day')
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch {} finally { loading.value = false } } const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } }
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch {} finally { loadingCharts.value = false } } const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }
const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch {} finally { loadingUsage.value = false } } const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch (error) { console.error('Failed to load recent usage:', error) } finally { loadingUsage.value = false } }
onMounted(() => { loadStats(); loadCharts(); loadRecent() }) onMounted(() => { loadStats(); loadCharts(); loadRecent() })
</script> </script>
...@@ -36,6 +36,6 @@ const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24' ...@@ -36,6 +36,6 @@ const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24'
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) } const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) } const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch {} }) onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch (error) { console.error('Failed to load contact info:', error) } })
const formatCurrency = (v: number) => `$${v.toFixed(2)}` const formatCurrency = (v: number) => `$${v.toFixed(2)}`
</script> </script>
\ No newline at end of file
...@@ -342,8 +342,8 @@ ...@@ -342,8 +342,8 @@
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<!-- Token Breakdown --> <!-- Token Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5"> <div>
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div> <div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span> <span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span> <span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
...@@ -389,6 +389,27 @@ ...@@ -389,6 +389,27 @@
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800" class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400" <span class="font-semibold text-blue-400"
......
...@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue' ...@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker' import checker from 'vite-plugin-checker'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
...@@ -29,7 +30,7 @@ export default defineConfig({ ...@@ -29,7 +30,7 @@ export default defineConfig({
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: Number(process.env.VITE_DEV_PORT || 3000),
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080', target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
......
#!/usr/bin/env python3
import argparse
import json
import sys
from datetime import date
HIGH_SEVERITIES = {"high", "critical"}
REQUIRED_FIELDS = {"package", "advisory", "severity", "mitigation", "expires_on"}
def split_kv(line: str) -> tuple[str, str]:
# 解析 "key: value" 形式的简单 YAML 行,并去除引号。
key, value = line.split(":", 1)
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
return key.strip(), value
def parse_exceptions(path: str) -> list[dict]:
# 轻量解析异常清单,避免引入额外依赖。
exceptions = []
current = None
with open(path, "r", encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("version:") or line.startswith("exceptions:"):
continue
if line.startswith("- "):
if current:
exceptions.append(current)
current = {}
line = line[2:].strip()
if line:
key, value = split_kv(line)
current[key] = value
continue
if current is not None and ":" in line:
key, value = split_kv(line)
current[key] = value
if current:
exceptions.append(current)
return exceptions
def pick_advisory_id(advisory: dict) -> str | None:
# 优先使用可稳定匹配的标识(GHSA/URL/CVE),避免误匹配到其他同名漏洞。
return (
advisory.get("github_advisory_id")
or advisory.get("url")
or (advisory.get("cves") or [None])[0]
or (str(advisory.get("id")) if advisory.get("id") is not None else None)
or advisory.get("title")
or advisory.get("advisory")
or advisory.get("overview")
)
def iter_vulns(data: dict):
# 兼容 pnpm audit 的不同输出结构(advisories / vulnerabilities),并提取 advisory 标识。
advisories = data.get("advisories")
if isinstance(advisories, dict):
for advisory in advisories.values():
name = advisory.get("module_name") or advisory.get("name")
severity = advisory.get("severity")
advisory_id = pick_advisory_id(advisory)
title = (
advisory.get("title")
or advisory.get("advisory")
or advisory.get("overview")
or advisory.get("url")
)
yield name, severity, advisory_id, title
vulnerabilities = data.get("vulnerabilities")
if isinstance(vulnerabilities, dict):
for name, vuln in vulnerabilities.items():
severity = vuln.get("severity")
via = vuln.get("via", [])
titles = []
advisories = []
if isinstance(via, list):
for item in via:
if isinstance(item, dict):
advisories.append(
item.get("github_advisory_id")
or item.get("url")
or item.get("source")
or item.get("title")
or item.get("name")
)
titles.append(
item.get("title")
or item.get("url")
or item.get("advisory")
or item.get("source")
)
elif isinstance(item, str):
advisories.append(item)
titles.append(item)
elif isinstance(via, str):
advisories.append(via)
titles.append(via)
title = "; ".join([t for t in titles if t])
for advisory_id in [a for a in advisories if a]:
yield name, severity, advisory_id, title
def normalize_severity(severity: str) -> str:
# 统一大小写,避免比较失败。
return (severity or "").strip().lower()
def normalize_package(name: str) -> str:
# 包名只去掉首尾空白,保留原始大小写,同时兼容非字符串输入。
if name is None:
return ""
return str(name).strip()
def normalize_advisory(advisory: str) -> str:
# advisory 统一为小写匹配,避免 GHSA/URL 因大小写差异导致漏匹配。
# pnpm 的 source 字段可能是数字,这里统一转为字符串以保证可比较。
if advisory is None:
return ""
return str(advisory).strip().lower()
def parse_date(value: str) -> date | None:
# 仅接受 ISO8601 日期格式,非法值视为无效。
try:
return date.fromisoformat(value)
except ValueError:
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--audit", required=True)
parser.add_argument("--exceptions", required=True)
args = parser.parse_args()
with open(args.audit, "r", encoding="utf-8") as handle:
audit = json.load(handle)
# 读取异常清单并建立索引,便于快速匹配包名 + advisory。
exceptions = parse_exceptions(args.exceptions)
exception_index = {}
errors = []
for exc in exceptions:
missing = [field for field in REQUIRED_FIELDS if not exc.get(field)]
if missing:
errors.append(
f"Exception missing required fields {missing}: {exc.get('package', '<unknown>')}"
)
continue
exc_severity = normalize_severity(exc.get("severity"))
exc_package = normalize_package(exc.get("package"))
exc_advisory = normalize_advisory(exc.get("advisory"))
exc_date = parse_date(exc.get("expires_on"))
if exc_date is None:
errors.append(
f"Exception has invalid expires_on date: {exc.get('package', '<unknown>')}"
)
continue
if not exc_package or not exc_advisory:
errors.append("Exception missing package or advisory value")
continue
key = (exc_package, exc_advisory)
if key in exception_index:
errors.append(
f"Duplicate exception for {exc_package} advisory {exc.get('advisory')}"
)
continue
exception_index[key] = {
"raw": exc,
"severity": exc_severity,
"expires_on": exc_date,
}
today = date.today()
missing_exceptions = []
expired_exceptions = []
# 去重处理:同一包名 + advisory 可能在不同字段重复出现。
seen = set()
for name, severity, advisory_id, title in iter_vulns(audit):
sev = normalize_severity(severity)
if sev not in HIGH_SEVERITIES or not name:
continue
advisory_key = normalize_advisory(advisory_id)
if not advisory_key:
errors.append(
f"High/Critical vulnerability missing advisory id: {name} ({sev})"
)
continue
key = (normalize_package(name), advisory_key)
if key in seen:
continue
seen.add(key)
exc = exception_index.get(key)
if exc is None:
missing_exceptions.append((name, sev, advisory_id, title))
continue
if exc["severity"] and exc["severity"] != sev:
errors.append(
"Exception severity mismatch: "
f"{name} ({advisory_id}) expected {sev}, got {exc['severity']}"
)
if exc["expires_on"] and exc["expires_on"] < today:
expired_exceptions.append(
(name, sev, advisory_id, exc["expires_on"].isoformat())
)
if missing_exceptions:
errors.append("High/Critical vulnerabilities missing exceptions:")
for name, sev, advisory_id, title in missing_exceptions:
label = f"{name} ({sev})"
if advisory_id:
label = f"{label} [{advisory_id}]"
if title:
label = f"{label}: {title}"
errors.append(f"- {label}")
if expired_exceptions:
errors.append("Exceptions expired:")
for name, sev, advisory_id, expires_on in expired_exceptions:
errors.append(
f"- {name} ({sev}) [{advisory_id}] expired on {expires_on}"
)
if errors:
sys.stderr.write("\n".join(errors) + "\n")
return 1
print("Audit exceptions validated.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment