mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
usage log table (#4288)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -41,7 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
:label="$t('credits.invoiceHistory')"
|
||||
text
|
||||
@@ -81,6 +82,8 @@
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
:label="$t('credits.faqs')"
|
||||
@@ -108,10 +111,11 @@ import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -131,12 +135,23 @@ const authActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
399
src/components/dialog/content/setting/UsageLogsTable.spec.ts
Normal file
399
src/components/dialog/content/setting/UsageLogsTable.spec.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
import UsageLogsTable from './UsageLogsTable.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
events: any[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
dataTableFirst: number
|
||||
tooltipContentMap: Map<string, string>
|
||||
loadEvents: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
onPageChange: (event: { page: number }) => void
|
||||
}
|
||||
|
||||
// Mock the customerEventsService
|
||||
const mockCustomerEventsService = vi.hoisted(() => ({
|
||||
getMyEvents: vi.fn(),
|
||||
formatEventType: vi.fn(),
|
||||
getEventSeverity: vi.fn(),
|
||||
formatAmount: vi.fn(),
|
||||
formatDate: vi.fn(),
|
||||
hasAdditionalInfo: vi.fn(),
|
||||
getTooltipContent: vi.fn(),
|
||||
error: { value: null },
|
||||
isLoading: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/services/customerEventsService', () => ({
|
||||
useCustomerEventsService: () => mockCustomerEventsService,
|
||||
EventType: {
|
||||
CREDIT_ADDED: 'credit_added',
|
||||
ACCOUNT_CREATED: 'account_created',
|
||||
API_USAGE_STARTED: 'api_usage_started',
|
||||
API_USAGE_COMPLETED: 'api_usage_completed'
|
||||
}
|
||||
}))
|
||||
|
||||
// Create i18n instance
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
credits: {
|
||||
eventType: 'Event Type',
|
||||
details: 'Details',
|
||||
time: 'Time',
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('UsageLogsTable', () => {
|
||||
const mockEventsResponse = {
|
||||
events: [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: 'credit_added',
|
||||
params: {
|
||||
amount: 1000,
|
||||
transaction_id: 'txn-123'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
},
|
||||
{
|
||||
event_id: 'event-2',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
api_name: 'Image Generation',
|
||||
model: 'sdxl-base',
|
||||
duration: 5000
|
||||
},
|
||||
createdAt: '2024-01-02T10:00:00Z'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 7,
|
||||
totalPages: 1
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup default service mock implementations
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'Credits Added'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'Account Created'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'API Usage'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
})
|
||||
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'success'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'info'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
})
|
||||
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
|
||||
if (!amount) return '0.00'
|
||||
return (amount / 100).toFixed(2)
|
||||
})
|
||||
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
})
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
|
||||
const { amount, api_name, model, ...otherParams } = event.params || {}
|
||||
return Object.keys(otherParams).length > 0
|
||||
})
|
||||
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
|
||||
return '<strong>Transaction Id:</strong> txn-123'
|
||||
})
|
||||
mockCustomerEventsService.error.value = null
|
||||
mockCustomerEventsService.isLoading.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (options = {}) => {
|
||||
return mount(UsageLogsTable, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createTestingPinia()],
|
||||
components: {
|
||||
DataTable,
|
||||
Column,
|
||||
Badge,
|
||||
Button,
|
||||
Message,
|
||||
ProgressSpinner
|
||||
},
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
}
|
||||
},
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('loading states', () => {
|
||||
it('shows loading spinner when loading is true', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = true
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error message when error exists', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.error = 'Failed to load events'
|
||||
vm.loading = false
|
||||
await nextTick()
|
||||
|
||||
const messageComponent = wrapper.findComponent(Message)
|
||||
expect(messageComponent.exists()).toBe(true)
|
||||
expect(messageComponent.props('severity')).toBe('error')
|
||||
expect(messageComponent.text()).toContain('Failed to load events')
|
||||
})
|
||||
|
||||
it('shows data table when loaded successfully', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
// Wait for component to mount and load data
|
||||
await wrapper.vm.$nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data rendering', () => {
|
||||
it('renders events data correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const dataTable = wrapper.findComponent(DataTable)
|
||||
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
|
||||
expect(dataTable.props('rows')).toBe(7)
|
||||
expect(dataTable.props('paginator')).toBe(true)
|
||||
expect(dataTable.props('lazy')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders badge for event types correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const badges = wrapper.findAllComponents(Badge)
|
||||
expect(badges.length).toBeGreaterThan(0)
|
||||
|
||||
// Check if formatEventType and getEventSeverity are called
|
||||
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
|
||||
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders different event details based on event type', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
// Check if formatAmount is called for credit_added events
|
||||
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders tooltip buttons for events with additional info', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('handles page change correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
// Simulate page change
|
||||
const dataTable = wrapper.findComponent(DataTable)
|
||||
await dataTable.vm.$emit('page', { page: 1 })
|
||||
|
||||
expect(vm.pagination.page).toBe(1) // page + 1
|
||||
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
limit: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates dataTableFirst correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
|
||||
await nextTick()
|
||||
|
||||
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltip functionality', () => {
|
||||
it('generates tooltip content map correctly', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||
mockCustomerEventsService.getTooltipContent.mockReturnValue(
|
||||
'<strong>Test:</strong> value'
|
||||
)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const tooltipMap = vm.tooltipContentMap
|
||||
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
|
||||
})
|
||||
|
||||
it('excludes events without additional info from tooltip map', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const tooltipMap = vm.tooltipContentMap
|
||||
expect(tooltipMap.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('component methods', () => {
|
||||
it('exposes refresh method', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(typeof wrapper.vm.refresh).toBe('function')
|
||||
})
|
||||
|
||||
it('resets to first page on refresh', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.pagination.page = 3
|
||||
|
||||
await vm.refresh()
|
||||
|
||||
expect(vm.pagination.page).toBe(1)
|
||||
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('component lifecycle', () => {
|
||||
it('initializes with correct default values', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
expect(vm.events).toEqual([])
|
||||
expect(vm.loading).toBe(true)
|
||||
expect(vm.error).toBeNull()
|
||||
expect(vm.pagination).toEqual({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('uses EventType enum in template conditions', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
// Verify that the component can access EventType enum
|
||||
expect(EventType.CREDIT_ADDED).toBe('credit_added')
|
||||
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
|
||||
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
|
||||
})
|
||||
})
|
||||
})
|
||||
188
src/components/dialog/content/setting/UsageLogsTable.vue
Normal file
188
src/components/dialog/content/setting/UsageLogsTable.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="flex items-center justify-center p-8">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
<div v-else-if="error" class="p-4">
|
||||
<Message severity="error" :closable="false">{{ error }}</Message>
|
||||
</div>
|
||||
<DataTable
|
||||
v-else
|
||||
:value="events"
|
||||
:paginator="true"
|
||||
:rows="pagination.limit"
|
||||
:total-records="pagination.total"
|
||||
:first="dataTableFirst"
|
||||
:lazy="true"
|
||||
class="p-datatable-sm custom-datatable"
|
||||
@page="onPageChange"
|
||||
>
|
||||
<Column field="event_type" :header="$t('credits.eventType')">
|
||||
<template #body="{ data }">
|
||||
<Badge
|
||||
:value="customerEventService.formatEventType(data.event_type)"
|
||||
:severity="customerEventService.getEventSeverity(data.event_type)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="details" :header="$t('credits.details')">
|
||||
<template #body="{ data }">
|
||||
<div class="event-details">
|
||||
<!-- Credits Added -->
|
||||
<template v-if="data.event_type === EventType.CREDIT_ADDED">
|
||||
<div class="text-green-500 font-semibold">
|
||||
{{ $t('credits.added') }} ${{
|
||||
customerEventService.formatAmount(data.params?.amount)
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Account Created -->
|
||||
<template v-else-if="data.event_type === EventType.ACCOUNT_CREATED">
|
||||
<div>{{ $t('credits.accountInitialized') }}</div>
|
||||
</template>
|
||||
|
||||
<!-- API Usage -->
|
||||
<template
|
||||
v-else-if="data.event_type === EventType.API_USAGE_COMPLETED"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-semibold">
|
||||
{{ data.params?.api_name || 'API' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ $t('credits.model') }}: {{ data.params?.model || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="createdAt" :header="$t('credits.time')">
|
||||
<template #body="{ data }">
|
||||
{{ customerEventService.formatDate(data.createdAt) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="params" :header="$t('credits.additionalInfo')">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
v-if="customerEventService.hasAdditionalInfo(data)"
|
||||
v-tooltip.top="{
|
||||
escape: false,
|
||||
value: tooltipContentMap.get(data.event_id) || '',
|
||||
pt: {
|
||||
text: {
|
||||
style: {
|
||||
width: 'max-content !important'
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
icon="pi pi-info-circle"
|
||||
class="p-button-text p-button-sm"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
AuditLog,
|
||||
EventType,
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const dataTableFirst = computed(
|
||||
() => (pagination.value.page - 1) * pagination.value.limit
|
||||
)
|
||||
|
||||
const tooltipContentMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
events.value.forEach((event) => {
|
||||
if (customerEventService.hasAdditionalInfo(event) && event.event_id) {
|
||||
map.set(event.event_id, customerEventService.getTooltipContent(event))
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const loadEvents = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
})
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
}
|
||||
|
||||
if (response.page) {
|
||||
pagination.value.page = response.page
|
||||
}
|
||||
|
||||
if (response.limit) {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPageChange = (event: { page: number }) => {
|
||||
pagination.value.page = event.page + 1
|
||||
void loadEvents()
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
pagination.value.page = 1
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user