Browse Source

feat: 添加章节详情页,章节在新页面打开

- 新增 chapter-detail.vue 章节详情页
- 章节点击跳转到独立页面,支持上一章/下一章导航
- 前言/后记点击弹窗显示
- 支持音频/视频播放和生成
- 更新 pages.json 注册新路由
MyFramework User 4 months ago
parent
commit
b797d19fc2

+ 6 - 0
my-uniapp-vue3/src/pages.json

@@ -74,6 +74,12 @@
         "navigationStyle": "custom"
         "navigationStyle": "custom"
       }
       }
     },
     },
+    {
+      "path": "pages/book-generator/chapter-detail",
+      "style": {
+        "navigationStyle": "custom"
+      }
+    },
     {
     {
       "path": "pages/ai-generate/index",
       "path": "pages/ai-generate/index",
       "style": {
       "style": {

+ 518 - 0
my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

@@ -0,0 +1,518 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航栏 -->
+    <view class="nav-bar">
+      <view class="nav-content">
+        <view class="nav-left" @click="goBack">
+          <text class="back-icon">←</text>
+        </view>
+        <text class="page-title">{{ chapterTitle || '章节详情' }}</text>
+        <view class="nav-right"></view>
+      </view>
+    </view>
+
+    <!-- 章节内容 -->
+    <scroll-view class="chapter-content" scroll-y="true">
+      <!-- 章节头部信息 -->
+      <view class="chapter-header">
+        <text class="chapter-num-badge">第{{ chapterNumber }}章</text>
+        <text class="chapter-title-large">{{ chapterTitle }}</text>
+        <view class="chapter-meta">
+          <text class="word-count">{{ chapterWordCount }}字</text>
+          <view v-if="chapterAudioUrl" class="media-badge audio-badge">
+            <text>🎵 音频</text>
+          </view>
+          <view v-if="chapterVideoUrl" class="media-badge video-badge">
+            <text>🎬 视频</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 章节正文 -->
+      <view class="chapter-body">
+        <text class="chapter-text">{{ chapterContent }}</text>
+      </view>
+
+      <!-- 底部操作区 -->
+      <view class="chapter-actions">
+        <button
+          v-if="chapterStatus === 'completed' && !chapterAudioUrl"
+          class="action-btn audio-btn"
+          :disabled="generatingAudio"
+          @click="handleGenerateAudio"
+        >
+          {{ generatingAudio ? '生成中...' : '🎵 生成音频' }}
+        </button>
+        <button
+          v-if="chapterStatus === 'completed' && chapterAudioUrl && !chapterVideoUrl"
+          class="action-btn video-btn"
+          :disabled="generatingVideo"
+          @click="handleGenerateVideo"
+        >
+          {{ generatingVideo ? '生成中...' : '🎬 生成视频' }}
+        </button>
+
+        <!-- 音频播放器 -->
+        <view v-if="chapterAudioUrl" class="audio-player">
+          <view class="audio-player-header">
+            <text class="audio-player-title">🎵 音频播放</text>
+          </view>
+          <view class="audio-controls">
+            <button class="play-btn" @click="toggleAudio">
+              {{ isAudioPlaying ? '⏸ 暂停' : '▶ 播放' }}
+            </button>
+            <text class="audio-time">{{ audioCurrentTime }} / {{ audioDuration }}</text>
+          </view>
+        </view>
+
+        <!-- 视频播放器 -->
+        <view v-if="chapterVideoUrl" class="video-player">
+          <view class="video-player-header">
+            <text class="video-player-title">🎬 视频播放</text>
+          </view>
+          <button class="play-btn video-play-btn" @click="playVideo">
+            ▶ 播放视频
+          </button>
+        </view>
+      </view>
+
+      <!-- 章节导航 -->
+      <view class="chapter-nav">
+        <view
+          v-if="prevChapter"
+          class="nav-btn prev-btn"
+          @click="goToChapter(prevChapter)"
+        >
+          <text class="nav-arrow">←</text>
+          <view class="nav-info">
+            <text class="nav-label">上一章</text>
+            <text class="nav-title">{{ prevChapter.title }}</text>
+          </view>
+        </view>
+        <view v-else class="nav-btn disabled">
+          <text class="nav-label">没有上一章</text>
+        </view>
+
+        <view
+          v-if="nextChapter"
+          class="nav-btn next-btn"
+          @click="goToChapter(nextChapter)"
+        >
+          <view class="nav-info">
+            <text class="nav-label">下一章</text>
+            <text class="nav-title">{{ nextChapter.title }}</text>
+          </view>
+          <text class="nav-arrow">→</text>
+        </view>
+        <view v-else class="nav-btn disabled">
+          <text class="nav-label">没有下一章</text>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
+import * as api from '../../utils/book-generator-api';
+import type { Book, Chapter } from '../../utils/book-generator-api';
+
+// 章节信息
+const bookId = ref<string>('');
+const chapterNumber = ref<number>(0);
+const chapterTitle = ref<string>('');
+const chapterContent = ref<string>('');
+const chapterWordCount = ref<number>(0);
+const chapterStatus = ref<string>('pending');
+const chapterAudioUrl = ref<string>('');
+const chapterVideoUrl = ref<string>('');
+
+// 章节列表(用于上下章导航)
+const chapters = ref<Chapter[]>([]);
+const prevChapter = ref<Chapter | null>(null);
+const nextChapter = ref<Chapter | null>(null);
+
+// 加载状态
+const generatingAudio = ref(false);
+const generatingVideo = ref(false);
+
+// 音频播放状态
+const isAudioPlaying = ref(false);
+const audioCurrentTime = ref('00:00');
+const audioDuration = ref('00:00');
+
+// 音频上下文
+let audioContext: any = null;
+
+// 从 URL 获取参数
+function getParamsFromUrl(): { bookId: string; chapterNumber: number } | null {
+  if (typeof window !== 'undefined') {
+    const hash = window.location.hash;
+    const match = hash.match(/bookId=([^&]+)&chapter=(\d+)/);
+    if (match) {
+      return {
+        bookId: decodeURIComponent(match[1]),
+        chapterNumber: parseInt(match[2], 10)
+      };
+    }
+  }
+  return null;
+}
+
+// 初始化音频播放器
+function initAudioPlayer() {
+  if (audioContext) {
+    audioContext.destroy();
+  }
+  
+  if (typeof uni !== 'undefined' && chapterAudioUrl.value) {
+    audioContext = uni.createInnerAudioContext();
+    audioContext.src = chapterAudioUrl.value;
+    
+    audioContext.onTimeUpdate(() => {
+      if (audioContext.duration) {
+        audioDuration.value = formatTime(audioContext.duration);
+        audioCurrentTime.value = formatTime(audioContext.currentTime);
+      }
+    });
+    
+    audioContext.onEnded(() => {
+      isAudioPlaying.value = false;
+      audioCurrentTime.value = '00:00';
+    });
+    
+    audioContext.onError(() => {
+      isAudioPlaying.value = false;
+      uni.showToast({ title: '音频播放失败', icon: 'none' });
+    });
+  }
+}
+
+// 格式化时间
+function formatTime(seconds: number): string {
+  const mins = Math.floor(seconds / 60);
+  const secs = Math.floor(seconds % 60);
+  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+}
+
+// 切换音频播放
+function toggleAudio() {
+  if (!audioContext) {
+    initAudioPlayer();
+  }
+  
+  if (audioContext) {
+    if (isAudioPlaying.value) {
+      audioContext.pause();
+      isAudioPlaying.value = false;
+    } else {
+      audioContext.play();
+      isAudioPlaying.value = true;
+    }
+  }
+}
+
+// 播放视频
+function playVideo() {
+  if (chapterVideoUrl.value) {
+    uni.navigateTo({
+      url: `/pages/player/index?url=${encodeURIComponent(chapterVideoUrl.value)}&title=${encodeURIComponent(chapterTitle.value)}`
+    });
+  }
+}
+
+// 加载书籍和章节信息
+async function loadChapter() {
+  const params = getParamsFromUrl();
+  if (!params) {
+    uni.showToast({ title: '参数错误', icon: 'none' });
+    return;
+  }
+
+  bookId.value = params.bookId;
+  chapterNumber.value = params.chapterNumber;
+
+  try {
+    const book: Book = await api.getBook(params.bookId);
+    chapters.value = book.chapters || [];
+
+    const chapter = book.chapters.find((c) => c.number === params.chapterNumber);
+    if (chapter) {
+      chapterTitle.value = chapter.title;
+      chapterContent.value = chapter.content || '章节内容正在生成中...';
+      chapterWordCount.value = chapter.wordCount || 0;
+      chapterStatus.value = chapter.status;
+      chapterAudioUrl.value = chapter.audioUrl || '';
+      chapterVideoUrl.value = chapter.videoUrl || '';
+
+      if (typeof document !== 'undefined') {
+        document.title = `${chapterTitle.value} - 书籍详情`;
+      }
+
+      if (chapterAudioUrl.value) {
+        setTimeout(() => {
+          initAudioPlayer();
+        }, 100);
+      }
+    }
+
+    updatePrevNextChapters();
+  } catch (e) {
+    console.error('加载章节失败:', e);
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  }
+}
+
+// 更新上下章信息
+function updatePrevNextChapters() {
+  const currentIndex = chapters.value.findIndex(c => c.number === chapterNumber.value);
+  prevChapter.value = currentIndex > 0 ? chapters.value[currentIndex - 1] : null;
+  nextChapter.value = currentIndex < chapters.value.length - 1 ? chapters.value[currentIndex + 1] : null;
+}
+
+function goBack() {
+  uni.navigateBack();
+}
+
+function goToChapter(chapter: Chapter) {
+  uni.redirectTo({
+    url: `/pages/book-generator/chapter-detail?bookId=${encodeURIComponent(bookId.value)}&chapter=${chapter.number}`
+  });
+}
+
+async function handleGenerateAudio() {
+  generatingAudio.value = true;
+  try {
+    await api.generateChapterAudio(bookId.value, chapterNumber.value, 'cherry');
+    uni.showToast({ title: '音频生成任务已启动', icon: 'none', duration: 2000 });
+    setTimeout(async () => {
+      await loadChapter();
+    }, 3000);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generatingAudio.value = false;
+  }
+}
+
+async function handleGenerateVideo() {
+  generatingVideo.value = true;
+  try {
+    await api.generateChapterVideo(bookId.value, chapterNumber.value);
+    uni.showToast({ title: '视频生成任务已启动', icon: 'none', duration: 2000 });
+    setTimeout(async () => {
+      await loadChapter();
+    }, 3000);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '生成失败', icon: 'none' });
+  } finally {
+    generatingVideo.value = false;
+  }
+}
+
+onMounted(() => {
+  loadChapter();
+});
+
+onShow(() => {
+  loadChapter();
+});
+
+onUnmounted(() => {
+  if (audioContext) {
+    audioContext.destroy();
+    audioContext = null;
+  }
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f9fafb;
+}
+
+.nav-bar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 100;
+  background: #ffffff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.nav-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 88rpx;
+  padding: 0 32rpx;
+  padding-top: env(safe-area-inset-top);
+}
+
+.nav-left, .nav-right { width: 80rpx; }
+.back-icon { font-size: 40rpx; color: #1f2937; }
+.page-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+  max-width: 400rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.chapter-content {
+  height: 100vh;
+  padding-top: calc(88rpx + env(safe-area-inset-top));
+}
+
+.chapter-header {
+  padding: 32rpx;
+  background: #ffffff;
+  margin-bottom: 16rpx;
+}
+
+.chapter-num-badge {
+  display: inline-block;
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #ffffff;
+  background: #4f46e5;
+  padding: 6rpx 16rpx;
+  border-radius: 8rpx;
+  margin-bottom: 16rpx;
+}
+
+.chapter-title-large {
+  display: block;
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 16rpx;
+  line-height: 1.3;
+}
+
+.chapter-meta {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+
+.word-count { font-size: 26rpx; color: #9ca3af; }
+.media-badge { padding: 6rpx 16rpx; border-radius: 8rpx; font-size: 24rpx; }
+.audio-badge { background: rgba(16, 185, 129, 0.1); color: #059669; }
+.video-badge { background: rgba(245, 158, 11, 0.1); color: #f5576c; }
+
+.chapter-body {
+  background: #ffffff;
+  padding: 32rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.chapter-text {
+  font-size: 30rpx;
+  line-height: 2;
+  color: #374151;
+  text-align: justify;
+  white-space: pre-wrap;
+}
+
+.chapter-actions {
+  padding: 32rpx;
+  background: #ffffff;
+  margin-bottom: 16rpx;
+}
+
+.action-btn {
+  width: 100%;
+  height: 88rpx;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  margin-bottom: 20rpx;
+}
+
+.action-btn.audio-btn { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
+.action-btn.video-btn { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: #ffffff; }
+.action-btn[disabled] { opacity: 0.6; }
+
+.audio-player, .video-player {
+  margin-top: 24rpx;
+  padding: 24rpx;
+  border-radius: 16rpx;
+}
+
+.audio-player {
+  background: rgba(16, 185, 129, 0.05);
+  border: 1px solid rgba(16, 185, 129, 0.2);
+}
+
+.video-player {
+  background: rgba(245, 158, 11, 0.05);
+  border: 1px solid rgba(245, 158, 11, 0.2);
+}
+
+.audio-player-header, .video-player-header { margin-bottom: 16rpx; }
+.audio-player-title { font-size: 28rpx; font-weight: 600; color: #059669; }
+.video-player-title { font-size: 28rpx; font-weight: 600; color: #f5576c; }
+
+.audio-controls {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+
+.play-btn {
+  padding: 16rpx 32rpx;
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  color: #ffffff;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  border: none;
+}
+
+.video-play-btn { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
+.audio-time { font-size: 26rpx; color: #6b7280; }
+
+.chapter-nav {
+  display: flex;
+  justify-content: space-between;
+  padding: 32rpx;
+  background: #ffffff;
+  margin-bottom: 40rpx;
+}
+
+.nav-btn {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 20rpx;
+  background: #f9fafb;
+  border-radius: 12rpx;
+}
+
+.nav-btn.disabled { opacity: 0.5; justify-content: center; }
+.nav-btn.prev-btn { margin-right: 16rpx; }
+.nav-btn.next-btn { justify-content: flex-end; margin-left: 16rpx; }
+
+.nav-info { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
+.nav-arrow { font-size: 28rpx; color: #4f46e5; flex-shrink: 0; }
+.nav-label { font-size: 24rpx; color: #6b7280; }
+.nav-title {
+  font-size: 26rpx;
+  color: #1f2937;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 321 - 193
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -56,8 +56,8 @@
             <view v-if="book.progress > 0 && book.progress < 100" class="progress-bar" @click="openBook(book)">
             <view v-if="book.progress > 0 && book.progress < 100" class="progress-bar" @click="openBook(book)">
               <view class="progress-fill" :style="{ width: book.progress + '%' }"></view>
               <view class="progress-fill" :style="{ width: book.progress + '%' }"></view>
             </view>
             </view>
-            <!-- 批量生成音频按钮 -->
-            <view v-if="book.status === 'completed'" class="book-actions">
+            <!-- 批量生成音频按钮 - 只要有章节就显示 -->
+            <view v-if="book.chapters && book.chapters.length > 0" class="book-actions">
               <button
               <button
                 class="audio-btn"
                 class="audio-btn"
                 :disabled="generatingAudio"
                 :disabled="generatingAudio"
@@ -266,7 +266,7 @@
           </view>
           </view>
           
           
           <!-- 中断提示 -->
           <!-- 中断提示 -->
-          <view v-if="currentBook.status === 'interrupted'" class="interrupted-tip">
+          <view v-if="currentBook.status === 'failed' && currentBook?.error?.includes('额度')" class="interrupted-tip">
             <text class="interrupted-icon">⚠️</text>
             <text class="interrupted-icon">⚠️</text>
             <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
             <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
             <text class="interrupted-hint">可升级套餐后继续生成</text>
             <text class="interrupted-hint">可升级套餐后继续生成</text>
@@ -303,55 +303,65 @@
           </view>
           </view>
         </view>
         </view>
 
 
-        <!-- 操作按钮 -->
-        <view class="card action-card">
-          <!-- 未生成大纲:显示所有生成方式 -->
-          <view v-if="!currentBook?.outline && currentBook?.status !== 'planning'" class="btn-col">
-            <view class="btn-row">
-              <button
-                class="action-btn"
-                :disabled="generating"
-                @click="handleGenerateOutline"
-              >
-                {{ generating ? '生成中...' : '📋 生成大纲' }}
-              </button>
-              <button
-                class="action-btn primary"
-                :disabled="generating"
-                @click="handleGenerateAll"
-              >
-                {{ generating ? '生成中...' : '🚀 一键生成' }}
-              </button>
-              <button
-                class="action-btn langgraph-btn"
-                :disabled="generating"
-                @click="handleLangGraphGenerate"
-              >
-                {{ generating ? '生成中...' : '🤖 LangGraph' }}
-              </button>
+        <!-- 完整内容区 - 前言/后记/章节 -->
+        <view v-if="currentBook?.chapters && currentBook.chapters.length > 0" class="card content-card">
+          <text class="card-title">📖 完整内容</text>
+
+          <!-- 前言 -->
+          <view v-if="currentBook?.metadata?.foreword" class="content-item foreword" @click="viewForeword">
+            <view class="content-item-left">
+              <text class="content-label">前言</text>
+              <text class="content-word-count">{{ currentBook?.metadata?.foreword?.length || 0 }}字</text>
+            </view>
+            <view class="content-item-right">
+              <text class="expand-icon">→</text>
             </view>
             </view>
           </view>
           </view>
 
 
-          <!-- 大纲已生成,显示所有生成方式 -->
-          <view v-if="currentBook?.outline && currentBook.status !== 'generating'" class="btn-col">
-            <view class="btn-row">
-              <button class="action-btn primary" :disabled="generating" @click="handleGenerateAll">
-                {{ generating ? '生成中...' : '🚀 一键生成' }}
-              </button>
-              <button class="action-btn langgraph-btn" :disabled="generating" @click="handleLangGraphGenerate">
-                {{ generating ? '生成中...' : '🤖 LangGraph' }}
-              </button>
+          <!-- 章节列表 -->
+          <view
+            v-for="chapter in currentBook.chapters"
+            :key="chapter.id"
+            class="content-item chapter-item"
+            @click="goToChapterDetail(chapter)"
+          >
+            <view class="content-item-header">
+              <view class="content-item-left">
+                <text class="chapter-num-badge">第{{ chapter.number }}章</text>
+                <text class="chapter-title-short">{{ chapter.title }}</text>
+                <text v-if="chapter.audioUrl" class="audio-badge">🎵</text>
+                <text v-if="chapter.videoUrl" class="video-badge">🎬</text>
+              </view>
+              <view class="content-item-right">
+                <text class="expand-icon">→</text>
+              </view>
+            </view>
+          </view>
+
+          <!-- 后记 -->
+          <view v-if="currentBook?.metadata?.afterword" class="content-item afterword" @click="viewAfterword">
+            <view class="content-item-left">
+              <text class="content-label">后记</text>
+              <text class="content-word-count">{{ currentBook?.metadata?.afterword?.length || 0 }}字</text>
+            </view>
+            <view class="content-item-right">
+              <text class="expand-icon">→</text>
             </view>
             </view>
+          </view>
+        </view>
+
+        <!-- 操作按钮 -->
+        <view class="card action-card">
+          <!-- LangGraph 生成按钮 -->
+          <view class="btn-col">
             <view class="btn-row">
             <view class="btn-row">
-              <button class="action-btn" @click="handleGenerateForeword">前言</button>
               <button
               <button
-                class="action-btn"
+                class="action-btn langgraph-btn"
                 :disabled="generating"
                 :disabled="generating"
-                @click="handleGenerateAllChapters"
+                @click="handleLangGraphGenerate"
               >
               >
-                {{ generating ? '生成中...' : '📝 章节' }}
+                {{ generating ? '生成中...' : '🤖 开始生成' }}
               </button>
               </button>
-              <button class="action-btn" @click="handleGenerateAfterword">后记</button>
             </view>
             </view>
           </view>
           </view>
 
 
@@ -360,118 +370,26 @@
             <text>正在生成中,请稍候...</text>
             <text>正在生成中,请稍候...</text>
           </view>
           </view>
 
 
-          <!-- 查看完整内容 -->
-          <button
-            v-if="currentBook?.status === 'completed'"
-            class="action-btn primary full-width"
-            @click="showFullContent"
-          >
-            📖 查看完整书籍
-          </button>
+          <!-- 无章节警告 -->
+          <view v-if="currentBook?.status === 'completed' && (!currentBook.chapters || currentBook.chapters.length === 0)" class="warning-tip">
+            <text class="warning-icon">⚠️</text>
+            <text class="warning-text">该书籍状态标记为已完成,但没有任何章节内容。</text>
+            <text class="warning-hint">请尝试点击上方按钮重新生成。</text>
+          </view>
 
 
           <!-- 返回列表 -->
           <!-- 返回列表 -->
-          <button class="action-btn full-width" @click="currentView = 'list'; loadBooks()">
+          <button class="action-btn full-width" @click="backToList">
             返回书籍列表
             返回书籍列表
           </button>
           </button>
         </view>
         </view>
       </view>
       </view>
     </view>
     </view>
-
-    <!-- 目录视图 -->
-    <view v-if="currentView === 'toc'" class="toc-view">
-      <!-- 顶部导航 -->
-      <view class="nav-bar">
-        <view class="nav-content">
-          <view class="nav-left" @click="currentView = 'detail'">
-            <text class="back-icon">←</text>
-          </view>
-          <text class="page-title">目录</text>
-          <view class="nav-right"></view>
-        </view>
-      </view>
-
-      <view class="toc-content">
-        <!-- 前言入口 -->
-        <view v-if="currentBook?.metadata?.foreword" class="toc-item foreword" @click="showForeword">
-          <text class="toc-label">前言</text>
-          <text class="toc-arrow">→</text>
-        </view>
-
-        <!-- 章节列表 -->
-        <view
-          v-for="chapter in currentBook?.chapters"
-          :key="chapter.id"
-          :class="['toc-item', 'chapter', chapter.status === 'completed' ? 'done' : 'pending']"
-        >
-          <view class="toc-left" @click="openChapter(chapter)">
-            <text class="chapter-num">第{{ chapter.number }}章</text>
-            <view class="chapter-info-col">
-              <text class="chapter-title">{{ chapter.title }}</text>
-              <text v-if="chapter.audioUrl" class="audio-status">🎵 已生成音频</text>
-            </view>
-          </view>
-          <view class="toc-actions">
-            <!-- 生成音频按钮 -->
-            <button
-              v-if="chapter.status === 'completed' && !chapter.audioUrl"
-              class="mini-audio-btn"
-              :disabled="generatingAudio"
-              @click.stop="handleGenerateChapterAudio(chapter)"
-            >
-              🎵
-            </button>
-            <!-- 生成视频按钮 -->
-            <button
-              v-if="chapter.status === 'completed' && chapter.audioUrl && !chapter.videoUrl"
-              class="mini-video-btn"
-              :disabled="generatingVideo"
-              @click.stop="handleGenerateChapterVideo(chapter)"
-            >
-              🎬
-            </button>
-            <text v-if="chapter.videoUrl" class="video-status">🎬 已生成</text>
-            <text class="toc-arrow" @click="openChapter(chapter)">→</text>
-          </view>
-        </view>
-
-        <!-- 后记入口 -->
-        <view v-if="currentBook?.metadata?.afterword" class="toc-item afterword" @click="showAfterword">
-          <text class="toc-label">后记</text>
-          <text class="toc-arrow">→</text>
-        </view>
-      </view>
-    </view>
-
-    <!-- 章节详情视图 -->
-    <view v-if="currentView === 'chapter-detail'" class="chapter-detail-view">
-      <!-- 顶部导航 -->
-      <view class="nav-bar">
-        <view class="nav-content">
-          <view class="nav-left" @click="currentView = 'toc'">
-            <text class="back-icon">←</text>
-          </view>
-          <text class="page-title">第{{ currentChapter?.number }}章</text>
-          <view class="nav-right"></view>
-        </view>
-      </view>
-
-      <view class="chapter-content">
-        <scroll-view class="chapter-scroll" scroll-y>
-          <view class="chapter-header">
-            <text class="chapter-title-large">{{ currentChapter?.title }}</text>
-            <text class="chapter-word-count">{{ currentChapter?.wordCount || 0 }}字</text>
-          </view>
-          <view class="chapter-body">
-            <text class="chapter-text">{{ currentChapter?.content }}</text>
-          </view>
-        </scroll-view>
-      </view>
-    </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import { ref, computed, onMounted, watch, nextTick } from 'vue';
 import { ref, computed, onMounted, watch, nextTick } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import * as api from '../../utils/book-generator-api';
 import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
 import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
 
 
@@ -497,9 +415,6 @@ const books = ref<Book[]>([]);
 // 当前书籍
 // 当前书籍
 const currentBook = ref<Book | null>(null);
 const currentBook = ref<Book | null>(null);
 
 
-// 当前查看的章节
-const currentChapter = ref<Chapter | null>(null);
-
 // 创建表单
 // 创建表单
 const showCreateModal = ref(false);
 const showCreateModal = ref(false);
 const creating = ref(false);
 const creating = ref(false);
@@ -727,6 +642,8 @@ async function openBook(book: Book) {
   try {
   try {
     currentBook.value = await api.getBook(book.id);
     currentBook.value = await api.getBook(book.id);
     currentView.value = 'detail';
     currentView.value = 'detail';
+    // 更新 URL hash,带上书籍 ID
+    updateUrlWithBookId(book.id);
     // 加载配额信息
     // 加载配额信息
     loadQuotaInfo();
     loadQuotaInfo();
   } catch (e) {
   } catch (e) {
@@ -734,6 +651,57 @@ async function openBook(book: Book) {
   }
   }
 }
 }
 
 
+// 更新 URL,带上书籍 ID
+function updateUrlWithBookId(bookId: string) {
+  if (typeof window !== 'undefined') {
+    const url = new URL(window.location.href);
+    url.hash = `/pages/book-generator/index?id=${bookId}`;
+    window.history.pushState({}, '', url.toString());
+  }
+}
+
+// 从 URL 获取书籍 ID
+function getBookIdFromUrl(): string | null {
+  if (typeof window !== 'undefined') {
+    const hash = window.location.hash;
+    const match = hash.match(/id=([^&]+)/);
+    return match ? match[1] : null;
+  }
+  return null;
+}
+
+// 加载指定 ID 的书籍
+async function loadBookById(bookId: string) {
+  try {
+    currentBook.value = await api.getBook(bookId);
+    currentView.value = 'detail';
+    loadQuotaInfo();
+  } catch (e) {
+    console.error('加载书籍失败:', e);
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  }
+}
+
+// 返回列表
+function backToList() {
+  currentView.value = 'list';
+  loadBooks();
+  // 清除 URL 参数
+  if (typeof window !== 'undefined') {
+    const url = new URL(window.location.href);
+    url.hash = '/pages/book-generator/index';
+    window.history.pushState({}, '', url.toString());
+  }
+}
+
+// 页面显示时检查 URL 参数
+onShow(() => {
+  const bookId = getBookIdFromUrl();
+  if (bookId && currentView.value === 'list') {
+    loadBookById(bookId);
+  }
+});
+
 // 加载用户配额信息
 // 加载用户配额信息
 async function loadQuotaInfo() {
 async function loadQuotaInfo() {
   try {
   try {
@@ -950,60 +918,55 @@ async function handleGenerateAfterword() {
   }
   }
 }
 }
 
 
-// 显示完整内容 - 现在显示目录
-async function showFullContent() {
+// 跳转到章节详情页
+function goToChapterDetail(chapter: Chapter) {
   if (!currentBook.value) return;
   if (!currentBook.value) return;
-
-  // 刷新书籍详情获取最新章节
-  currentBook.value = await api.getBook(currentBook.value.id);
-  currentView.value = 'toc';
+  // 跳转到独立的章节详情页面
+  uni.navigateTo({
+    url: `/pages/book-generator/chapter-detail?bookId=${encodeURIComponent(currentBook.value.id)}&chapter=${chapter.number}`
+  });
 }
 }
 
 
-// 打开章节详情
-function openChapter(chapter: Chapter) {
-  if (chapter.status !== 'completed') {
-    uni.showToast({ title: '该章节尚未生成', icon: 'none' });
-    return;
-  }
-  currentChapter.value = chapter;
-  currentView.value = 'chapter-detail';
-}
-
-// 显示前言
-function showForeword() {
-  if (!currentBook.value?.metadata?.foreword) {
-    uni.showToast({ title: '前言尚未生成', icon: 'none' });
-    return;
-  }
-  // 创建一个临时章节对象显示前言
-  currentChapter.value = {
-    id: 'foreword',
-    bookId: currentBook.value.id,
-    number: 0,
+// 查看前言
+function viewForeword() {
+  uni.showModal({
     title: '前言',
     title: '前言',
-    content: currentBook.value.metadata.foreword,
-    wordCount: currentBook.value.metadata.foreword.length,
-    status: 'completed',
-  };
-  currentView.value = 'chapter-detail';
+    content: currentBook.value?.metadata?.foreword || '',
+    showCancel: true,
+    cancelText: '关闭',
+    confirmText: '复制',
+    success: (res) => {
+      if (res.confirm) {
+        uni.setClipboardData({
+          data: currentBook.value?.metadata?.foreword || '',
+          success: () => {
+            uni.showToast({ title: '已复制', icon: 'success' });
+          }
+        });
+      }
+    }
+  });
 }
 }
 
 
-// 显示后记
-function showAfterword() {
-  if (!currentBook.value?.metadata?.afterword) {
-    uni.showToast({ title: '后记尚未生成', icon: 'none' });
-    return;
-  }
-  currentChapter.value = {
-    id: 'afterword',
-    bookId: currentBook.value.id,
-    number: currentBook.value.chapters.length + 1,
+// 查看后记
+function viewAfterword() {
+  uni.showModal({
     title: '后记',
     title: '后记',
-    content: currentBook.value.metadata.afterword,
-    wordCount: currentBook.value.metadata.afterword.length,
-    status: 'completed',
-  };
-  currentView.value = 'chapter-detail';
+    content: currentBook.value?.metadata?.afterword || '',
+    showCancel: true,
+    cancelText: '关闭',
+    confirmText: '复制',
+    success: (res) => {
+      if (res.confirm) {
+        uni.setClipboardData({
+          data: currentBook.value?.metadata?.afterword || '',
+          success: () => {
+            uni.showToast({ title: '已复制', icon: 'success' });
+          }
+        });
+      }
+    }
+  });
 }
 }
 
 
 // ============ 音频生成 ============
 // ============ 音频生成 ============
@@ -2007,6 +1970,136 @@ onMounted(() => {
   text-align: justify;
   text-align: justify;
 }
 }
 
 
+/* ==================== 完整内容区样式 ==================== */
+.content-card {
+  margin-top: 0;
+}
+
+.content-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24rpx;
+  background: #f9fafb;
+  border-radius: 16rpx;
+  margin-top: 16rpx;
+}
+
+.content-item.foreword,
+.content-item.afterword {
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+  cursor: pointer;
+}
+
+.content-item-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex: 1;
+  cursor: pointer;
+}
+
+.content-item-left {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  flex: 1;
+}
+
+.content-item-right {
+  flex-shrink: 0;
+  margin-left: 16rpx;
+}
+
+.content-label {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #92400e;
+}
+
+.content-item.foreword .content-label,
+.content-item.afterword .content-label {
+  color: #92400e;
+}
+
+.chapter-num-badge {
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #ffffff;
+  background: #4f46e5;
+  padding: 6rpx 12rpx;
+  border-radius: 8rpx;
+}
+
+.chapter-title-short {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2937;
+  flex: 1;
+}
+
+.audio-badge,
+.video-badge {
+  font-size: 24rpx;
+}
+
+.content-word-count {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+
+.expand-icon {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.content-body {
+  padding: 24rpx;
+  background: #ffffff;
+  border-radius: 12rpx;
+  margin-top: 12rpx;
+  border: 1px solid #e5e7eb;
+}
+
+.content-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 1px solid #f3f4f6;
+}
+
+.content-actions {
+  display: flex;
+  gap: 12rpx;
+}
+
+.mini-btn {
+  padding: 8rpx 16rpx;
+  border-radius: 8rpx;
+  font-size: 22rpx;
+  border: none;
+}
+
+.mini-btn.audio-btn {
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  color: #ffffff;
+}
+
+.mini-btn.video-btn {
+  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+  color: #ffffff;
+}
+
+.content-text {
+  font-size: 28rpx;
+  line-height: 1.9;
+  color: #374151;
+  text-align: justify;
+  white-space: pre-wrap;
+}
+
 /* ==================== 音频生成相关样式 ==================== */
 /* ==================== 音频生成相关样式 ==================== */
 
 
 /* 书籍卡片操作区 */
 /* 书籍卡片操作区 */
@@ -2170,4 +2263,39 @@ onMounted(() => {
   font-size: 24rpx;
   font-size: 24rpx;
   color: #b45309;
   color: #b45309;
 }
 }
+
+/* 无章节警告 */
+.warning-tip {
+  margin-top: 20rpx;
+  padding: 24rpx;
+  background: rgba(239, 68, 68, 0.1);
+  border: 1px solid #fca5a5;
+  border-radius: 12rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.warning-icon {
+  font-size: 48rpx;
+}
+
+.warning-text {
+  font-size: 26rpx;
+  color: #dc2626;
+  text-align: center;
+  font-weight: 500;
+}
+
+.warning-hint {
+  font-size: 24rpx;
+  color: #991b1b;
+  margin-bottom: 8rpx;
+}
+
+.warning-tip .action-btn {
+  margin-top: 8rpx;
+  width: 100%;
+}
 </style>
 </style>

+ 2 - 1
my-uniapp-vue3/src/utils/book-generator-api.ts

@@ -20,6 +20,7 @@ export interface Book {
   chapters: Chapter[];
   chapters: Chapter[];
   outline?: BookOutline;
   outline?: BookOutline;
   metadata?: BookMetadata;
   metadata?: BookMetadata;
+  error?: string;
   createdAt: string;
   createdAt: string;
   updatedAt: string;
   updatedAt: string;
 }
 }
@@ -253,7 +254,7 @@ export async function createBookWithLangGraph(data: {
  * 使用 LangGraph 生成已有书籍
  * 使用 LangGraph 生成已有书籍
  */
  */
 export async function generateWithLangGraph(bookId: string): Promise<{ bookId: string; taskId: string }> {
 export async function generateWithLangGraph(bookId: string): Promise<{ bookId: string; taskId: string }> {
-  const result = await request<{ bookId: string; taskId: string }>(`${LANGGRAPH_BASE}/books/${bookId}/generate`, {
+  const result = await request<{ bookId: string; taskId: string }>(`${BASE_URL}/books/${bookId}/generate`, {
     method: 'POST',
     method: 'POST',
   });
   });
   return result;
   return result;