This commit is contained in:
Julian Freeman
2026-04-19 11:08:28 -04:00
parent bcadf36b71
commit 634c349ebb
5 changed files with 250 additions and 11 deletions

View File

@@ -179,7 +179,90 @@ fn open_database(app: &AppHandle) -> Result<Connection> {
}
pub fn initialize_storage(app: &AppHandle) -> Result<()> {
let _ = open_database(app)?;
let connection = open_database(app)?;
migrate_legacy_files(app, &connection)?;
Ok(())
}
fn get_legacy_settings_path(app: &AppHandle) -> Result<PathBuf> {
Ok(get_app_data_dir(app)?.join("settings.json"))
}
fn get_legacy_history_path(app: &AppHandle) -> Result<PathBuf> {
Ok(get_app_data_dir(app)?.join("history.json"))
}
fn migrate_legacy_files(app: &AppHandle, connection: &Connection) -> Result<()> {
let task_count: i64 = connection.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))?;
let settings_path = get_legacy_settings_path(app)?;
if settings_path.exists() {
let content = fs::read_to_string(&settings_path)?;
let legacy_settings: Settings = serde_json::from_str(&content)?;
save_settings(app, &legacy_settings)?;
}
if task_count == 0 {
let history_path = get_legacy_history_path(app)?;
if history_path.exists() {
let content = fs::read_to_string(&history_path)?;
let legacy_history: Vec<HistoryItem> = serde_json::from_str(&content)?;
for item in legacy_history {
let metadata = VideoMetadata {
id: item.id.clone(),
title: item.title.clone(),
thumbnail: item.thumbnail.clone(),
duration: None,
uploader: None,
url: Some(item.url.clone()),
extractor: None,
site_name: None,
};
let options = DownloadOptions {
is_audio_only: false,
quality: "best".to_string(),
output_path: item.output_path.clone(),
output_format: item.format.clone(),
cookies_path: None,
};
let metadata_json = serde_json::to_string(&metadata)?;
let options_json = serde_json::to_string(&options)?;
let status = match item.status.as_str() {
"success" => "completed",
other => other,
};
let timestamp = to_rfc3339(item.timestamp);
connection.execute(
"INSERT OR IGNORE INTO tasks (
id, source_url, normalized_url, extractor, site_name, title, thumbnail, output_path,
file_path, status, progress, speed, eta, format, is_audio_only, quality, output_format,
cookies_path, error_message, created_at, started_at, finished_at, metadata_json, options_json
) VALUES (
?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6,
?7, ?8, 100, '-', NULL, ?9, 0, 'best', ?10,
'', NULL, ?11, ?11, ?11, ?12, ?13
)",
params![
item.id,
item.url,
item.url,
item.title,
item.thumbnail,
item.output_path,
item.file_path,
status,
item.format,
item.format,
timestamp,
metadata_json,
options_json
],
)?;
}
}
}
Ok(())
}

View File

@@ -19,6 +19,8 @@ export const useQueueStore = defineStore('queue', () => {
let taskUnlisten: (() => void) | null = null
const activeTasks = computed(() => tasks.value.filter(task => !['completed', 'failed', 'cancelled'].includes(task.status)))
const recentTasks = computed(() => tasks.value.slice(0, 12))
const terminalTasks = computed(() => tasks.value.filter(task => ['completed', 'failed', 'cancelled'].includes(task.status)).slice(0, 8))
function upsertTask(task: TaskRecord) {
const index = tasks.value.findIndex(item => item.id === task.id)
@@ -70,5 +72,5 @@ export const useQueueStore = defineStore('queue', () => {
isListening.value = false
}
return { tasks, activeTasks, initListener, loadTasks, cancelTask, retryTask, disposeListener }
return { tasks, activeTasks, recentTasks, terminalTasks, initListener, loadTasks, cancelTask, retryTask, disposeListener }
})

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Trash2, FolderOpen, RotateCcw } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { Trash2, FolderOpen, RotateCcw, FileText } from 'lucide-vue-next'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
@@ -18,6 +19,7 @@ interface HistoryItem {
}
const history = ref<HistoryItem[]>([])
const router = useRouter()
async function loadHistory() {
try {
@@ -56,6 +58,10 @@ async function retryItem(id: string) {
}
}
function openLogs(id: string) {
router.push({ path: '/logs', query: { taskId: id } })
}
onMounted(loadHistory)
</script>
@@ -137,6 +143,13 @@ onMounted(loadHistory)
>
<FolderOpen class="w-4 h-4" />
</button>
<button
@click="openLogs(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="查看日志"
>
<FileText class="w-4 h-4" />
</button>
<button
v-if="item.status !== 'completed'"
@click="retryItem(item.id)"

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Loader2, List, Link, X, RotateCcw } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { Loader2, List, Link, X, RotateCcw, FileText, FolderOpen } from 'lucide-vue-next'
import { useQueueStore } from '../stores/queue'
import { useSettingsStore } from '../stores/settings'
import { useAnalysisStore } from '../stores/analysis'
@@ -19,6 +20,7 @@ import {
const queueStore = useQueueStore()
const settingsStore = useSettingsStore()
const analysisStore = useAnalysisStore()
const router = useRouter()
const qualityOptions = [
{ label: '最佳画质', value: 'best' },
@@ -224,6 +226,29 @@ async function startDownload() {
analysisStore.error = `下载启动失败: ${error instanceof Error ? error.message : String(error)}`
}
}
function openTaskLogs(taskId: string) {
router.push({ path: '/logs', query: { taskId } })
}
async function openTaskOutput(path?: string | null) {
if (!path) return
await invoke('open_in_explorer', { path })
}
function statusLabel(status: string) {
switch (status) {
case 'queued': return '排队中'
case 'preparing': return '准备中'
case 'analyzing': return '分析中'
case 'downloading': return '下载中'
case 'postprocessing': return '处理中'
case 'completed': return '已完成'
case 'failed': return '失败'
case 'cancelled': return '已取消'
default: return status
}
}
</script>
<template>
@@ -455,12 +480,11 @@ async function startDownload() {
取消
</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"
@click="openTaskLogs(task.id)"
class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<RotateCcw class="w-3.5 h-3.5" />
重试
<FileText class="w-3.5 h-3.5" />
日志
</button>
</div>
</div>
@@ -468,5 +492,73 @@ async function startDownload() {
</div>
</div>
</div>
<div v-if="queueStore.terminalTasks.length > 0" class="mt-10">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">最近任务</h3>
<button
@click="router.push('/history')"
class="text-sm text-blue-600 hover:text-blue-700"
>
查看全部历史
</button>
</div>
<div class="space-y-3">
<div
v-for="task in queueStore.terminalTasks"
: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 items-start justify-between gap-4">
<div class="min-w-0">
<h4 class="font-medium truncate">{{ task.title }}</h4>
<p class="text-xs text-gray-500 mt-1">
{{ statusLabel(task.status) }}
<span v-if="task.site_name || task.extractor"> {{ task.site_name || task.extractor }}</span>
<span v-if="task.error_message"> {{ task.error_message }}</span>
</p>
</div>
<span
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
:class="task.status === 'completed'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: task.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'"
>
{{ statusLabel(task.status) }}
</span>
</div>
<div class="flex items-center gap-3 mt-3 text-xs">
<button
@click="openTaskLogs(task.id)"
class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<FileText class="w-3.5 h-3.5" />
查看日志
</button>
<button
v-if="task.file_path || task.output_path"
@click="openTaskOutput(task.file_path || task.output_path)"
class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<FolderOpen 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>
</div>
</template>

View File

@@ -1,21 +1,34 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLogsStore } from '../stores/logs'
import { Trash2, Search } from 'lucide-vue-next'
import { useQueueStore } from '../stores/queue'
import { Trash2, Search, X } from 'lucide-vue-next'
const logsStore = useLogsStore()
const queueStore = useQueueStore()
const route = useRoute()
const router = useRouter()
const logsContainer = ref<HTMLElement | null>(null)
const filterLevel = ref<'all' | 'info' | 'error'>('all')
const searchQuery = ref('')
const selectedTaskId = ref<string>((route.query.taskId as string) || '')
const selectedTask = computed(() => queueStore.tasks.find(task => task.id === selectedTaskId.value) || null)
const filteredLogs = computed(() => {
return logsStore.logs.filter(log => {
if (filterLevel.value !== 'all' && log.level !== filterLevel.value) return false
if (selectedTaskId.value && log.taskId !== selectedTaskId.value) return false
if (searchQuery.value && !log.message.toLowerCase().includes(searchQuery.value.toLowerCase()) && !log.taskId.includes(searchQuery.value)) return false
return true
})
})
const logTaskOptions = computed(() => queueStore.tasks.filter(task =>
logsStore.logs.some(log => log.taskId === task.id)
).slice(0, 20))
// Restore scroll position on mount
onMounted(() => {
if (logsContainer.value) {
@@ -27,6 +40,10 @@ onMounted(() => {
}
})
watch(() => route.query.taskId, (taskId) => {
selectedTaskId.value = typeof taskId === 'string' ? taskId : ''
})
// Auto-scroll watcher
watch(() => logsStore.logs.length, () => {
if (logsStore.autoScroll) {
@@ -57,6 +74,11 @@ function formatTime(ts: number) {
const seconds = String(d.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function selectTask(taskId: string) {
selectedTaskId.value = taskId
router.replace({ path: '/logs', query: taskId ? { taskId } : {} })
}
</script>
<template>
@@ -69,6 +91,12 @@ function formatTime(ts: number) {
</div>
<div class="flex items-center gap-3">
<div v-if="selectedTask" class="hidden lg:flex items-center gap-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2 text-xs">
<span class="truncate max-w-48">{{ selectedTask.title }}</span>
<button @click="selectTask('')" class="text-gray-400 hover:text-red-500">
<X class="w-4 h-4" />
</button>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
@@ -110,6 +138,26 @@ function formatTime(ts: number) {
</div>
<!-- Logs Console -->
<div class="mb-4 flex flex-wrap gap-2">
<button
@click="selectTask('')"
class="px-3 py-1.5 rounded-lg text-xs border transition-colors"
:class="selectedTaskId === '' ? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800' : 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-500'"
>
全部任务
</button>
<button
v-for="task in logTaskOptions"
:key="task.id"
@click="selectTask(task.id)"
class="px-3 py-1.5 rounded-lg text-xs border transition-colors max-w-56 truncate"
:class="selectedTaskId === task.id ? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800' : 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-500'"
:title="task.title"
>
{{ task.title }}
</button>
</div>
<div class="flex-1 overflow-hidden relative bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 font-mono text-xs md:text-sm">
<div
ref="logsContainer"
@@ -121,6 +169,7 @@ function formatTime(ts: number) {
</div>
<div v-for="log in filteredLogs" :key="log.id" class="flex gap-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 px-2 py-1 rounded transition-colors">
<span class="text-gray-400 dark:text-zinc-600 shrink-0 select-none w-36">{{ formatTime(log.timestamp) }}</span>
<span class="text-blue-500 dark:text-blue-400 shrink-0 select-none w-24 truncate">{{ log.taskId }}</span>
<span
class="break-all whitespace-pre-wrap"
:class="log.level === 'error' ? 'text-red-500 dark:text-red-400' : 'text-zinc-700 dark:text-gray-300'"
@@ -139,4 +188,4 @@ function formatTime(ts: number) {
</div>
</div>
</div>
</template>
</template>