op3
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user