feat(admin): 批量上传改为手动点击开始
This commit is contained in:
1 parent
2e89f8460c
commit
f174ba46ae
5 files changed
+135
-32
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<!-- 剧集批量上传弹窗:多选、队列、总进度、续传与批量建集入口 -->
|
||||
<!-- 剧集批量上传弹窗:先入队待上传,点「开始上传」后再执行 -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -60,6 +60,10 @@ watch(
|
||||
|
||||
const canCreateEpisodes = computed(() => queue.doneItems.value.length > 0)
|
||||
|
||||
const startButtonLabel = computed(() =>
|
||||
queue.isUploading.value ? t('episodeBatch.uploadingAction') : t('episodeBatch.startUpload')
|
||||
)
|
||||
|
||||
function extensionOf(fileName: string): string {
|
||||
const parts = String(fileName || '').split('.')
|
||||
return (parts.pop() || '').toLowerCase()
|
||||
@@ -92,14 +96,19 @@ function onPickFiles(event: Event): void {
|
||||
}
|
||||
rejectedNames.value = rejected
|
||||
if (!accepted.length) return
|
||||
// 只入队,不发起上传;等用户点「开始上传」
|
||||
queue.enqueue(accepted)
|
||||
queue.start()
|
||||
}
|
||||
|
||||
function openFilePicker(): void {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onStartUpload(): void {
|
||||
if (!queue.canStart.value) return
|
||||
queue.start()
|
||||
}
|
||||
|
||||
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
if (!queue.hasActiveUpload.value) return
|
||||
event.preventDefault()
|
||||
@@ -158,7 +167,6 @@ async function onResumeFiles(event: Event): Promise<void> {
|
||||
}
|
||||
if (matched.length) {
|
||||
ElMessage.success(t('episodeBatch.resumeMatched', { count: matched.length }))
|
||||
queue.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,10 +237,13 @@ function onEpisodesCreated(): void {
|
||||
<AppIcon :icon="AppIcons.upload" />
|
||||
{{ t('episodeBatch.selectFiles') }}
|
||||
</el-button>
|
||||
<el-button :disabled="!queue.items.value.length" @click="queue.pause()">
|
||||
<el-button
|
||||
:disabled="!queue.started.value || !queue.items.value.length"
|
||||
@click="queue.pause()"
|
||||
>
|
||||
{{ t('episodeBatch.pause') }}
|
||||
</el-button>
|
||||
<el-button :disabled="!queue.items.value.length" @click="queue.start()">
|
||||
<el-button :disabled="!queue.canStart.value" @click="queue.start()">
|
||||
{{ t('episodeBatch.resume') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -254,11 +265,22 @@ function onEpisodesCreated(): void {
|
||||
<el-progress :percentage="queue.overallPercent.value" :stroke-width="12" />
|
||||
</div>
|
||||
|
||||
<p v-if="queue.showResultSummary.value" class="batch-upload-summary">
|
||||
{{
|
||||
t('episodeBatch.resultSummary', {
|
||||
done: queue.doneItems.value.length,
|
||||
failed: queue.failedCount.value
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="batch-upload-list">
|
||||
<UploadTaskRow
|
||||
v-for="item in queue.items.value"
|
||||
v-for="(item, index) in queue.items.value"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:index="index + 1"
|
||||
:allow-remove="!queue.started.value && item.status === 'waiting'"
|
||||
@retry="queue.retry"
|
||||
@remove="queue.remove"
|
||||
/>
|
||||
@@ -267,7 +289,15 @@ function onEpisodesCreated(): void {
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :disabled="!canCreateEpisodes" @click="openCreateDialog">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!queue.canStart.value"
|
||||
:loading="queue.isUploading.value"
|
||||
@click="onStartUpload"
|
||||
>
|
||||
{{ startButtonLabel }}
|
||||
</el-button>
|
||||
<el-button type="success" :disabled="!canCreateEpisodes" @click="openCreateDialog">
|
||||
{{ t('episodeBatch.createEpisodes') }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -317,6 +347,15 @@ function onEpisodesCreated(): void {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.batch-upload-summary {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--el-color-success) 10%, transparent);
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.batch-upload-list {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<!-- 批量上传队列单行:文件名、大小、进度、状态与操作 -->
|
||||
<!-- 批量上传队列单行:序号、文件名、大小、进度、状态与操作 -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
@@ -8,6 +8,10 @@ import type { UploadQueueItem } from './useUploadQueue'
|
||||
|
||||
const props = defineProps<{
|
||||
item: UploadQueueItem
|
||||
/** 自然排序后的展示序号(从 1 起) */
|
||||
index: number
|
||||
/** 选择阶段才允许移除 */
|
||||
allowRemove: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -47,14 +51,12 @@ const statusType = computed(() => {
|
||||
})
|
||||
|
||||
const canRetry = computed(() => props.item.status === 'failed')
|
||||
const canRemove = computed(
|
||||
() => props.item.status !== 'uploading' && props.item.status !== 'merging'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-task-row" :class="{ 'is-failed': item.status === 'failed' }">
|
||||
<div class="upload-task-row__meta">
|
||||
<span class="upload-task-row__index">{{ index }}</span>
|
||||
<b class="upload-task-row__name" :title="item.fileName">{{ item.fileName }}</b>
|
||||
<small>{{ sizeLabel }}</small>
|
||||
<el-tag :type="statusType" effect="light" size="small">{{ statusLabel }}</el-tag>
|
||||
@@ -71,7 +73,13 @@ const canRemove = computed(
|
||||
<el-button v-if="canRetry" size="small" type="primary" @click="emit('retry', item.id)">
|
||||
{{ t('episodeBatch.retry') }}
|
||||
</el-button>
|
||||
<el-button v-if="canRemove" size="small" type="danger" text @click="emit('remove', item.id)">
|
||||
<el-button
|
||||
v-if="allowRemove"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="emit('remove', item.id)"
|
||||
>
|
||||
{{ t('episodeBatch.remove') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -91,6 +99,14 @@ const canRemove = computed(
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-task-row__index {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upload-task-row__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
/** 剧集批量上传队列:文件并发、进度加权、断点会话、失败隔离 */
|
||||
/** 剧集批量上传队列:手动开始、文件并发、进度加权、断点会话、失败隔离 */
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { getUploadProgressApi } from '@/api/admin/media'
|
||||
@@ -58,6 +58,11 @@ function isHttpNotFound(error: unknown): boolean {
|
||||
return status === 404
|
||||
}
|
||||
|
||||
/** 文件名自然排序(数字感知),保证队列序号稳定。 */
|
||||
function compareFileName(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建上传队列控制器。
|
||||
* @param options dramaId 与可选 bizType
|
||||
@@ -65,6 +70,8 @@ function isHttpNotFound(error: unknown): boolean {
|
||||
export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
const items = ref<UploadQueueItem[]>([])
|
||||
const paused = ref(false)
|
||||
/** 用户是否已点过「开始上传」;选文件阶段不派发、不占用并发。 */
|
||||
const started = ref(false)
|
||||
const bizType = options.bizType || DEFAULT_BIZ_TYPE
|
||||
let running = 0
|
||||
|
||||
@@ -77,18 +84,36 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
return Math.min(MAX_PERCENT, Math.round(weighted / totalBytes))
|
||||
})
|
||||
|
||||
const hasActiveUpload = computed(() =>
|
||||
items.value.some(
|
||||
const isUploading = computed(() =>
|
||||
items.value.some((item) => item.status === 'uploading' || item.status === 'merging')
|
||||
)
|
||||
|
||||
/** 关闭弹窗需二次确认:仅在已开始且仍有未完成任务时。 */
|
||||
const hasActiveUpload = computed(() => {
|
||||
if (!started.value) return false
|
||||
return items.value.some(
|
||||
(item) =>
|
||||
item.status === 'waiting' ||
|
||||
item.status === 'uploading' ||
|
||||
item.status === 'merging' ||
|
||||
item.status === 'paused'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const canStart = computed(() => {
|
||||
if (isUploading.value) return false
|
||||
return items.value.some((item) => item.status === 'waiting' || item.status === 'paused')
|
||||
})
|
||||
|
||||
const doneItems = computed(() => items.value.filter((item) => item.status === 'done'))
|
||||
|
||||
const failedCount = computed(() => items.value.filter((item) => item.status === 'failed').length)
|
||||
|
||||
/** 已开始且队列已无进行中任务时,可展示结果汇总。 */
|
||||
const showResultSummary = computed(
|
||||
() => started.value && items.value.length > 0 && !hasActiveUpload.value
|
||||
)
|
||||
|
||||
function persistSession(item: UploadQueueItem, uploadId: string): void {
|
||||
item.uploadId = uploadId
|
||||
upsertUploadSession({
|
||||
@@ -106,6 +131,10 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
if (uploadId) removeUploadSession(uploadId)
|
||||
}
|
||||
|
||||
function sortItemsByName(): void {
|
||||
items.value.sort((a, b) => compareFileName(a.fileName, b.fileName))
|
||||
}
|
||||
|
||||
async function runOne(item: UploadQueueItem, resumeId?: string): Promise<void> {
|
||||
item.status = 'uploading'
|
||||
item.error = ''
|
||||
@@ -140,7 +169,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
}
|
||||
|
||||
function pump(): void {
|
||||
if (paused.value) return
|
||||
if (!started.value || paused.value) return
|
||||
while (running < FILE_CONCURRENCY && !paused.value) {
|
||||
const next = items.value.find((item) => item.status === 'waiting')
|
||||
if (!next) return
|
||||
@@ -155,9 +184,10 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 入队本地文件(调用方已完成类型/大小校验)。 */
|
||||
/** 入队本地文件(调用方已完成类型/大小校验);只展示待上传,不发起请求。 */
|
||||
function enqueue(files: File[]): void {
|
||||
for (const file of files) {
|
||||
const sorted = [...files].sort((a, b) => compareFileName(a.name, b.name))
|
||||
for (const file of sorted) {
|
||||
items.value.push({
|
||||
id: nextItemId(),
|
||||
file,
|
||||
@@ -165,15 +195,18 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
fileSize: file.size,
|
||||
lastModified: file.lastModified,
|
||||
percent: 0,
|
||||
status: paused.value ? 'paused' : 'waiting',
|
||||
status: 'waiting',
|
||||
error: '',
|
||||
uploadId: ''
|
||||
})
|
||||
}
|
||||
sortItemsByName()
|
||||
}
|
||||
|
||||
/** 开始或继续派发。 */
|
||||
/** 开始或继续派发(含断点续传项上的 uploadId)。 */
|
||||
function start(): void {
|
||||
if (!items.value.length) return
|
||||
started.value = true
|
||||
paused.value = false
|
||||
for (const item of items.value) {
|
||||
if (item.status === 'paused') item.status = 'waiting'
|
||||
@@ -183,6 +216,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
|
||||
/** 暂停:不派新任务;进行中分片跑完。 */
|
||||
function pause(): void {
|
||||
if (!started.value) return
|
||||
paused.value = true
|
||||
for (const item of items.value) {
|
||||
if (item.status === 'waiting') item.status = 'paused'
|
||||
@@ -196,7 +230,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
item.error = ''
|
||||
item.percent = item.uploadId ? item.percent : 0
|
||||
item.status = paused.value ? 'paused' : 'waiting'
|
||||
if (!paused.value) void pump()
|
||||
if (started.value && !paused.value) void pump()
|
||||
}
|
||||
|
||||
/** 重试全部失败项。 */
|
||||
@@ -207,11 +241,12 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
item.status = paused.value ? 'paused' : 'waiting'
|
||||
}
|
||||
}
|
||||
if (!paused.value) void pump()
|
||||
if (started.value && !paused.value) void pump()
|
||||
}
|
||||
|
||||
/** 移除单项并清理会话。 */
|
||||
/** 仅选择阶段可移除;已开始上传后禁止直接移除,避免脏会话。 */
|
||||
function remove(id: string): void {
|
||||
if (started.value) return
|
||||
const index = items.value.findIndex((row) => row.id === id)
|
||||
if (index < 0) return
|
||||
const [removed] = items.value.splice(index, 1)
|
||||
@@ -225,6 +260,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
}
|
||||
items.value = []
|
||||
paused.value = false
|
||||
started.value = false
|
||||
running = 0
|
||||
}
|
||||
|
||||
@@ -234,7 +270,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用本地重选文件匹配会话并入队续传。
|
||||
* 用本地重选文件匹配会话并入队(待上传);不自动开始,由用户点「开始上传」续传。
|
||||
* @returns matched / unmatched 会话列表
|
||||
*/
|
||||
async function resumeFromSessions(
|
||||
@@ -267,7 +303,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
fileSize: file.size,
|
||||
lastModified: file.lastModified,
|
||||
percent: Math.min(MERGING_PERCENT, Number(progress.percent) || 0),
|
||||
status: paused.value ? 'paused' : 'waiting',
|
||||
status: 'waiting',
|
||||
error: '',
|
||||
uploadId: session.uploadId
|
||||
})
|
||||
@@ -280,6 +316,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
sortItemsByName()
|
||||
return { matched, unmatched }
|
||||
}
|
||||
|
||||
@@ -294,7 +331,12 @@ export function useUploadQueue(options: UseUploadQueueOptions) {
|
||||
items,
|
||||
overallPercent,
|
||||
hasActiveUpload,
|
||||
isUploading,
|
||||
canStart,
|
||||
doneItems,
|
||||
failedCount,
|
||||
showResultSummary,
|
||||
started,
|
||||
paused,
|
||||
enqueue,
|
||||
start,
|
||||
|
||||
Reference in new issue
Block a user