Files
PicList/src/renderer/pages/Upload.vue
2026-01-20 13:04:25 +08:00

1376 lines
58 KiB
Vue

<template>
<div class="relative no-scrollbar flex h-full w-full items-center justify-center">
<div
class="relative z-1 no-scrollbar flex h-full w-full flex-col items-center justify-start gap-6 overflow-auto rounded-xl border-none p-8 shadow-sm"
>
<!-- Header Card -->
<div
class="flex w-full flex-wrap items-center justify-between gap-4 rounded-2xl border border-border-secondary px-6 py-4 shadow-md max-md:flex-col max-md:items-stretch max-md:p-5"
>
<div class="flex max-w-[calc(100%-300px)] flex-1 flex-wrap items-center gap-2 max-md:order-1">
<button
class="group/provider flex w-auto min-w-[150px] shrink-0 cursor-pointer items-center gap-3 rounded-lg border border-border-secondary bg-bg-secondary px-4 py-2 font-[inherit] duration-fast ease-standard hover:-translate-y-px hover:border-accent-hover/70 hover:bg-surface hover:shadow-sm focus-visible:focus-ring max-xs:w-full max-xs:min-w-[100px]"
:title="t('pages.upload.uploadViewHint')"
@click="handlePicBedNameClick(picBedName)"
>
<div class="flex flex-1 flex-col items-start">
<span class="text-sm leading-[1.2] font-semibold text-main">{{ picBedName }}</span>
<span class="text-xs leading-[1.2] text-secondary">{{ defaultConfigNameG || 'Default' }}</span>
</div>
<EditIcon
:size="16"
class="text-secondary duration-fast ease-standard group-hover/provider:text-accent-hover"
/>
</button>
<div
class="flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-lg border border-border bg-surface font-[inherit] text-secondary duration-fast ease-standard hover:-translate-y-px hover:border-accent-hover hover:text-accent-hover data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50"
:title="t('pages.upload.addToFavorites')"
:data-disabled="favoritePicbeds.length >= MAX_FAVORITE_PICBEDS || isCurrentPicBedInFavorites"
@click="addCurrentPicbedToFavorites"
>
<component
:is="isCurrentPicBedInFavorites ? CheckIcon : PlusIcon"
:size="14"
class="duration-fast ease-standard"
/>
</div>
<transition-group
name="badges-slide"
tag="div"
class="flex max-w-[calc(100%-300px)] flex-wrap items-center gap-[0.2rem] [.has-many]:max-w-[300px]"
:class="{ 'has-many': favoritePicbeds.length >= 4 }"
>
<button
v-for="picbedType in favoritePicbeds"
:key="picbedType.id"
class="group/badge relative flex w-[85px] shrink-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md border border-border-secondary bg-bg-secondary pt-1.5 pr-2 pb-1.5 pl-3 text-xs font-medium whitespace-nowrap text-secondary transition-all duration-fast ease-standard select-none hover:-translate-y-px hover:border-accent-hover hover:bg-bg-tertiary hover:text-accent-hover [.is-active]:border-[0.1rem] [.is-active]:border-accent-hover [.is-active]:font-semibold [.show-delete]:pr-2"
:class="{ 'is-active': isCurrentPicbed(picbedType), 'show-delete': longPressedBadge === picbedType.id }"
:title="t('pages.upload.longPressToRemoveFromFavorites') + getPicbedName(picbedType)"
@click="handleBadgeClick(picbedType)"
@mousedown="handleBadgeMouseDown(picbedType)"
@mouseup="handleBadgeMouseUp"
@mouseleave="handleBadgeMouseUp"
@touchstart="handleBadgeTouchStart(picbedType, $event)"
@touchend="handleBadgeTouchEnd"
@touchcancel="handleBadgeTouchEnd"
>
<div class="min-w-0 flex-1 overflow-hidden">
<div
class="flex overflow-hidden text-ellipsis whitespace-nowrap group-hover/badge:w-fit group-hover/badge:animate-[badge-scroll_5s_linear_infinite] group-hover/badge:text-clip"
>
<span class="leading-none whitespace-nowrap group-hover/badge:pr-[20px]">{{
getPicbedName(picbedType)
}}</span>
<span class="hidden leading-none whitespace-nowrap group-hover/badge:block">{{
getPicbedName(picbedType)
}}</span>
</div>
</div>
<button
v-if="longPressedBadge === picbedType.id"
class="flex shrink-0 animate-[fade-in_0.2s_ease-in] cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0.5 text-inherit duration-fast ease-standard hover:bg-danger/20 hover:text-danger"
:title="t('pages.upload.removeFromFavorites')"
@click.stop="removePicbedFromFavorites(picbedType)"
>
<XIcon :size="12" />
</button>
</button>
</transition-group>
</div>
<div class="flex flex-wrap items-center gap-3 max-md:order-2 max-md:justify-stretch">
<div class="inline-flex overflow-hidden rounded-md border border-border-secondary bg-bg-secondary">
<button
class="segmented-button"
:title="t('pages.upload.imageProcessNameSingle')"
@click="handleImageProcessSingle"
>
<Settings :size="16" />
<span class="mt-1">{{ t('pages.upload.imageProcessNameSingle') }}</span>
</button>
<button class="segmented-button" :title="t('pages.upload.imageProcessName')" @click="handleImageProcess">
<span class="mt-1">{{ t('pages.upload.imageProcessName') }}</span>
</button>
</div>
<button
class="flex cursor-pointer items-center gap-2 rounded-md border-none bg-accent px-4 py-2.5 font-[inherit] text-sm font-medium text-white duration-fast ease-standard hover:-translate-y-px hover:bg-accent-hover hover:shadow-md focus-visible:focus-ring max-md:flex-1 max-md:justify-center max-xs:px-3 max-xs:py-2 max-xs:text-[0.8rem]"
@click="handleChangePicBed"
>
<ArrowLeftRightIcon :size="16" />
<span>{{ t('pages.upload.changePicBed') }}</span>
</button>
</div>
</div>
<!-- Main Upload Card -->
<div
class="flex min-h-[230px] w-full flex-1 flex-wrap items-center justify-center gap-4 rounded-2xl border border-border-secondary px-6 py-4 shadow-md max-md:flex-col max-md:items-stretch max-md:p-5"
>
<div
id="upload-area"
class="group/upload relative flex h-full w-full cursor-pointer items-center justify-center rounded-xl border-2 border-dashed border-border bg-bg-secondary px-1 py-12 duration-medium ease-standard focus-visible:focus-ring focus-visible:outline-offset-4 max-md:px-4 max-md:py-8 max-xs:px-2 max-xs:py-6 [:hover,.drag-active]:border-accent [:hover,.drag-active]:bg-[linear-gradient(135deg,var(--color-surface-elevated)_0%,color-mix(in_srgb,var(--color-accent),transparent_95%)_100%)] [:hover,.drag-active]:shadow-lg [:hover,.drag-active&]:-translate-y-[2px]"
:class="{ 'drag-active': dragover }"
@drop.prevent="onDrop"
@dragover.prevent="dragover = true"
@dragleave.prevent="dragover = false"
@click="openUploadWindow"
>
<div class="flex flex-col items-center justify-center gap-6 text-center">
<div
class="flex h-[80px] w-[80px] items-center justify-center rounded-full bg-accent text-white duration-medium ease-standard group-[:hover,.drag-active]/upload:animate-[float_1.5s_ease-in-out_infinite] max-md:h-[60px] max-md:w-[60px]"
>
<UploadCloudIcon :size="48" />
</div>
<div class="flex flex-col gap-2">
<h3 class="m-0 text-xl font-semibold tracking-tight text-main max-xs:text-lg">
{{ t('pages.upload.dragFileToHere') }}
</h3>
<p class="m-0 text-sm text-secondary">
{{ ' ' }}
</p>
<div class="mt-2 flex flex-col gap-1">
<span class="text-xs font-medium tracking-wide text-secondary uppercase">{{
t('pages.upload.uploadHint')
}}</span>
</div>
</div>
</div>
<input id="file-uploader" ref="fileInput" type="file" multiple class="hidden" @change="onChange" />
</div>
<!-- Progress Bar -->
</div>
<div
v-if="showProgress"
class="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-border-secondary p-0 shadow-md"
>
<div class="mx-2 my-2 w-full rounded-lg border border-border bg-surface p-4">
<div class="mb-2 h-3 overflow-hidden rounded-lg bg-bg-secondary">
<div
class="h-full rounded-lg bg-[linear-gradient(90deg,var(--color-accent)_0%,var(--color-primary)_50%)] duration-fast ease-standard data-[error=true]:bg-danger data-[error=true]:bg-none"
:data-error="showError"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="m-0 flex items-center justify-center text-center text-sm font-semibold text-secondary">
{{ showError ? t('pages.upload.uploadFailed') : `${progress}%` }}
</span>
</div>
</div>
<!-- Quick Actions Card -->
<div
class="flex w-full flex-col flex-wrap items-center justify-between gap-2 rounded-2xl border border-border-secondary px-6 py-4 shadow-md max-md:items-stretch max-md:p-5"
>
<div class="flex w-full items-start p-0">
<h4 class="m-0 text-[0.9rem] font-semibold tracking-tight text-main">
{{ t('pages.upload.quickUpload') }}
</h4>
</div>
<div class="flex w-full flex-1 flex-row flex-wrap items-center justify-center gap-4 max-md:gap-3 max-md:px-5">
<button class="quick-action-button" @click="uploadClipboardFiles">
<ClipboardIcon class="shrink-0 text-accent" :size="15" />
<span class="mt-1 text-sm font-medium text-secondary">{{ t('pages.upload.clipboardPicture') }}</span>
</button>
<button class="quick-action-button" @click="uploadURLFiles">
<LinkIcon class="shrink-0 text-accent" :size="15" />
<span class="mt-1 text-sm font-medium text-secondary">{{ t('pages.upload.urlUpload') }}</span>
</button>
<button
class="quick-action-button"
:class="{ 'has-badge': taskQueueStatus.tasks.length > 0 }"
@click="openTaskDialog"
>
<ListTodoIcon class="shrink-0 text-accent" :size="15" />
<span class="mt-1 text-sm font-medium text-secondary">{{ t('pages.upload.taskUpload') }}</span>
<span
v-if="taskQueueStatus.tasks.length > 0"
class="absolute top-[0.5] right-3 flex min-w-6 animate-[badge-pulse_2s_ease-in-out_infinite] items-center justify-center rounded-full border border-border-secondary px-1.5 py-0 text-sm font-bold text-accent"
>
{{ taskQueueStatus.tasks.length }}
</span>
</button>
</div>
</div>
<!-- Settings Card -->
<div
class="flex w-full flex-row flex-wrap items-center justify-between gap-0 rounded-2xl border border-border-secondary px-6 py-4 shadow-md max-md:flex-col max-md:items-stretch max-md:p-5"
>
<div class="flex w-full items-start p-0">
<h4 class="m-0 text-[0.9rem] font-semibold tracking-tight text-main">
{{ t('pages.upload.linkFormat') }}
</h4>
</div>
<div class="flex w-full flex-row gap-2 p-2">
<!-- Format Options -->
<div class="flex flex-1 flex-col gap-3">
<label class="m-0 text-xs font-medium text-secondary">{{ t('pages.upload.outputFormat') }}</label>
<div class="flex flex-row">
<button
v-for="(format, key) in pasteFormatList"
:key="key"
class="flex-1 cursor-pointer rounded-md border border-border-secondary bg-bg-secondary px-1 py-1 font-['SF_Mono',Monaco,'Cascadia_Code','Roboto_Mono',Consolas,'Courier_New',monospace] text-[0.7rem] font-medium text-secondary duration-fast ease-standard hover:border-accent-hover hover:text-main focus-visible:focus-ring data-[active=true]:border-accent data-[active=true]:bg-accent data-[active=true]:text-white"
:data-active="pasteStyle === key"
:title="format"
@click="updatePasteStyle(key)"
>
{{ key }}
</button>
</div>
</div>
<!-- URL Length Options -->
<div class="flex flex-1 flex-col gap-3">
<label class="m-0 text-xs font-medium text-secondary">{{ t('pages.upload.urlType.title') }}</label>
<div class="flex w-full overflow-hidden rounded-md border border-border-secondary bg-bg-secondary">
<button
class="flex-1 cursor-pointer border-0 bg-transparent py-1 font-[inherit] text-xs font-medium text-secondary duration-fast ease-standard hover:text-main focus-visible:focus-ring data-[active=true]:bg-accent data-[active=true]:text-white"
:data-active="!useShortUrl"
@click="updateUrlType(false)"
>
<span>{{ t('pages.upload.urlType.normal') }}</span>
</button>
<button
class="flex-1 cursor-pointer border-0 bg-transparent py-1 font-[inherit] text-xs font-medium text-secondary duration-fast ease-standard hover:text-main focus-visible:focus-ring data-[active=true]:bg-accent data-[active=true]:text-white"
:data-active="useShortUrl"
@click="updateUrlType(true)"
>
<span>{{ t('pages.upload.urlType.short') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Image Process Dialog -->
<transition name="modal">
<div
v-if="imageProcessDialogVisible"
class="fixed inset-0 z-1000 flex items-center justify-center overflow-y-auto bg-black/50 p-4 max-md:p-4"
:class="{ 'advanced-animation': enableAdvancedAnimation }"
@click.stop
>
<div
class="m-auto h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] overflow-hidden rounded-2xl border border-border-secondary bg-bg-tertiary shadow-xl"
@click.stop
>
<div
class="flex items-center justify-between border border-border-secondary bg-bg-tertiary px-5 py-4 max-md:p-2"
>
<h3 class="m-0 text-xl font-semibold text-main">
{{ t('pages.imageProcess.title') }}
</h3>
<span class="mt-1 text-xl font-semibold text-secondary">
{{
PicBedId === '' ? t('pages.imageProcess.subtitle-Global') : t('pages.imageProcess.subtitle-PerPicbed')
}}
</span>
<button
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-border bg-surface-elevated text-secondary transition-all duration-fast ease-apple hover:scale-105 hover:border-danger hover:bg-danger hover:text-white focus-visible:focus-ring"
@click="imageProcessDialogVisible = false"
>
<XIcon :size="20" />
</button>
</div>
<div class="no-scrollbar max-h-[calc(90vh-90px)] overflow-y-auto max-md:p-4">
<ImageProcessSetting :config-id="PicBedId" :current-picbed-name="defaultPicBedG" />
</div>
</div>
</div>
</transition>
<!-- Task Queue Manager Modal -->
<transition name="modal">
<div
v-if="taskDialogVisible"
class="fixed inset-0 z-1000 flex items-center justify-center overflow-y-auto bg-black/50 p-4 max-md:p-4"
:class="{ 'advanced-animation': enableAdvancedAnimation }"
>
<div
class="m-auto flex h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] flex-col overflow-hidden rounded-2xl border border-border-secondary bg-bg-tertiary shadow-xl max-md:max-h-[90vh] max-md:w-[95vw]"
@click.stop
>
<div class="flex items-center justify-between border border-border-secondary bg-bg-tertiary px-5 py-4">
<div class="flex flex-row items-center gap-4">
<h3 class="flex items-center gap-2.5 bg-clip-text text-xl font-bold tracking-tight text-main">
{{ t('pages.upload.taskQueue.title') }}
</h3>
<span class="m-0 text-lg font-semibold text-secondary">
{{
t('pages.upload.taskQueue.stats', {
completed: taskQueueStatus.stats.completed,
total: taskQueueStatus.stats.total,
})
}}
</span>
</div>
<button
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-border bg-surface-elevated text-secondary transition-all duration-fast ease-apple hover:scale-105 hover:border-danger hover:bg-danger hover:text-white focus-visible:focus-ring"
@click="taskDialogVisible = false"
>
<XIcon :size="20" />
</button>
</div>
<div class="no-scrollbar max-h-[calc(90vh-90px)] overflow-y-auto">
<!-- Action Bar -->
<div
class="flex flex-wrap items-center justify-between gap-4 border-b border-b-border px-5 py-4 max-md:flex-col max-md:items-stretch"
>
<div class="flex flex-wrap items-center gap-2.5 max-md:w-full max-md:justify-center">
<button
v-show="taskQueueStatus.tasks.length > 0"
class="flex cursor-pointer items-center justify-center gap-2 rounded-md bg-accent px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="addFilesToTask"
>
<PlusIcon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.addFiles') }}</span>
</button>
<button
v-if="!taskQueueStatus.config.isRunning && taskQueueStatus.stats.pending > 0"
class="flex cursor-pointer items-center gap-2 rounded-md bg-success px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="startTaskQueue"
>
<PlayIcon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.start') }}</span>
</button>
<button
v-if="taskQueueStatus.config.isRunning && !taskQueueStatus.config.isPaused"
class="flex cursor-pointer items-center gap-2 rounded-md bg-warning px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="pauseTaskQueue"
>
<PauseIcon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.pause') }}</span>
</button>
<button
v-if="taskQueueStatus.config.isPaused"
class="flex cursor-pointer items-center gap-2 rounded-md bg-success px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="resumeTaskQueue"
>
<PlayIcon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.resume') }}</span>
</button>
</div>
<div class="flex flex-wrap items-center gap-2.5 max-md:w-full max-md:justify-center">
<button
v-if="taskQueueStatus.stats.failed > 0"
class="flex cursor-pointer items-center gap-2 rounded-md bg-warning px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="retryAllFailedTasks"
>
<RefreshCwIcon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.retryAllFailed') }}</span>
</button>
<button
v-if="taskQueueStatus.config.isRunning || taskQueueStatus.stats.pending > 0"
class="flex cursor-pointer items-center gap-2 rounded-md bg-danger px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="cancelAllTasks"
>
<XIcon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.cancelAll') }}</span>
</button>
<button
v-if="
taskQueueStatus.stats.completed > 0 ||
taskQueueStatus.stats.failed > 0 ||
taskQueueStatus.stats.cancelled > 0
"
class="flex cursor-pointer items-center gap-2 rounded-md bg-danger px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="clearFinishedTasks"
>
<Trash2Icon :size="16" />
<span class="mt-1">{{ t('pages.upload.taskQueue.clearFinished') }}</span>
</button>
<button
class="flex cursor-pointer items-center gap-2 rounded-md bg-accent px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
:class="{ active: showTaskSettings }"
@click="showTaskSettings = !showTaskSettings"
>
<SettingsIcon :size="16" />
</button>
</div>
</div>
<!-- Overall Progress -->
<div v-if="taskQueueStatus.stats.total > 0" class="border-b border-b-border p-5">
<div class="mb-3.5 flex items-center justify-between">
<span class="text-sm font-semibold text-main">{{ t('pages.upload.taskQueue.overallProgress') }}</span>
<span class="text-xl leading-1 font-bold text-accent">{{ overallProgressPercent }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-surface-elevated">
<div
class="h-full bg-[linear-gradient(90deg,var(--color-accent)_0%,var(--color-primary)_50%)] shadow-sm transition-[width] duration-medium ease-standard"
:style="{ width: `${overallProgressPercent}%` }"
/>
</div>
<div class="mt-2 flex flex-wrap justify-between gap-4">
<span
v-if="taskQueueStatus.stats.avgSpeed > 0"
class="flex items-center gap-2 py-1.5 text-xs text-secondary"
>
<ZapIcon :size="14" class="text-accent" />
{{ formatSpeed(taskQueueStatus.stats.avgSpeed) }}
</span>
<span
v-if="taskQueueStatus.stats.estimatedTimeMs > 0 && taskQueueStatus.config.isRunning"
class="flex items-center gap-2 py-1.5 text-xs text-secondary"
>
<ClockIcon :size="14" class="text-accent" />
{{ formatTime(taskQueueStatus.stats.estimatedTimeMs) }}
</span>
<span class="flex items-center gap-2 py-1.5 text-xs text-secondary">
<HardDriveIcon :size="14" class="text-accent" />
{{ formatSize(taskQueueStatus.stats.completedSize) }} /
{{ formatSize(taskQueueStatus.stats.totalSize) }}
</span>
</div>
</div>
<!-- Settings Panel -->
<transition name="settings-slide">
<div v-if="showTaskSettings" class="overflow-visible border-b border-b-border p-4">
<div class="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] items-center gap-4 max-md:grid-cols-1">
<div class="flex min-w-0 flex-col gap-2">
<label class="m-0 flex items-center gap-2 text-sm font-medium text-main">
{{ t('pages.upload.taskQueue.interval') }}
</label>
<div class="flex items-center gap-2.5 max-sm:flex-row">
<input
v-model.number="uploadInterval"
type="number"
min="1"
max="99999"
step="1"
class="box-border w-full flex-1 rounded-md bg-surface-elevated px-3 py-2 text-sm text-main transition-all duration-fast ease-standard hover:border-accent hover:bg-surface focus:border-accent focus:bg-white focus:shadow-md focus:outline-0 disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-60"
:disabled="taskQueueStatus.config.isRunning"
@change="updateInterval"
/>
<span class="bg-transparent px-1 py-2 text-sm font-semibold text-secondary">s</span>
</div>
</div>
<div class="flex min-w-0 flex-col gap-2">
<label class="m-0 flex items-center gap-2 text-sm font-medium text-main">{{
t('pages.upload.taskQueue.maxRetry')
}}</label>
<input
v-model.number="maxRetryCount"
type="number"
min="0"
max="10"
step="1"
class="box-border w-full rounded-md bg-surface-elevated px-3 py-2 text-sm text-main transition-all duration-fast ease-standard hover:border-accent hover:bg-surface focus:border-accent focus:bg-white focus:shadow-md focus:outline-0 disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-60"
@change="updateSettings"
/>
</div>
<div
class="flex min-h-[40px] min-w-0 flex-row items-center justify-between gap-2 rounded-md bg-transparent px-3 py-2.5 transition-all duration-fast ease-standard hover:bg-surface-elevated"
>
<label class="m-0 flex items-center gap-2 text-base font-semibold text-main" for="task-auto-start">
{{ t('pages.upload.taskQueue.autoStart') }}
</label>
<input
id="task-auto-start"
v-model="autoStart"
type="checkbox"
class="h-[16px] w-[16px] cursor-pointer accent-accent"
@change="updateSettings"
/>
</div>
<div
class="flex min-h-[40px] min-w-0 flex-row items-center justify-between gap-2 rounded-md bg-transparent px-3 py-2.5 transition-all duration-fast ease-standard hover:bg-surface-elevated"
>
<label
class="m-0 flex items-center gap-2 text-base font-semibold text-main"
for="task-pause-on-error"
>
{{ t('pages.upload.taskQueue.pauseOnError') }}
</label>
<input
id="task-pause-on-error"
v-model="pauseOnError"
type="checkbox"
class="h-[16px] w-[16px] cursor-pointer accent-accent"
@change="updateSettings"
/>
</div>
</div>
</div>
</transition>
<!-- Filter & Search Bar -->
<div v-if="taskQueueStatus.tasks.length > 0" class="flex flex-col gap-2 border-b border-b-border p-5">
<div
class="flex items-center gap-2.5 rounded-lg border border-border-secondary bg-bg-secondary px-4 py-2.5 shadow-sm transition-all duration-fast ease-standard focus-within:border-accent focus-within:bg-white focus-within:shadow-md"
>
<SearchIcon :size="16" class="shrink-0 text-accent" />
<input
v-model="taskSearchQuery"
type="text"
class="flex border-0 bg-transparent text-sm text-main outline-0 placeholder:text-tertiary max-sm:max-w-none"
:placeholder="t('pages.upload.taskQueue.searchPlaceholder')"
/>
</div>
<div class="flex flex-wrap gap-2.5">
<button class="filter-tab" :class="{ active: taskFilter === 'all' }" @click="taskFilter = 'all'">
{{ t('pages.upload.taskQueue.filterAll') }}
</button>
<button
class="filter-tab"
:class="{ active: taskFilter === 'pending' }"
@click="taskFilter = 'pending'"
>
{{ t('pages.upload.taskQueue.filterPending') }}
</button>
<button
class="filter-tab"
:class="{ active: taskFilter === 'completed' }"
@click="taskFilter = 'completed'"
>
{{ t('pages.upload.taskQueue.filterCompleted') }}
</button>
<button class="filter-tab" :class="{ active: taskFilter === 'failed' }" @click="taskFilter = 'failed'">
{{ t('pages.upload.taskQueue.filterFailed') }}
</button>
</div>
</div>
<!-- Task List -->
<div v-if="taskQueueStatus.tasks.length > 0" class="min-h-[300px] flex-1 overflow-y-auto">
<TransitionGroup name="task" tag="div" class="flex flex-col">
<div
v-for="task in filteredTasks"
:key="task.id"
class="group/tasklist relative flex items-center justify-between gap-2 border-b border-b-border bg-surface px-5 py-4 transition-all duration-fast ease-standard last:border-b-0 hover:bg-surface-elevated hover:shadow-sm max-md:flex-col max-md:items-start max-md:gap-3"
:class="getTaskStatusClass(task.status)"
>
<div class="flex min-w-0 flex-1 flex-col gap-2.5">
<div class="flex items-center justify-between gap-3.5">
<div class="flex min-w-0 flex-1 items-center gap-2.5">
<span
class="overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-main group-[.status-cancelled]/tasklist:text-tertiary group-[.status-cancelled]/tasklist:line-through group-[.status-completed]/tasklist:text-success group-[.status-failed]/tasklist:text-danger"
:title="task.filePath"
>{{ task.fileName }}</span
>
<span
v-if="task.priority === 2"
class="flex shrink-0 items-center justify-center rounded-full bg-warning p-1 text-white"
>
<StarIcon :size="13" />
</span>
</div>
<div
class="rounded-full px-2.5 py-1 text-xs font-semibold tracking-wider whitespace-nowrap uppercase [.status-cancelled]:bg-tertiary/15 [.status-cancelled]:text-tertiary [.status-cancelled]:line-through [.status-completed]:bg-success/15 [.status-completed]:text-success [.status-failed]:bg-danger/15 [.status-failed]:text-danger [.status-pending]:bg-accent/15 [.status-pending]:text-secondary [.status-uploading]:bg-primary/15 [.status-uploading]:text-primary"
:class="getTaskStatusClass(task.status)"
>
{{ getTaskStatusText(task.status) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<span v-if="task.fileSize > 0" class="flex items-center gap-1 text-[0.75rem] text-tertiary">
<HardDriveIcon :size="12" class="text-secondary" />
{{ formatSize(task.fileSize) }}
</span>
<span
v-if="task.uploadSpeed && task.status === 'uploading'"
class="flex items-center gap-1 text-[0.75rem] text-tertiary"
>
<ZapIcon :size="12" />
{{ formatSpeed(task.uploadSpeed) }}
</span>
<span v-if="task.retryCount > 0" class="flex items-center gap-1 text-[0.75rem] text-warning">
{{ t('pages.upload.taskQueue.retryCount', { count: task.retryCount }) }}
</span>
<span
v-if="task.error"
class="flex max-w-[200px] items-center gap-1 overflow-hidden text-[0.75rem] text-ellipsis whitespace-nowrap text-danger"
:title="task.error"
>
{{ task.error }}
</span>
</div>
</div>
<div class="flex items-center gap-1.5">
<!-- Pending task actions -->
<template v-if="task.status === 'pending'">
<button
class="task-icon-btn"
:title="t('pages.upload.taskQueue.moveUp')"
@click="moveTaskUp(task.id)"
>
<ChevronUpIcon :size="16" />
</button>
<button
class="task-icon-btn"
:title="t('pages.upload.taskQueue.moveDown')"
@click="moveTaskDown(task.id)"
>
<ChevronDownIcon :size="16" />
</button>
<button
class="task-icon-btn"
:class="{ 'is-high': task.priority === 2 }"
:title="t('pages.upload.taskQueue.togglePriority')"
@click="toggleTaskPriority(task.id, task.priority)"
>
<StarIcon :size="16" />
</button>
<button
class="task-icon-btn danger"
:title="t('pages.upload.taskQueue.cancelTask')"
@click="cancelTask(task.id)"
>
<XIcon :size="16" />
</button>
</template>
<!-- Failed task actions -->
<template v-if="task.status === 'failed'">
<button
class="task-icon-btn"
:title="t('pages.upload.taskQueue.retryTask')"
@click="retryTask(task.id)"
>
<RefreshCwIcon :size="16" />
</button>
<button
class="task-icon-btn danger"
:title="t('pages.upload.taskQueue.removeTask')"
@click="removeTask(task.id)"
>
<Trash2Icon :size="16" />
</button>
</template>
<!-- Completed/Cancelled task actions -->
<template v-if="task.status === 'completed' || task.status === 'cancelled'">
<button
class="task-icon-btn"
:title="t('pages.upload.taskQueue.removeTask')"
@click="removeTask(task.id)"
>
<Trash2Icon :size="16" />
</button>
</template>
<!-- Status icon -->
<div class="flex h-[32px] w-[32px] items-center justify-center">
<CheckCircleIcon v-if="task.status === 'completed'" :size="18" class="text-success" />
<XCircleIcon v-if="task.status === 'failed'" :size="18" class="text-error" />
<LoaderIcon
v-if="task.status === 'uploading'"
:size="18"
class="animate-[spin_1s_linear_infinite] text-accent"
/>
<ClockIcon v-if="task.status === 'pending'" :size="18" class="text-tertiary" />
</div>
</div>
</div>
</TransitionGroup>
</div>
<!-- Empty State -->
<div v-else class="flex h-full flex-col items-center gap-4 bg-bg-tertiary px-8 py-12 text-center">
<ListTodoIcon class="text-accent opacity-90" :size="48" />
<h4 class="m-0 text-xl font-semibold text-main">{{ t('pages.upload.taskQueue.empty') }}</h4>
<p class="m-0 max-w-[400px] text-base text-secondary">{{ t('pages.upload.taskQueue.emptyHint') }}</p>
<button
class="flex cursor-pointer items-center justify-center gap-2 rounded-md bg-accent px-4 py-2.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-fast ease-standard hover:-translate-y-[2px] hover:shadow-md"
@click="addFilesToTask"
>
<PlusIcon :size="16" />
<span class="mt-0.5">{{ t('pages.upload.taskQueue.selectFiles') }}</span>
</button>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import { useStorage } from '@vueuse/core'
import {
ArrowLeftRightIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
ClipboardIcon,
ClockIcon,
EditIcon,
HardDriveIcon,
LinkIcon,
ListTodoIcon,
LoaderIcon,
PauseIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Settings,
SettingsIcon,
StarIcon,
Trash2Icon,
UploadCloudIcon,
XCircleIcon,
XIcon,
ZapIcon,
} from 'lucide-vue-next'
import { computed, onBeforeMount, onBeforeUnmount, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import ImageProcessSetting from '@/components/ImageProcessSetting.vue'
import { usePicBed } from '@/hooks/useGlobal'
import useMessage from '@/hooks/useMessage'
import { PICBEDS_PAGE } from '@/router/config'
import $bus from '@/utils/bus'
import { isUrl } from '@/utils/common'
import { configPaths } from '@/utils/configPaths'
import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { useDragEventListeners } from '@/utils/drag'
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
// Task queue types
interface IUploadTaskItem {
id: string
fileName: string
filePath: string
fileSize: number
status: string
progress: number
error?: string
result?: any
createdAt: number
startedAt?: number
completedAt?: number
retryCount: number
priority: number
uploadSpeed?: number
uploadDuration?: number
}
interface IUploadTaskQueueStatus {
tasks: IUploadTaskItem[]
config: {
intervalS: number
isRunning: boolean
isPaused: boolean
autoStart: boolean
pauseOnError: boolean
maxRetryCount: number
}
stats: {
total: number
pending: number
completed: number
failed: number
cancelled: number
uploading: number
totalSize: number
completedSize: number
avgSpeed: number
estimatedTimeMs: number
}
}
useDragEventListeners()
const $router = useRouter()
const { t } = useI18n()
const message = useMessage()
const { picBedG, defaultPicBedG, defaultConfigNameG, defaultIdG, updatePicBeds } = usePicBed()
const imageProcessDialogVisible = ref(false)
const taskDialogVisible = ref(false)
const useShortUrl = ref(false)
const dragover = ref(false)
const progress = ref(0)
const showProgress = ref(false)
const showError = ref(false)
const pasteStyle = ref(IPasteStyle.MARKDOWN)
const enableAdvancedAnimation = ref(false)
const PicBedId = ref('')
const fileInput = useTemplateRef('fileInput')
const uploadInterval = ref(1000)
const favoritePicbeds = useStorage<IFavoritePicbedItem[]>('favorite-picbeds', [])
const MAX_FAVORITE_PICBEDS = 6
const longPressedBadge = ref<string | null>(null)
let longPressTimer: NodeJS.Timeout | null = null
const LONG_PRESS_DURATION = 500
const isCurrentPicBedInFavorites = computed(() => {
const result = favoritePicbeds.value.some(item => item.id === defaultIdG.value)
return result
})
// New task queue settings
const showTaskSettings = useStorage('upload-task-queue-show-settings', true)
const taskSearchQuery = ref('')
const taskFilter = ref<'all' | 'pending' | 'completed' | 'failed'>('all')
const autoStart = ref(false)
const pauseOnError = ref(false)
const maxRetryCount = ref(3)
// Task queue status
const taskQueueStatus = reactive<IUploadTaskQueueStatus>({
tasks: [],
config: {
intervalS: 1,
isRunning: false,
isPaused: false,
autoStart: false,
pauseOnError: false,
maxRetryCount: 3,
},
stats: {
total: 0,
pending: 0,
completed: 0,
failed: 0,
cancelled: 0,
uploading: 0,
totalSize: 0,
completedSize: 0,
avgSpeed: 0,
estimatedTimeMs: 0,
},
})
// Computed properties
const filteredTasks = computed(() => {
let tasks = taskQueueStatus.tasks
if (taskFilter.value !== 'all') {
tasks = tasks.filter(t => t.status === taskFilter.value)
}
if (taskSearchQuery.value) {
const query = taskSearchQuery.value.toLowerCase()
tasks = tasks.filter(t => t.fileName.toLowerCase().includes(query))
}
return tasks
})
const overallProgressPercent = computed(() => {
if (taskQueueStatus.stats.total === 0) return 0
const completed = taskQueueStatus.stats.completed
const total = taskQueueStatus.stats.total - taskQueueStatus.stats.cancelled
return total > 0 ? Math.round((completed / total) * 100) : 0
})
const picBedName = computed(() => {
if (!picBedG.value || picBedG.value.length === 0) {
return ''
}
const target = picBedG.value.find(item => item.type === defaultPicBedG.value)
return target ? target.name : defaultPicBedG.value
})
const pasteFormatList = ref<Record<string, string>>({
[IPasteStyle.MARKDOWN]: '![alt](url)',
[IPasteStyle.HTML]: '<img src="url"/>',
[IPasteStyle.URL]: 'http://test.com/test.png',
[IPasteStyle.UBB]: '[img]url[/img]',
[IPasteStyle.CUSTOM]: '',
})
function syncPicBedHandler(): void {
updatePicBeds()
}
let removeUploadProgressListenerCallback: () => void = () => {}
let removeSyncPicBedListenerCallback: () => void = () => {}
function uploadProgressHandler(p: number): void {
if (p !== -1) {
showProgress.value = true
progress.value = p
} else {
progress.value = 100
showError.value = true
}
}
const handleImageProcess = () => {
PicBedId.value = ''
imageProcessDialogVisible.value = true
}
const handleImageProcessSingle = () => {
PicBedId.value = defaultIdG.value
imageProcessDialogVisible.value = true
}
watch(progress, onProgressChange)
function onProgressChange(val: number) {
if (val === 100) {
setTimeout(() => {
showProgress.value = false
showError.value = false
}, 1000)
setTimeout(() => {
progress.value = 0
}, 1200)
}
}
async function handlePicBedNameClick(_picBedName: string) {
const currentPicBedConfig = ((await getConfig<any[]>(`uploader.${defaultPicBedG.value}`)) as any) || {}
$router.push({
name: PICBEDS_PAGE,
params: {
type: defaultPicBedG.value,
configId: defaultIdG.value,
},
query: {
defaultConfigId: currentPicBedConfig.defaultId || '',
},
})
}
function onDrop(e: DragEvent) {
dragover.value = false
// send files first
if (e.dataTransfer?.files?.length) {
ipcSendFiles(e.dataTransfer.files)
} else if (e.dataTransfer?.items) {
const items = e.dataTransfer.items
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer.getData(items[0].type)
if (isUrl(str)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }])
} else {
message.error(t('pages.upload.dragValidPictureOrUrl'))
}
}
}
}
function handleURLDrag(items: DataTransferItemList, dataTransfer: DataTransfer) {
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: urlMatch[1],
},
])
} else {
message.error(t('pages.upload.dragValidPictureOrUrl'))
}
}
function openUploadWindow() {
fileInput.value?.click()
}
function onChange(e: any) {
ipcSendFiles(e.target.files)
;(fileInput.value as HTMLInputElement).value = ''
}
function ipcSendFiles(files: FileList) {
const sendFiles: IFileWithPath[] = []
Array.from(files).forEach(item => {
const obj = {
name: item.name,
path: window.electron.showFilePath(item),
}
sendFiles.push(obj)
})
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, sendFiles)
}
async function initConf() {
const settingConfig = await getConfig<any>('settings')
enableAdvancedAnimation.value = settingConfig?.enableAdvancedAnimation || false
pasteStyle.value = settingConfig?.pasteStyle || IPasteStyle.MARKDOWN
pasteFormatList.value.Custom = settingConfig?.customLink || '![$fileName]($url)'
useShortUrl.value = settingConfig?.useShortUrl || false
}
function updatePasteStyle(style: string) {
pasteStyle.value = style
saveConfig({
[configPaths.settings.pasteStyle]: style || IPasteStyle.MARKDOWN,
})
}
function updateUrlType(shortUrl: boolean) {
useShortUrl.value = shortUrl
saveConfig({
[configPaths.settings.useShortUrl]: shortUrl,
})
}
async function valideFavoritePicbeds() {
if (!favoritePicbeds.value.length) return
const allUploaders = (await getConfig<IStringKeyMap>(configPaths.uploader)) || {}
const availableFavorites = favoritePicbeds.value.filter(item => {
return (
Object.keys(allUploaders).includes(item.type) &&
allUploaders[item.type]?.configList.some((cfg: any) => cfg._id === item.id && cfg._configName === item.configName)
)
})
if (JSON.stringify(availableFavorites) !== JSON.stringify(favoritePicbeds.value)) {
favoritePicbeds.value = availableFavorites
}
}
watch(favoritePicbeds, valideFavoritePicbeds, { immediate: true })
function addCurrentPicbedToFavorites() {
favoritePicbeds.value.push({
id: defaultIdG.value,
type: defaultPicBedG.value,
configName: defaultConfigNameG.value,
})
message.success(t('pages.upload.picbedAddedToFavorites'))
}
function removePicbedFromFavorites(picbedType: IFavoritePicbedItem) {
const index = favoritePicbeds.value.findIndex(
item => item.type === picbedType.type && item.id === picbedType.id && item.configName === picbedType.configName,
)
if (index === -1) return
favoritePicbeds.value.splice(index, 1)
}
async function switchToPicbed(picbedType: IFavoritePicbedItem) {
if (!picbedType.id || !picbedType.type || !picbedType.configName) {
return
}
const uploaders = (await getConfig<IStringKeyMap>(`uploader.${picbedType.type}`)) || {}
const targetConfig = uploaders?.configList.find(
(cfg: any) => cfg._id === picbedType.id && cfg._configName === picbedType.configName,
)
if (!targetConfig) {
return
}
saveConfig({
[`uploader.${picbedType.type}.defaultId`]: picbedType.id,
[`picBed.${picbedType.type}`]: targetConfig,
[configPaths.picBed.current]: picbedType.type,
[configPaths.picBed.uploader]: picbedType.type,
})
await updatePicBeds()
const name = getPicbedName(picbedType).split('-')[0]
window.electron.sendRPC(IRPCActionType.TRAY_SET_TOOL_TIP, `${name} ${targetConfig._configName}`)
message.success(t('pages.upload.picbedSwitched', { name: getPicbedName(picbedType) }))
}
function getPicbedName(picbedType: IFavoritePicbedItem): string {
if (!picBedG.value || picBedG.value.length === 0) {
return picbedType.configName || 'Default'
}
const target = picBedG.value.find(item => item.type === picbedType.type)
return `${target ? target.name : picbedType.type}-${picbedType.configName}`
}
function isCurrentPicbed(picbedType: IFavoritePicbedItem): boolean {
return defaultIdG.value === picbedType.id
}
function handleBadgeClick(picbedType: IFavoritePicbedItem) {
if (longPressedBadge.value === picbedType.id) {
return
}
if (isCurrentPicbed(picbedType)) {
return
}
switchToPicbed(picbedType)
}
function handleBadgeMouseDown(picbedType: IFavoritePicbedItem) {
longPressTimer = setTimeout(() => {
longPressedBadge.value = picbedType.id
}, LONG_PRESS_DURATION)
}
function handleBadgeMouseUp() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
setTimeout(() => {
longPressedBadge.value = null
}, 10000)
}
function handleBadgeTouchStart(picbedType: IFavoritePicbedItem, event: TouchEvent) {
longPressTimer = setTimeout(() => {
longPressedBadge.value = picbedType.id
event.preventDefault()
}, LONG_PRESS_DURATION)
}
function handleBadgeTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
setTimeout(() => {
longPressedBadge.value = null
}, 10000)
}
function uploadClipboardFiles() {
window.electron.sendRPC(IRPCActionType.UPLOAD_CLIPBOARD_FILES_FROM_UPLOAD_PAGE)
}
async function uploadURLFiles() {
const str = await navigator.clipboard.readText()
$bus.emit(SHOW_INPUT_BOX, {
value: isUrl(str) ? str : '',
title: t('pages.upload.inputUrlTip'),
placeholder: t('pages.upload.httpPrefixTip') + '\n' + t('pages.upload.multipleUrlsHint'),
multiLine: true,
})
}
function handleInputBoxValue(val: string) {
if (val === '') return
const urls = val
.split('\n')
.map(url => url.trim())
.filter(url => url !== '')
if (urls.length === 0) return
const invalidUrls: string[] = []
const validUrls: string[] = []
urls.forEach(url => {
if (isUrl(url)) {
validUrls.push(url)
} else {
invalidUrls.push(url)
}
})
if (invalidUrls.length > 0) {
const errorMessage =
invalidUrls.length === 1
? t('pages.upload.inputValidUrl') + ': ' + invalidUrls[0]
: t('pages.upload.invalidUrlsFound', {
count: invalidUrls.length,
urls: invalidUrls.slice(0, 3).join(', ') + (invalidUrls.length > 3 ? '...' : ''),
})
message.error(errorMessage)
}
if (validUrls.length > 0) {
const filesToUpload = validUrls.map(url => ({ path: url }))
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, filesToUpload)
if (validUrls.length > 1) {
message.success(t('pages.upload.uploadingMultipleUrls', { count: validUrls.length }))
}
}
}
async function handleChangePicBed() {
window.electron.sendRPC(IRPCActionType.SHOW_UPLOAD_PAGE_MENU)
}
function openTaskDialog() {
taskDialogVisible.value = true
refreshTaskStatus()
}
async function refreshTaskStatus() {
const status = await window.electron.triggerRPC<IUploadTaskQueueStatus>(IRPCActionType.UPLOAD_TASK_GET_STATUS)
if (status) {
Object.assign(taskQueueStatus, status)
uploadInterval.value = status.config.intervalS
autoStart.value = status.config.autoStart
pauseOnError.value = status.config.pauseOnError
maxRetryCount.value = status.config.maxRetryCount
}
}
async function addFilesToTask() {
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
input.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const files: IFileWithPath[] = Array.from(target.files).map(file => ({
name: file.name,
path: window.electron.showFilePath(file),
}))
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_ADD, files)
await refreshTaskStatus()
message.success(t('pages.upload.taskQueue.filesAdded', { count: files.length }))
}
}
input.click()
}
async function startTaskQueue() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_START, uploadInterval.value)
await refreshTaskStatus()
message.success(t('pages.upload.taskQueue.started'))
}
async function pauseTaskQueue() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_PAUSE)
await refreshTaskStatus()
message.info(t('pages.upload.taskQueue.paused'))
}
async function resumeTaskQueue() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_RESUME)
await refreshTaskStatus()
message.success(t('pages.upload.taskQueue.resumed'))
}
async function cancelAllTasks() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_CANCEL_ALL)
await refreshTaskStatus()
message.info(t('pages.upload.taskQueue.allCancelled'))
}
async function cancelTask(taskId: string) {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_CANCEL_ONE, taskId)
await refreshTaskStatus()
}
async function removeTask(taskId: string) {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_REMOVE_ONE, taskId)
await refreshTaskStatus()
}
async function clearFinishedTasks() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_CLEAR_FINISHED)
await refreshTaskStatus()
message.success(t('pages.upload.taskQueue.cleared'))
}
async function updateInterval() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_SET_INTERVAL, uploadInterval.value)
}
async function retryTask(taskId: string) {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_RETRY_ONE, taskId)
await refreshTaskStatus()
message.success(t('pages.upload.taskQueue.taskRetried'))
}
async function retryAllFailedTasks() {
const count = await window.electron.triggerRPC<number>(IRPCActionType.UPLOAD_TASK_RETRY_ALL_FAILED)
await refreshTaskStatus()
message.success(t('pages.upload.taskQueue.retriedAllFailed', { count }))
}
async function moveTaskUp(taskId: string) {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_MOVE_UP, taskId)
await refreshTaskStatus()
}
async function moveTaskDown(taskId: string) {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_MOVE_DOWN, taskId)
await refreshTaskStatus()
}
async function toggleTaskPriority(taskId: string, currentPriority: number) {
const newPriority = currentPriority === 2 ? 1 : 2
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_SET_PRIORITY, taskId, newPriority)
await refreshTaskStatus()
}
async function updateSettings() {
await window.electron.triggerRPC(IRPCActionType.UPLOAD_TASK_UPDATE_SETTINGS, {
intervalS: uploadInterval.value,
autoStart: autoStart.value,
pauseOnError: pauseOnError.value,
maxRetryCount: maxRetryCount.value,
})
}
// Helper functions
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function formatSpeed(bytesPerSecond: number): string {
return formatSize(bytesPerSecond) + '/s'
}
function formatTime(ms: number): string {
if (ms < 1000) return '< 1s'
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return `${hours}h ${remainingMinutes}m`
}
function getTaskStatusClass(status: string): string {
const statusMap: Record<string, string> = {
pending: 'status-pending',
uploading: 'status-uploading',
completed: 'status-completed',
failed: 'status-failed',
cancelled: 'status-cancelled',
}
return statusMap[status] || ''
}
function getTaskStatusText(status: string): string {
const statusMap: Record<string, string> = {
pending: t('pages.upload.taskQueue.statusPending'),
uploading: t('pages.upload.taskQueue.statusUploading'),
completed: t('pages.upload.taskQueue.statusCompleted'),
failed: t('pages.upload.taskQueue.statusFailed'),
cancelled: t('pages.upload.taskQueue.statusCancelled'),
}
return statusMap[status] || status
}
function taskQueueUpdateHandler(status: IUploadTaskQueueStatus) {
Object.assign(taskQueueStatus, status)
uploadInterval.value = status.config.intervalS
}
let removeTaskQueueUpdateListenerCallback: () => void = () => {}
onBeforeUnmount(() => {
$bus.off(SHOW_INPUT_BOX_RESPONSE)
removeUploadProgressListenerCallback()
removeSyncPicBedListenerCallback()
removeTaskQueueUpdateListenerCallback()
})
onBeforeMount(async () => {
removeUploadProgressListenerCallback = window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
removeSyncPicBedListenerCallback = window.electron.ipcRendererOn('syncPicBed', syncPicBedHandler)
removeTaskQueueUpdateListenerCallback = window.electron.ipcRendererOn('uploadTaskQueueUpdate', taskQueueUpdateHandler)
$bus.on(SHOW_INPUT_BOX_RESPONSE, handleInputBoxValue)
await Promise.all([initConf(), refreshTaskStatus()])
})
</script>
<script lang="ts">
export default {
name: 'UploadPage',
}
</script>
<style scoped src="./css/UploadPage.css"></style>