Template
Add one-click Navidrome ingestion workflow
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="ingest-container">
|
||||
<el-row :gutter="24">
|
||||
<!-- 任务控制卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<task-card-header :icon="Upload" title="一键导入" />
|
||||
</template>
|
||||
|
||||
<div class="ingest-description">
|
||||
<p>自动扫描 Input 目录中的音频文件,校验元数据、繁简转换、转码为 FLAC、去重后整理入 Library 目录。</p>
|
||||
</div>
|
||||
|
||||
<el-form label-width="100px" label-position="left">
|
||||
<el-form-item label="输入目录">
|
||||
<el-input :model-value="config.inputDir" disabled>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="库目录">
|
||||
<el-input :model-value="config.libraryDir" disabled>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="拒绝目录">
|
||||
<el-input :model-value="config.rejectedDir" disabled>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canStart"
|
||||
@click="startIngest"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
||||
<span style="margin-left: 4px">{{ submitting ? '导入中...' : '开始一键导入' }}</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
:disabled="!config.basePath"
|
||||
@click="loadConfig"
|
||||
size="default"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span style="margin-left: 4px">刷新配置</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 配置提示 -->
|
||||
<el-alert
|
||||
v-if="!config.basePath"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
请先在<strong>配置</strong>页面设置工作根目录
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 任务进度卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<task-card-header :icon="DataLine" title="导入进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
<el-icon class="empty-icon"><Upload /></el-icon>
|
||||
<p class="empty-text">准备就绪,点击「开始一键导入」...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : progress.message?.includes('失败') ? 'exception' : undefined"
|
||||
:stroke-width="12"
|
||||
:show-text="true"
|
||||
:format="formatProgress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件 -->
|
||||
<div v-if="progress.currentFile" class="current-file-section">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-text">{{ progress.currentFile }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="progress.message" class="message-section">
|
||||
<el-alert
|
||||
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ progress.message }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Upload, VideoPlay, Refresh, DataLine, FolderOpened, Document } from '@element-plus/icons-vue';
|
||||
import { getConfig } from '../api/config';
|
||||
import type { ConfigResponse } from '../api/config';
|
||||
import { startIngest as apiStartIngest, getIngestStatus } from '../api/ingest';
|
||||
import type { IngestStatusResponse } from '../api/ingest';
|
||||
import { useWebSocket } from '../composables/useWebSocket';
|
||||
import type { ProgressMessage } from '../composables/useWebSocket';
|
||||
import TaskCardHeader from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid from './common/TaskStatsGrid.vue';
|
||||
import type { StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
const submitting = ref(false);
|
||||
const taskId = ref<string | null>(null);
|
||||
|
||||
// 配置
|
||||
const config = reactive<{ basePath: string; inputDir: string; libraryDir: string; rejectedDir: string }>({
|
||||
basePath: '',
|
||||
inputDir: '',
|
||||
libraryDir: '',
|
||||
rejectedDir: ''
|
||||
});
|
||||
|
||||
// 进度
|
||||
const progress = reactive<ProgressMessage>({
|
||||
taskId: '',
|
||||
type: '',
|
||||
total: 0,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
currentFile: '',
|
||||
message: '',
|
||||
completed: false
|
||||
});
|
||||
|
||||
// 连接状态
|
||||
const connectionStatus = ref<'connected' | 'connecting' | 'completed' | null>(null);
|
||||
|
||||
const canStart = computed(() => !!config.basePath && !submitting.value);
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!progress.total || !progress.processed) return 0;
|
||||
if (progress.completed) return 100;
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
const progressStats = computed((): StatItem[] => {
|
||||
if (!progress.taskId) return [];
|
||||
return [
|
||||
{ label: '已处理', value: progress.processed ?? 0 },
|
||||
{ label: '已入库', value: progress.ingestedFiles ?? 0, tone: 'success' },
|
||||
{ label: '重复跳过', value: progress.duplicateFiles ?? 0, tone: 'warning' },
|
||||
{ label: '缺元数据', value: progress.missingMetadataFiles ?? 0, tone: 'failed' },
|
||||
{ label: '不可读', value: progress.unreadableFiles ?? 0, tone: 'failed' },
|
||||
{ label: '转码失败', value: progress.conversionFailedFiles ?? 0, tone: 'failed' },
|
||||
{ label: '其他拒绝', value: progress.otherRejectedFiles ?? 0, tone: 'failed' },
|
||||
];
|
||||
});
|
||||
|
||||
function formatProgress() {
|
||||
if (progress.completed) return '完成';
|
||||
return `${progress.processed ?? 0} / ${progress.total ?? 0}`;
|
||||
}
|
||||
|
||||
// WebSocket 消息处理
|
||||
function handleProgressMessage(msg: ProgressMessage) {
|
||||
Object.assign(progress, msg);
|
||||
connectionStatus.value = msg.completed ? 'completed' : 'connected';
|
||||
|
||||
if (msg.completed) {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
const { connect: wsConnect, disconnect: wsDisconnect } = useWebSocket(taskId, handleProgressMessage);
|
||||
|
||||
// 轮询兜底
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!taskId.value) return;
|
||||
try {
|
||||
const resp = await getIngestStatus(taskId.value);
|
||||
if (resp) {
|
||||
handleProgressMessage({
|
||||
taskId: resp.taskId,
|
||||
type: 'ingest',
|
||||
total: resp.total,
|
||||
processed: resp.processed,
|
||||
success: resp.ingestedFiles,
|
||||
failed: resp.otherRejectedFiles,
|
||||
currentFile: '',
|
||||
message: resp.message,
|
||||
completed: resp.completed,
|
||||
ingestedFiles: resp.ingestedFiles,
|
||||
duplicateFiles: resp.duplicateFiles,
|
||||
missingMetadataFiles: resp.missingMetadataFiles,
|
||||
unreadableFiles: resp.unreadableFiles,
|
||||
conversionFailedFiles: resp.conversionFailedFiles,
|
||||
otherRejectedFiles: resp.otherRejectedFiles,
|
||||
});
|
||||
if (resp.completed) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始导入
|
||||
async function startIngest() {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
const resp = await apiStartIngest();
|
||||
taskId.value = resp.taskId;
|
||||
|
||||
// 重置进度
|
||||
Object.assign(progress, {
|
||||
taskId: resp.taskId, type: 'ingest', total: 0, processed: 0,
|
||||
success: 0, failed: 0, currentFile: '', message: '开始导入任务...',
|
||||
completed: false, ingestedFiles: 0, duplicateFiles: 0,
|
||||
missingMetadataFiles: 0, unreadableFiles: 0,
|
||||
conversionFailedFiles: 0, otherRejectedFiles: 0
|
||||
});
|
||||
|
||||
// 连接 WebSocket
|
||||
wsConnect();
|
||||
// 开启轮询兜底
|
||||
startPolling();
|
||||
} catch (e: any) {
|
||||
submitting.value = false;
|
||||
const msg = e?.response?.data?.message || e?.message || '启动任务失败';
|
||||
ElMessage.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面挂载恢复:查询是否存在正在运行或最近完成的任务
|
||||
async function resumeFromCurrentTask() {
|
||||
try {
|
||||
const { getIngestCurrentStatus } = await import('../api/ingest');
|
||||
let resp: IngestStatusResponse | null = null;
|
||||
try {
|
||||
resp = await getIngestCurrentStatus();
|
||||
} catch {
|
||||
// 没有当前任务或接口不可用
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp || (!resp.taskId && !resp.running)) return;
|
||||
|
||||
taskId.value = resp.taskId;
|
||||
submitting.value = resp.running;
|
||||
|
||||
// 组装进度消息
|
||||
handleProgressMessage({
|
||||
taskId: resp.taskId,
|
||||
type: 'ingest',
|
||||
total: resp.total,
|
||||
processed: resp.processed,
|
||||
success: resp.ingestedFiles,
|
||||
failed: resp.otherRejectedFiles,
|
||||
currentFile: '',
|
||||
message: resp.message || (resp.running ? '恢复轮询...' : '已完成'),
|
||||
completed: resp.completed,
|
||||
ingestedFiles: resp.ingestedFiles,
|
||||
duplicateFiles: resp.duplicateFiles,
|
||||
missingMetadataFiles: resp.missingMetadataFiles,
|
||||
unreadableFiles: resp.unreadableFiles,
|
||||
conversionFailedFiles: resp.conversionFailedFiles,
|
||||
otherRejectedFiles: resp.otherRejectedFiles,
|
||||
});
|
||||
|
||||
if (resp.running) {
|
||||
wsConnect();
|
||||
startPolling();
|
||||
}
|
||||
} catch {
|
||||
// 恢复失败不阻塞页面
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const cfg: ConfigResponse | null = await getConfig();
|
||||
if (cfg) {
|
||||
config.basePath = cfg.basePath || '';
|
||||
config.inputDir = cfg.inputDir || '';
|
||||
config.libraryDir = cfg.libraryDir || '';
|
||||
config.rejectedDir = cfg.rejectedDir || '';
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载配置失败');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
resumeFromCurrentTask();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wsDisconnect) wsDisconnect();
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
.ingest-description {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user