Template
feat: productize ingest lifecycle and library health
This commit is contained in:
@@ -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)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前/最近一次导入任务状态
|
||||
*/
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user