|
@@ -51,15 +51,32 @@
|
|
|
<button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
|
|
<button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
|
|
+ <!-- 大纲已就绪、等待触发内容生成(interactive 模式特有状态) -->
|
|
|
|
|
+ <view v-if="currentBook.genStage === 'outline_ready' && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-color: #f59e0b;">
|
|
|
|
|
+ <text class="failed-icon">📋</text>
|
|
|
|
|
+ <text class="failed-text" style="color: #b45309;">大纲已生成,等待生成正文</text>
|
|
|
|
|
+ <text class="failed-hint">进度长时间停在 5%,是因为大纲确认后内容生成尚未启动。点击下方按钮立即开始生成。</text>
|
|
|
|
|
+ <button class="retry-btn" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);" :disabled="outlineStarting" @click="handleStartContentGenerate">🚀 继续生成内容</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
<!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
|
|
<!-- 卡死状态:进度长时间无变化,后台疑似已停 -->
|
|
|
- <view v-if="stalledTip && !isGenerationFailed" class="failed-tip">
|
|
|
|
|
|
|
+ <view v-if="stalledTip && !isGenerationFailed && currentBook.genStage !== 'outline_ready'" class="failed-tip">
|
|
|
<text class="failed-icon">⏸️</text>
|
|
<text class="failed-icon">⏸️</text>
|
|
|
<text class="failed-text">生成似乎已停止</text>
|
|
<text class="failed-text">生成似乎已停止</text>
|
|
|
<text class="failed-hint">进度长时间未更新,后台任务可能已中断。可继续补齐未完成的章节,或整本重新生成。</text>
|
|
<text class="failed-hint">进度长时间未更新,后台任务可能已中断。可继续补齐未完成的章节,或整本重新生成。</text>
|
|
|
<button class="retry-btn" @click="handleResume">▶️ 继续生成</button>
|
|
<button class="retry-btn" @click="handleResume">▶️ 继续生成</button>
|
|
|
|
|
+ <button class="retry-btn" style="margin-top: 12rpx; background: linear-gradient(135deg, #10b981 0%, #059669 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全缺失章节</button>
|
|
|
<button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
|
|
<button class="retry-btn" style="margin-top: 12rpx; background: #888;" @click="handleLangGraphGenerate">🔄 整本重新生成</button>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
|
|
+ <!-- 始终可用的兜底按钮:只要有缺失章节就显示 -->
|
|
|
|
|
+ <view v-if="hasMissingChapters && !isCurrentlyGenerating && !isGenerationFailed" class="failed-tip" style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border-color: #3b82f6;">
|
|
|
|
|
+ <text class="failed-icon">🩹</text>
|
|
|
|
|
+ <text class="failed-text" style="color: #1d4ed8;">检测到 {{ missingChapterCount }} 个章节未生成内容</text>
|
|
|
|
|
+ <text class="failed-hint">这本书可能有章节被 LangGraph 节点漏掉。点击下方按钮并发补全(不依赖 LangGraph 重跑)。</text>
|
|
|
|
|
+ <button class="retry-btn" style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);" :disabled="fillingMissing" @click="handleFillMissing">⚡ 补全 {{ missingChapterCount }} 个缺失章节</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
<!-- 实时生成状态面板 -->
|
|
<!-- 实时生成状态面板 -->
|
|
|
<view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" class="generation-status">
|
|
<view v-if="isCurrentlyGenerating && !isGenerationFailed && !stalledTip" class="generation-status">
|
|
|
<view class="status-header">
|
|
<view class="status-header">
|
|
@@ -74,7 +91,7 @@
|
|
|
<text class="chapter-label">正在处理:</text>
|
|
<text class="chapter-label">正在处理:</text>
|
|
|
<text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
|
|
<text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
|
|
|
</view>
|
|
</view>
|
|
|
- <view v-if="currentBook.error" class="status-error">
|
|
|
|
|
|
|
+ <view v-if="isGenerationFailed && currentBook.error" class="status-error">
|
|
|
<text class="error-icon">⚠️</text>
|
|
<text class="error-icon">⚠️</text>
|
|
|
<text class="error-text">{{ currentBook.error }}</text>
|
|
<text class="error-text">{{ currentBook.error }}</text>
|
|
|
</view>
|
|
</view>
|
|
@@ -107,13 +124,36 @@
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
<!-- 失败提示 -->
|
|
<!-- 失败提示 -->
|
|
|
- <view v-if="currentBook.genStage === 'failed' && !currentBook?.error?.includes('额度')" class="failed-tip">
|
|
|
|
|
|
|
+ <view v-if="isGenerationFailed && !currentBook?.error?.includes('额度')" class="failed-tip">
|
|
|
<text class="failed-icon">❌</text>
|
|
<text class="failed-icon">❌</text>
|
|
|
<text class="failed-text">生成失败</text>
|
|
<text class="failed-text">生成失败</text>
|
|
|
<text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
|
|
<text v-if="currentBook.error" class="failed-error">{{ currentBook.error }}</text>
|
|
|
<text class="failed-hint">请检查网络或稍后重试</text>
|
|
<text class="failed-hint">请检查网络或稍后重试</text>
|
|
|
<button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
|
|
<button class="retry-btn" @click="handleRetryGenerate">🔄 重新生成</button>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 质量报告(成功路径的日志,不是错误) -->
|
|
|
|
|
+ <view v-if="!isGenerationFailed && (currentBook.qualityReport || currentBook.continuityReport || currentBook.rewriteReport)" class="report-card">
|
|
|
|
|
+ <view class="report-header" @click="reportExpanded = !reportExpanded">
|
|
|
|
|
+ <text class="report-icon">📊</text>
|
|
|
|
|
+ <text class="report-title">质量/连贯性报告</text>
|
|
|
|
|
+ <text class="report-toggle">{{ reportExpanded ? '收起' : '展开' }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="reportExpanded" class="report-body">
|
|
|
|
|
+ <view v-if="currentBook.qualityReport" class="report-item">
|
|
|
|
|
+ <text class="report-label">质量校验:</text>
|
|
|
|
|
+ <text class="report-text">{{ currentBook.qualityReport }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="currentBook.continuityReport" class="report-item">
|
|
|
|
|
+ <text class="report-label">连贯性编辑:</text>
|
|
|
|
|
+ <text class="report-text">{{ currentBook.continuityReport }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="currentBook.rewriteReport" class="report-item">
|
|
|
|
|
+ <text class="report-label">改写优化:</text>
|
|
|
|
|
+ <text class="report-text">{{ currentBook.rewriteReport }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
<!-- 完整内容区 -->
|
|
<!-- 完整内容区 -->
|
|
@@ -292,7 +332,7 @@
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
<!-- 操作按钮 -->
|
|
|
- <view class="card action-card">
|
|
|
|
|
|
|
+ <view v-if="!loadError" class="card action-card">
|
|
|
<!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
|
|
<!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
|
|
|
<view v-if="showManualContentHint" class="manual-hint">
|
|
<view v-if="showManualContentHint" class="manual-hint">
|
|
|
<text class="manual-hint-icon">📝</text>
|
|
<text class="manual-hint-icon">📝</text>
|
|
@@ -376,11 +416,21 @@ const allExpanded = ref(true);
|
|
|
|
|
|
|
|
// 当前书籍
|
|
// 当前书籍
|
|
|
const currentBook = ref<Book | null>(null);
|
|
const currentBook = ref<Book | null>(null);
|
|
|
|
|
+// 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
|
|
|
|
|
+const currentBookId = ref<string>('');
|
|
|
|
|
+
|
|
|
|
|
+// 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
|
|
|
|
|
+// 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
|
|
|
|
|
+const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
|
|
|
|
|
|
|
|
// 生成状态
|
|
// 生成状态
|
|
|
const generating = ref(false);
|
|
const generating = ref(false);
|
|
|
const generatingAudio = ref(false);
|
|
const generatingAudio = ref(false);
|
|
|
const generatingVideo = ref(false);
|
|
const generatingVideo = ref(false);
|
|
|
|
|
+// interactive 模式下点击"继续生成内容"的请求中状态
|
|
|
|
|
+const outlineStarting = ref(false);
|
|
|
|
|
+// 手动补全缺失章节的请求中状态
|
|
|
|
|
+const fillingMissing = ref(false);
|
|
|
|
|
|
|
|
// 是否有内容(章节有实际内容文本)
|
|
// 是否有内容(章节有实际内容文本)
|
|
|
const hasContent = computed(() => {
|
|
const hasContent = computed(() => {
|
|
@@ -428,6 +478,15 @@ const isContentCompleted = computed(() => {
|
|
|
currentBook.value.genStage === 'video_completed';
|
|
currentBook.value.genStage === 'video_completed';
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+// 是否有章节内容缺失(level>=2 且 content 为空)
|
|
|
|
|
+const missingChapterCount = computed(() => {
|
|
|
|
|
+ if (!currentBook.value?.chapters) return 0;
|
|
|
|
|
+ return currentBook.value.chapters.filter((c: any) =>
|
|
|
|
|
+ c.level >= 2 && (!c.content || c.content.length < 50)
|
|
|
|
|
+ ).length;
|
|
|
|
|
+});
|
|
|
|
|
+const hasMissingChapters = computed(() => missingChapterCount.value > 0);
|
|
|
|
|
+
|
|
|
// 所有叶子节点音频是否全部生成完成
|
|
// 所有叶子节点音频是否全部生成完成
|
|
|
const allLeafAudioCompleted = computed(() => {
|
|
const allLeafAudioCompleted = computed(() => {
|
|
|
if (!currentBook.value?.chapters?.length) return false;
|
|
if (!currentBook.value?.chapters?.length) return false;
|
|
@@ -459,6 +518,8 @@ const showManualAudioHint = computed(() => {
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
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;
|
|
|
|
|
+// outline_ready 状态下的"等待后端自愈"轮询定时器(间隔较长,避免过多请求)
|
|
|
|
|
+let outlineReadyPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
|
|
// 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
|
|
// 卡死检测:进度/阶段长时间无变化则判定后台已停(避免永久"生成中"转圈)
|
|
|
const stalledTip = ref(false); // 是否疑似卡死
|
|
const stalledTip = ref(false); // 是否疑似卡死
|
|
@@ -466,6 +527,9 @@ let lastProgressSnapshot = ''; // 上次的 progress+genStage 快照
|
|
|
let stalledTicks = 0; // 连续无变化的轮询次数
|
|
let stalledTicks = 0; // 连续无变化的轮询次数
|
|
|
const STALLED_THRESHOLD = 40; // 40 次 × 3s ≈ 2 分钟无变化判定卡死
|
|
const STALLED_THRESHOLD = 40; // 40 次 × 3s ≈ 2 分钟无变化判定卡死
|
|
|
|
|
|
|
|
|
|
+// 质量报告折叠
|
|
|
|
|
+const reportExpanded = ref(false);
|
|
|
|
|
+
|
|
|
// 计算属性
|
|
// 计算属性
|
|
|
const completedChapters = computed(() => {
|
|
const completedChapters = computed(() => {
|
|
|
if (!currentBook.value) return 0;
|
|
if (!currentBook.value) return 0;
|
|
@@ -716,8 +780,18 @@ function goBack() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadBook(id: string) {
|
|
async function loadBook(id: string) {
|
|
|
|
|
+ // 每次重试前先清空错误状态
|
|
|
|
|
+ loadError.value = null;
|
|
|
try {
|
|
try {
|
|
|
currentBook.value = await api.getBook(id);
|
|
currentBook.value = await api.getBook(id);
|
|
|
|
|
+ if (!currentBook.value) {
|
|
|
|
|
+ loadError.value = {
|
|
|
|
|
+ title: '书籍不存在',
|
|
|
|
|
+ message: '该书籍可能已被删除,或当前账号无权限访问。',
|
|
|
|
|
+ recoverable: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
// 同步 generating 状态(只要不是最终完成状态,就开始轮询)
|
|
// 同步 generating 状态(只要不是最终完成状态,就开始轮询)
|
|
|
const stage = currentBook.value.genStage;
|
|
const stage = currentBook.value.genStage;
|
|
|
const failedStage = currentBook.value.failedStage;
|
|
const failedStage = currentBook.value.failedStage;
|
|
@@ -728,12 +802,35 @@ async function loadBook(id: string) {
|
|
|
generating.value = true;
|
|
generating.value = true;
|
|
|
}
|
|
}
|
|
|
// 开始生成时(包括 content_completed 后台生成音频)都要轮询,失败状态不轮询
|
|
// 开始生成时(包括 content_completed 后台生成音频)都要轮询,失败状态不轮询
|
|
|
- if (stage && stage !== 'video_completed' && !isFailed) {
|
|
|
|
|
|
|
+ if (stage === 'outline_ready') {
|
|
|
|
|
+ // 大纲就绪、等用户操作/或后端 book-recovery-scanner 自动推进:每 30 秒降频轮询
|
|
|
|
|
+ startOutlineReadyPolling(id);
|
|
|
|
|
+ } else if (stage && stage !== 'video_completed' && !isFailed) {
|
|
|
startPollingProgress(id);
|
|
startPollingProgress(id);
|
|
|
}
|
|
}
|
|
|
- } catch (e) {
|
|
|
|
|
|
|
+ } catch (e: any) {
|
|
|
console.error('加载书籍失败:', e);
|
|
console.error('加载书籍失败:', e);
|
|
|
- uni.showToast({ title: '加载失败', icon: 'none' });
|
|
|
|
|
|
|
+ // 致命错误落到页面(不要只 toast 一闪而过),让用户明确知道发生了什么
|
|
|
|
|
+ const raw = (e?.message || '').trim();
|
|
|
|
|
+ const msg = raw || '网络请求失败,请稍后重试';
|
|
|
|
|
+ const looksLikeNotExist = /书籍不存在|不存在|not\s*found|404/i.test(msg);
|
|
|
|
|
+ loadError.value = {
|
|
|
|
|
+ title: looksLikeNotExist ? '书籍不存在' : '加载失败',
|
|
|
|
|
+ message: looksLikeNotExist
|
|
|
|
|
+ ? '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。'
|
|
|
|
|
+ : msg,
|
|
|
|
|
+ recoverable: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 返回书籍列表(致命错误时给用户一个明确出口)
|
|
|
|
|
+function goBackToList() {
|
|
|
|
|
+ const pages = getCurrentPages();
|
|
|
|
|
+ if (pages.length > 1) {
|
|
|
|
|
+ uni.navigateBack();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.reLaunch({ url: '/pages/book-generator/index' });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -766,6 +863,7 @@ function startPollingProgress(bookId: string) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
// 以下状态认为生成已完成,停止轮询
|
|
// 以下状态认为生成已完成,停止轮询
|
|
|
|
|
+ // outline_ready 不在 doneStages 里——它有专门的降频轮询 startOutlineReadyPolling 检测后端自愈
|
|
|
const doneStages = ['content_completed', 'audio_completed', 'video_completed', 'failed'];
|
|
const doneStages = ['content_completed', 'audio_completed', 'video_completed', 'failed'];
|
|
|
if (doneStages.includes(book.genStage)) {
|
|
if (doneStages.includes(book.genStage)) {
|
|
|
stopPollingProgress();
|
|
stopPollingProgress();
|
|
@@ -805,6 +903,53 @@ function startPollingProgress(bookId: string) {
|
|
|
}, 3000);
|
|
}, 3000);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * outline_ready 状态的降频轮询:每 30 秒拉一次,检测后端 book-recovery-scanner 是否自动推进
|
|
|
|
|
+ * 若 genStage 变为 content_generating / generating_content / audio_generating 等活跃态,
|
|
|
|
|
+ * 自动切回正常 polling,让用户实时看到进度
|
|
|
|
|
+ */
|
|
|
|
|
+function startOutlineReadyPolling(bookId: string) {
|
|
|
|
|
+ stopOutlineReadyPolling();
|
|
|
|
|
+ console.log('[Detail] 启动 outline_ready 降频轮询(30s 间隔)');
|
|
|
|
|
+ outlineReadyPollTimer = setInterval(async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const book = await api.getBook(bookId);
|
|
|
|
|
+ if (!book) return;
|
|
|
|
|
+ const newStage = book.genStage as string;
|
|
|
|
|
+ // 一旦进入"活跃生成"状态,切回正常 polling
|
|
|
|
|
+ const activeStages = ['content_generating', 'audio_generating', 'video_generating', 'generating_content', 'outline_generating'];
|
|
|
|
|
+ if (activeStages.includes(newStage)) {
|
|
|
|
|
+ console.log(`[Detail] 检测到后端已自愈推进 (genStage=${newStage}),切回正常轮询`);
|
|
|
|
|
+ if (currentBook.value) {
|
|
|
|
|
+ currentBook.value.genStage = newStage as any;
|
|
|
|
|
+ currentBook.value.progress = book.progress;
|
|
|
|
|
+ }
|
|
|
|
|
+ stopOutlineReadyPolling();
|
|
|
|
|
+ startPollingProgress(bookId);
|
|
|
|
|
+ generating.value = true;
|
|
|
|
|
+ } else if (newStage !== 'outline_ready') {
|
|
|
|
|
+ // 变成其它稳定态(completed/failed 等),停止降频轮询
|
|
|
|
|
+ stopOutlineReadyPolling();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 还在 outline_ready:更新一下 progress(虽然后端没改)
|
|
|
|
|
+ if (currentBook.value && book.progress !== currentBook.value.progress) {
|
|
|
|
|
+ currentBook.value.progress = book.progress;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('[Detail] outline_ready 降频轮询失败:', e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 30_000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function stopOutlineReadyPolling() {
|
|
|
|
|
+ if (outlineReadyPollTimer) {
|
|
|
|
|
+ clearInterval(outlineReadyPollTimer);
|
|
|
|
|
+ outlineReadyPollTimer = null;
|
|
|
|
|
+ console.log('[Detail] outline_ready 降频轮询已停止');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function stopPollingProgress() {
|
|
function stopPollingProgress() {
|
|
|
if (pollTimer) {
|
|
if (pollTimer) {
|
|
|
clearInterval(pollTimer);
|
|
clearInterval(pollTimer);
|
|
@@ -866,6 +1011,65 @@ function stopPollingVideoStatus() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// interactive 模式下 outline 已就绪,触发正文内容生成(调 /interactive/generate)
|
|
|
|
|
+async function handleStartContentGenerate() {
|
|
|
|
|
+ if (!currentBook.value || outlineStarting.value) return;
|
|
|
|
|
+ const bookId = currentBook.value.id;
|
|
|
|
|
+ outlineStarting.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ uni.showLoading({ title: '启动内容生成...', mask: true });
|
|
|
|
|
+ const res: any = await post(`/book-generator/langgraph/books/${bookId}/interactive/generate`, {});
|
|
|
|
|
+ uni.hideLoading();
|
|
|
|
|
+ // 成功后端返回 genStage=generating_content,前端轮询会自然跟进进度
|
|
|
|
|
+ if (currentBook.value) {
|
|
|
|
|
+ currentBook.value.genStage = (res?.genStage || 'content_generating') as any;
|
|
|
|
|
+ currentBook.value.progress = 10;
|
|
|
|
|
+ }
|
|
|
|
|
+ uni.showToast({ title: '已开始生成内容', icon: 'success' });
|
|
|
|
|
+ // 重新拉取详情(后端可能立刻推进 genStage)
|
|
|
|
|
+ await loadBook(bookId);
|
|
|
|
|
+ // 启动轮询
|
|
|
|
|
+ startPollingProgress(bookId);
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ uni.hideLoading();
|
|
|
|
|
+ uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
|
|
|
|
|
+ console.error('[handleStartContentGenerate] 失败:', e);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ outlineStarting.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 手动补全缺失章节(不依赖 LangGraph,绕过 rich_outline 超时问题)
|
|
|
|
|
+async function handleFillMissing() {
|
|
|
|
|
+ if (!currentBook.value || fillingMissing.value) return;
|
|
|
|
|
+ const bookId = currentBook.value.id;
|
|
|
|
|
+ fillingMissing.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ uni.showLoading({ title: '启动补全...', mask: true });
|
|
|
|
|
+ const res: any = await post(`/book-generator/langgraph/books/${bookId}/fill-missing`, {});
|
|
|
|
|
+ uni.hideLoading();
|
|
|
|
|
+ if (res?.data?.missingCount === 0) {
|
|
|
|
|
+ uni.showToast({ title: '书籍已完整', icon: 'success' });
|
|
|
|
|
+ await loadBook(bookId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const cnt = res?.data?.missingCount || '?';
|
|
|
|
|
+ uni.showToast({ title: `已启动补全 ${cnt} 节`, icon: 'success' });
|
|
|
|
|
+ // 立即刷新页面状态,启动 polling 跟进
|
|
|
|
|
+ if (currentBook.value) {
|
|
|
|
|
+ currentBook.value.genStage = 'generating_content' as any;
|
|
|
|
|
+ }
|
|
|
|
|
+ await loadBook(bookId);
|
|
|
|
|
+ startPollingProgress(bookId);
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ uni.hideLoading();
|
|
|
|
|
+ uni.showToast({ title: e?.message || '启动失败', icon: 'none' });
|
|
|
|
|
+ console.error('[handleFillMissing] 失败:', e);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ fillingMissing.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function handleRetryGenerate() {
|
|
async function handleRetryGenerate() {
|
|
|
if (!currentBook.value) return;
|
|
if (!currentBook.value) return;
|
|
|
uni.showModal({
|
|
uni.showModal({
|
|
@@ -1073,7 +1277,8 @@ function viewAfterword() {
|
|
|
|
|
|
|
|
onLoad((query: any) => {
|
|
onLoad((query: any) => {
|
|
|
if (query?.id) {
|
|
if (query?.id) {
|
|
|
- loadBook(query.id);
|
|
|
|
|
|
|
+ currentBookId.value = String(query.id);
|
|
|
|
|
+ loadBook(currentBookId.value);
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -1084,7 +1289,8 @@ onMounted(() => {
|
|
|
const hash = window.location.hash;
|
|
const hash = window.location.hash;
|
|
|
const match = hash.match(/detail\?id=([^&]+)/);
|
|
const match = hash.match(/detail\?id=([^&]+)/);
|
|
|
if (match && !currentBook.value) {
|
|
if (match && !currentBook.value) {
|
|
|
- loadBook(decodeURIComponent(match[1]));
|
|
|
|
|
|
|
+ currentBookId.value = decodeURIComponent(match[1]);
|
|
|
|
|
+ loadBook(currentBookId.value);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
// #endif
|
|
// #endif
|
|
@@ -1209,6 +1415,26 @@ onUnmounted(() => {
|
|
|
.failed-hint { font-size: 24rpx; color: #6b7280; display: block; text-align: center; margin-bottom: 16rpx; }
|
|
.failed-hint { font-size: 24rpx; color: #6b7280; display: block; text-align: center; margin-bottom: 16rpx; }
|
|
|
.retry-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; border: none; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
|
|
.retry-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; border: none; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
|
|
|
|
|
|
|
|
|
|
+/* 质量报告卡片(成功路径,不是错误) */
|
|
|
|
|
+.report-card { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #ecfeff 0%, #cffafe 100%); border-radius: 16rpx; border: 2rpx solid #67e8f9; }
|
|
|
|
|
+.report-header { display: flex; align-items: center; gap: 12rpx; }
|
|
|
|
|
+.report-icon { font-size: 32rpx; flex-shrink: 0; }
|
|
|
|
|
+.report-title { flex: 1; font-size: 28rpx; font-weight: 600; color: #155e75; }
|
|
|
|
|
+.report-toggle { font-size: 24rpx; color: #0e7490; font-weight: 500; }
|
|
|
|
|
+.report-body { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid rgba(14, 116, 144, 0.2); }
|
|
|
|
|
+.report-item { margin-bottom: 12rpx; }
|
|
|
|
|
+.report-label { font-size: 24rpx; font-weight: 600; color: #155e75; display: block; margin-bottom: 6rpx; }
|
|
|
|
|
+.report-text { font-size: 24rpx; color: #0e7490; line-height: 1.6; display: block; }
|
|
|
|
|
+
|
|
|
|
|
+/* 致命错误卡:书籍不存在 / 加载失败 / 权限不足等,全屏卡片式提示 */
|
|
|
|
|
+.fatal-error-card { display: flex; flex-direction: column; align-items: center; padding: 64rpx 32rpx; margin-top: 40rpx; }
|
|
|
|
|
+.fatal-error-icon { font-size: 96rpx; display: block; margin-bottom: 24rpx; }
|
|
|
|
|
+.fatal-error-title { font-size: 36rpx; font-weight: 700; color: #dc2626; display: block; margin-bottom: 16rpx; text-align: center; }
|
|
|
|
|
+.fatal-error-message { font-size: 28rpx; color: #4b5563; line-height: 1.6; text-align: center; margin-bottom: 40rpx; padding: 0 24rpx; }
|
|
|
|
|
+.fatal-error-actions { display: flex; flex-direction: column; gap: 20rpx; width: 100%; max-width: 600rpx; }
|
|
|
|
|
+.fatal-error-btn { width: 100%; height: 80rpx; background: #ffffff; color: #374151; border: 2rpx solid #d1d5db; border-radius: 12rpx; font-size: 28rpx; font-weight: 600; }
|
|
|
|
|
+.fatal-error-btn.primary { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white; border: none; }
|
|
|
|
|
+
|
|
|
.outline-theme { font-size: 24rpx; color: #6b7280; }
|
|
.outline-theme { font-size: 24rpx; color: #6b7280; }
|
|
|
|
|
|
|
|
/* 章节搜索工具栏 */
|
|
/* 章节搜索工具栏 */
|