Commit 506cb21c authored by IanShaw027's avatar IanShaw027
Browse files

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

- DataTable组件操作列自适应
- 优化各种Modal弹窗
- 统一API调用方式(AbortSignal)
- 添加全局订阅状态管理
- 优化各管理视图的交互和布局
- 修复国际化翻译问题
parent 9bbe468c
......@@ -66,6 +66,7 @@
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
......
......@@ -563,13 +563,13 @@ const installing = ref(false)
const confirmPassword = ref('')
const serviceReady = ref(false)
// Get current server port from browser location (set by install.sh)
// Default server port
const getCurrentPort = (): number => {
const port = window.location.port
if (port) {
return parseInt(port, 10)
}
// Default port based on protocol
return window.location.protocol === 'https:' ? 443 : 80
}
......@@ -674,29 +674,23 @@ async function performInstall() {
// Wait for service to restart and become available
async function waitForServiceRestart() {
const maxAttempts = 30 // 30 attempts, ~30 seconds max
const maxAttempts = 60 // Increase to 60 attempts, ~60 seconds max
const interval = 1000 // 1 second between attempts
// Wait a moment for the service to start restarting
await new Promise((resolve) => setTimeout(resolve, 2000))
await new Promise((resolve) => setTimeout(resolve, 3000))
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
// Try to access the health endpoint
const response = await fetch('/health', {
// Use setup status endpoint as it tells us the real mode
// Service might return 404 or connection refused while restarting
const response = await fetch('/setup/status', {
method: 'GET',
cache: 'no-store'
})
if (response.ok) {
// Service is up, check if setup is no longer needed
const statusResponse = await fetch('/setup/status', {
method: 'GET',
cache: 'no-store'
})
if (statusResponse.ok) {
const data = await statusResponse.json()
const data = await response.json()
// If needs_setup is false, service has restarted in normal mode
if (data.data && !data.data.needs_setup) {
serviceReady.value = true
......@@ -707,9 +701,8 @@ async function waitForServiceRestart() {
return
}
}
}
} catch {
// Service not ready yet, continue polling
// Service not ready or network error during restart, continue polling
}
await new Promise((resolve) => setTimeout(resolve, interval))
......
......@@ -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,17 +656,25 @@ 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) {
if (!isAbortError(e)) {
console.error('Failed to load usage stats:', e)
}
}
}
} catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('keys.failedToLoad'))
} finally {
if (abortController === controller) {
loading.value = false
}
}
}
const loadGroups = async () => {
......@@ -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,23 +556,41 @@ 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 {
if (abortController === currentAbortController) {
loading.value = false
}
}
}
const loadApiKeys = async () => {
......@@ -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
}
exporting.value = true
appStore.showInfo(t('usage.preparingExport'))
try {
const allLogs: UsageLog[] = []
const pageSize = 100 // Use a larger page size for export to reduce requests
const totalRequests = Math.ceil(pagination.total / pageSize)
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)
}
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 Write Tokens',
'Total Cost',
'Cache Creation Tokens',
'Rate Multiplier',
'Billed Cost',
'Original Cost',
'Billing Type',
'First Token (ms)',
'Duration (ms)',
'Time'
'Duration (ms)'
]
const rows = usageLogs.value.map((log) => [
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.total_cost.toFixed(6),
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,
log.created_at
])
log.duration_ms
].map(escapeCSVValue)
)
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
const csvContent = [
headers.map(escapeCSVValue).join(','),
...rows.map((row) => row.join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
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_${new Date().toISOString().split('T')[0]}.csv`
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
......
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