This commit is contained in:
Julian Freeman
2026-04-19 10:26:07 -04:00
parent e86bc86793
commit bcadf36b71
15 changed files with 1236 additions and 411 deletions

View File

@@ -1,7 +1,14 @@
// filepath: src/stores/logs.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import type { TaskLogEntry } from '../types/task'
interface LogEvent {
id: string
message: string
level: 'info' | 'error'
}
export interface LogEntry {
id: string
@@ -11,10 +18,14 @@ export interface LogEntry {
timestamp: number
}
interface LogEvent {
id: string
message: string
level: string
function mapTaskLog(entry: TaskLogEntry): LogEntry {
return {
id: `${entry.id}`,
taskId: entry.task_id,
message: entry.message,
level: entry.level,
timestamp: new Date(entry.timestamp).getTime()
}
}
export const useLogsStore = defineStore('logs', () => {
@@ -22,6 +33,11 @@ export const useLogsStore = defineStore('logs', () => {
const isListening = ref(false)
let unlisten: (() => void) | null = null
async function loadLogs() {
const result = await invoke<TaskLogEntry[]>('get_task_logs')
logs.value = result.map(mapTaskLog)
}
function addLog(taskId: string, message: string, level: 'info' | 'error') {
logs.value.push({
id: crypto.randomUUID(),
@@ -30,28 +46,29 @@ export const useLogsStore = defineStore('logs', () => {
level,
timestamp: Date.now()
})
// Optional: Limit log size to avoid memory issues
if (logs.value.length > 5000) {
logs.value = logs.value.slice(-5000)
logs.value = logs.value.slice(-5000)
}
}
async function initListener() {
if (isListening.value) return
isListening.value = true
await loadLogs()
unlisten = await listen<LogEvent>('download-log', (event) => {
const { id, message, level } = event.payload
addLog(id, message, level as 'info' | 'error')
addLog(id, message, level)
})
}
function clearLogs() {
logs.value = []
async function clearLogs() {
await invoke('clear_task_logs')
logs.value = []
}
// UI State Persistence
const autoScroll = ref(true)
const scrollTop = ref(0)
@@ -61,5 +78,5 @@ export const useLogsStore = defineStore('logs', () => {
isListening.value = false
}
return { logs, addLog, initListener, clearLogs, disposeListener, autoScroll, scrollTop }
return { logs, initListener, clearLogs, disposeListener, autoScroll, scrollTop }
})

View File

@@ -1,54 +1,74 @@
// filepath: src/stores/queue.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
export interface DownloadTask {
id: string
title: string
thumbnail: string
progress: number
speed: string
status: 'pending' | 'downloading' | 'finished' | 'error'
}
import type { TaskRecord } from '../types/task'
interface ProgressEvent {
id: string
progress: number
speed: string
status: string
eta?: string | null
status: TaskRecord['status']
}
export const useQueueStore = defineStore('queue', () => {
const tasks = ref<DownloadTask[]>([])
const tasks = ref<TaskRecord[]>([])
const isListening = ref(false)
let unlisten: (() => void) | null = null
let progressUnlisten: (() => void) | null = null
let taskUnlisten: (() => void) | null = null
function addTask(task: DownloadTask) {
tasks.value.push(task)
const activeTasks = computed(() => tasks.value.filter(task => !['completed', 'failed', 'cancelled'].includes(task.status)))
function upsertTask(task: TaskRecord) {
const index = tasks.value.findIndex(item => item.id === task.id)
if (index === -1) {
tasks.value.unshift(task)
return
}
tasks.value[index] = task
}
async function loadTasks() {
const result = await invoke<TaskRecord[]>('get_tasks')
tasks.value = result
}
async function initListener() {
if (isListening.value) return
isListening.value = true
unlisten = await listen<ProgressEvent>('download-progress', (event) => {
const { id, progress, speed, status } = event.payload
const task = tasks.value.find(t => t.id === id)
if (task) {
task.progress = progress
task.speed = speed
// Map status string to type if needed, or just assign
task.status = status as any
}
await loadTasks()
taskUnlisten = await listen<TaskRecord>('task-updated', (event) => {
upsertTask(event.payload)
})
progressUnlisten = await listen<ProgressEvent>('download-progress', (event) => {
const task = tasks.value.find(item => item.id === event.payload.id)
if (!task) return
task.progress = event.payload.progress
task.speed = event.payload.speed
task.eta = event.payload.eta || null
task.status = event.payload.status
})
}
async function cancelTask(id: string) {
await invoke('cancel_task', { id })
}
async function retryTask(id: string) {
return invoke<string>('retry_task', { id })
}
function disposeListener() {
unlisten?.()
unlisten = null
progressUnlisten?.()
taskUnlisten?.()
progressUnlisten = null
taskUnlisten = null
isListening.value = false
}
return { tasks, addTask, initListener, disposeListener }
return { tasks, activeTasks, initListener, loadTasks, cancelTask, retryTask, disposeListener }
})

View File

@@ -2,6 +2,7 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import type { RuntimeStatus } from '../types/task'
export interface Settings {
download_path: string
@@ -21,6 +22,7 @@ export const useSettingsStore = defineStore('settings', () => {
const ytdlpVersion = ref('Checking...')
const quickjsVersion = ref('Checking...')
const ffmpegVersion = ref('Checking...')
const runtimeStatus = ref<RuntimeStatus | null>(null)
const isInitializing = ref(true)
const hasInitialized = ref(false)
let mediaQuery: MediaQueryList | null = null
@@ -64,15 +66,17 @@ export const useSettingsStore = defineStore('settings', () => {
}
async function refreshVersions() {
const [ytdlp, quickjs, ffmpeg] = await Promise.allSettled([
const [ytdlp, quickjs, ffmpeg, runtime] = await Promise.allSettled([
invoke<string>('get_ytdlp_version'),
invoke<string>('get_quickjs_version'),
invoke<string>('get_ffmpeg_version')
invoke<string>('get_ffmpeg_version'),
invoke<RuntimeStatus>('get_runtime_status')
])
ytdlpVersion.value = ytdlp.status === 'fulfilled' ? ytdlp.value : 'Error'
quickjsVersion.value = quickjs.status === 'fulfilled' ? quickjs.value : 'Error'
ffmpegVersion.value = ffmpeg.status === 'fulfilled' ? ffmpeg.value : 'Error'
runtimeStatus.value = runtime.status === 'fulfilled' ? runtime.value : null
}
function applyTheme(theme: string) {
@@ -115,6 +119,7 @@ export const useSettingsStore = defineStore('settings', () => {
ytdlpVersion,
quickjsVersion,
ffmpegVersion,
runtimeStatus,
isInitializing
}
})

39
src/types/task.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface TaskRecord {
id: string
source_url: string
normalized_url: string
extractor?: string | null
site_name?: string | null
title: string
thumbnail: string
output_path: string
file_path?: string | null
status: 'queued' | 'preparing' | 'analyzing' | 'downloading' | 'postprocessing' | 'completed' | 'failed' | 'cancelled'
progress: number
speed: string
eta?: string | null
format: string
is_audio_only: boolean
quality: string
output_format: string
cookies_path?: string | null
error_message?: string | null
created_at: string
started_at?: string | null
finished_at?: string | null
}
export interface TaskLogEntry {
id: number
task_id: string
message: string
level: 'info' | 'error'
timestamp: string
}
export interface RuntimeStatus {
ffmpeg_source: 'system' | 'managed' | 'unavailable'
ffmpeg_version: string
js_runtime_name: string
js_runtime_source: 'system' | 'managed' | 'unavailable'
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Trash2, FolderOpen } from 'lucide-vue-next'
import { Trash2, FolderOpen, RotateCcw } from 'lucide-vue-next'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
@@ -47,6 +47,15 @@ async function openFolder(path: string) {
await invoke('open_in_explorer', { path })
}
async function retryItem(id: string) {
try {
await invoke('retry_task', { id })
await loadHistory()
} catch (e) {
console.error(e)
}
}
onMounted(loadHistory)
</script>
@@ -107,10 +116,17 @@ onMounted(loadHistory)
<td class="px-6 py-4">
<span
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
:class="item.status === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
:class="item.status === 'completed'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: item.status === 'cancelled'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="item.status === 'success' ? 'bg-green-500' : 'bg-red-500'"></span>
{{ item.status === 'success' ? '已完成' : '失败' }}
<span
class="w-1.5 h-1.5 rounded-full"
:class="item.status === 'completed' ? 'bg-green-500' : item.status === 'cancelled' ? 'bg-amber-500' : 'bg-red-500'"
></span>
{{ item.status === 'completed' ? '已完成' : item.status === 'cancelled' ? '已取消' : '失败' }}
</span>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
@@ -121,6 +137,14 @@ onMounted(loadHistory)
>
<FolderOpen class="w-4 h-4" />
</button>
<button
v-if="item.status !== 'completed'"
@click="retryItem(item.id)"
class="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors ml-1"
title="重试任务"
>
<RotateCcw class="w-4 h-4" />
</button>
<button
@click="deleteItem(item.id)"
class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors ml-1"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Loader2, List, Link } from 'lucide-vue-next'
import { Loader2, List, Link, X, RotateCcw } from 'lucide-vue-next'
import { useQueueStore } from '../stores/queue'
import { useSettingsStore } from '../stores/settings'
import { useAnalysisStore } from '../stores/analysis'
@@ -201,40 +201,22 @@ async function startDownload() {
for (const entry of selectedEntries) {
const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`
const id = await invoke<string>('start_download', {
await invoke<string>('start_download', {
url: videoUrl,
options: analysisStore.options,
metadata: entry
})
queueStore.addTask({
id,
title: entry.title,
thumbnail: entry.thumbnail,
progress: 0,
speed: '等待中...',
status: 'pending'
})
}
} else if (singleMetadata.value) {
const urlToDownload = analysisStore.isMix && !analysisStore.scanMix
? stripPlaylistContext(analysisStore.url)
: analysisStore.url
const id = await invoke<string>('start_download', {
await invoke<string>('start_download', {
url: urlToDownload,
options: analysisStore.options,
metadata: singleMetadata.value
})
queueStore.addTask({
id,
title: singleMetadata.value.title,
thumbnail: singleMetadata.value.thumbnail,
progress: 0,
speed: '等待中...',
status: 'pending'
})
}
analysisStore.reset()
@@ -443,25 +425,45 @@ async function startDownload() {
</div>
</div>
<div v-if="queueStore.tasks.length > 0">
<div v-if="queueStore.activeTasks.length > 0">
<h3 class="text-lg font-bold mb-4">进行中的任务</h3>
<div class="space-y-3">
<div v-for="task in queueStore.tasks.slice().reverse()" :key="task.id" class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4">
<div v-for="task in queueStore.activeTasks" :key="task.id" class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4">
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
<div class="flex-1 min-w-0">
<div class="flex justify-between mb-1">
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
{{ task.status === 'finished' ? '已完成' : (task.status === 'error' ? '失败' : task.speed) }}
{{ task.status === 'postprocessing' ? '处理中' : (task.speed || task.status) }}
</span>
</div>
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:style="{ width: `${task.progress}%` }"
:class="task.status === 'error' ? 'bg-red-500' : (task.status === 'finished' ? 'bg-green-500' : 'bg-blue-600')"
:class="task.status === 'postprocessing' ? 'bg-amber-500' : 'bg-blue-600'"
/>
</div>
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
<span>{{ task.site_name || task.extractor || '未知站点' }}</span>
<div class="flex items-center gap-2">
<button
@click="queueStore.cancelTask(task.id)"
class="inline-flex items-center gap-1 text-red-500 hover:text-red-600"
>
<X class="w-3.5 h-3.5" />
取消
</button>
<button
v-if="task.status === 'failed' || task.status === 'cancelled'"
@click="queueStore.retryTask(task.id)"
class="inline-flex items-center gap-1 text-blue-500 hover:text-blue-600"
>
<RotateCcw class="w-3.5 h-3.5" />
重试
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -250,6 +250,14 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
<div v-if="updateStatus" class="mt-4 p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg text-xs font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap border border-gray-200 dark:border-zinc-700">
{{ updateStatus }}
</div>
<div v-if="settingsStore.runtimeStatus" class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800 px-3 py-2">
FFmpeg 来源{{ settingsStore.runtimeStatus.ffmpeg_source }}
</div>
<div class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800 px-3 py-2">
JS Runtime{{ settingsStore.runtimeStatus.js_runtime_name }} ({{ settingsStore.runtimeStatus.js_runtime_source }})
</div>
</div>
<p class="text-xs text-gray-400 mt-4 text-right">上次检查 {{ settingsStore.settings.last_updated ? format(new Date(settingsStore.settings.last_updated), 'yyyy-MM-dd HH:mm:ss') : '从未' }}</p>
</section>
</div>