mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
refactor: migrate object task controls to shadcn
This commit is contained in:
@@ -5,7 +5,15 @@ import Spinner from '@/components/ui/spinner/Spinner.vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
type AppButtonVariant = 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'danger'
|
||||
type AppButtonVariant =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| 'link'
|
||||
| 'danger'
|
||||
| 'destructive'
|
||||
type AppButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
const variantMap: Record<AppButtonVariant, ButtonVariants['variant']> = {
|
||||
@@ -16,6 +24,7 @@ const variantMap: Record<AppButtonVariant, ButtonVariants['variant']> = {
|
||||
ghost: 'ghost',
|
||||
link: 'link',
|
||||
danger: 'destructive',
|
||||
destructive: 'destructive',
|
||||
}
|
||||
|
||||
const sizeMap: Record<AppButtonSize, ButtonVariants['size']> = {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { ProgressIndicator, ProgressRoot, type ProgressRootProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
{
|
||||
value?: number
|
||||
processing?: boolean
|
||||
height?: number | string
|
||||
class?: HTMLAttributes['class']
|
||||
} & ProgressRootProps
|
||||
>(),
|
||||
{
|
||||
value: 0,
|
||||
processing: false,
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'value', 'processing', 'height', 'class')
|
||||
|
||||
const normalizedValue = computed(() => {
|
||||
if (props.value === undefined || Number.isNaN(props.value)) return 0
|
||||
return Math.min(100, Math.max(0, props.value))
|
||||
})
|
||||
|
||||
const heightStyle = computed(() => {
|
||||
if (!props.height) return undefined
|
||||
if (typeof props.height === 'number') {
|
||||
return `${props.height}px`
|
||||
}
|
||||
return props.height
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProgressRoot
|
||||
v-bind="delegatedProps"
|
||||
:model-value="normalizedValue"
|
||||
:class="cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', props.class)"
|
||||
:style="heightStyle ? { height: heightStyle } : undefined"
|
||||
>
|
||||
<ProgressIndicator
|
||||
:class="cn(
|
||||
'h-full w-full flex-1 bg-primary transition-all',
|
||||
props.processing && 'animate-[progress-indeterminate_1.2s_ease_infinite]'
|
||||
)"
|
||||
:style="`transform: translateX(-${100 - normalizedValue}%);`"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes progress-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2Icon } from 'lucide-vue-next'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
class?: HTMLAttributes['class']
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
}
|
||||
)
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'size-3'
|
||||
case 'lg':
|
||||
return 'size-6'
|
||||
case 'xl':
|
||||
return 'size-8'
|
||||
default:
|
||||
return 'size-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
:class="cn('animate-spin text-muted-foreground', sizeClass, props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -15,4 +15,6 @@ export { default as AppDrawer } from './AppDrawer.vue'
|
||||
export { default as AppEmpty } from './AppEmpty.vue'
|
||||
export { default as AppTabs } from './AppTabs.vue'
|
||||
export { default as AppUploadZone } from './AppUploadZone.vue'
|
||||
export { default as AppProgress } from './AppProgress.vue'
|
||||
export { default as AppSpinner } from './AppSpinner.vue'
|
||||
export * from './data-table'
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ task.key }}</div>
|
||||
<n-button text @click="handleDeleteTask" type="info">{{ t('Delete Record') }}</n-button>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ task.key }}</div>
|
||||
<AppButton variant="ghost" size="xs" class="h-auto px-2 text-xs" @click="handleDeleteTask">
|
||||
{{ t('Delete Record') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:height="2"
|
||||
:percentage="task.progress"
|
||||
:show-indicator="false"
|
||||
:processing="task.status == 'deleting'"
|
||||
/>
|
||||
<AppProgress :value="task.progress" :processing="task.status === 'deleting'" :height="2" />
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<div></div>
|
||||
<div class="text-muted-foreground">
|
||||
@@ -25,14 +21,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DeleteTask } from '~/lib/delete-task-manager';
|
||||
import { useDeleteTaskManagerStore } from '~/store/delete-tasks';
|
||||
const { t } = useI18n();
|
||||
const store = useDeleteTaskManagerStore();
|
||||
import { AppButton, AppProgress } from '@/components/app'
|
||||
import type { DeleteTask } from '~/lib/delete-task-manager'
|
||||
import { useDeleteTaskManagerStore } from '~/store/delete-tasks'
|
||||
|
||||
const props = defineProps<{ task: DeleteTask }>();
|
||||
const { t } = useI18n()
|
||||
const store = useDeleteTaskManagerStore()
|
||||
|
||||
const props = defineProps<{ task: DeleteTask }>()
|
||||
|
||||
const handleDeleteTask = () => {
|
||||
store.removeTask(props.task.id);
|
||||
};
|
||||
store.removeTask(props.task.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,113 +1,150 @@
|
||||
<template>
|
||||
<n-button text v-if="total > 0" @click="toggleDrawer">
|
||||
<AppButton
|
||||
v-if="total > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-0 text-sm font-medium text-primary hover:text-primary"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<div v-if="pending.length" class="flex items-center gap-2">
|
||||
<n-spin :size="14" />
|
||||
<span>{{
|
||||
t('In Progress', {
|
||||
total: total,
|
||||
processing: processing.length,
|
||||
completed: completed.length,
|
||||
})
|
||||
}}</span>
|
||||
<AppSpinner size="sm" />
|
||||
<span>
|
||||
{{
|
||||
t('In Progress', {
|
||||
total: total,
|
||||
processing: processing.length,
|
||||
completed: completed.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ t('Task Completed', { completed: completed.length, failed: failed.length }) }}
|
||||
</div>
|
||||
</n-button>
|
||||
</AppButton>
|
||||
|
||||
<n-drawer v-model:show="showDrawer" :width="502">
|
||||
<n-drawer-content :title="t('Task Management')" closable>
|
||||
<div class="flex flex-col gap-4">
|
||||
<n-alert type="warning" :show-icon="false">
|
||||
<AppDrawer v-model="showDrawer" :title="t('Task Management')" size="lg">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Alert
|
||||
class="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
>
|
||||
<AlertDescription class="space-y-2 text-sm leading-relaxed">
|
||||
<p>
|
||||
<span class="text-red-500">{{ t('Browser Warning') }}</span>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-300">{{ t('Browser Warning') }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-red-500">{{ t('Cache Warning') }}</span>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-300">{{ t('Cache Warning') }}</span>
|
||||
</p>
|
||||
</n-alert>
|
||||
<div v-if="total > 0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ total - (pending.length + processing.length) }}/{{ total }}</div>
|
||||
<n-button text type="info" @click="clearTasks">{{ t('Clear Records') }}</n-button>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:height="2"
|
||||
:percentage="percentage"
|
||||
:show-indicator="false"
|
||||
:processing="percentage < 100"
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div v-if="total > 0" class="space-y-3">
|
||||
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>{{ total - (pending.length + processing.length) }}/{{ total }}</div>
|
||||
<AppButton variant="ghost" size="sm" class="h-auto px-2" @click="clearTasks">
|
||||
{{ t('Clear Records') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<n-tabs type="line" animated v-model:value="tab">
|
||||
<n-tab-pane :tab="t('Pending', { count: pending.length })" name="pending">
|
||||
<slot name="task-list" :tasks="pending" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="t('Processing', { count: processing.length })" name="processing">
|
||||
<slot name="task-list" :tasks="processing" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="t('Completed', { count: completed.length })" name="completed">
|
||||
<slot name="task-list" :tasks="completed" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="t('Failed', { count: failed.length })" name="failed">
|
||||
<slot name="task-list" :tasks="failed" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :tab="t('Paused', { count: paused.length })" name="paused">
|
||||
<slot name="task-list" :tasks="paused" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<AppProgress :value="percentage" :processing="percentage < 100" :height="3" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<Tabs v-model="tab" class="flex flex-col gap-4">
|
||||
<TabsList class="w-full justify-start overflow-x-auto">
|
||||
<TabsTrigger v-for="tabItem in tabs" :key="tabItem.value" :value="tabItem.value" class="capitalize">
|
||||
{{ tabItem.label }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending" class="mt-0">
|
||||
<slot name="task-list" :tasks="pending" />
|
||||
</TabsContent>
|
||||
<TabsContent value="processing" class="mt-0">
|
||||
<slot name="task-list" :tasks="processing" />
|
||||
</TabsContent>
|
||||
<TabsContent value="completed" class="mt-0">
|
||||
<slot name="task-list" :tasks="completed" />
|
||||
</TabsContent>
|
||||
<TabsContent value="failed" class="mt-0">
|
||||
<slot name="task-list" :tasks="failed" />
|
||||
</TabsContent>
|
||||
<TabsContent value="paused" class="mt-0">
|
||||
<slot name="task-list" :tasks="paused" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AppButton, AppDrawer, AppProgress, AppSpinner } from '@/components/app'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: any[];
|
||||
processingStatus: string;
|
||||
onClearTasks: () => void;
|
||||
}>();
|
||||
tasks: any[]
|
||||
processingStatus: string
|
||||
onClearTasks: () => void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n();
|
||||
const showDrawer = ref(false);
|
||||
const { t } = useI18n()
|
||||
const showDrawer = ref(false)
|
||||
|
||||
const total = computed(() => props.tasks.length);
|
||||
const percentage = computed(() => (total.value === 0 ? 100 : Math.floor((completed.value.length / total.value) * 100)));
|
||||
const total = computed(() => props.tasks.length)
|
||||
const percentage = computed(() => (total.value === 0 ? 100 : Math.floor((completed.value.length / total.value) * 100)))
|
||||
|
||||
const pending = computed(() => props.tasks.filter(task => task.status === 'pending'));
|
||||
const processing = computed(() => props.tasks.filter(task => task.status === props.processingStatus));
|
||||
const completed = computed(() => props.tasks.filter(task => task.status === 'completed'));
|
||||
const failed = computed(() => props.tasks.filter(task => task.status === 'failed'));
|
||||
const paused = computed(() => props.tasks.filter(task => task.status === 'paused'));
|
||||
const pending = computed(() => props.tasks.filter(task => task.status === 'pending'))
|
||||
const processing = computed(() => props.tasks.filter(task => task.status === props.processingStatus))
|
||||
const completed = computed(() => props.tasks.filter(task => task.status === 'completed'))
|
||||
const failed = computed(() => props.tasks.filter(task => task.status === 'failed'))
|
||||
const paused = computed(() => props.tasks.filter(task => task.status === 'paused'))
|
||||
|
||||
const tab = ref('pending');
|
||||
const tab = ref('pending')
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'pending', label: t('Pending', { count: pending.value.length }) },
|
||||
{ value: 'processing', label: t('Processing', { count: processing.value.length }) },
|
||||
{ value: 'completed', label: t('Completed', { count: completed.value.length }) },
|
||||
{ value: 'failed', label: t('Failed', { count: failed.value.length }) },
|
||||
{ value: 'paused', label: t('Paused', { count: paused.value.length }) },
|
||||
])
|
||||
|
||||
// 根据任务执行状态切换任务列表
|
||||
watch(percentage, () => {
|
||||
if (processing.value.length > 0) {
|
||||
tab.value = 'processing';
|
||||
} else {
|
||||
tab.value = 'completed';
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => ({
|
||||
processing: processing.value.length,
|
||||
pending: pending.value.length,
|
||||
completed: completed.value.length,
|
||||
}),
|
||||
({ processing, pending, completed }) => {
|
||||
if (processing > 0) {
|
||||
tab.value = 'processing'
|
||||
} else if (pending > 0) {
|
||||
tab.value = 'pending'
|
||||
} else if (completed > 0) {
|
||||
tab.value = 'completed'
|
||||
} else {
|
||||
tab.value = 'pending'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 第一次加入任务时自动打开抽屉
|
||||
const autoOpened = ref(false);
|
||||
const autoOpened = ref(false)
|
||||
watch(total, (newVal, oldVal) => {
|
||||
if (!autoOpened.value && !showDrawer.value && oldVal === 0 && newVal > 0) {
|
||||
showDrawer.value = true;
|
||||
autoOpened.value = true;
|
||||
showDrawer.value = true
|
||||
autoOpened.value = true
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const toggleDrawer = () => {
|
||||
showDrawer.value = !showDrawer.value;
|
||||
};
|
||||
showDrawer.value = !showDrawer.value
|
||||
}
|
||||
|
||||
const clearTasks = () => {
|
||||
props.onClearTasks();
|
||||
};
|
||||
props.onClearTasks()
|
||||
}
|
||||
</script>
|
||||
|
||||
+482
-370
@@ -1,439 +1,551 @@
|
||||
<template>
|
||||
<n-modal :show="show" @update:show="(val: boolean) => $emit('update:show', val)" size="huge">
|
||||
<n-card class="max-w-screen-md">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>{{ t('Upload File') }}</span>
|
||||
<n-button size="small" ghost @click="closeModal">{{ t('Close') }}</n-button>
|
||||
<AppModal v-model="visible" :title="t('Upload File')" size="xl" :close-on-backdrop="false">
|
||||
<div class="space-y-5">
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelect" />
|
||||
<input
|
||||
ref="folderInput"
|
||||
type="file"
|
||||
webkitdirectory
|
||||
directory
|
||||
class="hidden"
|
||||
@change="handleFolderSelect"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">
|
||||
{{ t('Target Bucket') }}: {{ bucketName }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('Current Prefix') }}: {{ prefix || '/' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="file" ref="fileInput" multiple hidden @change="handleFileSelect" />
|
||||
<input type="file" ref="folderInput" webkitdirectory directory hidden @change="handleFolderSelect" />
|
||||
<n-button type="primary" :disabled="isFolderLoading" @click="selectFile">{{ t('Select File') }}</n-button>
|
||||
<div>{{ t('Or') }}</div>
|
||||
<n-button :disabled="isFolderLoading" @click="selectFolder">{{ t('Select Folder') }}</n-button>
|
||||
<div>{{ t('Upload To') }}</div>
|
||||
<div class="text-cyan-600">{{ bucketName }}{{ prefix || '/' }}</div>
|
||||
</div>
|
||||
|
||||
<n-alert title="" type="info">
|
||||
{{ t('Overwrite Warning') }}
|
||||
</n-alert>
|
||||
|
||||
<!-- 内存警告 -->
|
||||
<n-alert v-if="isMemoryWarning" title="" type="warning" style="margin-bottom: 16px">
|
||||
{{ t('Large File Count Warning', { count: totalFileCount, max: MAX_FILES_LIMIT }) }}
|
||||
</n-alert>
|
||||
|
||||
<!-- 文件统计信息 -->
|
||||
<div v-if="totalFileCount > 0" class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>{{ t('Total Files') }}: {{ totalFileCount.toLocaleString() }}</span>
|
||||
<span>{{ t('Memory Usage') }}: {{ getMemoryUsageLevel() }}</span>
|
||||
</div>
|
||||
<n-button size="small" type="warning" ghost @click="clearAllFiles" :disabled="isFolderLoading || isAdding">
|
||||
{{ t('Clear All') }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹读取 loading -->
|
||||
<n-progress
|
||||
v-if="isFolderLoading"
|
||||
:percentage="folderLoadingProgress"
|
||||
type="line"
|
||||
:show-indicator="true"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #default> {{ t('Reading Folder Files') }} ({{ folderLoadingProgress }}%) </template>
|
||||
</n-progress>
|
||||
|
||||
<div @dragover.prevent @drop.prevent="handleDrop">
|
||||
<div
|
||||
v-if="selectedItems.length === 0"
|
||||
class="flex flex-col gap-6 items-center justify-center border border-dashed border-muted rounded p-4 h-[40vh]"
|
||||
>
|
||||
<div class="text-gray-500 font-bold">{{ t('No Selection') }}</div>
|
||||
<div class="text-muted-foreground text-center">
|
||||
{{ t('Drag Drop Info') }}<br />
|
||||
{{ t('File Size Limit') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟滚动文件与文件夹列表 -->
|
||||
<n-virtual-list
|
||||
v-if="selectedItems.length"
|
||||
:items="selectedItems"
|
||||
:item-size="60"
|
||||
class="h-[40vh] overflow-y-auto border rounded"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="flex items-center gap-2 w-full border-b p-1">
|
||||
<div class="flex-1">
|
||||
<div>
|
||||
<span v-if="item.type === 'folder'">{{ item.name }}/</span>
|
||||
<span v-else>{{ item.name }}</span>
|
||||
</div>
|
||||
<div v-if="item.type === 'folder' && item.fileCount" class="text-xs text-gray-500">
|
||||
{{ item.fileCount }} 个文件
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-32 text-center">{{ formatBytes(item.size) }}</div>
|
||||
<div class="w-32 text-center">
|
||||
<n-button text type="info" @click="removeItem(index)">{{ t('Delete') }}</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
|
||||
<n-progress
|
||||
v-if="isAdding"
|
||||
:percentage="addProgress"
|
||||
type="line"
|
||||
:show-indicator="true"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #default> {{ t('Adding to Upload Queue') }} ({{ addProgress }}%) </template>
|
||||
</n-progress>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<n-button type="default" :disabled="!hasFiles || isAdding || isFolderLoading">{{ t('Configure') }}</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!hasFiles || isAdding || isFolderLoading"
|
||||
:loading="isAdding"
|
||||
@click="handleUpload"
|
||||
>{{ t('Start Upload') }}</n-button
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AppButton variant="outline" size="sm" :disabled="isFolderLoading" @click="selectFile">
|
||||
<Icon name="ri:file-add-line" class="size-4" />
|
||||
{{ t('Select File') }}
|
||||
</AppButton>
|
||||
<AppButton variant="outline" size="sm" :disabled="isFolderLoading" @click="selectFolder">
|
||||
<Icon name="ri:folder-add-line" class="size-4" />
|
||||
{{ t('Select Folder') }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" size="sm" @click="closeModal">
|
||||
<Icon name="ri:close-line" class="size-4" />
|
||||
{{ t('Close') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<Alert class="border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100">
|
||||
<AlertDescription>
|
||||
{{ t('Overwrite Warning') }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
v-if="isMemoryWarning"
|
||||
class="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
>
|
||||
<AlertDescription>
|
||||
{{ t('Large File Count Warning', { count: totalFileCount.toLocaleString(), max: MAX_FILES_LIMIT }) }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<AppCard padded class="space-y-4 border border-border/60">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="totalFileCount > 0">
|
||||
{{ t('Total Files') }}: {{ totalFileCount.toLocaleString() }}
|
||||
</p>
|
||||
<p v-if="totalFileCount > 0">
|
||||
{{ t('Memory Usage') }}: {{ getMemoryUsageLevel() }}
|
||||
</p>
|
||||
</div>
|
||||
<AppButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!hasFiles || isFolderLoading || isAdding"
|
||||
@click="clearAllFiles"
|
||||
>
|
||||
<Icon name="ri:delete-bin-line" class="size-4" />
|
||||
{{ t('Clear All') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-md border border-dashed border-border/60 transition"
|
||||
:class="isDragOver ? 'border-primary bg-primary/5' : ''"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div
|
||||
v-if="!selectedItems.length"
|
||||
class="flex h-[42vh] flex-col items-center justify-center gap-4 p-6 text-center"
|
||||
>
|
||||
<Icon name="ri:cloud-upload-line" class="size-10 text-muted-foreground" />
|
||||
<p class="text-base font-medium text-muted-foreground">{{ t('No Selection') }}</p>
|
||||
<p class="max-w-[320px] text-sm text-muted-foreground">
|
||||
{{ t('Drag Drop Info') }}<br />
|
||||
{{ t('File Size Limit') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="max-h-[42vh] overflow-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
{{ t('Name') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
{{ t('Size') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
{{ t('Actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in selectedItems" :key="item.uid" class="border-b last:border-b-0">
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon
|
||||
:name="item.type === 'folder' ? 'ri:folder-3-line' : 'ri:file-line'"
|
||||
class="mt-0.5 size-4 text-muted-foreground"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ item.name }}<span v-if="item.type === 'folder'">/</span>
|
||||
</div>
|
||||
<div v-if="item.type === 'folder' && item.fileCount" class="text-xs text-muted-foreground">
|
||||
{{ item.fileCount.toLocaleString() }} {{ t('Files') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-muted-foreground">
|
||||
{{ formatBytes(item.size) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<AppButton variant="ghost" size="sm" class="h-auto px-2" @click="removeItem(index)">
|
||||
<Icon name="ri:delete-bin-line" class="size-4" />
|
||||
{{ t('Delete') }}
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFolderLoading" class="space-y-2 rounded-md border border-dashed border-border/60 p-3">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t('Reading Folder Files') }}</span>
|
||||
<span>{{ folderLoadingProgress }}%</span>
|
||||
</div>
|
||||
<AppProgress :value="folderLoadingProgress" :processing="true" />
|
||||
</div>
|
||||
|
||||
<div v-if="isAdding" class="space-y-2 rounded-md border border-dashed border-border/60 p-3">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t('Adding to Upload Queue') }}</span>
|
||||
<span>{{ addProgress }}%</span>
|
||||
</div>
|
||||
<AppProgress :value="addProgress" :processing="true" />
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="outline" :disabled="!hasFiles || isAdding || isFolderLoading">
|
||||
{{ t('Configure') }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="primary"
|
||||
:disabled="!hasFiles || isAdding || isFolderLoading"
|
||||
:loading="isAdding"
|
||||
@click="handleUpload"
|
||||
>
|
||||
{{ t('Start Upload') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, defineEmits, defineProps, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks';
|
||||
import { Icon } from '#components'
|
||||
import { AppButton, AppCard, AppModal, AppProgress } from '@/components/app'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
const uploadTaskManagerStore = useUploadTaskManagerStore();
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
bucketName: string
|
||||
prefix: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'submit'): void
|
||||
}>()
|
||||
|
||||
interface FileItem {
|
||||
file: File;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
interface OptimizedFileGroup {
|
||||
files: File[];
|
||||
totalSize: number;
|
||||
fileCount: number;
|
||||
file: File
|
||||
prefix: string
|
||||
}
|
||||
|
||||
interface SelectedItem {
|
||||
type: 'file' | 'folder';
|
||||
name: string;
|
||||
size: number;
|
||||
fileCount?: number;
|
||||
files?: FileItem[];
|
||||
uid: string
|
||||
type: 'file' | 'folder'
|
||||
name: string
|
||||
size: number
|
||||
fileCount?: number
|
||||
files: FileItem[]
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:show', 'submit']);
|
||||
interface OptimizedFileGroup {
|
||||
files: File[]
|
||||
totalSize: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ show: boolean; bucketName: string; prefix: string }>();
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const uploadTaskManagerStore = useUploadTaskManagerStore()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const folderInput = ref<HTMLInputElement | null>(null);
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: value => emit('update:show', value),
|
||||
})
|
||||
|
||||
// 待上传的文件/文件夹列表
|
||||
const selectedItems = ref<SelectedItem[]>([]);
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const isAdding = ref(false);
|
||||
const addProgress = ref(0);
|
||||
const isFolderLoading = ref(false);
|
||||
const folderLoadingProgress = ref(0);
|
||||
const selectedItems = ref<SelectedItem[]>([])
|
||||
const isMemoryWarning = ref(false)
|
||||
const totalFileCount = ref(0)
|
||||
const isFolderLoading = ref(false)
|
||||
const folderLoadingProgress = ref(0)
|
||||
const isAdding = ref(false)
|
||||
const addProgress = ref(0)
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 内存优化配置
|
||||
const MAX_FILES_LIMIT = 10000; // 最大文件数限制
|
||||
const MEMORY_WARNING_THRESHOLD = 5000; // 内存警告阈值
|
||||
const isMemoryWarning = ref(false);
|
||||
const totalFileCount = ref(0);
|
||||
const MAX_FILES_LIMIT = 10_000
|
||||
const MEMORY_WARNING_THRESHOLD = 5_000
|
||||
|
||||
const hasFiles = computed(() => selectedItems.value.length > 0)
|
||||
const allFiles = computed(() => selectedItems.value.flatMap(item => item.files))
|
||||
|
||||
const selectFile = () => fileInput.value?.click()
|
||||
const selectFolder = () => folderInput.value?.click()
|
||||
|
||||
// 清理内存函数
|
||||
const clearAllFiles = () => {
|
||||
selectedItems.value = [];
|
||||
totalFileCount.value = 0;
|
||||
isMemoryWarning.value = false;
|
||||
selectedItems.value = []
|
||||
totalFileCount.value = 0
|
||||
isMemoryWarning.value = false
|
||||
addProgress.value = 0
|
||||
folderLoadingProgress.value = 0
|
||||
|
||||
// 强制垃圾回收(如果支持)
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
if (typeof window !== 'undefined' && 'gc' in window && typeof (window as any).gc === 'function') {
|
||||
try {
|
||||
;(window as any).gc()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
// 关闭时清理内存
|
||||
clearAllFiles();
|
||||
emit('update:show', false);
|
||||
};
|
||||
clearAllFiles()
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const selectFile = () => fileInput.value?.click();
|
||||
const selectFolder = () => folderInput.value?.click();
|
||||
const createUid = () =>
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
|
||||
// 内存监控
|
||||
const checkMemoryUsage = () => {
|
||||
if ('memory' in performance) {
|
||||
const memInfo = (performance as any).memory;
|
||||
const usedMB = memInfo.usedJSHeapSize / 1024 / 1024;
|
||||
|
||||
// 如果内存使用超过 100MB 且文件数量很大,建议清理
|
||||
if (usedMB > 100 && totalFileCount.value > MEMORY_WARNING_THRESHOLD) {
|
||||
message.warning(
|
||||
t('High Memory Usage Warning', {
|
||||
memory: Math.round(usedMB),
|
||||
files: totalFileCount.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
const fileCount = input.files.length;
|
||||
const currentTotal = totalFileCount.value + fileCount;
|
||||
|
||||
// 检查文件数量限制
|
||||
if (currentTotal > MAX_FILES_LIMIT) {
|
||||
message.error(
|
||||
t('File Count Limit Exceeded', {
|
||||
current: currentTotal,
|
||||
max: MAX_FILES_LIMIT,
|
||||
})
|
||||
);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 内存警告
|
||||
if (currentTotal > MEMORY_WARNING_THRESHOLD && !isMemoryWarning.value) {
|
||||
isMemoryWarning.value = true;
|
||||
message.warning(
|
||||
t('Memory Warning', {
|
||||
count: currentTotal,
|
||||
threshold: MEMORY_WARNING_THRESHOLD,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 更新展示列表
|
||||
Array.from(input.files).forEach(f =>
|
||||
selectedItems.value.push({
|
||||
type: 'file',
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
files: [{ file: f, prefix: props.prefix }],
|
||||
})
|
||||
);
|
||||
|
||||
// 更新总文件数
|
||||
totalFileCount.value = currentTotal;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderSelect = async (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const fileCount = input.files.length;
|
||||
const currentTotal = totalFileCount.value + fileCount;
|
||||
|
||||
// 检查文件数量限制
|
||||
if (currentTotal > MAX_FILES_LIMIT) {
|
||||
const ensureCapacity = (incoming: number) => {
|
||||
const nextTotal = totalFileCount.value + incoming
|
||||
if (nextTotal > MAX_FILES_LIMIT) {
|
||||
message.error(
|
||||
t('File Count Limit Exceeded', {
|
||||
current: currentTotal,
|
||||
max: MAX_FILES_LIMIT,
|
||||
current: nextTotal.toLocaleString(),
|
||||
max: MAX_FILES_LIMIT.toLocaleString(),
|
||||
})
|
||||
);
|
||||
input.value = '';
|
||||
return;
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 内存警告
|
||||
if (currentTotal > MEMORY_WARNING_THRESHOLD && !isMemoryWarning.value) {
|
||||
isMemoryWarning.value = true;
|
||||
if (nextTotal > MEMORY_WARNING_THRESHOLD && !isMemoryWarning.value) {
|
||||
isMemoryWarning.value = true
|
||||
message.warning(
|
||||
t('Memory Warning', {
|
||||
count: currentTotal,
|
||||
threshold: MEMORY_WARNING_THRESHOLD,
|
||||
count: nextTotal.toLocaleString(),
|
||||
threshold: MEMORY_WARNING_THRESHOLD.toLocaleString(),
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 开始 loading
|
||||
isFolderLoading.value = true;
|
||||
folderLoadingProgress.value = 0;
|
||||
totalFileCount.value = nextTotal
|
||||
return true
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
if (!files.length) return
|
||||
|
||||
if (!ensureCapacity(files.length)) {
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const items = files.map(file => ({
|
||||
uid: createUid(),
|
||||
type: 'file' as const,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
files: [{ file, prefix: props.prefix }],
|
||||
}))
|
||||
|
||||
selectedItems.value.push(...items)
|
||||
setTimeout(checkMemoryUsage, 500)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const handleFolderSelect = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
if (!files.length) return
|
||||
|
||||
const nextTotal = totalFileCount.value + files.length
|
||||
if (nextTotal > MAX_FILES_LIMIT) {
|
||||
message.error(
|
||||
t('File Count Limit Exceeded', {
|
||||
current: nextTotal.toLocaleString(),
|
||||
max: MAX_FILES_LIMIT.toLocaleString(),
|
||||
})
|
||||
)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (nextTotal > MEMORY_WARNING_THRESHOLD && !isMemoryWarning.value) {
|
||||
isMemoryWarning.value = true
|
||||
message.warning(
|
||||
t('Memory Warning', {
|
||||
count: nextTotal.toLocaleString(),
|
||||
threshold: MEMORY_WARNING_THRESHOLD.toLocaleString(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
isFolderLoading.value = true
|
||||
folderLoadingProgress.value = 0
|
||||
|
||||
try {
|
||||
const folderFiles = Array.from(input.files);
|
||||
const folderMap = new Map<string, OptimizedFileGroup>();
|
||||
const batchSize = 200; // 增加批处理大小以提高性能
|
||||
const folderMap = new Map<string, OptimizedFileGroup>()
|
||||
const batchSize = 200
|
||||
|
||||
// 分批处理文件,避免阻塞主线程
|
||||
for (let i = 0; i < folderFiles.length; i += batchSize) {
|
||||
const batch = folderFiles.slice(i, i + batchSize);
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize)
|
||||
|
||||
// 处理当前批次的文件
|
||||
for (const file of batch) {
|
||||
// 保留完整目录路径(去掉最后的文件名)
|
||||
const pathParts = file.webkitRelativePath.split('/');
|
||||
pathParts.pop(); // 移除文件名
|
||||
const folderPath = pathParts.join('/') || t('Unknown Folder');
|
||||
const pathParts = file.webkitRelativePath.split('/')
|
||||
pathParts.pop()
|
||||
const folderPath = pathParts.join('/') || t('Unknown Folder')
|
||||
|
||||
if (!folderMap.has(folderPath)) {
|
||||
folderMap.set(folderPath, {
|
||||
files: [],
|
||||
totalSize: 0,
|
||||
fileCount: 0,
|
||||
});
|
||||
folderMap.set(folderPath, { files: [], totalSize: 0, fileCount: 0 })
|
||||
}
|
||||
|
||||
const group = folderMap.get(folderPath)!;
|
||||
group.files.push(file);
|
||||
group.totalSize += file.size;
|
||||
group.fileCount++;
|
||||
const group = folderMap.get(folderPath)!
|
||||
group.files.push(file)
|
||||
group.totalSize += file.size
|
||||
group.fileCount++
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
folderLoadingProgress.value = Math.round(((i + batch.length) / folderFiles.length) * 100);
|
||||
|
||||
// 让出主线程,保证 UI 可响应
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
folderLoadingProgress.value = Math.round(((i + batch.length) / files.length) * 100)
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
}
|
||||
|
||||
// 将每个完整路径和对应文件放到 selectedItems
|
||||
for (const [folderPath, group] of folderMap.entries()) {
|
||||
selectedItems.value.push({
|
||||
uid: createUid(),
|
||||
type: 'folder',
|
||||
name: folderPath,
|
||||
size: group.totalSize,
|
||||
fileCount: group.fileCount,
|
||||
files: group.files.map(file => ({ file, prefix: `${props.prefix}${folderPath}/` })),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新总文件数
|
||||
totalFileCount.value = currentTotal;
|
||||
|
||||
// 检查内存使用情况
|
||||
setTimeout(checkMemoryUsage, 1000);
|
||||
} catch (error) {
|
||||
console.error('处理文件夹时出错:', error);
|
||||
message.error(t('Folder Processing Error'));
|
||||
} finally {
|
||||
// 结束 loading
|
||||
isFolderLoading.value = false;
|
||||
folderLoadingProgress.value = 100;
|
||||
|
||||
// 重置文件输入,允许重复选择同一文件夹
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.files) {
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
droppedFiles.forEach(file =>
|
||||
selectedItems.value.push({
|
||||
type: 'file',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
files: [{ file, prefix: props.prefix }],
|
||||
files: group.files.map(file => ({
|
||||
file,
|
||||
prefix: `${props.prefix}${folderPath ? `${folderPath}/` : ''}`,
|
||||
})),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const hasFiles = computed(() => selectedItems.value.length > 0);
|
||||
const allFiles = computed(() => selectedItems.value.flatMap(item => item.files || []));
|
||||
|
||||
// 内存使用级别计算
|
||||
const getMemoryUsageLevel = () => {
|
||||
const count = totalFileCount.value;
|
||||
if (count < 1000) return t('Memory Low');
|
||||
if (count < MEMORY_WARNING_THRESHOLD) return t('Memory Medium');
|
||||
if (count < MAX_FILES_LIMIT) return t('Memory High');
|
||||
return t('Memory Critical');
|
||||
};
|
||||
|
||||
// 格式化字节大小
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
function removeItem(index: number) {
|
||||
const item = selectedItems.value[index];
|
||||
if (item.type === 'folder' && item.fileCount) {
|
||||
totalFileCount.value -= item.fileCount;
|
||||
} else if (item.type === 'file') {
|
||||
totalFileCount.value -= 1;
|
||||
}
|
||||
|
||||
selectedItems.value.splice(index, 1);
|
||||
|
||||
// 如果文件数量降低,重置内存警告
|
||||
if (totalFileCount.value <= MEMORY_WARNING_THRESHOLD) {
|
||||
isMemoryWarning.value = false;
|
||||
totalFileCount.value = nextTotal
|
||||
setTimeout(checkMemoryUsage, 1000)
|
||||
} catch (error) {
|
||||
console.error('Failed to process folder:', error)
|
||||
message.error(t('Folder Processing Error'))
|
||||
} finally {
|
||||
isFolderLoading.value = false
|
||||
folderLoadingProgress.value = 100
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const filesToUpload = allFiles.value;
|
||||
if (!filesToUpload.length) return;
|
||||
const handleDragEnter = () => {
|
||||
if (!isFolderLoading.value && !isAdding.value) {
|
||||
isDragOver.value = true
|
||||
}
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
addProgress.value = 0;
|
||||
const handleDragOver = () => {
|
||||
if (!isFolderLoading.value && !isAdding.value) {
|
||||
isDragOver.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 分组
|
||||
const uploadGroups = new Map<string, File[]>();
|
||||
filesToUpload.forEach(fileItem => {
|
||||
const key = `${props.bucketName}:${fileItem.prefix}`;
|
||||
if (!uploadGroups.has(key)) uploadGroups.set(key, []);
|
||||
uploadGroups.get(key)!.push(fileItem.file);
|
||||
});
|
||||
const handleDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
const total = Array.from(uploadGroups.values()).reduce((sum, arr) => sum + arr.length, 0);
|
||||
let done = 0;
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (isFolderLoading.value || isAdding.value) return
|
||||
isDragOver.value = false
|
||||
|
||||
// 分批异步添加,避免阻塞主线程
|
||||
for (const [key, files] of uploadGroups.entries()) {
|
||||
const [bucketName, prefix] = key.split(':');
|
||||
for (let i = 0; i < files.length; i += 50) {
|
||||
const batch = files.slice(i, i + 50);
|
||||
// 让出主线程,保证 UI 可响应
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await uploadTaskManagerStore.addFiles(batch, bucketName, prefix);
|
||||
done += batch.length;
|
||||
addProgress.value = Math.round((done / total) * 100);
|
||||
const droppedFiles = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []
|
||||
if (!droppedFiles.length) return
|
||||
|
||||
if (!ensureCapacity(droppedFiles.length)) return
|
||||
|
||||
const items = droppedFiles.map(file => ({
|
||||
uid: createUid(),
|
||||
type: 'file' as const,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
files: [{ file, prefix: props.prefix }],
|
||||
}))
|
||||
|
||||
selectedItems.value.push(...items)
|
||||
setTimeout(checkMemoryUsage, 500)
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const item = selectedItems.value[index]
|
||||
if (!item) return
|
||||
|
||||
if (item.type === 'folder' && item.fileCount) {
|
||||
totalFileCount.value = Math.max(0, totalFileCount.value - item.fileCount)
|
||||
} else {
|
||||
totalFileCount.value = Math.max(0, totalFileCount.value - 1)
|
||||
}
|
||||
|
||||
selectedItems.value.splice(index, 1)
|
||||
|
||||
if (totalFileCount.value <= MEMORY_WARNING_THRESHOLD) {
|
||||
isMemoryWarning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
const filesToUpload = allFiles.value
|
||||
if (!filesToUpload.length) return
|
||||
|
||||
isAdding.value = true
|
||||
addProgress.value = 0
|
||||
|
||||
try {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
bucket: string
|
||||
prefix: string
|
||||
files: File[]
|
||||
}
|
||||
>()
|
||||
|
||||
filesToUpload.forEach(item => {
|
||||
const prefix = item.prefix ?? ''
|
||||
const key = JSON.stringify([props.bucketName, prefix])
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { bucket: props.bucketName, prefix, files: [] })
|
||||
}
|
||||
groups.get(key)!.files.push(item.file)
|
||||
})
|
||||
|
||||
const totals = Array.from(groups.values()).reduce((sum, group) => sum + group.files.length, 0)
|
||||
let processed = 0
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const { bucket, prefix, files } = group
|
||||
|
||||
for (let i = 0; i < files.length; i += 50) {
|
||||
const batch = files.slice(i, i + 50)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await Promise.resolve(uploadTaskManagerStore.addFiles(batch, bucket, prefix))
|
||||
processed += batch.length
|
||||
addProgress.value = Math.round((processed / totals) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
emit('submit')
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue upload tasks:', error)
|
||||
message.error(t('Add Failed'))
|
||||
} finally {
|
||||
isAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkMemoryUsage = () => {
|
||||
if (typeof performance === 'undefined' || !('memory' in performance)) return
|
||||
|
||||
const memInfo = (performance as any).memory
|
||||
if (!memInfo) return
|
||||
|
||||
const usedMB = memInfo.usedJSHeapSize / 1024 / 1024
|
||||
if (usedMB > 100 && totalFileCount.value > MEMORY_WARNING_THRESHOLD) {
|
||||
message.warning(
|
||||
t('High Memory Usage Warning', {
|
||||
memory: Math.round(usedMB),
|
||||
files: totalFileCount.value.toLocaleString(),
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const getMemoryUsageLevel = () => {
|
||||
const count = totalFileCount.value
|
||||
if (count < 1_000) return t('Memory Low')
|
||||
if (count < MEMORY_WARNING_THRESHOLD) return t('Memory Medium')
|
||||
if (count < MAX_FILES_LIMIT) return t('Memory High')
|
||||
return t('Memory Critical')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
value => {
|
||||
if (!value) {
|
||||
clearAllFiles()
|
||||
isFolderLoading.value = false
|
||||
isAdding.value = false
|
||||
isDragOver.value = false
|
||||
}
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
addProgress.value = 100;
|
||||
selectedItems.value = [];
|
||||
emit('submit');
|
||||
closeModal();
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ task.file.name }}</div>
|
||||
<div class="flex gap-2">
|
||||
<n-button v-if="task.status === 'uploading'" text @click="handlePauseTask" type="warning">{{
|
||||
t('Pause')
|
||||
}}</n-button>
|
||||
<n-button v-if="task.status === 'paused'" text @click="handleResumeTask" type="success">{{
|
||||
t('Resume')
|
||||
}}</n-button>
|
||||
<n-button text @click="handleDeleteTask" type="info">{{ t('Delete Record') }}</n-button>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ task.file.name }}</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<AppButton
|
||||
v-if="task.status === 'uploading'"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="h-auto px-2 text-xs"
|
||||
@click="handlePauseTask"
|
||||
>
|
||||
{{ t('Pause') }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-if="task.status === 'paused'"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="h-auto px-2 text-xs"
|
||||
@click="handleResumeTask"
|
||||
>
|
||||
{{ t('Resume') }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="xs" class="h-auto px-2 text-xs" @click="handleDeleteTask">
|
||||
{{ t('Delete Record') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:height="2"
|
||||
:percentage="task.progress"
|
||||
:show-indicator="false"
|
||||
:processing="task.status == 'uploading'"
|
||||
/>
|
||||
<AppProgress :value="task.progress" :processing="task.status === 'uploading'" :height="2" />
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<div>{{ formatBytes(task.file.size) }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
@@ -33,20 +41,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UploadTask } from '~/lib/upload-task-manager';
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks';
|
||||
const { t } = useI18n();
|
||||
const store = useUploadTaskManagerStore();
|
||||
import { AppButton, AppProgress } from '@/components/app'
|
||||
import type { UploadTask } from '~/lib/upload-task-manager'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
import { formatBytes } from '~/utils/functions'
|
||||
|
||||
const props = defineProps<{ task: UploadTask }>();
|
||||
const { t } = useI18n()
|
||||
const store = useUploadTaskManagerStore()
|
||||
|
||||
const props = defineProps<{ task: UploadTask }>()
|
||||
|
||||
const handleDeleteTask = () => {
|
||||
store.removeTask(props.task.id);
|
||||
};
|
||||
store.removeTask(props.task.id)
|
||||
}
|
||||
const handlePauseTask = () => {
|
||||
store.pauseTask(props.task.id);
|
||||
};
|
||||
store.pauseTask(props.task.id)
|
||||
}
|
||||
const handleResumeTask = () => {
|
||||
store.resumeTask(props.task.id);
|
||||
};
|
||||
store.resumeTask(props.task.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import { AppButton, AppCard, AppDataTable, AppModal } from '@/components/app'
|
||||
import { useDataTable } from '@/components/app/data-table'
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3'
|
||||
|
||||
@@ -61,4 +61,15 @@ After each page group migrates, commit & push to `refactor/shadcn-vue`.
|
||||
- Update README to reflect the new UI stack.
|
||||
- Run lint/tests plus a targeted manual regression of menus, forms, uploads, and table interactions.
|
||||
|
||||
## Remaining TODO (2025-10-27)
|
||||
|
||||
- [ ] Object task management: `components/object/task-stats.vue`, `components/object/upload/task/item.vue`, `components/object/delete/task/item.vue`, and finish `components/object/upload/picker.vue`.
|
||||
- [ ] Bucket dialogs: migrate `components/buckets/new-form.vue` and `components/buckets/info.vue`.
|
||||
- [ ] User management suite: group/user edit & policy components under `components/users/**` (forms, tables, modals).
|
||||
- [ ] Performance dashboard: refactor `pages/performance/index.vue` to shadcn primitives.
|
||||
- [ ] License center: migrate `pages/license/index.vue` & `pages/license/components/license-article.vue`.
|
||||
- [ ] SSE monitor & metrics: convert `pages/sse/index.vue`.
|
||||
- [ ] System configuration & tools: update `pages/config.vue` and `pages/kms-test.vue`.
|
||||
- [ ] Final pass: remove Naive UI resolver/module, scrub `<n-*>` usage (`rg "<n-"` should return nothing), and tidy shared components.
|
||||
|
||||
This plan will guide the subsequent implementation steps while keeping the migration reviewable.
|
||||
|
||||
Reference in New Issue
Block a user