✨ File bundle
This commit is contained in:
		
							
								
								
									
										195
									
								
								DysonNetwork.Drive/Client/src/components/UploadArea.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								DysonNetwork.Drive/Client/src/components/UploadArea.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,195 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <n-collapse-transition :show="showRecycleHint"> | ||||
|       <n-alert size="small" type="warning" title="Recycle Enabled" class="mb-3"> | ||||
|         You're uploading to a pool which enabled recycle. If the file you uploaded didn't | ||||
|         referenced from the Solar Network. It will be marked and will be deleted some while later. | ||||
|       </n-alert> | ||||
|     </n-collapse-transition> | ||||
|  | ||||
|     <n-collapse-transition :show="modeAdvanced"> | ||||
|       <n-card title="Advance Options" size="small" class="mb-3"> | ||||
|         <div class="flex flex-col gap-3"> | ||||
|           <div> | ||||
|             <p class="pl-1 mb-0.5">File Password</p> | ||||
|             <n-input | ||||
|               v-model:value="filePass" | ||||
|               :disabled="!currentFilePool?.allow_encryption" | ||||
|               placeholder="Enter password to protect the file" | ||||
|               show-password-toggle | ||||
|               size="large" | ||||
|               type="password" | ||||
|               class="mb-2" | ||||
|             /> | ||||
|             <p class="pl-1 text-xs opacity-75 mt-[-4px]"> | ||||
|               Only available for Stellar Program and certian file pool. | ||||
|             </p> | ||||
|           </div> | ||||
|           <div> | ||||
|             <p class="pl-1 mb-0.5">File Expiration Date</p> | ||||
|             <n-date-picker | ||||
|               v-model:value="fileExpire" | ||||
|               type="datetime" | ||||
|               clearable | ||||
|               :is-date-disabled="disablePreviousDate" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </n-card> | ||||
|     </n-collapse-transition> | ||||
|  | ||||
|     <n-upload | ||||
|       multiple | ||||
|       directory-dnd | ||||
|       with-credentials | ||||
|       show-preview-button | ||||
|       list-type="image" | ||||
|       show-download-button | ||||
|       :custom-request="customRequest" | ||||
|       :custom-download="customDownload" | ||||
|       :create-thumbnail-url="createThumbnailUrl" | ||||
|       @preview="customPreview" | ||||
|     > | ||||
|       <n-upload-dragger> | ||||
|         <div style="margin-bottom: 12px"> | ||||
|           <n-icon size="48" :depth="3"> | ||||
|             <cloud-upload-round /> | ||||
|           </n-icon> | ||||
|         </div> | ||||
|         <n-text style="font-size: 16px"> Click or drag a file to this area to upload </n-text> | ||||
|         <n-p depth="3" style="margin: 8px 0 0 0"> | ||||
|           Strictly prohibit from uploading sensitive information. For example, your bank card PIN or | ||||
|           your credit card expiry date. | ||||
|         </n-p> | ||||
|       </n-upload-dragger> | ||||
|     </n-upload> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|   NUpload, | ||||
|   NUploadDragger, | ||||
|   NIcon, | ||||
|   NText, | ||||
|   NP, | ||||
|   NInput, | ||||
|   NCollapseTransition, | ||||
|   NDatePicker, | ||||
|   NAlert, | ||||
|   NCard, | ||||
|   type UploadCustomRequestOptions, | ||||
|   type UploadSettledFileInfo, | ||||
|   type UploadFileInfo, | ||||
|   useMessage, | ||||
| } from 'naive-ui' | ||||
| import { computed, ref } from 'vue' | ||||
| import { CloudUploadRound } from '@vicons/material' | ||||
| import type { SnFilePool } from '@/types/pool' | ||||
|  | ||||
| import * as tus from 'tus-js-client' | ||||
|  | ||||
| const props = defineProps<{ filePool: string | null; modeAdvanced: boolean; pools: SnFilePool[] }>() | ||||
|  | ||||
| const filePass = ref<string>('') | ||||
| const fileExpire = ref<number | null>(null) | ||||
|  | ||||
| const currentFilePool = computed(() => { | ||||
|   if (!props.filePool) return null | ||||
|   return props.pools?.find((pool) => pool.id === props.filePool) ?? null | ||||
| }) | ||||
| const showRecycleHint = computed(() => { | ||||
|   if (!props.filePool) return true | ||||
|   return currentFilePool.value.policy_config?.enable_recycle || false | ||||
| }) | ||||
|  | ||||
| const messageDisplay = useMessage() | ||||
|  | ||||
| function customRequest({ | ||||
|   file, | ||||
|   headers, | ||||
|   withCredentials, | ||||
|   onFinish, | ||||
|   onError, | ||||
|   onProgress, | ||||
| }: UploadCustomRequestOptions) { | ||||
|   const requestHeaders: Record<string, string> = {} | ||||
|   if (props.filePool) requestHeaders['X-FilePool'] = props.filePool | ||||
|   if (filePass.value) requestHeaders['X-FilePass'] = filePass.value | ||||
|   if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString() | ||||
|   const upload = new tus.Upload(file.file, { | ||||
|     endpoint: '/api/tus', | ||||
|     retryDelays: [0, 3000, 5000, 10000, 20000], | ||||
|     removeFingerprintOnSuccess: false, | ||||
|     uploadDataDuringCreation: false, | ||||
|     metadata: { | ||||
|       filename: file.name, | ||||
|       'content-type': file.type ?? 'application/octet-stream', | ||||
|     }, | ||||
|     headers: { | ||||
|       'X-DirectUpload': 'true', | ||||
|       ...requestHeaders, | ||||
|       ...headers, | ||||
|     }, | ||||
|     onShouldRetry: () => false, | ||||
|     onError: function (error) { | ||||
|       if (error instanceof tus.DetailedError) { | ||||
|         const failedBody = error.originalResponse?.getBody() | ||||
|         if (failedBody != null) | ||||
|           messageDisplay.error(`Upload failed: ${failedBody}`, { | ||||
|             duration: 10000, | ||||
|             closable: true, | ||||
|           }) | ||||
|       } | ||||
|       console.error('[DRIVE] Upload failed:', error) | ||||
|       onError() | ||||
|     }, | ||||
|     onProgress: function (bytesUploaded, bytesTotal) { | ||||
|       onProgress({ percent: (bytesUploaded / bytesTotal) * 100 }) | ||||
|     }, | ||||
|     onSuccess: function (payload) { | ||||
|       const rawInfo = payload.lastResponse.getHeader('x-fileinfo') | ||||
|       const jsonInfo = JSON.parse(rawInfo as string) | ||||
|       console.log('[DRIVE] Upload successful: ', jsonInfo) | ||||
|       file.url = `/api/files/${jsonInfo.id}` | ||||
|       file.type = jsonInfo.mime_type | ||||
|       onFinish() | ||||
|     }, | ||||
|     onBeforeRequest: function (req) { | ||||
|       const xhr = req.getUnderlyingObject() | ||||
|       xhr.withCredentials = withCredentials | ||||
|     }, | ||||
|   }) | ||||
|   upload.findPreviousUploads().then(function (previousUploads) { | ||||
|     if (previousUploads.length) { | ||||
|       upload.resumeFromPreviousUpload(previousUploads[0]) | ||||
|     } | ||||
|     upload.start() | ||||
|   }) | ||||
| } | ||||
|  | ||||
| function createThumbnailUrl( | ||||
|   _file: File | null, | ||||
|   fileInfo: UploadSettledFileInfo, | ||||
| ): string | undefined { | ||||
|   if (!fileInfo) return undefined | ||||
|   return fileInfo.url ?? undefined | ||||
| } | ||||
|  | ||||
| function customDownload(file: UploadFileInfo) { | ||||
|   const { url } = file | ||||
|   if (!url) return | ||||
|   window.open(url.replace('/api', ''), '_blank') | ||||
| } | ||||
|  | ||||
| function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) { | ||||
|   detail.event.preventDefault() | ||||
|   const { url } = file | ||||
|   if (!url) return | ||||
|   window.open(url.replace('/api', ''), '_blank') | ||||
| } | ||||
|  | ||||
| function disablePreviousDate(ts: number) { | ||||
|   return ts <= Date.now() | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										75
									
								
								DysonNetwork.Drive/Client/src/components/form/BundleForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								DysonNetwork.Drive/Client/src/components/form/BundleForm.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| <template> | ||||
|   <n-form :model="formValue" :rules="rules" ref="formRef"> | ||||
|     <n-form-item label="Slug" path="slug"> | ||||
|       <n-input v-model:value="formValue.slug" placeholder="Input Slug" /> | ||||
|     </n-form-item> | ||||
|     <n-form-item label="Name" path="name"> | ||||
|       <n-input v-model:value="formValue.name" placeholder="Input Name" /> | ||||
|     </n-form-item> | ||||
|     <n-form-item label="Description" path="description"> | ||||
|       <n-input | ||||
|         v-model:value="formValue.description" | ||||
|         placeholder="Input Description" | ||||
|         type="textarea" | ||||
|       /> | ||||
|     </n-form-item> | ||||
|     <n-form-item label="Passcode" path="passcode"> | ||||
|       <n-input | ||||
|         v-model:value="formValue.passcode" | ||||
|         placeholder="Input Passcode" | ||||
|         type="password" | ||||
|       /> | ||||
|     </n-form-item> | ||||
|     <n-form-item label="Expired At" path="expiredAt"> | ||||
|       <n-date-picker v-model:value="formValue.expiredAt" type="datetime" /> | ||||
|     </n-form-item> | ||||
|   </n-form> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { | ||||
|   NForm, | ||||
|   NFormItem, | ||||
|   NInput, | ||||
|   NDatePicker, | ||||
|   type FormInst, | ||||
|   type FormRules, | ||||
| } from 'naive-ui' | ||||
| import { ref } from 'vue' | ||||
|  | ||||
| const formRef = ref<FormInst | null>(null) | ||||
|  | ||||
| const props = defineProps<{ value: any }>() | ||||
| const formValue = ref(props.value) | ||||
|  | ||||
| const rules: FormRules = { | ||||
|   slug: [ | ||||
|     { | ||||
|       max: 1024, | ||||
|       message: 'Slug can be at most 1024 characters long', | ||||
|     }, | ||||
|   ], | ||||
|   name: [ | ||||
|     { | ||||
|       max: 1024, | ||||
|       message: 'Name can be at most 1024 characters long', | ||||
|     }, | ||||
|   ], | ||||
|   description: [ | ||||
|     { | ||||
|       max: 8192, | ||||
|       message: 'Description can be at most 8192 characters long', | ||||
|     }, | ||||
|   ], | ||||
|   passcode: [ | ||||
|     { | ||||
|       max: 256, | ||||
|       message: 'Passcode can be at most 256 characters long', | ||||
|     }, | ||||
|   ], | ||||
| } | ||||
|  | ||||
| defineExpose({ | ||||
|   formRef, | ||||
| }) | ||||
| </script> | ||||
| @@ -16,7 +16,12 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { DataUsageRound, AllInboxFilled } from '@vicons/material' | ||||
| import { | ||||
|   DataUsageRound, | ||||
|   AllInboxFilled, | ||||
|   PermDataSettingRound, | ||||
|   ShoppingBagRound, | ||||
| } from '@vicons/material' | ||||
| import { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui' | ||||
| import { h, type Component } from 'vue' | ||||
| import { RouterView, useRoute, useRouter } from 'vue-router' | ||||
| @@ -38,7 +43,17 @@ const menuOptions: MenuOption[] = [ | ||||
|     label: 'Files', | ||||
|     key: 'dashboardFiles', | ||||
|     icon: renderIcon(AllInboxFilled), | ||||
|   } | ||||
|   }, | ||||
|   { | ||||
|     label: 'Bundles', | ||||
|     key: 'dashboardBundles', | ||||
|     icon: renderIcon(ShoppingBagRound), | ||||
|   }, | ||||
|   { | ||||
|     label: 'Quota', | ||||
|     key: 'dashboardQuota', | ||||
|     icon: renderIcon(PermDataSettingRound), | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| function updateMenuSelect(key: string) { | ||||
|   | ||||
| @@ -32,7 +32,19 @@ const router = createRouter({ | ||||
|           name: 'dashboardFiles', | ||||
|           component: () => import('../views/dashboard/files.vue'), | ||||
|           meta: { requiresAuth: true }, | ||||
|         } | ||||
|         }, | ||||
|         { | ||||
|           path: 'bundles', | ||||
|           name: 'dashboardBundles', | ||||
|           component: () => import('../views/dashboard/bundles.vue'), | ||||
|           meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|           path: 'quotas', | ||||
|           name: 'dashboardQuota', | ||||
|           component: () => import('../views/dashboard/quotas.vue'), | ||||
|           meta: { requiresAuth: true }, | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|     { | ||||
| @@ -66,4 +78,4 @@ router.beforeEach(async (to, from, next) => { | ||||
|   } | ||||
| }) | ||||
|  | ||||
| export default router | ||||
| export default router | ||||
							
								
								
									
										183
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,183 @@ | ||||
| <template> | ||||
|   <section class="h-full px-5 py-4"> | ||||
|     <n-data-table | ||||
|       remote | ||||
|       :row-key="(row) => row.id" | ||||
|       :columns="tableColumns" | ||||
|       :data="bundles" | ||||
|       :loading="loading" | ||||
|       :pagination="tablePagination" | ||||
|       @page-change="handlePageChange" | ||||
|     /> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { | ||||
|   NDataTable, | ||||
|   type DataTableColumns, | ||||
|   type PaginationProps, | ||||
|   useMessage, | ||||
|   useLoadingBar, | ||||
|   NButton, | ||||
|   NIcon, | ||||
|   NSpace, | ||||
|   useDialog, | ||||
| } from 'naive-ui' | ||||
| import { h, onMounted, ref } from 'vue' | ||||
| import { useRouter } from 'vue-router' | ||||
| import { DeleteRound } from '@vicons/material' | ||||
|  | ||||
| const router = useRouter() | ||||
|  | ||||
| const bundles = ref<any[]>([]) | ||||
|  | ||||
| const tableColumns: DataTableColumns<any> = [ | ||||
|   { | ||||
|     title: 'Name', | ||||
|     key: 'name', | ||||
|     render(row: any) { | ||||
|       return h( | ||||
|         NButton, | ||||
|         { | ||||
|           text: true, | ||||
|           onClick: () => { | ||||
|             router.push(`/bundles/${row.id}`) | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           default: () => row.name, | ||||
|         }, | ||||
|       ) | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Description', | ||||
|     key: 'description', | ||||
|   }, | ||||
|   { | ||||
|     title: 'Files', | ||||
|     key: 'files', | ||||
|     render(row: any) { | ||||
|       return row.files.length | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Expired At', | ||||
|     key: 'expired_at', | ||||
|     render(row: any) { | ||||
|       if (!row.expired_at) return 'Never' | ||||
|       return new Date(row.expired_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Created At', | ||||
|     key: 'created_at', | ||||
|     render(row: any) { | ||||
|       return new Date(row.created_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Updated At', | ||||
|     key: 'updated_at', | ||||
|     render(row: any) { | ||||
|       return new Date(row.updated_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Action', | ||||
|     key: 'action', | ||||
|     render(row: any) { | ||||
|       return h(NSpace, {}, [ | ||||
|         h( | ||||
|           NButton, | ||||
|           { | ||||
|             circle: true, | ||||
|             text: true, | ||||
|             type: 'error', | ||||
|             onClick: () => { | ||||
|               askDeleteBundle(row) | ||||
|             }, | ||||
|           }, | ||||
|           { | ||||
|             icon: () => h(NIcon, {}, { default: () => h(DeleteRound) }), | ||||
|           }, | ||||
|         ), | ||||
|       ]) | ||||
|     }, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| const tablePagination = ref<PaginationProps>({ | ||||
|   page: 1, | ||||
|   itemCount: 0, | ||||
|   pageSize: 10, | ||||
|   showSizePicker: true, | ||||
|   pageSizes: [10, 20, 30, 40, 50], | ||||
| }) | ||||
|  | ||||
| async function fetchBundles() { | ||||
|   if (loading.value) return | ||||
|   try { | ||||
|     loading.value = true | ||||
|     const pag = tablePagination.value | ||||
|     const response = await fetch( | ||||
|       `/api/bundles/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}`, | ||||
|     ) | ||||
|     if (!response.ok) { | ||||
|       throw new Error('Network response was not ok') | ||||
|     } | ||||
|     const data = await response.json() | ||||
|     bundles.value = data | ||||
|     tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0') | ||||
|   } catch (error) { | ||||
|     messageDialog.error('Failed to fetch bundles: ' + (error as Error).message) | ||||
|     console.error('Failed to fetch bundles:', error) | ||||
|   } finally { | ||||
|     loading.value = false | ||||
|   } | ||||
| } | ||||
| onMounted(() => fetchBundles()) | ||||
|  | ||||
| function handlePageChange(page: number) { | ||||
|   tablePagination.value.page = page | ||||
|   fetchBundles() | ||||
| } | ||||
|  | ||||
| const loading = ref(false) | ||||
|  | ||||
| const messageDialog = useMessage() | ||||
| const loadingBar = useLoadingBar() | ||||
| const dialog = useDialog() | ||||
|  | ||||
| function askDeleteBundle(bundle: any) { | ||||
|   dialog.warning({ | ||||
|     title: 'Confirm', | ||||
|     content: `Are you sure you want to delete the bundle ${bundle.name}?`, | ||||
|     positiveText: 'Sure', | ||||
|     negativeText: 'Not Sure', | ||||
|     onPositiveClick: () => { | ||||
|       deleteBundle(bundle) | ||||
|     }, | ||||
|   }) | ||||
| } | ||||
|  | ||||
| async function deleteBundle(bundle: any) { | ||||
|   try { | ||||
|     loadingBar.start() | ||||
|     const response = await fetch(`/api/bundles/${bundle.id}`, { | ||||
|       method: 'DELETE', | ||||
|     }) | ||||
|     if (!response.ok) { | ||||
|       throw new Error('Network response was not ok') | ||||
|     } | ||||
|     tablePagination.value.page = 1 | ||||
|     await fetchBundles() | ||||
|     loadingBar.finish() | ||||
|     messageDialog.success('Bundle deleted successfully') | ||||
|   } catch (error) { | ||||
|     loadingBar.error() | ||||
|     messageDialog.error('Failed to delete bundle: ' + (error as Error).message) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @@ -140,7 +140,7 @@ const tableColumns: DataTableColumns<any> = [ | ||||
|     title: 'Expired At', | ||||
|     key: 'expired_at', | ||||
|     render(row: any) { | ||||
|       if (!row.expired_at) return 'Keep-alive' | ||||
|       if (!row.expired_at) return 'Never' | ||||
|       return new Date(row.expired_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										101
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| <template> | ||||
|   <section class="h-full px-5 py-4"> | ||||
|     <n-data-table | ||||
|       remote | ||||
|       :row-key="(row) => row.id" | ||||
|       :columns="tableColumns" | ||||
|       :data="quotas" | ||||
|       :loading="loading" | ||||
|       :pagination="tablePagination" | ||||
|       @page-change="handlePageChange" | ||||
|     /> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { NDataTable, type DataTableColumns, type PaginationProps, useMessage } from 'naive-ui' | ||||
| import { onMounted, ref } from 'vue' | ||||
| import { formatBytes } from '../format' | ||||
|  | ||||
| const quotas = ref<any[]>([]) | ||||
|  | ||||
| const tableColumns: DataTableColumns<any> = [ | ||||
|   { | ||||
|     title: 'Name', | ||||
|     key: 'name', | ||||
|   }, | ||||
|   { | ||||
|     title: 'Description', | ||||
|     key: 'description', | ||||
|   }, | ||||
|   { | ||||
|     title: 'Quota', | ||||
|     key: 'quota', | ||||
|     render(row: any) { | ||||
|       return formatBytes(row.quota * 1024 * 1024) | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Expired At', | ||||
|     key: 'expired_at', | ||||
|     render(row: any) { | ||||
|       if (!row.expired_at) return 'Never' | ||||
|       return new Date(row.expired_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Created At', | ||||
|     key: 'created_at', | ||||
|     render(row: any) { | ||||
|       return new Date(row.created_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Updated At', | ||||
|     key: 'updated_at', | ||||
|     render(row: any) { | ||||
|       return new Date(row.updated_at).toLocaleString() | ||||
|     }, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| const tablePagination = ref<PaginationProps>({ | ||||
|   page: 1, | ||||
|   itemCount: 0, | ||||
|   pageSize: 10, | ||||
|   showSizePicker: true, | ||||
|   pageSizes: [10, 20, 30, 40, 50], | ||||
| }) | ||||
|  | ||||
| async function fetchQuotas() { | ||||
|   if (loading.value) return | ||||
|   try { | ||||
|     loading.value = true | ||||
|     const pag = tablePagination.value | ||||
|     const response = await fetch( | ||||
|       `/api/billing/quota/records?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}`, | ||||
|     ) | ||||
|     if (!response.ok) { | ||||
|       throw new Error('Network response was not ok') | ||||
|     } | ||||
|     const data = await response.json() | ||||
|     quotas.value = data | ||||
|     tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0') | ||||
|   } catch (error) { | ||||
|     messageDialog.error('Failed to fetch quotas: ' + (error as Error).message) | ||||
|     console.error('Failed to fetch quotas:', error) | ||||
|   } finally { | ||||
|     loading.value = false | ||||
|   } | ||||
| } | ||||
| onMounted(() => fetchQuotas()) | ||||
|  | ||||
| function handlePageChange(page: number) { | ||||
|   tablePagination.value.page = page | ||||
|   fetchQuotas() | ||||
| } | ||||
|  | ||||
| const loading = ref(false) | ||||
|  | ||||
| const messageDialog = useMessage() | ||||
| </script> | ||||
		Reference in New Issue
	
	Block a user