Feature(custom): add upload task system

This commit is contained in:
Kuingsmile
2026-01-10 20:42:48 +08:00
parent 3865401a72
commit 13986215d4
15 changed files with 3134 additions and 65 deletions

View File

@@ -27,7 +27,6 @@
<span>{{ t('pages.upload.imageProcessNameSingle') }}</span>
</button>
<button class="segmented-button" :title="t('pages.upload.imageProcessName')" @click="handleImageProcess">
<Settings :size="16" />
<span>{{ t('pages.upload.imageProcessName') }}</span>
</button>
</div>
@@ -98,6 +97,17 @@
<LinkIcon :size="20" />
<span>{{ t('pages.upload.urlUpload') }}</span>
</button>
<button
class="quick-action-button"
:class="{ 'has-badge': taskQueueStatus.tasks.length > 0 }"
@click="openTaskDialog"
>
<ListTodoIcon :size="20" />
<span>{{ t('pages.upload.taskUpload') }}</span>
<span v-if="taskQueueStatus.tasks.length > 0" class="task-count-badge">
{{ taskQueueStatus.tasks.length }}
</span>
</button>
</div>
</div>
@@ -164,20 +174,379 @@
</div>
</div>
</transition>
<!-- Task Queue Manager Modal -->
<transition name="modal">
<div v-if="taskDialogVisible" class="modal-overlay" @click="taskDialogVisible = false">
<div class="modal-container task-queue-modal" @click.stop>
<div class="modal-header">
<div class="modal-header-text">
<h3 class="modal-title">
<ListTodoIcon :size="22" />
{{ t('pages.upload.taskQueue.title') }}
</h3>
<span class="modal-subtitle">
{{
t('pages.upload.taskQueue.stats', {
completed: taskQueueStatus.stats.completed,
total: taskQueueStatus.stats.total,
})
}}
</span>
</div>
<button class="modal-close" @click="taskDialogVisible = false">
<XIcon :size="20" />
</button>
</div>
<div class="modal-content task-queue-content">
<!-- Action Bar -->
<div class="task-action-bar">
<div class="action-bar-left">
<button class="action-btn primary" @click="addFilesToTask">
<PlusIcon :size="16" />
{{ t('pages.upload.taskQueue.addFiles') }}
</button>
<button
v-if="!taskQueueStatus.config.isRunning && taskQueueStatus.stats.pending > 0"
class="action-btn success"
@click="startTaskQueue"
>
<PlayIcon :size="16" />
{{ t('pages.upload.taskQueue.start') }}
</button>
<button
v-if="taskQueueStatus.config.isRunning && !taskQueueStatus.config.isPaused"
class="action-btn warning"
@click="pauseTaskQueue"
>
<PauseIcon :size="16" />
{{ t('pages.upload.taskQueue.pause') }}
</button>
<button v-if="taskQueueStatus.config.isPaused" class="action-btn success" @click="resumeTaskQueue">
<PlayIcon :size="16" />
{{ t('pages.upload.taskQueue.resume') }}
</button>
</div>
<div class="action-bar-right">
<button v-if="taskQueueStatus.stats.failed > 0" class="action-btn" @click="retryAllFailedTasks">
<RefreshCwIcon :size="16" />
{{ t('pages.upload.taskQueue.retryAllFailed') }}
</button>
<button
v-if="taskQueueStatus.config.isRunning || taskQueueStatus.stats.pending > 0"
class="action-btn danger"
@click="cancelAllTasks"
>
<XIcon :size="16" />
{{ t('pages.upload.taskQueue.cancelAll') }}
</button>
<button
v-if="
taskQueueStatus.stats.completed > 0 ||
taskQueueStatus.stats.failed > 0 ||
taskQueueStatus.stats.cancelled > 0
"
class="action-btn"
@click="clearFinishedTasks"
>
<Trash2Icon :size="16" />
{{ t('pages.upload.taskQueue.clearFinished') }}
</button>
<button
class="action-btn"
:class="{ active: showTaskSettings }"
@click="showTaskSettings = !showTaskSettings"
>
<SettingsIcon :size="16" />
</button>
</div>
</div>
<!-- Overall Progress -->
<div v-if="taskQueueStatus.stats.total > 0" class="overall-progress">
<div class="progress-info">
<span class="progress-label">{{ t('pages.upload.taskQueue.overallProgress') }}</span>
<span class="progress-percentage">{{ overallProgressPercent }}%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" :style="{ width: `${overallProgressPercent}%` }" />
</div>
<div class="progress-details">
<span v-if="taskQueueStatus.stats.avgSpeed > 0" class="progress-detail-item">
<ZapIcon :size="14" />
{{ formatSpeed(taskQueueStatus.stats.avgSpeed) }}
</span>
<span
v-if="taskQueueStatus.stats.estimatedTimeMs > 0 && taskQueueStatus.config.isRunning"
class="progress-detail-item"
>
<ClockIcon :size="14" />
{{ formatTime(taskQueueStatus.stats.estimatedTimeMs) }}
</span>
<span class="progress-detail-item">
<HardDriveIcon :size="14" />
{{ formatSize(taskQueueStatus.stats.completedSize) }} /
{{ formatSize(taskQueueStatus.stats.totalSize) }}
</span>
</div>
</div>
<!-- Settings Panel -->
<transition name="settings-slide">
<div v-if="showTaskSettings" class="settings-panel">
<div class="settings-grid">
<div class="setting-item">
<label class="setting-label">
<TimerIcon :size="14" />
{{ t('pages.upload.taskQueue.interval') }}
</label>
<div class="input-with-unit">
<input
v-model.number="uploadInterval"
type="number"
min="0.1"
max="99999"
step="0.1"
class="setting-input"
:disabled="taskQueueStatus.config.isRunning"
@change="updateInterval"
/>
<span class="input-unit">s</span>
</div>
</div>
<div class="setting-item">
<label class="setting-label">{{ t('pages.upload.taskQueue.maxRetry') }}</label>
<input
v-model.number="maxRetryCount"
type="number"
min="0"
max="10"
step="1"
class="setting-input"
@change="updateSettings"
/>
</div>
<div class="setting-item toggle-item">
<label class="setting-label" for="task-auto-start">
{{ t('pages.upload.taskQueue.autoStart') }}
</label>
<input
id="task-auto-start"
v-model="autoStart"
type="checkbox"
class="setting-checkbox"
@change="updateSettings"
/>
</div>
<div class="setting-item toggle-item">
<label class="setting-label" for="task-pause-on-error">
{{ t('pages.upload.taskQueue.pauseOnError') }}
</label>
<input
id="task-pause-on-error"
v-model="pauseOnError"
type="checkbox"
class="setting-checkbox"
@change="updateSettings"
/>
</div>
</div>
</div>
</transition>
<!-- Filter & Search Bar -->
<div v-if="taskQueueStatus.tasks.length > 0" class="filter-search-bar">
<div class="search-box">
<SearchIcon :size="16" />
<input
v-model="taskSearchQuery"
type="text"
class="search-input"
:placeholder="t('pages.upload.taskQueue.searchPlaceholder')"
/>
</div>
<div class="filter-tabs">
<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="task-list-container">
<TransitionGroup name="task" tag="div" class="task-list">
<div
v-for="task in filteredTasks"
:key="task.id"
class="task-item"
:class="getTaskStatusClass(task.status)"
>
<div class="task-content">
<div class="task-header-row">
<div class="task-name">
<span class="task-filename" :title="task.filePath">{{ task.fileName }}</span>
<span v-if="task.priority === 2" class="priority-badge">
<StarIcon :size="12" />
</span>
</div>
<div class="task-status-badge" :class="getTaskStatusClass(task.status)">
{{ getTaskStatusText(task.status) }}
</div>
</div>
<div class="task-meta-row">
<span v-if="task.fileSize > 0" class="task-meta-item">
<HardDriveIcon :size="12" />
{{ formatSize(task.fileSize) }}
</span>
<span v-if="task.uploadSpeed && task.status === 'uploading'" class="task-meta-item">
<ZapIcon :size="12" />
{{ formatSpeed(task.uploadSpeed) }}
</span>
<span v-if="task.retryCount > 0" class="task-meta-item retry">
{{ t('pages.upload.taskQueue.retryCount', { count: task.retryCount }) }}
</span>
<span v-if="task.error" class="task-meta-item error" :title="task.error">
{{ task.error }}
</span>
</div>
</div>
<div class="task-actions">
<!-- 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 priority"
: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="task-status-icon">
<CheckCircleIcon v-if="task.status === 'completed'" :size="18" class="icon-success" />
<XCircleIcon v-if="task.status === 'failed'" :size="18" class="icon-error" />
<LoaderIcon v-if="task.status === 'uploading'" :size="18" class="icon-loading spinning" />
<ClockIcon v-if="task.status === 'pending'" :size="18" class="icon-pending" />
</div>
</div>
</div>
</TransitionGroup>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<ListTodoIcon :size="48" />
<h4>{{ t('pages.upload.taskQueue.empty') }}</h4>
<p>{{ t('pages.upload.taskQueue.emptyHint') }}</p>
<button class="action-btn primary" @click="addFilesToTask">
<PlusIcon :size="16" />
{{ t('pages.upload.taskQueue.selectFiles') }}
</button>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import { useStorage } from '@vueuse/core'
import {
ArrowLeftRightIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
ClipboardIcon,
ClockIcon,
EditIcon,
HardDriveIcon,
LinkIcon,
ListTodoIcon,
LoaderIcon,
PauseIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Settings,
SettingsIcon,
StarIcon,
TimerIcon,
Trash2Icon,
UploadCloudIcon,
XCircleIcon,
XIcon,
ZapIcon,
} from 'lucide-vue-next'
import { computed, onBeforeMount, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { computed, onBeforeMount, onBeforeUnmount, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -193,6 +562,49 @@ 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()
@@ -200,6 +612,7 @@ 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)
@@ -208,6 +621,65 @@ const showError = ref(false)
const pasteStyle = ref(IPasteStyle.MARKDOWN)
const PicBedId = ref('')
const fileInput = useTemplateRef('fileInput')
const uploadInterval = ref(1000)
// 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
// Filter by status
if (taskFilter.value !== 'all') {
tasks = tasks.filter(t => t.status === taskFilter.value)
}
// Filter by search query
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) {
@@ -421,18 +893,193 @@ 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() {
uploadInterval.value = Math.max(100, Math.min(60000, uploadInterval.value))
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(() => {
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)
getUseShortUrl()
getPasteStyle()
refreshTaskStatus()
})
</script>

File diff suppressed because it is too large Load Diff