"backend/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "dabed96af4b5796df85a34da1e903e603a096a69"
Commit fb9d0878 authored by shaw's avatar shaw
Browse files

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

parents fd51ff69 18c6686f
...@@ -39,6 +39,7 @@ ...@@ -39,6 +39,7 @@
v-model="formData.email" v-model="formData.email"
type="email" type="email"
required required
autofocus
autocomplete="email" autocomplete="email"
:disabled="isLoading" :disabled="isLoading"
class="input pl-11" class="input pl-11"
......
...@@ -66,6 +66,7 @@ ...@@ -66,6 +66,7 @@
v-model="formData.email" v-model="formData.email"
type="email" type="email"
required required
autofocus
autocomplete="email" autocomplete="email"
:disabled="isLoading" :disabled="isLoading"
class="input pl-11" class="input pl-11"
......
...@@ -563,13 +563,13 @@ const installing = ref(false) ...@@ -563,13 +563,13 @@ const installing = ref(false)
const confirmPassword = ref('') const confirmPassword = ref('')
const serviceReady = ref(false) const serviceReady = ref(false)
// Get current server port from browser location (set by install.sh) // Default server port
const getCurrentPort = (): number => { const getCurrentPort = (): number => {
const port = window.location.port const port = window.location.port
if (port) { if (port) {
return parseInt(port, 10) return parseInt(port, 10)
} }
// Default port based on protocol
return window.location.protocol === 'https:' ? 443 : 80 return window.location.protocol === 'https:' ? 443 : 80
} }
...@@ -674,42 +674,35 @@ async function performInstall() { ...@@ -674,42 +674,35 @@ async function performInstall() {
// Wait for service to restart and become available // Wait for service to restart and become available
async function waitForServiceRestart() { 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 const interval = 1000 // 1 second between attempts
// Wait a moment for the service to start restarting // 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++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
// Try to access the health endpoint // Use setup status endpoint as it tells us the real mode
const response = await fetch('/health', { // Service might return 404 or connection refused while restarting
const response = await fetch('/setup/status', {
method: 'GET', method: 'GET',
cache: 'no-store' cache: 'no-store'
}) })
if (response.ok) { if (response.ok) {
// Service is up, check if setup is no longer needed const data = await response.json()
const statusResponse = await fetch('/setup/status', { // If needs_setup is false, service has restarted in normal mode
method: 'GET', if (data.data && !data.data.needs_setup) {
cache: 'no-store' serviceReady.value = true
}) // Redirect to login page after a short delay
setTimeout(() => {
if (statusResponse.ok) { window.location.href = '/login'
const data = await statusResponse.json() }, 1500)
// If needs_setup is false, service has restarted in normal mode return
if (data.data && !data.data.needs_setup) {
serviceReady.value = true
// Redirect to login page after a short delay
setTimeout(() => {
window.location.href = '/login'
}, 1500)
return
}
} }
} }
} catch { } catch {
// Service not ready yet, continue polling // Service not ready or network error during restart, continue polling
} }
await new Promise((resolve) => setTimeout(resolve, interval)) await new Promise((resolve) => setTimeout(resolve, interval))
......
...@@ -322,7 +322,13 @@ ...@@ -322,7 +322,13 @@
<!-- Charts Grid --> <!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart --> <!-- 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"> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.modelDistribution') }} {{ t('dashboard.modelDistribution') }}
</h3> </h3>
...@@ -330,6 +336,7 @@ ...@@ -330,6 +336,7 @@
<div class="h-48 w-48"> <div class="h-48 w-48">
<Doughnut <Doughnut
v-if="modelChartData" v-if="modelChartData"
ref="modelChartRef"
:data="modelChartData" :data="modelChartData"
:options="doughnutOptions" :options="doughnutOptions"
/> />
...@@ -383,12 +390,23 @@ ...@@ -383,12 +390,23 @@
</div> </div>
<!-- Token Usage Trend Chart --> <!-- 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"> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.tokenUsageTrend') }} {{ t('dashboard.tokenUsageTrend') }}
</h3> </h3>
<div class="h-48"> <div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" /> <Line
v-if="trendChartData"
ref="trendChartRef"
:data="trendChartData"
:options="lineOptions"
/>
<div <div
v-else v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400" class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
...@@ -645,10 +663,11 @@ ...@@ -645,10 +663,11 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
...@@ -689,15 +708,21 @@ ChartJS.register( ...@@ -689,15 +708,21 @@ ChartJS.register(
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const stats = ref<UserDashboardStats | null>(null) const stats = ref<UserDashboardStats | null>(null)
const loading = ref(false) const loading = ref(false)
const loadingUsage = ref(false) const loadingUsage = ref(false)
const loadingCharts = ref(false)
type ChartComponentRef = { chart?: ChartJS }
// Chart data // Chart data
const trendData = ref<TrendDataPoint[]>([]) const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const modelChartRef = ref<ChartComponentRef | null>(null)
const trendChartRef = ref<ChartComponentRef | null>(null)
// Recent usage // Recent usage
const recentUsage = ref<UsageLog[]>([]) const recentUsage = ref<UsageLog[]>([])
...@@ -964,6 +989,7 @@ const loadDashboardStats = async () => { ...@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
} }
const loadChartData = async () => { const loadChartData = async () => {
loadingCharts.value = true
try { try {
const params = { const params = {
start_date: startDate.value, start_date: startDate.value,
...@@ -981,18 +1007,15 @@ const loadChartData = async () => { ...@@ -981,18 +1007,15 @@ const loadChartData = async () => {
modelStats.value = modelResponse.models || [] modelStats.value = modelResponse.models || []
} catch (error) { } catch (error) {
console.error('Error loading chart data:', error) console.error('Error loading chart data:', error)
} finally {
loadingCharts.value = false
} }
} }
const loadRecentUsage = async () => { const loadRecentUsage = async () => {
loadingUsage.value = true loadingUsage.value = true
try { try {
// 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD(见 backend usage handler 的校验逻辑)。 const endDate = new Date().toISOString().split('T')[0]
// 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。
// 注意:toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径,
// 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone)。
const now = new Date()
const endDate = now.toISOString().split('T')[0]
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).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) const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
recentUsage.value = usageResponse.items.slice(0, 5) recentUsage.value = usageResponse.items.slice(0, 5)
...@@ -1003,16 +1026,30 @@ const loadRecentUsage = async () => { ...@@ -1003,16 +1026,30 @@ const loadRecentUsage = async () => {
} }
} }
onMounted(() => { onMounted(async () => {
loadDashboardStats() // 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() 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 for dark mode changes
watch(isDarkMode, () => { watch(isDarkMode, () => {
// Force chart re-render on theme change nextTick(() => {
modelChartRef.value?.chart?.update()
trendChartRef.value?.chart?.update()
})
}) })
</script> </script>
......
...@@ -292,17 +292,19 @@ ...@@ -292,17 +292,19 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<Modal <BaseDialog
:show="showCreateModal || showEditModal" :show="showCreateModal || showEditModal"
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')" :title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
width="narrow"
@close="closeModals" @close="closeModals"
> >
<form @submit.prevent="handleSubmit" class="space-y-5"> <form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('keys.nameLabel') }}</label> <label class="input-label">{{ t('keys.nameLabel') }}</label>
<input <input
...@@ -383,12 +385,13 @@ ...@@ -383,12 +385,13 @@
:placeholder="t('keys.selectStatus')" :placeholder="t('keys.selectStatus')"
/> />
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div class="flex justify-end gap-3">
<button @click="closeModals" type="button" class="btn btn-secondary"> <button @click="closeModals" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -418,8 +421,8 @@ ...@@ -418,8 +421,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
...@@ -501,7 +504,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' ...@@ -501,7 +504,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.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 ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null) ...@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null) const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map()) const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
let abortController: AbortController | null = null
// Get the currently selected key for group change // Get the currently selected key for group change
const selectedKeyForGroup = computed(() => { const selectedKeyForGroup = computed(() => {
...@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => { ...@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
copiedKeyId.value = keyId copiedKeyId.value = keyId
setTimeout(() => { setTimeout(() => {
copiedKeyId.value = null 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 () => { const loadApiKeys = async () => {
abortController?.abort()
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true loading.value = true
try { 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 apiKeys.value = response.items
pagination.value.total = response.total pagination.value.total = response.total
pagination.value.pages = response.pages pagination.value.pages = response.pages
...@@ -639,16 +656,24 @@ const loadApiKeys = async () => { ...@@ -639,16 +656,24 @@ const loadApiKeys = async () => {
if (response.items.length > 0) { if (response.items.length > 0) {
const keyIds = response.items.map((k) => k.id) const keyIds = response.items.map((k) => k.id)
try { try {
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds) const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal })
if (signal.aborted) return
usageStats.value = usageResponse.stats usageStats.value = usageResponse.stats
} catch (e) { } catch (e) {
console.error('Failed to load usage stats:', e) if (!isAbortError(e)) {
console.error('Failed to load usage stats:', e)
}
} }
} }
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('keys.failedToLoad')) appStore.showError(t('keys.failedToLoad'))
} finally { } finally {
loading.value = false if (abortController === controller) {
loading.value = false
}
} }
} }
...@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => { ...@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
loadApiKeys() loadApiKeys()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadApiKeys()
}
const editKey = (key: ApiKey) => { const editKey = (key: ApiKey) => {
selectedKey.value = key selectedKey.value = key
formData.value = { formData.value = {
......
...@@ -244,6 +244,12 @@ ...@@ -244,6 +244,12 @@
autocomplete="new-password" autocomplete="new-password"
class="input" 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>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
...@@ -392,6 +398,12 @@ const handleChangePassword = async () => { ...@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
} }
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
// Basic validation
if (!profileForm.value.username.trim()) {
appStore.showError(t('profile.usernameRequired'))
return
}
updatingProfile.value = true updatingProfile.value = true
try { try {
const updatedUser = await userAPI.updateProfile({ const updatedUser = await userAPI.updateProfile({
......
...@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue' ...@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api' import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
...@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format' ...@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const appStore = useAppStore() const appStore = useAppStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
...@@ -544,6 +546,16 @@ const handleRedeem = async () => { ...@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency // Refresh user data to get updated balance/concurrency
await authStore.refreshUser() 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 // Clear the input
redeemCode.value = '' redeemCode.value = ''
......
...@@ -164,8 +164,28 @@ ...@@ -164,8 +164,28 @@
<button @click="resetFilters" class="btn btn-secondary"> <button @click="resetFilters" class="btn btn-secondary">
{{ t('common.reset') }} {{ t('common.reset') }}
</button> </button>
<button @click="exportToCSV" class="btn btn-primary"> <button @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportCsv') }} <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> </button>
</div> </div>
</div> </div>
...@@ -366,6 +386,7 @@ ...@@ -366,6 +386,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
...@@ -412,7 +433,7 @@ ...@@ -412,7 +433,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api' import { usageAPI, keysAPI } from '@/api'
...@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format' ...@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
let abortController: AbortController | null = null
// Tooltip state // Tooltip state
const tooltipVisible = ref(false) const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 }) const tooltipPosition = ref({ x: 0, y: 0 })
...@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [ ...@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
const usageLogs = ref<UsageLog[]>([]) const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<ApiKey[]>([]) const apiKeys = ref<ApiKey[]>([])
const loading = ref(false) const loading = ref(false)
const exporting = ref(false)
const apiKeyOptions = computed(() => { const apiKeyOptions = computed(() => {
return [ return [
...@@ -498,7 +522,7 @@ const onDateRangeChange = (range: { ...@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
applyFilters() applyFilters()
} }
const pagination = ref({ const pagination = reactive({
page: 1, page: 1,
page_size: 20, page_size: 20,
total: 0, total: 0,
...@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => { ...@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => {
} }
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const params: UsageQueryParams = { const params: UsageQueryParams = {
page: pagination.value.page, page: pagination.page,
page_size: pagination.value.page_size, page_size: pagination.page_size,
...filters.value ...filters.value
} }
const response = await usageAPI.query(params) const response = await usageAPI.query(params, { signal })
if (signal.aborted) {
return
}
usageLogs.value = response.items usageLogs.value = response.items
pagination.value.total = response.total pagination.total = response.total
pagination.value.pages = response.pages pagination.pages = response.pages
} catch (error) { } 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')) appStore.showError(t('usage.failedToLoad'))
} finally { } finally {
loading.value = false if (abortController === currentAbortController) {
loading.value = false
}
} }
} }
...@@ -575,7 +617,7 @@ const loadUsageStats = async () => { ...@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
} }
const applyFilters = () => { const applyFilters = () => {
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
...@@ -588,60 +630,128 @@ const resetFilters = () => { ...@@ -588,60 +630,128 @@ const resetFilters = () => {
} }
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() initializeDateRange()
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
pagination.value.page = page pagination.page = page
loadUsageLogs() loadUsageLogs()
} }
const exportToCSV = () => { const handlePageSizeChange = (pageSize: number) => {
if (usageLogs.value.length === 0) { 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')) appStore.showWarning(t('usage.noDataToExport'))
return return
} }
const headers = [ exporting.value = true
'Model', appStore.showInfo(t('usage.preparingExport'))
'Type',
'Input Tokens', try {
'Output Tokens', const allLogs: UsageLog[] = []
'Cache Read Tokens', const pageSize = 100 // Use a larger page size for export to reduce requests
'Cache Write Tokens', const totalRequests = Math.ceil(pagination.total / pageSize)
'Total Cost',
'Billing Type', for (let page = 1; page <= totalRequests; page++) {
'First Token (ms)', const params: UsageQueryParams = {
'Duration (ms)', page: page,
'Time' page_size: pageSize,
] ...filters.value
const rows = usageLogs.value.map((log) => [ }
log.model, const response = await usageAPI.query(params)
log.stream ? 'Stream' : 'Sync', allLogs.push(...response.items)
log.input_tokens, }
log.output_tokens,
log.cache_read_tokens, if (allLogs.length === 0) {
log.cache_creation_tokens, appStore.showWarning(t('usage.noDataToExport'))
log.total_cost.toFixed(6), return
log.billing_type === 1 ? 'Subscription' : 'Balance', }
log.first_token_ms ?? '',
log.duration_ms, const headers = [
log.created_at 'Time',
]) 'API Key Name',
'Model',
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n') 'Type',
'Input Tokens',
const blob = new Blob([csvContent], { type: 'text/csv' }) 'Output Tokens',
const url = window.URL.createObjectURL(blob) 'Cache Read Tokens',
const link = document.createElement('a') 'Cache Creation Tokens',
link.href = url 'Rate Multiplier',
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv` 'Billed Cost',
link.click() 'Original Cost',
window.URL.revokeObjectURL(url) 'Billing Type',
'First Token (ms)',
appStore.showSuccess(t('usage.exportSuccess')) '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 // 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