refactor(frontend): UI/UX改进和组件优化

- DataTable组件操作列自适应
- 优化各种Modal弹窗
- 统一API调用方式(AbortSignal)
- 添加全局订阅状态管理
- 优化各管理视图的交互和布局
- 修复国际化翻译问题
This commit is contained in:
IanShaw027
2025-12-28 01:00:06 +08:00
parent 9bbe468c91
commit 506cb21cb1
46 changed files with 1582 additions and 644 deletions

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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({

View File

@@ -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