|
@@ -104,6 +104,38 @@
|
|
|
</view>
|
|
</view>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
|
|
+ <!-- 分阶段进度(解决进度条卡 95% 不动的问题:内容完成后继续展示音频/合并/完成阶段) -->
|
|
|
|
|
+ <view v-if="currentBook && (isCurrentlyGenerating || isContentCompleted)" class="stage-progress-card">
|
|
|
|
|
+ <view class="stage-progress-header">
|
|
|
|
|
+ <text class="stage-progress-icon">📊</text>
|
|
|
|
|
+ <text class="stage-progress-title">生成进度</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="stage-progress-list">
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="(stage, idx) in stageProgress"
|
|
|
|
|
+ :key="stage.key"
|
|
|
|
|
+ class="stage-row"
|
|
|
|
|
+ :class="['stage-' + stage.status]"
|
|
|
|
|
+ >
|
|
|
|
|
+ <view class="stage-icon-cell">
|
|
|
|
|
+ <text class="stage-icon">{{ stage.status === 'done' ? '✓' : (stage.status === 'active' ? '◐' : '○') }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="stage-content-cell">
|
|
|
|
|
+ <view class="stage-line">
|
|
|
|
|
+ <text class="stage-name">{{ stage.name }}</text>
|
|
|
|
|
+ <text class="stage-percent">{{ stage.percent }}%</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="stage.status !== 'pending' || stage.percent > 0" class="stage-bar-wrap">
|
|
|
|
|
+ <view class="stage-bar">
|
|
|
|
|
+ <view class="stage-bar-fill" :style="{ width: stage.percent + '%' }"></view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="stage-detail">{{ stage.detail }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
<!-- 实时配额显示 -->
|
|
<!-- 实时配额显示 -->
|
|
|
<view v-if="isCurrentlyGenerating" class="quota-monitor">
|
|
<view v-if="isCurrentlyGenerating" class="quota-monitor">
|
|
|
<text class="quota-monitor-title">🎧 额度监控</text>
|
|
<text class="quota-monitor-title">🎧 额度监控</text>
|
|
@@ -430,6 +462,11 @@ const currentBook = ref<Book | null>(null);
|
|
|
// 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
|
|
// 当前正在查看的 bookId(来自 onLoad 的 query.id / H5 hash),供致命错误时重试用
|
|
|
const currentBookId = ref<string>('');
|
|
const currentBookId = ref<string>('');
|
|
|
|
|
|
|
|
|
|
+// 音频生成阶段进度(由 /books/:id/audio-status 轮询填充)
|
|
|
|
|
+const audioStatus = ref<{ total: number; completed: number; pending: number; allCompleted: boolean }>({
|
|
|
|
|
+ total: 0, completed: 0, pending: 0, allCompleted: false,
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
// 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
|
|
// 加载书籍时的致命错误(如"书籍不存在"、网络不可达、权限拒绝等)
|
|
|
// 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
|
|
// 不只是 toast 一闪而过,而是落到页面让用户看清,并可返回书籍列表
|
|
|
const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
|
|
const loadError = ref<{ title: string; message: string; recoverable: boolean } | null>(null);
|
|
@@ -541,6 +578,56 @@ const STALLED_THRESHOLD = 40; // 40 次 × 3s ≈ 2 分钟无变化
|
|
|
// 质量报告折叠
|
|
// 质量报告折叠
|
|
|
const reportExpanded = ref(false);
|
|
const reportExpanded = ref(false);
|
|
|
|
|
|
|
|
|
|
+// 分阶段进度:把生成流程拆成 6 个阶段,每阶段返回 { name, status, detail, percent }
|
|
|
|
|
+// status: 'done' | 'active' | 'pending'
|
|
|
|
|
+// 解决原页面"内容生成完进度卡在 95% 不动"的问题——音频/合并阶段独立显示。
|
|
|
|
|
+const stageProgress = computed(() => {
|
|
|
|
|
+ const b = currentBook.value;
|
|
|
|
|
+ if (!b) return [];
|
|
|
|
|
+ const gs = b.genStage || 'draft';
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 大纲
|
|
|
|
|
+ const outlineDone = ['outline_ready', 'outline_completed', 'content_generating', 'content_generating_timeout', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(gs);
|
|
|
|
|
+ const outlineActive = gs === 'outlining';
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 内容:用 book.progress (0-95) + completedChapters / totalChapters 推算
|
|
|
|
|
+ // book.progress 包含了"内容"的整体进度,0~95 区间
|
|
|
|
|
+ const contentPct = Math.min(100, Math.max(0, Math.round(((b.progress ?? 0) / 95) * 100)));
|
|
|
|
|
+ const contentDone = ['content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(gs);
|
|
|
|
|
+ const contentActive = gs === 'content_generating';
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 前言/后记(content_completed 之后才生成,耗时短,不单独轮询进度)
|
|
|
|
|
+ const postDone = contentDone; // 只要内容完结,前言/后记也已经包含在 content 阶段
|
|
|
|
|
+ const postActive = false;
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 音频:用 audioStatus 实时填充
|
|
|
|
|
+ const aTotal = audioStatus.value.total;
|
|
|
|
|
+ const aDone = audioStatus.value.completed;
|
|
|
|
|
+ const aPct = aTotal > 0 ? Math.round((aDone / aTotal) * 100) : 0;
|
|
|
|
|
+ const audioDone = aTotal > 0 && audioStatus.value.allCompleted;
|
|
|
|
|
+ const audioActive = gs === 'audio_generating';
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 合并章节音频:audio_done 之后开始(暂无独立进度字段)
|
|
|
|
|
+ const mergeDone = audioDone && (gs === 'audio_completed' || gs === 'video_completed');
|
|
|
|
|
+ const mergeActive = audioDone && gs !== 'audio_completed' && gs !== 'video_completed';
|
|
|
|
|
+
|
|
|
|
|
+ // 6. 整体完成
|
|
|
|
|
+ const allDone = gs === 'audio_completed' || gs === 'video_completed';
|
|
|
|
|
+
|
|
|
|
|
+ const ch = b.chapters || [];
|
|
|
|
|
+ const totalCh = ch.length;
|
|
|
|
|
+ const contentDoneCh = ch.filter((c: any) => c.genStage === 'content_completed' || c.genStage === 'audio_completed' || c.genStage === 'video_completed').length;
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ { key: 'outline', name: '📝 大纲生成', status: outlineDone ? 'done' : (outlineActive ? 'active' : 'pending'), detail: outlineDone ? '已完成' : (outlineActive ? '生成中...' : '等待'), percent: outlineDone ? 100 : 0 },
|
|
|
|
|
+ { key: 'content', name: '✍️ 内容生成', status: contentDone ? 'done' : (contentActive ? 'active' : 'pending'), detail: contentDone ? `已完成 ${contentDoneCh}/${totalCh}` : (contentActive ? `生成中 ${contentDoneCh}/${totalCh}` : '等待'), percent: contentDone ? 100 : contentPct },
|
|
|
|
|
+ { key: 'post', name: '📖 前言/后记', status: postDone ? 'done' : (postActive ? 'active' : 'pending'), detail: postDone ? '已完成' : (postActive ? '生成中...' : '等待'), percent: postDone ? 100 : 0 },
|
|
|
|
|
+ { key: 'audio', name: '🎵 音频生成', status: audioDone ? 'done' : (audioActive ? 'active' : (aTotal > 0 ? 'active' : 'pending')), detail: aTotal > 0 ? `${aDone}/${aTotal} 节` : '等待', percent: audioDone ? 100 : aPct },
|
|
|
|
|
+ { key: 'merge', name: '🔗 章节合并', status: mergeDone ? 'done' : (mergeActive ? 'active' : 'pending'), detail: mergeDone ? '已完成' : (mergeActive ? '合并中...' : '等待'), percent: mergeDone ? 100 : 0 },
|
|
|
|
|
+ { key: 'done', name: '✅ 全部完成', status: allDone ? 'done' : 'pending', detail: allDone ? '可播放/下载' : '等待', percent: allDone ? 100 : 0 },
|
|
|
|
|
+ ];
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
// 计算属性
|
|
// 计算属性
|
|
|
const completedChapters = computed(() => {
|
|
const completedChapters = computed(() => {
|
|
|
if (!currentBook.value) return 0;
|
|
if (!currentBook.value) return 0;
|
|
@@ -975,6 +1062,13 @@ function startPollingAudioStatus(bookId: string) {
|
|
|
audioPollTimer = setInterval(async () => {
|
|
audioPollTimer = setInterval(async () => {
|
|
|
try {
|
|
try {
|
|
|
const status = await api.getAudioStatus(bookId);
|
|
const status = await api.getAudioStatus(bookId);
|
|
|
|
|
+ // 实时回填 audioStatus(驱动 detail 页的"分阶段进度"展示)
|
|
|
|
|
+ audioStatus.value = {
|
|
|
|
|
+ total: status.total ?? 0,
|
|
|
|
|
+ completed: status.completed ?? 0,
|
|
|
|
|
+ pending: status.pending ?? 0,
|
|
|
|
|
+ allCompleted: !!status.allCompleted,
|
|
|
|
|
+ };
|
|
|
if (status.allCompleted) {
|
|
if (status.allCompleted) {
|
|
|
stopPollingAudioStatus();
|
|
stopPollingAudioStatus();
|
|
|
generatingAudio.value = false;
|
|
generatingAudio.value = false;
|
|
@@ -1421,6 +1515,37 @@ onUnmounted(() => {
|
|
|
.status-tip { padding: 12rpx; background: rgba(255, 255, 255, 0.1); border-radius: 8rpx; }
|
|
.status-tip { padding: 12rpx; background: rgba(255, 255, 255, 0.1); border-radius: 8rpx; }
|
|
|
.tip-text { font-size: 24rpx; opacity: 0.9; }
|
|
.tip-text { font-size: 24rpx; opacity: 0.9; }
|
|
|
|
|
|
|
|
|
|
+/* 分阶段进度卡片(生成中 + 内容完成后都显示) */
|
|
|
|
|
+.stage-progress-card { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 16rpx; border: 2rpx solid #7dd3fc; }
|
|
|
|
|
+.stage-progress-header { display: flex; align-items: center; margin-bottom: 16rpx; padding-bottom: 12rpx; border-bottom: 2rpx solid rgba(125, 211, 252, 0.3); }
|
|
|
|
|
+.stage-progress-icon { font-size: 32rpx; margin-right: 8rpx; }
|
|
|
|
|
+.stage-progress-title { font-size: 30rpx; font-weight: 600; color: #075985; }
|
|
|
|
|
+.stage-progress-list { display: flex; flex-direction: column; gap: 12rpx; }
|
|
|
|
|
+.stage-row { display: flex; align-items: flex-start; padding: 14rpx 16rpx; background: rgba(255, 255, 255, 0.6); border-radius: 10rpx; border-left: 6rpx solid #cbd5e1; transition: all 0.3s; }
|
|
|
|
|
+.stage-row.stage-active { background: linear-gradient(90deg, #fef3c7 0%, #fffbeb 100%); border-left-color: #f59e0b; box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.15); }
|
|
|
|
|
+.stage-row.stage-done { background: linear-gradient(90deg, #d1fae5 0%, #ecfdf5 100%); border-left-color: #10b981; }
|
|
|
|
|
+.stage-icon-cell { width: 40rpx; flex-shrink: 0; display: flex; justify-content: center; padding-top: 4rpx; }
|
|
|
|
|
+.stage-icon { font-size: 32rpx; font-weight: 700; color: #94a3b8; }
|
|
|
|
|
+.stage-row.stage-active .stage-icon { color: #f59e0b; animation: stage-spin 1.6s linear infinite; }
|
|
|
|
|
+.stage-row.stage-done .stage-icon { color: #10b981; }
|
|
|
|
|
+@keyframes stage-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
|
|
|
+.stage-content-cell { flex: 1; min-width: 0; }
|
|
|
|
|
+.stage-line { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6rpx; }
|
|
|
|
|
+.stage-name { font-size: 28rpx; font-weight: 500; color: #334155; }
|
|
|
|
|
+.stage-row.stage-active .stage-name { color: #92400e; font-weight: 600; }
|
|
|
|
|
+.stage-row.stage-done .stage-name { color: #065f46; }
|
|
|
|
|
+.stage-percent { font-size: 24rpx; color: #64748b; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.stage-row.stage-active .stage-percent { color: #b45309; }
|
|
|
|
|
+.stage-row.stage-done .stage-percent { color: #047857; }
|
|
|
|
|
+.stage-bar-wrap { margin: 4rpx 0; }
|
|
|
|
|
+.stage-bar { height: 8rpx; background: rgba(203, 213, 225, 0.4); border-radius: 4rpx; overflow: hidden; }
|
|
|
|
|
+.stage-bar-fill { height: 100%; background: linear-gradient(90deg, #94a3b8 0%, #64748b 100%); border-radius: 4rpx; transition: width 0.4s ease; }
|
|
|
|
|
+.stage-row.stage-active .stage-bar-fill { background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); }
|
|
|
|
|
+.stage-row.stage-done .stage-bar-fill { background: linear-gradient(90deg, #34d399 0%, #10b981 100%); }
|
|
|
|
|
+.stage-detail { font-size: 22rpx; color: #64748b; margin-top: 4rpx; }
|
|
|
|
|
+.stage-row.stage-active .stage-detail { color: #92400e; }
|
|
|
|
|
+.stage-row.stage-done .stage-detail { color: #065f46; }
|
|
|
|
|
+
|
|
|
.failed-tip { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border-radius: 16rpx; border: 2rpx solid #fca5a5; }
|
|
.failed-tip { margin-top: 24rpx; padding: 24rpx; background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); border-radius: 16rpx; border: 2rpx solid #fca5a5; }
|
|
|
.failed-icon { font-size: 40rpx; display: block; text-align: center; margin-bottom: 12rpx; }
|
|
.failed-icon { font-size: 40rpx; display: block; text-align: center; margin-bottom: 12rpx; }
|
|
|
.failed-text { font-size: 32rpx; font-weight: 600; color: #dc2626; display: block; text-align: center; margin-bottom: 12rpx; }
|
|
.failed-text { font-size: 32rpx; font-weight: 600; color: #dc2626; display: block; text-align: center; margin-bottom: 12rpx; }
|