feat(sync): full code sync from release
This commit is contained in:
@@ -184,7 +184,11 @@
|
||||
</button>
|
||||
</template>
|
||||
<template #cell-today_stats="{ row }">
|
||||
<AccountTodayStatsCell :account="row" />
|
||||
<AccountTodayStatsCell
|
||||
:stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||
:loading="todayStatsLoading"
|
||||
:error="todayStatsError"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-groups="{ row }">
|
||||
<AccountGroupsCell :groups="row.groups" :max-display="4" />
|
||||
@@ -273,7 +277,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, toRaw } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, toRaw, watch } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -303,7 +307,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, AccountPlatform, Proxy, AdminGroup } from '@/types'
|
||||
import type { Account, AccountPlatform, Proxy, AdminGroup, WindowStats } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -366,6 +370,59 @@ const autoRefreshFetching = ref(false)
|
||||
const AUTO_REFRESH_SILENT_WINDOW_MS = 15000
|
||||
const autoRefreshSilentUntil = ref(0)
|
||||
const hasPendingListSync = ref(false)
|
||||
const todayStatsByAccountId = ref<Record<string, WindowStats>>({})
|
||||
const todayStatsLoading = ref(false)
|
||||
const todayStatsError = ref<string | null>(null)
|
||||
const todayStatsReqSeq = ref(0)
|
||||
const pendingTodayStatsRefresh = ref(false)
|
||||
|
||||
const buildDefaultTodayStats = (): WindowStats => ({
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
standard_cost: 0,
|
||||
user_cost: 0
|
||||
})
|
||||
|
||||
const refreshTodayStatsBatch = async () => {
|
||||
if (hiddenColumns.has('today_stats')) {
|
||||
todayStatsLoading.value = false
|
||||
todayStatsError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const accountIDs = accounts.value.map(account => account.id)
|
||||
const reqSeq = ++todayStatsReqSeq.value
|
||||
if (accountIDs.length === 0) {
|
||||
todayStatsByAccountId.value = {}
|
||||
todayStatsError.value = null
|
||||
todayStatsLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
todayStatsLoading.value = true
|
||||
todayStatsError.value = null
|
||||
|
||||
try {
|
||||
const result = await adminAPI.accounts.getBatchTodayStats(accountIDs)
|
||||
if (reqSeq !== todayStatsReqSeq.value) return
|
||||
const serverStats = result.stats ?? {}
|
||||
const nextStats: Record<string, WindowStats> = {}
|
||||
for (const accountID of accountIDs) {
|
||||
const key = String(accountID)
|
||||
nextStats[key] = serverStats[key] ?? buildDefaultTodayStats()
|
||||
}
|
||||
todayStatsByAccountId.value = nextStats
|
||||
} catch (error) {
|
||||
if (reqSeq !== todayStatsReqSeq.value) return
|
||||
todayStatsError.value = 'Failed'
|
||||
console.error('Failed to load account today stats:', error)
|
||||
} finally {
|
||||
if (reqSeq === todayStatsReqSeq.value) {
|
||||
todayStatsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const autoRefreshIntervalLabel = (sec: number) => {
|
||||
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||
@@ -453,12 +510,18 @@ const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number])
|
||||
}
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
const wasHidden = hiddenColumns.has(key)
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
if (key === 'today_stats' && wasHidden) {
|
||||
refreshTodayStatsBatch().catch((error) => {
|
||||
console.error('Failed to load account today stats after showing column:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
@@ -485,33 +548,49 @@ const resetAutoRefreshCache = () => {
|
||||
const load = async () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = false
|
||||
await baseLoad()
|
||||
await refreshTodayStatsBatch()
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = false
|
||||
await baseReload()
|
||||
await refreshTodayStatsBatch()
|
||||
}
|
||||
|
||||
const debouncedReload = () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = true
|
||||
baseDebouncedReload()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = true
|
||||
baseHandlePageChange(page)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = true
|
||||
baseHandlePageSizeChange(size)
|
||||
}
|
||||
|
||||
watch(loading, (isLoading, wasLoading) => {
|
||||
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
|
||||
pendingTodayStatsRefresh.value = false
|
||||
refreshTodayStatsBatch().catch((error) => {
|
||||
console.error('Failed to refresh account today stats after table load:', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
@@ -609,14 +688,14 @@ const refreshAccountsIncrementally = async () => {
|
||||
if (result.etag) {
|
||||
autoRefreshETag.value = result.etag
|
||||
}
|
||||
if (result.notModified || !result.data) {
|
||||
return
|
||||
if (!result.notModified && result.data) {
|
||||
pagination.total = result.data.total || 0
|
||||
pagination.pages = result.data.pages || 0
|
||||
mergeAccountsIncrementally(result.data.items || [])
|
||||
hasPendingListSync.value = false
|
||||
}
|
||||
|
||||
pagination.total = result.data.total || 0
|
||||
pagination.pages = result.data.pages || 0
|
||||
mergeAccountsIncrementally(result.data.items || [])
|
||||
hasPendingListSync.value = false
|
||||
await refreshTodayStatsBatch()
|
||||
} catch (error) {
|
||||
console.error('Auto refresh failed:', error)
|
||||
} finally {
|
||||
|
||||
514
frontend/src/views/admin/DataManagementView.vue
Normal file
514
frontend/src/views/admin/DataManagementView.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.soraS3.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraS3.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="startCreateSoraProfile">
|
||||
{{ t('admin.settings.soraS3.newProfile') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loadingSoraProfiles" @click="loadSoraS3Profiles">
|
||||
{{ loadingSoraProfiles ? t('common.loading') : t('admin.settings.soraS3.reloadProfiles') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[1000px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profile') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.endpoint') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.bucket') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.quota') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
|
||||
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-top dark:border-dark-800">
|
||||
<td class="py-3 pr-4">
|
||||
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{{ profile.name }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
:class="profile.is_active ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300'"
|
||||
>
|
||||
{{ profile.is_active ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-xs">
|
||||
<div>{{ profile.endpoint || '-' }}</div>
|
||||
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-xs">{{ profile.bucket || '-' }}</td>
|
||||
<td class="py-3 pr-4 text-xs">{{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}</td>
|
||||
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
|
||||
<td class="py-3 text-xs">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-xs" @click="editSoraProfile(profile.profile_id)">
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!profile.is_active"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-xs"
|
||||
:disabled="activatingSoraProfile"
|
||||
@click="activateSoraProfile(profile.profile_id)"
|
||||
>
|
||||
{{ t('admin.settings.soraS3.activateProfile') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-xs"
|
||||
:disabled="deletingSoraProfile"
|
||||
@click="removeSoraProfile(profile.profile_id)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="soraS3Profiles.length === 0">
|
||||
<td colspan="7" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraS3.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="dm-drawer-mask">
|
||||
<div
|
||||
v-if="soraProfileDrawerOpen"
|
||||
class="fixed inset-0 z-[54] bg-black/40 backdrop-blur-sm"
|
||||
@click="closeSoraProfileDrawer"
|
||||
></div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="dm-drawer-panel">
|
||||
<div
|
||||
v-if="soraProfileDrawerOpen"
|
||||
class="fixed inset-y-0 right-0 z-[55] flex h-full w-full max-w-2xl flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-800 dark:hover:text-gray-200"
|
||||
@click="closeSoraProfileDrawer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<input
|
||||
v-model="soraProfileForm.profile_id"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.profileID')"
|
||||
:disabled="!creatingSoraProfile"
|
||||
/>
|
||||
<input
|
||||
v-model="soraProfileForm.name"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.profileName')"
|
||||
/>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
|
||||
<input v-model="soraProfileForm.enabled" type="checkbox" />
|
||||
<span>{{ t('admin.settings.soraS3.enabled') }}</span>
|
||||
</label>
|
||||
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
|
||||
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
|
||||
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
|
||||
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
|
||||
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
|
||||
<input
|
||||
v-model="soraProfileForm.secret_access_key"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
|
||||
/>
|
||||
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
|
||||
<div>
|
||||
<input
|
||||
v-model.number="soraProfileForm.default_storage_quota_gb"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.defaultQuota')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.defaultQuotaHint') }}</p>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
|
||||
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
|
||||
</label>
|
||||
<label v-if="creatingSoraProfile" class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
|
||||
<input v-model="soraProfileForm.set_active" type="checkbox" />
|
||||
<span>{{ t('admin.settings.soraS3.setActive') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="closeSoraProfileDrawer">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingSoraProfile || !soraProfileForm.enabled" @click="testSoraProfileConnection">
|
||||
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
|
||||
{{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import type { SoraS3Profile } from '@/api/admin/settings'
|
||||
import { adminAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loadingSoraProfiles = ref(false)
|
||||
const savingSoraProfile = ref(false)
|
||||
const testingSoraProfile = ref(false)
|
||||
const activatingSoraProfile = ref(false)
|
||||
const deletingSoraProfile = ref(false)
|
||||
const creatingSoraProfile = ref(false)
|
||||
const soraProfileDrawerOpen = ref(false)
|
||||
|
||||
const soraS3Profiles = ref<SoraS3Profile[]>([])
|
||||
const selectedSoraProfileID = ref('')
|
||||
|
||||
type SoraS3ProfileForm = {
|
||||
profile_id: string
|
||||
name: string
|
||||
set_active: boolean
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
secret_access_key_configured: boolean
|
||||
prefix: string
|
||||
force_path_style: boolean
|
||||
cdn_url: string
|
||||
default_storage_quota_gb: number
|
||||
}
|
||||
|
||||
const soraProfileForm = ref<SoraS3ProfileForm>(newDefaultSoraS3ProfileForm())
|
||||
|
||||
async function loadSoraS3Profiles() {
|
||||
loadingSoraProfiles.value = true
|
||||
try {
|
||||
const result = await adminAPI.settings.listSoraS3Profiles()
|
||||
soraS3Profiles.value = result.items || []
|
||||
if (!creatingSoraProfile.value) {
|
||||
const stillExists = selectedSoraProfileID.value
|
||||
? soraS3Profiles.value.some((item) => item.profile_id === selectedSoraProfileID.value)
|
||||
: false
|
||||
if (!stillExists) {
|
||||
selectedSoraProfileID.value = pickPreferredSoraProfileID()
|
||||
}
|
||||
syncSoraProfileFormWithSelection()
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
loadingSoraProfiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCreateSoraProfile() {
|
||||
creatingSoraProfile.value = true
|
||||
selectedSoraProfileID.value = ''
|
||||
soraProfileForm.value = newDefaultSoraS3ProfileForm()
|
||||
soraProfileDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function editSoraProfile(profileID: string) {
|
||||
selectedSoraProfileID.value = profileID
|
||||
creatingSoraProfile.value = false
|
||||
syncSoraProfileFormWithSelection()
|
||||
soraProfileDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function closeSoraProfileDrawer() {
|
||||
soraProfileDrawerOpen.value = false
|
||||
if (creatingSoraProfile.value) {
|
||||
creatingSoraProfile.value = false
|
||||
selectedSoraProfileID.value = pickPreferredSoraProfileID()
|
||||
syncSoraProfileFormWithSelection()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSoraProfile() {
|
||||
if (!soraProfileForm.value.name.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.profileNameRequired'))
|
||||
return
|
||||
}
|
||||
if (creatingSoraProfile.value && !soraProfileForm.value.profile_id.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.profileIDRequired'))
|
||||
return
|
||||
}
|
||||
if (!creatingSoraProfile.value && !selectedSoraProfileID.value) {
|
||||
appStore.showError(t('admin.settings.soraS3.profileSelectRequired'))
|
||||
return
|
||||
}
|
||||
if (soraProfileForm.value.enabled) {
|
||||
if (!soraProfileForm.value.endpoint.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.endpointRequired'))
|
||||
return
|
||||
}
|
||||
if (!soraProfileForm.value.bucket.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.bucketRequired'))
|
||||
return
|
||||
}
|
||||
if (!soraProfileForm.value.access_key_id.trim()) {
|
||||
appStore.showError(t('admin.settings.soraS3.accessKeyRequired'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savingSoraProfile.value = true
|
||||
try {
|
||||
if (creatingSoraProfile.value) {
|
||||
const created = await adminAPI.settings.createSoraS3Profile({
|
||||
profile_id: soraProfileForm.value.profile_id.trim(),
|
||||
name: soraProfileForm.value.name.trim(),
|
||||
set_active: soraProfileForm.value.set_active,
|
||||
enabled: soraProfileForm.value.enabled,
|
||||
endpoint: soraProfileForm.value.endpoint,
|
||||
region: soraProfileForm.value.region,
|
||||
bucket: soraProfileForm.value.bucket,
|
||||
access_key_id: soraProfileForm.value.access_key_id,
|
||||
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
|
||||
prefix: soraProfileForm.value.prefix,
|
||||
force_path_style: soraProfileForm.value.force_path_style,
|
||||
cdn_url: soraProfileForm.value.cdn_url,
|
||||
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
})
|
||||
selectedSoraProfileID.value = created.profile_id
|
||||
creatingSoraProfile.value = false
|
||||
soraProfileDrawerOpen.value = false
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileCreated'))
|
||||
} else {
|
||||
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, {
|
||||
name: soraProfileForm.value.name.trim(),
|
||||
enabled: soraProfileForm.value.enabled,
|
||||
endpoint: soraProfileForm.value.endpoint,
|
||||
region: soraProfileForm.value.region,
|
||||
bucket: soraProfileForm.value.bucket,
|
||||
access_key_id: soraProfileForm.value.access_key_id,
|
||||
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
|
||||
prefix: soraProfileForm.value.prefix,
|
||||
force_path_style: soraProfileForm.value.force_path_style,
|
||||
cdn_url: soraProfileForm.value.cdn_url,
|
||||
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
})
|
||||
soraProfileDrawerOpen.value = false
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileSaved'))
|
||||
}
|
||||
await loadSoraS3Profiles()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
savingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testSoraProfileConnection() {
|
||||
testingSoraProfile.value = true
|
||||
try {
|
||||
const result = await adminAPI.settings.testSoraS3Connection({
|
||||
profile_id: creatingSoraProfile.value ? undefined : selectedSoraProfileID.value,
|
||||
enabled: soraProfileForm.value.enabled,
|
||||
endpoint: soraProfileForm.value.endpoint,
|
||||
region: soraProfileForm.value.region,
|
||||
bucket: soraProfileForm.value.bucket,
|
||||
access_key_id: soraProfileForm.value.access_key_id,
|
||||
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
|
||||
prefix: soraProfileForm.value.prefix,
|
||||
force_path_style: soraProfileForm.value.force_path_style,
|
||||
cdn_url: soraProfileForm.value.cdn_url,
|
||||
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
})
|
||||
appStore.showSuccess(result.message || t('admin.settings.soraS3.testSuccess'))
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
testingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function activateSoraProfile(profileID: string) {
|
||||
activatingSoraProfile.value = true
|
||||
try {
|
||||
await adminAPI.settings.setActiveSoraS3Profile(profileID)
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileActivated'))
|
||||
await loadSoraS3Profiles()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
activatingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSoraProfile(profileID: string) {
|
||||
if (!window.confirm(t('admin.settings.soraS3.deleteConfirm', { profileID }))) {
|
||||
return
|
||||
}
|
||||
deletingSoraProfile.value = true
|
||||
try {
|
||||
await adminAPI.settings.deleteSoraS3Profile(profileID)
|
||||
if (selectedSoraProfileID.value === profileID) {
|
||||
selectedSoraProfileID.value = ''
|
||||
}
|
||||
appStore.showSuccess(t('admin.settings.soraS3.profileDeleted'))
|
||||
await loadSoraS3Profiles()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
deletingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatStorageQuotaGB(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return '0 GB'
|
||||
}
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
|
||||
}
|
||||
|
||||
function pickPreferredSoraProfileID(): string {
|
||||
const active = soraS3Profiles.value.find((item) => item.is_active)
|
||||
if (active) {
|
||||
return active.profile_id
|
||||
}
|
||||
return soraS3Profiles.value[0]?.profile_id || ''
|
||||
}
|
||||
|
||||
function syncSoraProfileFormWithSelection() {
|
||||
const profile = soraS3Profiles.value.find((item) => item.profile_id === selectedSoraProfileID.value)
|
||||
soraProfileForm.value = newDefaultSoraS3ProfileForm(profile)
|
||||
}
|
||||
|
||||
function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm {
|
||||
if (!profile) {
|
||||
return {
|
||||
profile_id: '',
|
||||
name: '',
|
||||
set_active: false,
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
secret_access_key_configured: false,
|
||||
prefix: 'sora/',
|
||||
force_path_style: false,
|
||||
cdn_url: '',
|
||||
default_storage_quota_gb: 0
|
||||
}
|
||||
}
|
||||
|
||||
const quotaBytes = profile.default_storage_quota_bytes || 0
|
||||
|
||||
return {
|
||||
profile_id: profile.profile_id,
|
||||
name: profile.name,
|
||||
set_active: false,
|
||||
enabled: profile.enabled,
|
||||
endpoint: profile.endpoint || '',
|
||||
region: profile.region || '',
|
||||
bucket: profile.bucket || '',
|
||||
access_key_id: profile.access_key_id || '',
|
||||
secret_access_key: '',
|
||||
secret_access_key_configured: Boolean(profile.secret_access_key_configured),
|
||||
prefix: profile.prefix || '',
|
||||
force_path_style: Boolean(profile.force_path_style),
|
||||
cdn_url: profile.cdn_url || '',
|
||||
default_storage_quota_gb: Number((quotaBytes / (1024 * 1024 * 1024)).toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSoraS3Profiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dm-drawer-mask-enter-active,
|
||||
.dm-drawer-mask-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dm-drawer-mask-enter-from,
|
||||
.dm-drawer-mask-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dm-drawer-panel-enter-active,
|
||||
.dm-drawer-panel-leave-active {
|
||||
transition:
|
||||
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dm-drawer-panel-enter-from,
|
||||
.dm-drawer-panel-leave-to {
|
||||
opacity: 0.96;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-drawer-mask-enter-active,
|
||||
.dm-drawer-mask-leave-active,
|
||||
.dm-drawer-panel-enter-active,
|
||||
.dm-drawer-panel-leave-active {
|
||||
transition-duration: 0s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -532,6 +532,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="createForm.sora_storage_quota_gb"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="10"
|
||||
/>
|
||||
<span class="shrink-0 text-sm text-gray-500">GB</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.soraPricing.storageQuotaHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
@@ -1212,6 +1229,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="editForm.sora_storage_quota_gb"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="10"
|
||||
/>
|
||||
<span class="shrink-0 text-sm text-gray-500">GB</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.groups.soraPricing.storageQuotaHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
@@ -1881,6 +1915,7 @@ const createForm = reactive({
|
||||
sora_image_price_540: null as number | null,
|
||||
sora_video_price_per_request: null as number | null,
|
||||
sora_video_price_per_request_hd: null as number | null,
|
||||
sora_storage_quota_gb: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -2121,6 +2156,7 @@ const editForm = reactive({
|
||||
sora_image_price_540: null as number | null,
|
||||
sora_video_price_per_request: null as number | null,
|
||||
sora_video_price_per_request_hd: null as number | null,
|
||||
sora_storage_quota_gb: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -2220,6 +2256,7 @@ const closeCreateModal = () => {
|
||||
createForm.sora_image_price_540 = null
|
||||
createForm.sora_video_price_per_request = null
|
||||
createForm.sora_video_price_per_request_hd = null
|
||||
createForm.sora_storage_quota_gb = null
|
||||
createForm.claude_code_only = false
|
||||
createForm.fallback_group_id = null
|
||||
createForm.fallback_group_id_on_invalid_request = null
|
||||
@@ -2237,8 +2274,10 @@ const handleCreateGroup = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
// 构建请求数据,包含模型路由配置
|
||||
const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm
|
||||
const requestData = {
|
||||
...createForm,
|
||||
...createRest,
|
||||
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
|
||||
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
|
||||
}
|
||||
await adminAPI.groups.create(requestData)
|
||||
@@ -2277,6 +2316,7 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.sora_image_price_540 = group.sora_image_price_540
|
||||
editForm.sora_video_price_per_request = group.sora_video_price_per_request
|
||||
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
|
||||
editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
|
||||
editForm.claude_code_only = group.claude_code_only || false
|
||||
editForm.fallback_group_id = group.fallback_group_id
|
||||
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
|
||||
@@ -2310,8 +2350,10 @@ const handleUpdateGroup = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
|
||||
const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm
|
||||
const payload = {
|
||||
...editForm,
|
||||
...editRest,
|
||||
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
|
||||
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
|
||||
fallback_group_id_on_invalid_request:
|
||||
editForm.fallback_group_id_on_invalid_request === null
|
||||
|
||||
@@ -994,6 +994,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sora Client Toggle -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.soraClient.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraClient.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.soraClient.enabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraClient.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.sora_client_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
@@ -1145,6 +1170,7 @@ const form = reactive<SettingsForm>({
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
sora_client_enabled: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -1273,6 +1299,7 @@ async function saveSettings() {
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||
purchase_subscription_url: form.purchase_subscription_url,
|
||||
sora_client_enabled: form.sora_client_enabled,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
@@ -73,6 +73,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import { formatReasoningEffort } from '@/utils/format'
|
||||
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
|
||||
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 UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||
@@ -99,32 +100,52 @@ const formatLD = (d: Date) => {
|
||||
}
|
||||
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const loadLogs = async () => {
|
||||
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
|
||||
try {
|
||||
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value }, { signal: c.signal })
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
|
||||
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
|
||||
} 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 (error) { console.error('Failed to load usage stats:', error) } }
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const s = await adminAPI.usage.getStats({ ...filters.value, stream: legacyStream === null ? undefined : legacyStream })
|
||||
usageStats.value = s
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage stats:', error)
|
||||
}
|
||||
}
|
||||
const loadChartData = async () => {
|
||||
chartsLoading.value = true
|
||||
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, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream, billing_type: filters.value.billing_type }
|
||||
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, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream, billing_type: params.billing_type })])
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
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, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, request_type: requestType, stream: legacyStream === null ? undefined : legacyStream, billing_type: filters.value.billing_type }
|
||||
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, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, request_type: params.request_type, stream: params.stream, billing_type: params.billing_type })])
|
||||
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
|
||||
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
|
||||
}
|
||||
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
|
||||
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
const getRequestTypeLabel = (log: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'ws_v2') return t('usage.ws')
|
||||
if (requestType === 'stream') return t('usage.stream')
|
||||
if (requestType === 'sync') return t('usage.sync')
|
||||
return t('usage.unknown')
|
||||
}
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||
@@ -146,11 +167,13 @@ const exportToExcel = async () => {
|
||||
]
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers])
|
||||
while (true) {
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
const rows = (res.items || []).map((log: AdminUsageLog) => [
|
||||
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
|
||||
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.stream ? t('usage.stream') : t('usage.sync'),
|
||||
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', getRequestTypeLabel(log),
|
||||
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',
|
||||
|
||||
369
frontend/src/views/user/SoraView.vue
Normal file
369
frontend/src/views/user/SoraView.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="sora-root">
|
||||
<!-- Sora 页面内容 -->
|
||||
<div class="sora-page">
|
||||
<!-- 功能未启用提示 -->
|
||||
<div v-if="!soraEnabled" class="sora-not-enabled">
|
||||
<svg class="sora-not-enabled-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||
</svg>
|
||||
<h2 class="sora-not-enabled-title">{{ t('sora.notEnabled') }}</h2>
|
||||
<p class="sora-not-enabled-desc">{{ t('sora.notEnabledDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sora 主界面 -->
|
||||
<template v-else>
|
||||
<!-- 自定义 Sora 头部 -->
|
||||
<header class="sora-header">
|
||||
<div class="sora-header-left">
|
||||
<!-- 返回主页按钮 -->
|
||||
<router-link :to="dashboardPath" class="sora-back-btn" :title="t('common.back')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<nav class="sora-nav-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="['sora-nav-tab', activeTab === tab.key && 'active']"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sora-header-right">
|
||||
<SoraQuotaBar v-if="quota" :quota="quota" />
|
||||
<div v-if="activeTaskCount > 0" class="sora-queue-indicator">
|
||||
<span class="sora-queue-dot" :class="{ busy: hasGeneratingTask }"></span>
|
||||
<span>{{ activeTaskCount }} {{ t('sora.queueTasks') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<main class="sora-main">
|
||||
<SoraGeneratePage
|
||||
v-show="activeTab === 'generate'"
|
||||
@task-count-change="onTaskCountChange"
|
||||
/>
|
||||
<SoraLibraryPage
|
||||
v-show="activeTab === 'library'"
|
||||
@switch-to-generate="activeTab = 'generate'"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import SoraQuotaBar from '@/components/sora/SoraQuotaBar.vue'
|
||||
import SoraGeneratePage from '@/components/sora/SoraGeneratePage.vue'
|
||||
import SoraLibraryPage from '@/components/sora/SoraLibraryPage.vue'
|
||||
import soraAPI, { type QuotaInfo } from '@/api/sora'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const soraEnabled = computed(() => appStore.cachedPublicSettings?.sora_client_enabled ?? false)
|
||||
|
||||
const activeTab = ref<'generate' | 'library'>('generate')
|
||||
const quota = ref<QuotaInfo | null>(null)
|
||||
const activeTaskCount = ref(0)
|
||||
const hasGeneratingTask = ref(false)
|
||||
const dashboardPath = computed(() => (authStore.isAdmin ? '/admin/dashboard' : '/dashboard'))
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'generate' as const, label: t('sora.tabGenerate') },
|
||||
{ key: 'library' as const, label: t('sora.tabLibrary') }
|
||||
])
|
||||
|
||||
function onTaskCountChange(counts: { active: number; generating: boolean }) {
|
||||
activeTaskCount.value = counts.active
|
||||
hasGeneratingTask.value = counts.generating
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!soraEnabled.value) return
|
||||
try {
|
||||
quota.value = await soraAPI.getQuota()
|
||||
} catch {
|
||||
// 配额查询失败不阻塞页面
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================
|
||||
Sora 主题 CSS 变量 — 亮色模式(跟随应用主题)
|
||||
============================================================ */
|
||||
.sora-root {
|
||||
--sora-bg-primary: #F9FAFB;
|
||||
--sora-bg-secondary: #FFFFFF;
|
||||
--sora-bg-tertiary: #F3F4F6;
|
||||
--sora-bg-elevated: #FFFFFF;
|
||||
--sora-bg-hover: #E5E7EB;
|
||||
--sora-bg-input: #FFFFFF;
|
||||
--sora-text-primary: #111827;
|
||||
--sora-text-secondary: #6B7280;
|
||||
--sora-text-tertiary: #9CA3AF;
|
||||
--sora-text-muted: #D1D5DB;
|
||||
--sora-accent-primary: #14b8a6;
|
||||
--sora-accent-secondary: #0d9488;
|
||||
--sora-accent-gradient: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
|
||||
--sora-accent-gradient-hover: linear-gradient(135deg, #2dd4bf 0%, #14b8a6 100%);
|
||||
--sora-success: #10B981;
|
||||
--sora-warning: #F59E0B;
|
||||
--sora-error: #EF4444;
|
||||
--sora-info: #3B82F6;
|
||||
--sora-border-color: #E5E7EB;
|
||||
--sora-border-subtle: #F3F4F6;
|
||||
--sora-radius-sm: 8px;
|
||||
--sora-radius-md: 12px;
|
||||
--sora-radius-lg: 16px;
|
||||
--sora-radius-xl: 20px;
|
||||
--sora-radius-full: 9999px;
|
||||
--sora-shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--sora-shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--sora-shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
|
||||
--sora-shadow-glow: 0 0 20px rgba(20,184,166,0.25);
|
||||
--sora-transition-fast: 150ms ease;
|
||||
--sora-transition-normal: 250ms ease;
|
||||
--sora-header-height: 56px;
|
||||
--sora-header-bg: rgba(249, 250, 251, 0.85);
|
||||
--sora-placeholder-gradient: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%);
|
||||
--sora-modal-backdrop: rgba(0, 0, 0, 0.4);
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--sora-bg-primary);
|
||||
color: var(--sora-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
页面布局
|
||||
============================================================ */
|
||||
.sora-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
头部导航栏
|
||||
============================================================ */
|
||||
.sora-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: var(--sora-header-height);
|
||||
background: var(--sora-header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--sora-border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.sora-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sora-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 返回按钮 */
|
||||
.sora-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--sora-radius-sm);
|
||||
color: var(--sora-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all var(--sora-transition-fast);
|
||||
}
|
||||
|
||||
.sora-back-btn:hover {
|
||||
background: var(--sora-bg-tertiary);
|
||||
color: var(--sora-text-primary);
|
||||
}
|
||||
|
||||
/* Tab 导航 */
|
||||
.sora-nav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--sora-bg-secondary);
|
||||
border-radius: var(--sora-radius-full);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.sora-nav-tab {
|
||||
padding: 6px 20px;
|
||||
border-radius: var(--sora-radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--sora-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--sora-transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sora-nav-tab:hover {
|
||||
color: var(--sora-text-primary);
|
||||
}
|
||||
|
||||
.sora-nav-tab.active {
|
||||
background: var(--sora-bg-tertiary);
|
||||
color: var(--sora-text-primary);
|
||||
}
|
||||
|
||||
/* 队列指示器 */
|
||||
.sora-queue-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--sora-bg-secondary);
|
||||
border-radius: var(--sora-radius-full);
|
||||
font-size: 12px;
|
||||
color: var(--sora-text-secondary);
|
||||
}
|
||||
|
||||
.sora-queue-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--sora-success);
|
||||
animation: sora-pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sora-queue-dot.busy {
|
||||
background: var(--sora-warning);
|
||||
}
|
||||
|
||||
@keyframes sora-pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
主内容区
|
||||
============================================================ */
|
||||
.sora-main {
|
||||
min-height: calc(100vh - var(--sora-header-height));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
功能未启用
|
||||
============================================================ */
|
||||
.sora-not-enabled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.sora-not-enabled-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--sora-text-tertiary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sora-not-enabled-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--sora-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sora-not-enabled-desc {
|
||||
font-size: 14px;
|
||||
color: var(--sora-text-tertiary);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 900px) {
|
||||
.sora-header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sora-header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.sora-nav-tab {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.sora-root ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.sora-root ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sora-root ::-webkit-scrollbar-thumb {
|
||||
background: var(--sora-bg-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sora-root ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--sora-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 暗色模式:必须明确命中 .sora-root,避免被 scoped 编译后的变量覆盖问题 */
|
||||
html.dark .sora-root {
|
||||
--sora-bg-primary: #020617;
|
||||
--sora-bg-secondary: #0f172a;
|
||||
--sora-bg-tertiary: #1e293b;
|
||||
--sora-bg-elevated: #1e293b;
|
||||
--sora-bg-hover: #334155;
|
||||
--sora-bg-input: #0f172a;
|
||||
--sora-text-primary: #f1f5f9;
|
||||
--sora-text-secondary: #94a3b8;
|
||||
--sora-text-tertiary: #64748b;
|
||||
--sora-text-muted: #475569;
|
||||
--sora-border-color: #334155;
|
||||
--sora-border-subtle: #1e293b;
|
||||
--sora-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--sora-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--sora-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--sora-shadow-glow: 0 0 20px rgba(20, 184, 166, 0.3);
|
||||
--sora-header-bg: rgba(2, 6, 23, 0.85);
|
||||
--sora-placeholder-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 50%, #020617 100%);
|
||||
--sora-modal-backdrop: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
</style>
|
||||
@@ -166,13 +166,9 @@
|
||||
<template #cell-stream="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="
|
||||
row.stream
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
"
|
||||
:class="getRequestTypeBadgeClass(row)"
|
||||
>
|
||||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||||
{{ getRequestTypeLabel(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -473,12 +469,13 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { resolveUsageRequestType } from '@/utils/usageRequestType'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -577,6 +574,30 @@ const formatUserAgent = (ua: string): string => {
|
||||
return ua
|
||||
}
|
||||
|
||||
const getRequestTypeLabel = (log: UsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'ws_v2') return t('usage.ws')
|
||||
if (requestType === 'stream') return t('usage.stream')
|
||||
if (requestType === 'sync') return t('usage.sync')
|
||||
return t('usage.unknown')
|
||||
}
|
||||
|
||||
const getRequestTypeBadgeClass = (log: UsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
|
||||
if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}
|
||||
|
||||
const getRequestTypeExportText = (log: UsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'ws_v2') return 'WS'
|
||||
if (requestType === 'stream') return 'Stream'
|
||||
if (requestType === 'sync') return 'Sync'
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
@@ -768,7 +789,7 @@ const exportToCSV = async () => {
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
formatReasoningEffort(log.reasoning_effort),
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
getRequestTypeExportText(log),
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
|
||||
Reference in New Issue
Block a user