op2
This commit is contained in:
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
39
src/types/task.ts
Normal 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'
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user