Commit fb9d0878 authored by shaw's avatar shaw
Browse files

Merge PR #62: refactor(frontend): 前端界面优化与订阅状态管理增强

parents fd51ff69 18c6686f
......@@ -39,6 +39,7 @@
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
......
......@@ -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>
......@@ -330,6 +336,7 @@
<div class="h-48 w-48">
<Doughnut
v-if="modelChartData"
ref="modelChartRef"
:data="modelChartData"
:options="doughnutOptions"
/>
......@@ -383,12 +390,23 @@
</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>
<div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
<Line
v-if="trendChartData"
ref="trendChartRef"
:data="trendChartData"
:options="lineOptions"
/>
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
......@@ -645,10 +663,11 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
......@@ -689,15 +708,21 @@ ChartJS.register(
const router = useRouter()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user)
const stats = ref<UserDashboardStats | null>(null)
const loading = ref(false)
const loadingUsage = ref(false)
const loadingCharts = ref(false)
type ChartComponentRef = { chart?: ChartJS }
// Chart data
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const modelChartRef = ref<ChartComponentRef | null>(null)
const trendChartRef = ref<ChartComponentRef | null>(null)
// Recent usage
const recentUsage = ref<UsageLog[]>([])
......@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
}
const loadChartData = async () => {
loadingCharts.value = true
try {
const params = {
start_date: startDate.value,
......@@ -981,18 +1007,15 @@ 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 {
// 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD(见 backend usage handler 的校验逻辑)。
// 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。
// 注意:toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径,
// 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone)。
const now = new Date()
const endDate = now.toISOString().split('T')[0]
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)
......@@ -1003,16 +1026,30 @@ const loadRecentUsage = async () => {
}
}
onMounted(() => {
loadDashboardStats()
onMounted(async () => {
// Load critical data first
await loadDashboardStats()
// Force refresh subscription status when entering dashboard (bypass cache)
subscriptionStore.fetchActiveSubscriptions(true).catch((error) => {
console.error('Failed to refresh subscription status:', error)
})
// 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
watch(isDarkMode, () => {
// Force chart re-render on theme change
nextTick(() => {
modelChartRef.value?.chart?.update()
trendChartRef.value?.chart?.update()
})
})
</script>
......
......@@ -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({
......
......@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import { formatDateTime } from '@/utils/format'
......@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user)
......@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency
await authStore.refreshUser()
// If subscription type, immediately refresh subscription status
if (result.type === 'subscription') {
try {
await subscriptionStore.fetchActiveSubscriptions(true) // force refresh
} catch (error) {
console.error('Failed to refresh subscriptions after redeem:', error)
appStore.showWarning(t('redeem.subscriptionRefreshFailed'))
}
}
// Clear the input
redeemCode.value = ''
......
......@@ -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