Commit fb313356 authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'main' into test-dev

parents 048ed061 91f9d4c7
...@@ -28,6 +28,8 @@ services: ...@@ -28,6 +28,8 @@ services:
volumes: volumes:
# Data persistence (config.yaml will be auto-generated here) # Data persistence (config.yaml will be auto-generated here)
- sub2api_data:/app/data - sub2api_data:/app/data
# Mount custom config.yaml (optional, overrides auto-generated config)
- ./config.yaml:/app/data/config.yaml:ro
environment: environment:
# ======================================================================= # =======================================================================
# Auto Setup (REQUIRED for Docker deployment) # Auto Setup (REQUIRED for Docker deployment)
...@@ -91,6 +93,13 @@ services: ...@@ -91,6 +93,13 @@ services:
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
# =======================================================================
# Security Configuration (URL Allowlist)
# =======================================================================
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
# Allow private IP addresses for CRS sync (for internal deployments)
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
......
legacy-peer-deps=true
# 允许运行所有包的构建脚本
# esbuild 和 vue-demi 是已知安全的包,需要 postinstall 脚本才能正常工作
ignore-scripts=false
This diff is collapsed.
This diff is collapsed.
...@@ -34,6 +34,9 @@ export interface SystemSettings { ...@@ -34,6 +34,9 @@ export interface SystemSettings {
turnstile_enabled: boolean turnstile_enabled: boolean
turnstile_site_key: string turnstile_site_key: string
turnstile_secret_key_configured: boolean turnstile_secret_key_configured: boolean
// Identity patch configuration (Claude -> Gemini)
enable_identity_patch: boolean
identity_patch_prompt: string
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
...@@ -57,6 +60,8 @@ export interface UpdateSettingsRequest { ...@@ -57,6 +60,8 @@ export interface UpdateSettingsRequest {
turnstile_enabled?: boolean turnstile_enabled?: boolean
turnstile_site_key?: string turnstile_site_key?: string
turnstile_secret_key?: string turnstile_secret_key?: string
enable_identity_patch?: boolean
identity_patch_prompt?: string
} }
/** /**
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios' import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { ApiResponse } from '@/types' import type { ApiResponse } from '@/types'
import { getLocale } from '@/i18n'
// ==================== Axios Instance Configuration ==================== // ==================== Axios Instance Configuration ====================
...@@ -27,6 +28,12 @@ apiClient.interceptors.request.use( ...@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
// Attach locale for backend translations
if (config.headers) {
config.headers['Accept-Language'] = getLocale()
}
return config return config
}, },
(error) => { (error) => {
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
v-if="isTempUnschedulable" v-if="isTempUnschedulable"
type="button" type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']" :class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.tempUnschedulable.viewDetails')" :title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick" @click="handleTempUnschedClick"
> >
{{ statusText }} {{ statusText }}
...@@ -61,7 +61,7 @@ ...@@ -61,7 +61,7 @@
<div <div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700" class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
> >
Rate limited until {{ formatTime(account.rate_limit_reset_at) }} {{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
<div <div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div> ></div>
...@@ -86,7 +86,7 @@ ...@@ -86,7 +86,7 @@
<div <div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700" class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
> >
Overloaded until {{ formatTime(account.overload_until) }} {{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
<div <div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div> ></div>
...@@ -160,7 +160,7 @@ const statusClass = computed(() => { ...@@ -160,7 +160,7 @@ const statusClass = computed(() => {
// Computed: status text // Computed: status text
const statusText = computed(() => { const statusText = computed(() => {
if (hasError.value) { if (hasError.value) {
return t('common.error') return t('admin.accounts.status.error')
} }
if (isTempUnschedulable.value) { if (isTempUnschedulable.value) {
return t('admin.accounts.status.tempUnschedulable') return t('admin.accounts.status.tempUnschedulable')
...@@ -171,7 +171,7 @@ const statusText = computed(() => { ...@@ -171,7 +171,7 @@ const statusText = computed(() => {
if (isRateLimited.value || isOverloaded.value) { if (isRateLimited.value || isOverloaded.value) {
return t('admin.accounts.status.limited') return t('admin.accounts.status.limited')
} }
return t(`common.${props.account.status}`) return t(`admin.accounts.status.${props.account.status}`)
}) })
const handleTempUnschedClick = () => { const handleTempUnschedClick = () => {
...@@ -179,4 +179,4 @@ const handleTempUnschedClick = () => { ...@@ -179,4 +179,4 @@ const handleTempUnschedClick = () => {
emit('show-temp-unsched', props.account) emit('show-temp-unsched', props.account)
} }
</script> </script>
\ No newline at end of file
...@@ -48,21 +48,18 @@ ...@@ -48,21 +48,18 @@
</span> </span>
</div> </div>
<!-- Model Selection -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }} {{ t('admin.accounts.selectTestModel') }}
</label> </label>
<select <Select
v-model="selectedModelId" v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'" :disabled="loadingModels || status === 'connecting'"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100" value-key="id"
> label-key="display_name"
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option> :placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
<option v-for="model in availableModels" :key="model.id" :value="model.id"> />
{{ model.display_name }} ({{ model.id }})
</option>
</select>
</div> </div>
<!-- Terminal Output --> <!-- Terminal Output -->
...@@ -280,6 +277,7 @@ ...@@ -280,6 +277,7 @@
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types' import type { Account, ClaudeModel } from '@/types'
......
...@@ -1522,7 +1522,7 @@ ...@@ -1522,7 +1522,7 @@
</ul> </ul>
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
<a <a
href="https://gemini.google.com/faq#location" href="https://policies.google.com/terms"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400" class="text-sm text-blue-600 hover:underline dark:text-blue-400"
...@@ -1531,7 +1531,16 @@ ...@@ -1531,7 +1531,16 @@
</a> </a>
<span class="text-gray-400">·</span> <span class="text-gray-400">·</span>
<a <a
href="https://gemini.google.com" href="https://policies.google.com/country-association-form"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
修改归属地
</a>
<span class="text-gray-400">·</span>
<a
href="https://gemini.google.com/gems/create?hl=en-US&pli=1"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400" class="text-sm text-blue-600 hover:underline dark:text-blue-400"
...@@ -1869,8 +1878,9 @@ const geminiHelpLinks = { ...@@ -1869,8 +1878,9 @@ const geminiHelpLinks = {
apiKey: 'https://aistudio.google.com/app/apikey', apiKey: 'https://aistudio.google.com/app/apikey',
aiStudioPricing: 'https://ai.google.dev/pricing', aiStudioPricing: 'https://ai.google.dev/pricing',
gcpProject: 'https://console.cloud.google.com/welcome/new', gcpProject: 'https://console.cloud.google.com/welcome/new',
geminiWebActivation: 'https://gemini.google.com/gems/create?hl=en-US', geminiWebActivation: 'https://gemini.google.com/gems/create?hl=en-US&pli=1',
countryCheck: 'https://policies.google.com/country-association-form' countryCheck: 'https://policies.google.com/terms',
countryChange: 'https://policies.google.com/country-association-form'
} }
// Computed: current preset mappings based on platform // Computed: current preset mappings based on platform
......
<template>
<Teleport to="body">
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"><span class="text-green-500"></span> {{ t('admin.accounts.testConnection') }}</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"><span class="text-indigo-500">📊</span> {{ t('admin.accounts.viewStats') }}</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-blue-600">🔗 {{ t('admin.accounts.reAuthorize') }}</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-purple-600">🔄 {{ t('admin.accounts.refreshToken') }}</button>
</template>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['show', 'account', 'position']); defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token']); const { t } = useI18n()
</script>
\ No newline at end of file
<template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg">
<span class="text-sm font-medium">{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}</span>
<div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit']); const { t } = useI18n()
</script>
\ No newline at end of file
This diff is collapsed.
<template>
<div class="flex max-w-full flex-wrap justify-end gap-3">
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary flex-shrink-0"><svg :class="['h-5 w-5', loading ? 'animate-spin' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg></button>
<button @click="$emit('sync')" class="btn btn-secondary flex-shrink-0">{{ t('admin.accounts.syncFromCrs') }}</button>
<button @click="$emit('create')" class="btn btn-primary flex-shrink-0">{{ t('admin.accounts.createAccount') }}</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; defineProps(['loading']); defineEmits(['refresh', 'sync', 'create']); const { t } = useI18n()
</script>
<template>
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1">
<SearchInput
:model-value="searchQuery"
:placeholder="t('admin.accounts.searchAccounts')"
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
</div>
<div class="flex flex-wrap items-center gap-3">
<Select v-model="filters.platform" class="w-40 flex-shrink-0" :options="pOpts" @change="$emit('change')" />
<Select v-model="filters.status" class="w-40 flex-shrink-0" :options="sOpts" @change="$emit('change')" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
defineProps(['searchQuery', 'filters']); defineEmits(['update:searchQuery', 'change']); const { t } = useI18n()
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.testAccountConnection')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<!-- Account Info Card -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{ account.type }}
</span>
<span>{{ t('admin.accounts.account') }}</span>
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
<div class="group relative">
<div
ref="terminalRef"
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
<!-- Output Lines -->
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
{{ line.text }}
</div>
<!-- Streaming Content -->
<div v-if="streamingContent" class="text-green-400">
{{ streamingContent }}<span class="animate-pulse">_</span>
</div>
<!-- Result Status -->
<div
v-if="status === 'success'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div
v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if="outputLines.length > 0"
@click="copyOutput"
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{{ t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{{ t('admin.accounts.testPrompt') }}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || !selectedModelId"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<svg
v-if="status === 'connecting'"
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else-if="status === 'idle'"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>
{{
status === 'connecting'
? t('admin.accounts.testing')
: status === 'idle'
? t('admin.accounts.startTest')
: t('admin.accounts.retry')
}}
</span>
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'
const { t } = useI18n()
const { copyToClipboard } = useClipboard()
interface OutputLine {
text: string
class: string
}
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const terminalRef = ref<HTMLElement | null>(null)
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
const outputLines = ref<OutputLine[]>([])
const streamingContent = ref('')
const errorMessage = ref('')
const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
// Load available models when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
resetState()
await loadAvailableModels()
} else {
closeEventSource()
}
}
)
const loadAvailableModels = async () => {
if (!props.account) return
loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading
try {
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
// Default selection by platform
if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') {
const preferred =
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
selectedModelId.value = preferred?.id || availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
}
}
} catch (error) {
console.error('Failed to load available models:', error)
// Fallback to empty list
availableModels.value = []
selectedModelId.value = ''
} finally {
loadingModels.value = false
}
}
const resetState = () => {
status.value = 'idle'
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const addLine = (text: string, className: string = 'text-gray-300') => {
outputLines.value.push({ text, class: className })
scrollToBottom()
}
const scrollToBottom = async () => {
await nextTick()
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
}
const startTest = async () => {
if (!props.account || !selectedModelId.value) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
try {
// Create EventSource for SSE
const url = `/api/v1/admin/accounts/${props.account.id}/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ model_id: selectedModelId.value })
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr) {
try {
const event = JSON.parse(jsonStr)
handleEvent(event)
} catch (e) {
console.error('Failed to parse SSE event:', e)
}
}
}
}
}
} catch (error: any) {
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
}
}
const handleEvent = (event: {
type: string
text?: string
model?: string
success?: boolean
error?: string
}) => {
switch (event.type) {
case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400')
break
case 'content':
if (event.text) {
streamingContent.value += event.text
scrollToBottom()
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
if (event.success) {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
break
}
}
const copyOutput = () => {
const text = outputLines.value.map((l) => l.text).join('\n')
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>
This diff is collapsed.
<template>
<ExportProgressDialog
:show="show"
:progress="progress"
:current="current"
:total="total"
:estimated-time="estimatedTime"
@cancel="$emit('cancel')"
/>
</template>
<script setup lang="ts">
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
defineProps<{ show: boolean, progress: number, current: number, total: number, estimatedTime: string }>()
defineEmits(['cancel'])
</script>
\ No newline at end of file
<template>
<div class="card p-6">
<!-- Toolbar: left filters (multi-line) + right actions -->
<div class="flex flex-wrap items-end justify-between gap-4">
<!-- Left: filters (allowed to wrap to multiple rows) -->
<div class="flex flex-1 flex-wrap items-end gap-4">
<!-- User Search -->
<div ref="userSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
<input
v-model="userKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchUserPlaceholder')"
@input="debounceUserSearch"
@focus="showUserDropdown = true"
/>
<button
v-if="filters.user_id"
type="button"
@click="clearUser"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear user filter"
>
</button>
<div
v-if="showUserDropdown && (userResults.length > 0 || userKeyword)"
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="u in userResults"
:key="u.id"
type="button"
@click="selectUser(u)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>{{ u.email }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
</button>
</div>
</div>
<!-- API Key Search -->
<div ref="apiKeySearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
<input
v-model="apiKeyKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
@input="debounceApiKeySearch"
@focus="showApiKeyDropdown = true"
/>
<button
v-if="filters.api_key_id"
type="button"
@click="onClearApiKey"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear API key filter"
>
</button>
<div
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
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="k in apiKeyResults"
:key="k.id"
type="button"
@click="selectApiKey(k)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ k.name || `#${k.id}` }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ k.id }}</span>
</button>
</div>
</div>
<!-- Model Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('usage.model') }}</label>
<Select v-model="filters.model" :options="modelOptions" searchable @change="emitChange" />
</div>
<!-- Account Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.account') }}</label>
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" />
</div>
<!-- Stream Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.billingType') }}</label>
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div>
<!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
<Select v-model="filters.group_id" :options="groupOptions" searchable @change="emitChange" />
</div>
<!-- Date Range Filter -->
<div class="w-full sm:w-auto [&_.date-picker-trigger]:w-full">
<label class="input-label">{{ t('usage.timeRange') }}</label>
<DateRangePicker
:start-date="startDate"
:end-date="endDate"
@update:startDate="updateStartDate"
@update:endDate="updateEndDate"
@change="emitChange"
/>
</div>
</div>
<!-- Right: actions -->
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
type ModelValue = Record<string, any>
interface Props {
modelValue: ModelValue
exporting: boolean
startDate: string
endDate: string
}
const props = defineProps<Props>()
const emit = defineEmits([
'update:modelValue',
'update:startDate',
'update:endDate',
'change',
'reset',
'export'
])
const { t } = useI18n()
const filters = toRef(props, 'modelValue')
const userSearchRef = ref<HTMLElement | null>(null)
const apiKeySearchRef = ref<HTMLElement | null>(null)
const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([])
const showUserDropdown = ref(false)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const apiKeyKeyword = ref('')
const apiKeyResults = ref<SimpleApiKey[]>([])
const showApiKeyDropdown = ref(false)
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
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[]>([
{ value: null, label: t('admin.usage.allTypes') },
{ value: true, label: t('usage.stream') },
{ value: false, label: t('usage.sync') }
])
const billingTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingTypes') },
{ value: 1, label: t('usage.subscription') },
{ value: 0, label: t('usage.balance') }
])
const emitChange = () => emit('change')
const updateStartDate = (value: string) => {
emit('update:startDate', value)
filters.value.start_date = value
}
const updateEndDate = (value: string) => {
emit('update:endDate', value)
filters.value.end_date = value
}
const debounceUserSearch = () => {
if (userSearchTimeout) clearTimeout(userSearchTimeout)
userSearchTimeout = setTimeout(async () => {
if (!userKeyword.value) {
userResults.value = []
return
}
try {
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
} catch {
userResults.value = []
}
}, 300)
}
const debounceApiKeySearch = () => {
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
apiKeySearchTimeout = setTimeout(async () => {
if (!apiKeyKeyword.value) {
apiKeyResults.value = []
return
}
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
filters.value.user_id,
apiKeyKeyword.value
)
} catch {
apiKeyResults.value = []
}
}, 300)
}
const selectUser = (u: SimpleUser) => {
userKeyword.value = u.email
showUserDropdown.value = false
filters.value.user_id = u.id
clearApiKey()
emitChange()
}
const clearUser = () => {
userKeyword.value = ''
userResults.value = []
showUserDropdown.value = false
filters.value.user_id = undefined
clearApiKey()
emitChange()
}
const selectApiKey = (k: SimpleApiKey) => {
apiKeyKeyword.value = k.name || String(k.id)
showApiKeyDropdown.value = false
filters.value.api_key_id = k.id
emitChange()
}
const clearApiKey = () => {
apiKeyKeyword.value = ''
apiKeyResults.value = []
showApiKeyDropdown.value = false
filters.value.api_key_id = undefined
}
const onClearApiKey = () => {
clearApiKey()
emitChange()
}
const onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node | null
if (!target) return
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
if (!clickedInsideUser) showUserDropdown.value = false
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
}
watch(
() => props.startDate,
(value) => {
filters.value.start_date = value
},
{ immediate: true }
)
watch(
() => props.endDate,
(value) => {
filters.value.end_date = value
},
{ immediate: true }
)
watch(
() => filters.value.user_id,
(userId) => {
if (!userId) {
userKeyword.value = ''
userResults.value = []
}
}
)
watch(
() => filters.value.api_key_id,
(apiKeyId) => {
if (!apiKeyId) {
apiKeyKeyword.value = ''
apiKeyResults.value = []
}
}
)
onMounted(async () => {
document.addEventListener('click', onDocumentClick)
try {
const [gs, ms, as] = await Promise.all([
adminAPI.groups.list(1, 1000),
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 })))
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
modelOptions.value.push(
...Array.from(uniqueModels)
.sort()
.map((m) => ({ value: m, label: m }))
)
} catch {
// Ignore filter option loading errors (page still usable)
}
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg></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>
<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><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>
<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"><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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></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>
<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"><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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; import type { AdminUsageStatsResponse } from '@/api/admin/usage'
defineProps<{ stats: AdminUsageStatsResponse | null }>(); const { t } = useI18n()
const formatDuration = (ms: number) => ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms/1000).toFixed(2)}s`
const formatTokens = (v: number) => { if (v >= 1e9) return (v/1e9).toFixed(2) + 'B'; if (v >= 1e6) return (v/1e6).toFixed(2) + 'M'; if (v >= 1e3) return (v/1e3).toFixed(2) + 'K'; return v.toLocaleString() }
</script>
\ No newline at end of file
<template>
<div class="card overflow-hidden"><div class="overflow-auto">
<DataTable :columns="cols" :data="data" :loading="loading">
<template #cell-user="{ row }"><div class="text-sm"><span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span><span class="ml-1 text-xs text-gray-400">#{{ row.user_id }}</span></div></template>
<template #cell-model="{ value }"><span class="font-medium">{{ value }}</span></template>
<template #cell-tokens="{ row }"><div class="text-sm">In: {{ row.input_tokens.toLocaleString() }} / Out: {{ row.output_tokens.toLocaleString() }}</div></template>
<template #cell-cost="{ row }"><span class="font-medium text-green-600">${{ row.actual_cost.toFixed(6) }}</span></template>
<template #cell-created_at="{ value }"><span class="text-sm text-gray-500">{{ formatDateTime(value) }}</span></template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable>
</div></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { formatDateTime } from '@/utils/format'; import DataTable from '@/components/common/DataTable.vue'; import EmptyState from '@/components/common/EmptyState.vue'
defineProps(['data', 'loading']); const { t } = useI18n()
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user') }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'tokens', label: t('usage.tokens') }, { key: 'cost', label: t('usage.cost') },
{ key: 'created_at', label: t('usage.time'), sortable: true }
])
</script>
\ No newline at end of file
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