refactor(frontend): UI/UX改进和组件优化
- DataTable组件操作列自适应 - 优化各种Modal弹窗 - 统一API调用方式(AbortSignal) - 添加全局订阅状态管理 - 优化各管理视图的交互和布局 - 修复国际化翻译问题
This commit is contained in:
@@ -322,7 +322,13 @@
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Model Distribution Chart -->
|
||||
<div class="card p-4">
|
||||
<div class="card relative overflow-hidden p-4">
|
||||
<div
|
||||
v-if="loadingCharts"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
|
||||
>
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('dashboard.modelDistribution') }}
|
||||
</h3>
|
||||
@@ -383,7 +389,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<div class="card relative overflow-hidden p-4">
|
||||
<div
|
||||
v-if="loadingCharts"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
|
||||
>
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('dashboard.tokenUsageTrend') }}
|
||||
</h3>
|
||||
@@ -694,6 +706,7 @@ const user = computed(() => authStore.user)
|
||||
const stats = ref<UserDashboardStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingUsage = ref(false)
|
||||
const loadingCharts = ref(false)
|
||||
|
||||
// Chart data
|
||||
const trendData = ref<TrendDataPoint[]>([])
|
||||
@@ -964,6 +977,7 @@ const loadDashboardStats = async () => {
|
||||
}
|
||||
|
||||
const loadChartData = async () => {
|
||||
loadingCharts.value = true
|
||||
try {
|
||||
const params = {
|
||||
start_date: startDate.value,
|
||||
@@ -981,14 +995,16 @@ const loadChartData = async () => {
|
||||
modelStats.value = modelResponse.models || []
|
||||
} catch (error) {
|
||||
console.error('Error loading chart data:', error)
|
||||
} finally {
|
||||
loadingCharts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecentUsage = async () => {
|
||||
loadingUsage.value = true
|
||||
try {
|
||||
const endDate = new Date().toISOString()
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const endDate = new Date().toISOString().split('T')[0]
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
|
||||
recentUsage.value = usageResponse.items.slice(0, 5)
|
||||
} catch (error) {
|
||||
@@ -998,11 +1014,17 @@ const loadRecentUsage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardStats()
|
||||
onMounted(async () => {
|
||||
// Load critical data first
|
||||
await loadDashboardStats()
|
||||
|
||||
// Initialize date range (synchronous)
|
||||
initializeDateRange()
|
||||
loadChartData()
|
||||
loadRecentUsage()
|
||||
|
||||
// Load chart data and recent usage in parallel (non-critical)
|
||||
Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => {
|
||||
console.error('Error loading secondary data:', error)
|
||||
})
|
||||
})
|
||||
|
||||
// Watch for dark mode changes
|
||||
|
||||
@@ -292,17 +292,19 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||
width="narrow"
|
||||
@close="closeModals"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.nameLabel') }}</label>
|
||||
<input
|
||||
@@ -383,12 +385,13 @@
|
||||
:placeholder="t('keys.selectStatus')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeModals" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -418,8 +421,8 @@
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
@@ -501,7 +504,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Get the currently selected key for group change
|
||||
const selectedKeyForGroup = computed(() => {
|
||||
@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
|
||||
copiedKeyId.value = keyId
|
||||
setTimeout(() => {
|
||||
copiedKeyId.value = null
|
||||
}, 2000)
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown) => {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const { name, code } = error as { name?: string; code?: string }
|
||||
return name === 'AbortError' || code === 'ERR_CANCELED'
|
||||
}
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
abortController?.abort()
|
||||
const controller = new AbortController()
|
||||
abortController = controller
|
||||
const { signal } = controller
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size)
|
||||
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, {
|
||||
signal
|
||||
})
|
||||
if (signal.aborted) return
|
||||
apiKeys.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
@@ -639,16 +656,24 @@ const loadApiKeys = async () => {
|
||||
if (response.items.length > 0) {
|
||||
const keyIds = response.items.map((k) => k.id)
|
||||
try {
|
||||
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds)
|
||||
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal })
|
||||
if (signal.aborted) return
|
||||
usageStats.value = usageResponse.stats
|
||||
} catch (e) {
|
||||
console.error('Failed to load usage stats:', e)
|
||||
if (!isAbortError(e)) {
|
||||
console.error('Failed to load usage stats:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('keys.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === controller) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.page_size = pageSize
|
||||
pagination.value.page = 1
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
formData.value = {
|
||||
|
||||
@@ -244,6 +244,12 @@
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p
|
||||
v-if="passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="input-error-text"
|
||||
>
|
||||
{{ t('profile.passwordsNotMatch') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
// Basic validation
|
||||
if (!profileForm.value.username.trim()) {
|
||||
appStore.showError(t('profile.usernameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
updatingProfile.value = true
|
||||
try {
|
||||
const updatedUser = await userAPI.updateProfile({
|
||||
|
||||
@@ -164,8 +164,28 @@
|
||||
<button @click="resetFilters" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button @click="exportToCSV" class="btn btn-primary">
|
||||
{{ t('usage.exportCsv') }}
|
||||
<button @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
|
||||
<svg
|
||||
v-if="exporting"
|
||||
class="-ml-1 mr-2 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>
|
||||
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,6 +386,7 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
@@ -412,7 +433,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { usageAPI, keysAPI } from '@/api'
|
||||
@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Tooltip state
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
|
||||
const usageLogs = ref<UsageLog[]>([])
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
|
||||
const apiKeyOptions = computed(() => {
|
||||
return [
|
||||
@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const pagination = ref({
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => {
|
||||
}
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const currentAbortController = new AbortController()
|
||||
abortController = currentAbortController
|
||||
const { signal } = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const params: UsageQueryParams = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.page_size,
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
...filters.value
|
||||
}
|
||||
|
||||
const response = await usageAPI.query(params)
|
||||
const response = await usageAPI.query(params, { signal })
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
usageLogs.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const abortError = error as { name?: string; code?: string }
|
||||
if (abortError?.name === 'AbortError' || abortError?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('usage.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === currentAbortController) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.value.page = 1
|
||||
pagination.page = 1
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
}
|
||||
@@ -588,60 +630,128 @@ const resetFilters = () => {
|
||||
}
|
||||
// Reset date range to default (last 7 days)
|
||||
initializeDateRange()
|
||||
pagination.value.page = 1
|
||||
pagination.page = 1
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
pagination.page = page
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (usageLogs.value.length === 0) {
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV value to prevent injection and handle special characters
|
||||
*/
|
||||
const escapeCSVValue = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
|
||||
const str = String(value)
|
||||
const escaped = str.replace(/"/g, '""')
|
||||
|
||||
// Prevent formula injection by prefixing dangerous characters with single quote
|
||||
if (/^[=+\-@\t\r]/.test(str)) {
|
||||
return `"\'${escaped}"`
|
||||
}
|
||||
|
||||
// Escape values containing comma, quote, or newline
|
||||
if (/[,"\n\r]/.test(str)) {
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
const exportToCSV = async () => {
|
||||
if (pagination.total === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Model',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
'Cache Read Tokens',
|
||||
'Cache Write Tokens',
|
||||
'Total Cost',
|
||||
'Billing Type',
|
||||
'First Token (ms)',
|
||||
'Duration (ms)',
|
||||
'Time'
|
||||
]
|
||||
const rows = usageLogs.value.map((log) => [
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
log.total_cost.toFixed(6),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
exporting.value = true
|
||||
appStore.showInfo(t('usage.preparingExport'))
|
||||
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
|
||||
try {
|
||||
const allLogs: UsageLog[] = []
|
||||
const pageSize = 100 // Use a larger page size for export to reduce requests
|
||||
const totalRequests = Math.ceil(pagination.total / pageSize)
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
for (let page = 1; page <= totalRequests; page++) {
|
||||
const params: UsageQueryParams = {
|
||||
page: page,
|
||||
page_size: pageSize,
|
||||
...filters.value
|
||||
}
|
||||
const response = await usageAPI.query(params)
|
||||
allLogs.push(...response.items)
|
||||
}
|
||||
|
||||
appStore.showSuccess(t('usage.exportSuccess'))
|
||||
if (allLogs.length === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Time',
|
||||
'API Key Name',
|
||||
'Model',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
'Cache Read Tokens',
|
||||
'Cache Creation Tokens',
|
||||
'Rate Multiplier',
|
||||
'Billed Cost',
|
||||
'Original Cost',
|
||||
'Billing Type',
|
||||
'First Token (ms)',
|
||||
'Duration (ms)'
|
||||
]
|
||||
const rows = allLogs.map((log) =>
|
||||
[
|
||||
log.created_at,
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
log.rate_multiplier,
|
||||
log.actual_cost.toFixed(8),
|
||||
log.total_cost.toFixed(8),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms
|
||||
].map(escapeCSVValue)
|
||||
)
|
||||
|
||||
const csvContent = [
|
||||
headers.map(escapeCSVValue).join(','),
|
||||
...rows.map((row) => row.join(','))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `usage_${filters.value.start_date}_to_${filters.value.end_date}.csv`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
appStore.showSuccess(t('usage.exportSuccess'))
|
||||
} catch (error) {
|
||||
appStore.showError(t('usage.exportFailed'))
|
||||
console.error('CSV Export failed:', error)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip functions
|
||||
|
||||
Reference in New Issue
Block a user