|
@@ -51,8 +51,17 @@
|
|
|
<button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
|
|
<button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
|
|
+ <!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
|
|
|
|
|
+ <view v-if="stalledTip && !isGenerationFailed" class="failed-tip">
|
|
|
|
|
+ <text class="failed-icon">⏸️</text>
|
|
|
|
|
+ <text class="failed-text">生成似乎已停止</text>
|
|
|
|
|
+ <text class="failed-hint">进度长时间未更新,后台任务可能已中断。可继续补齐未完成的章节,或整本重新生成。</text>
|
|
|
|
|
+ <button class="retry-btn" @click="handleResume">▶️ 继续生成</button>
|
|
|
|
|
+ <button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
<!-- 实时生成状态面板 -->
|
|
<!-- 实时生成状态面板 -->
|
|
|
- <view v-if="isCurrentlyGenerating && !isGenerationFailed" class="generation-status">
|
|
|
|
|
|
|
+ <view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" class="generation-status">
|
|
|
<view class="status-header">
|
|
<view class="status-header">
|
|
|
<text class="status-icon">⚡</text>
|
|
<text class="status-icon">⚡</text>
|
|
|
<text class="status-title">正在生成中...【时间较长,请耐心等待,可关闭页面,后台会继续生成】</text>
|
|
<text class="status-title">正在生成中...【时间较长,请耐心等待,可关闭页面,后台会继续生成】</text>
|
|
@@ -426,6 +435,12 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
let audioPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
let audioPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
let videoPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
let videoPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
|
|
|
|
+// 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
|
|
|
|
|
+const stalledTip = ref(false); // 是否疑似卡死
|
|
|
|
|
+let lastProgressSnapshot = ''; // 上次的 progress+genStage 快照
|
|
|
|
|
+let stalledTicks = 0; // 连续无变化的轮询次数
|
|
|
|
|
+const STALLED_THRESHOLD = 40; // 40 次 × 3s ≈ 2 分钟无变化判定卡死
|
|
|
|
|
+
|
|
|
// 计算属性
|
|
// 计算属性
|
|
|
const completedChapters = computed(() => {
|
|
const completedChapters = computed(() => {
|
|
|
if (!currentBook.value) return 0;
|
|
if (!currentBook.value) return 0;
|
|
@@ -699,6 +714,10 @@ async function loadBook(id: string) {
|
|
|
|
|
|
|
|
function startPollingProgress(bookId: string) {
|
|
function startPollingProgress(bookId: string) {
|
|
|
stopPollingProgress();
|
|
stopPollingProgress();
|
|
|
|
|
+ // 重置卡死检测
|
|
|
|
|
+ stalledTip.value = false;
|
|
|
|
|
+ lastProgressSnapshot = '';
|
|
|
|
|
+ stalledTicks = 0;
|
|
|
pollTimer = setInterval(async () => {
|
|
pollTimer = setInterval(async () => {
|
|
|
try {
|
|
try {
|
|
|
const book = await api.getBook(bookId);
|
|
const book = await api.getBook(bookId);
|
|
@@ -726,6 +745,7 @@ function startPollingProgress(bookId: string) {
|
|
|
if (doneStages.includes(book.genStage)) {
|
|
if (doneStages.includes(book.genStage)) {
|
|
|
stopPollingProgress();
|
|
stopPollingProgress();
|
|
|
generating.value = false;
|
|
generating.value = false;
|
|
|
|
|
+ stalledTip.value = false;
|
|
|
currentBook.value = book;
|
|
currentBook.value = book;
|
|
|
if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
|
|
if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
|
|
|
uni.showToast({ title: '生成完成!', icon: 'success' });
|
|
uni.showToast({ title: '生成完成!', icon: 'success' });
|
|
@@ -734,6 +754,25 @@ function startPollingProgress(bookId: string) {
|
|
|
const stageLabel = book.genStage === 'content_completed' ? '内容' : book.genStage === 'audio_completed' ? '音频' : '视频';
|
|
const stageLabel = book.genStage === 'content_completed' ? '内容' : book.genStage === 'audio_completed' ? '音频' : '视频';
|
|
|
notifStore.add({ type: 'content_complete', title: `${stageLabel}生成完成`, message: `《${currentBook.value?.title || '书籍'}》${stageLabel}已生成完毕`, bookId });
|
|
notifStore.add({ type: 'content_complete', title: `${stageLabel}生成完成`, message: `《${currentBook.value?.title || '书籍'}》${stageLabel}已生成完毕`, bookId });
|
|
|
}
|
|
}
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 卡死检测:进度+阶段连续 STALLED_THRESHOLD 次无变化 → 后台疑似已停
|
|
|
|
|
+ // 用每章 genStage 拼快照,能感知"内容在逐章推进"的细微变化,避免误判
|
|
|
|
|
+ const chapterSig = (book.chapters || []).map((c: any) => c.genStage).join(',');
|
|
|
|
|
+ const snapshot = `${book.progress}|${book.genStage}|${chapterSig}`;
|
|
|
|
|
+ if (snapshot === lastProgressSnapshot) {
|
|
|
|
|
+ stalledTicks++;
|
|
|
|
|
+ if (stalledTicks >= STALLED_THRESHOLD) {
|
|
|
|
|
+ // 判定卡死:停止轮询,退出"生成中"假象,提示用户可重新生成/续生成
|
|
|
|
|
+ stopPollingProgress();
|
|
|
|
|
+ generating.value = false;
|
|
|
|
|
+ stalledTip.value = true;
|
|
|
|
|
+ console.warn('[Detail] 生成进度长时间无变化,判定后台已停止,停止轮询');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lastProgressSnapshot = snapshot;
|
|
|
|
|
+ stalledTicks = 0;
|
|
|
}
|
|
}
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
console.error('轮询进度失败:', e);
|
|
console.error('轮询进度失败:', e);
|
|
@@ -815,6 +854,32 @@ async function handleRetryGenerate() {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// 续生成:只补齐未完成(失败/空壳)的章节,保留已生成的内容,不重建大纲
|
|
|
|
|
+async function handleResume() {
|
|
|
|
|
+ if (!currentBook.value) return;
|
|
|
|
|
+ const bookId = currentBook.value.id;
|
|
|
|
|
+ uni.showModal({
|
|
|
|
|
+ title: '继续生成',
|
|
|
|
|
+ content: '将补齐未完成的章节内容(保留已生成的部分),是否继续?',
|
|
|
|
|
+ success: async (res) => {
|
|
|
|
|
+ if (!res.confirm) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ uni.showLoading({ title: '提交中...', mask: true });
|
|
|
|
|
+ await post(`/book-generator/langgraph/books/${bookId}/resume`, {});
|
|
|
|
|
+ uni.hideLoading();
|
|
|
|
|
+ uni.showToast({ title: '已开始续生成', icon: 'success' });
|
|
|
|
|
+ // 切回生成中并重新轮询
|
|
|
|
|
+ stalledTip.value = false;
|
|
|
|
|
+ generating.value = true;
|
|
|
|
|
+ startPollingProgress(bookId);
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ uni.hideLoading();
|
|
|
|
|
+ uni.showToast({ title: e?.message || '续生成启动失败', icon: 'none' });
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function handleGenerateAudio() {
|
|
async function handleGenerateAudio() {
|
|
|
if (!currentBook.value || generatingAudio.value) return;
|
|
if (!currentBook.value || generatingAudio.value) return;
|
|
|
uni.showModal({
|
|
uni.showModal({
|