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

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

- DataTable组件操作列自适应
- 优化各种Modal弹窗
- 统一API调用方式(AbortSignal)
- 添加全局订阅状态管理
- 优化各管理视图的交互和布局
- 修复国际化翻译问题
parent 9bbe468c
...@@ -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,29 +674,23 @@ async function performInstall() { ...@@ -674,29 +674,23 @@ 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', {
method: 'GET',
cache: 'no-store'
})
if (statusResponse.ok) {
const data = await statusResponse.json()
// If needs_setup is false, service has restarted in normal mode // If needs_setup is false, service has restarted in normal mode
if (data.data && !data.data.needs_setup) { if (data.data && !data.data.needs_setup) {
serviceReady.value = true serviceReady.value = true
...@@ -707,9 +701,8 @@ async function waitForServiceRestart() { ...@@ -707,9 +701,8 @@ async function waitForServiceRestart() {
return 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>
...@@ -383,7 +389,13 @@ ...@@ -383,7 +389,13 @@
</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>
...@@ -694,6 +706,7 @@ const user = computed(() => authStore.user) ...@@ -694,6 +706,7 @@ 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)
// Chart data // Chart data
const trendData = ref<TrendDataPoint[]>([]) const trendData = ref<TrendDataPoint[]>([])
...@@ -964,6 +977,7 @@ const loadDashboardStats = async () => { ...@@ -964,6 +977,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,14 +995,16 @@ const loadChartData = async () => { ...@@ -981,14 +995,16 @@ 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 {
const endDate = new Date().toISOString() const endDate = new Date().toISOString().split('T')[0]
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() 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)
} catch (error) { } catch (error) {
...@@ -998,11 +1014,17 @@ const loadRecentUsage = async () => { ...@@ -998,11 +1014,17 @@ const loadRecentUsage = async () => {
} }
} }
onMounted(() => { onMounted(async () => {
loadDashboardStats() // Load critical data first
await loadDashboardStats()
// 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
......
...@@ -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,17 +656,25 @@ const loadApiKeys = async () => { ...@@ -639,17 +656,25 @@ 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) {
if (!isAbortError(e)) {
console.error('Failed to load usage stats:', 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 {
if (abortController === controller) {
loading.value = false loading.value = false
} }
}
} }
const loadGroups = async () => { const loadGroups = async () => {
...@@ -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({
......
...@@ -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,23 +556,41 @@ const formatCacheTokens = (value: number): string => { ...@@ -532,23 +556,41 @@ 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 {
if (abortController === currentAbortController) {
loading.value = false loading.value = false
} }
}
} }
const loadApiKeys = async () => { const loadApiKeys = async () => {
...@@ -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'))
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')) appStore.showWarning(t('usage.noDataToExport'))
return return
} }
const headers = [ const headers = [
'Time',
'API Key Name',
'Model', 'Model',
'Type', 'Type',
'Input Tokens', 'Input Tokens',
'Output Tokens', 'Output Tokens',
'Cache Read Tokens', 'Cache Read Tokens',
'Cache Write Tokens', 'Cache Creation Tokens',
'Total Cost', 'Rate Multiplier',
'Billed Cost',
'Original Cost',
'Billing Type', 'Billing Type',
'First Token (ms)', 'First Token (ms)',
'Duration (ms)', 'Duration (ms)'
'Time'
] ]
const rows = usageLogs.value.map((log) => [ const rows = allLogs.map((log) =>
[
log.created_at,
log.api_key?.name || '',
log.model, log.model,
log.stream ? 'Stream' : 'Sync', log.stream ? 'Stream' : 'Sync',
log.input_tokens, log.input_tokens,
log.output_tokens, log.output_tokens,
log.cache_read_tokens, log.cache_read_tokens,
log.cache_creation_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.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms, log.duration_ms
log.created_at ].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 url = window.URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url 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() link.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess')) 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