feat: productize ingest lifecycle and library health

This commit is contained in:
2026-07-21 08:56:38 +08:00
parent f5033c7020
commit f4f4ab092f
23 changed files with 2556 additions and 38 deletions
+17
View File
@@ -16,6 +16,9 @@ export interface IngestStatusResponse {
unreadableFiles: number;
conversionFailedFiles: number;
otherRejectedFiles: number;
lyricsFound: number;
lyricsMissing: number;
lyricsFailed: number;
message: string;
}
@@ -26,6 +29,20 @@ export function startIngest(): Promise<IngestResponse> {
return request.post('/api/ingest/start', {});
}
/**
* 请求取消当前导入任务(已完成文件不会被删除)
*/
export function cancelIngest(): Promise<string> {
return request.post('/api/ingest/cancel', {});
}
/**
* 获取指定任务的结构化导入报告 JSON 文本(taskId 传 "latest" 取最近一份)
*/
export function getIngestReport(taskId: string): Promise<string> {
return request.get(`/api/ingest/report/${encodeURIComponent(taskId)}`);
}
/**
* 获取当前/最近一次导入任务状态
*/
+117 -1
View File
@@ -128,6 +128,29 @@
<span>正在导入分析 ({{ percentage }}%)</span>
</button>
<button
v-if="running"
class="btn btn-ghost"
type="button"
:disabled="cancelling"
title="取消后已入库文件保留,未处理文件留在 Input 供下次导入"
@click="cancelIngest"
>
<app-icon name="reject" :size="14" />
<span>{{ cancelling ? '正在取消…' : '取消导入' }}</span>
</button>
<button
class="btn btn-ghost"
type="button"
:disabled="running || reportLoading"
title="下载最近一次导入的结构化报告(含逐文件结果与歌词统计)"
@click="downloadReport"
>
<app-icon name="chart" :size="14" />
<span>{{ reportLoading ? '获取报告中…' : '下载导入报告' }}</span>
</button>
<button
class="btn btn-ghost"
type="button"
@@ -192,6 +215,22 @@
</div>
</div>
<!-- 歌词统计歌词有无绝不影响音频入库仅观测 -->
<div class="stats-grid stats-secondary lyric-stats">
<div class="stat-cell-sm">
<span class="stat-name-sm">有歌词</span>
<span class="stat-num-sm">{{ lyricStats.found }}</span>
</div>
<div class="stat-cell-sm">
<span class="stat-name-sm">无歌词</span>
<span class="stat-num-sm">{{ lyricStats.missing }}</span>
</div>
<div class="stat-cell-sm">
<span class="stat-name-sm">歌词失败</span>
<span class="stat-num-sm">{{ lyricStats.failed }}</span>
</div>
</div>
<!-- 进度条 -->
<div class="progress-block">
<div class="progress-meta">
@@ -288,7 +327,13 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import AppIcon from './common/AppIcon.vue';
import { startIngest as apiStartIngest, getIngestStatus, getIngestCurrentStatus } from '../api/ingest';
import {
startIngest as apiStartIngest,
cancelIngest as apiCancelIngest,
getIngestReport,
getIngestStatus,
getIngestCurrentStatus
} from '../api/ingest';
import type { IngestStatusResponse } from '../api/ingest';
import { useWebSocket } from '../composables/useWebSocket';
import type { ProgressMessage } from '../composables/useWebSocket';
@@ -331,6 +376,31 @@ const progress = reactive<ProgressMessage>({
const canStart = computed(() => isConfigured.value && !submitting.value);
/**
* 歌词统计(独立于 ProgressMessage 类型,避免修改共享类型)。
* 歌词的有/无/失败绝不影响音频入库,仅作观测展示。
*/
const lyricStats = reactive<{ found: number; missing: number; failed: number }>({
found: 0,
missing: 0,
failed: 0
});
type LyricCarrier = { lyricsFound?: number; lyricsMissing?: number; lyricsFailed?: number };
function applyLyricStats(src: LyricCarrier | null | undefined) {
if (!src) return;
if (typeof src.lyricsFound === 'number') lyricStats.found = src.lyricsFound;
if (typeof src.lyricsMissing === 'number') lyricStats.missing = src.lyricsMissing;
if (typeof src.lyricsFailed === 'number') lyricStats.failed = src.lyricsFailed;
}
function resetLyricStats() {
lyricStats.found = 0;
lyricStats.missing = 0;
lyricStats.failed = 0;
}
const percentage = computed(() => {
if (progress.completed) return 100;
if (!progress.total || !progress.processed) return 0;
@@ -530,6 +600,7 @@ async function scrollConsoleIfPinned() {
function handleProgressMessage(msg: ProgressMessage) {
Object.assign(progress, msg);
applyLyricStats(msg as unknown as LyricCarrier);
recordActivity(msg);
if (typeof msg.running === 'boolean') {
@@ -576,6 +647,7 @@ function startPolling() {
try {
const resp = await getIngestStatus(taskId.value);
if (resp) {
applyLyricStats(resp);
handleProgressMessage(statusToProgress(resp));
if (resp.completed || !resp.running) {
stopPolling();
@@ -601,6 +673,7 @@ async function startIngest() {
if (submitting.value) return;
submitting.value = true;
setIngestRunning(true);
resetLyricStats();
try {
const resp = await apiStartIngest();
@@ -636,6 +709,49 @@ async function startIngest() {
}
}
/* ---------------- 取消当前任务 ---------------- */
const cancelling = ref(false);
async function cancelIngest() {
if (!submitting.value || cancelling.value) return;
cancelling.value = true;
try {
await apiCancelIngest();
ElMessage.success('已请求取消,任务将在当前文件完成后停止(已入库文件保留)');
} catch (e: unknown) {
ElMessage.error(extractError(e, '取消失败'));
} finally {
cancelling.value = false;
}
}
/* ---------------- 下载/查看报告 ---------------- */
const reportLoading = ref(false);
async function downloadReport() {
const id = taskId.value || 'latest';
reportLoading.value = true;
try {
const content = await getIngestReport(id);
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ingest-report-${id === 'latest' ? 'latest' : id.slice(0, 8)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
ElMessage.success('报告已下载');
} catch (e: unknown) {
ElMessage.error(extractError(e, '获取报告失败'));
} finally {
reportLoading.value = false;
}
}
/* ---------------- 刷新后恢复当前任务 ---------------- */
async function resumeFromCurrentTask() {