Explorar el Código

fix: 修复章节详情页无法显示内容的bug

将 loadChapterWithParams 函数参数名从 chapterId 改为 chapterIdParam,
避免与 chapterId.value (ref) 混淆导致的 "Cannot create property 'value' on number" 错误

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User hace 4 meses
padre
commit
1d64787a8b
Se han modificado 1 ficheros con 427 adiciones y 90 borrados
  1. 427 90
      my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

+ 427 - 90
my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

@@ -12,7 +12,7 @@
     </view>
 
     <!-- 章节内容 -->
-    <scroll-view class="chapter-content" scroll-y="true">
+    <scroll-view class="chapter-content" scroll-y="true" :scroll-top="scrollTop">
       <!-- 章节头部信息 -->
       <view class="chapter-header">
         <text class="chapter-num-badge">第{{ chapterNumber }}章</text>
@@ -25,34 +25,76 @@
           <view v-if="chapterVideoUrl" class="media-badge video-badge">
             <text>🎬 视频</text>
           </view>
+          <view v-if="isLeafNode" class="edit-toggle-btn" @click="toggleEditMode">
+            <text>{{ isEditMode ? '✓ 完成' : '✏️ 编辑' }}</text>
+          </view>
+          <view v-if="isLeafNode && chapterStatus === 'completed'" class="regenerate-btn" @click="handleRegenerateContent">
+            <text>{{ regeneratingContent ? '生成中...' : '🔄 重新生成' }}</text>
+          </view>
         </view>
       </view>
 
-      <!-- 只有小节(level=3)才显示内容 -->
-      <view v-if="chapterLevel === 3" class="chapter-body">
-        <rich-text :nodes="renderedContent" class="chapter-text"></rich-text>
+      <!-- 章节内容(level=3小节 或 level=1短文章节) -->
+      <!-- 加载中 -->
+      <view v-if="isLoading && !chapterContent" class="chapter-body">
+        <view class="level-hint-large">
+          <text class="hint-icon-large">⏳</text>
+          <text class="hint-text-large">正在加载章节内容...</text>
+        </view>
+      </view>
+      <!-- 加载失败 -->
+      <view v-else-if="loadError" class="chapter-body">
+        <view class="level-hint-large">
+          <text class="hint-icon-large">❌</text>
+          <text class="hint-text-large">加载章节失败</text>
+          <text class="hint-subtext">{{ loadError }}</text>
+          <button class="retry-btn" @click="retryLoad">重试</button>
+        </view>
+      </view>
+      <view v-else-if="isLeafNode || (chapterLevel === 1 && chapterContent)" class="chapter-body">
+        <!-- 短文类章标识 -->
+        <view v-if="chapterLevel === 1 && isLeafNode" class="article-badge">
+          <text>📄 短文正文</text>
+        </view>
+        <!-- 编辑模式 -->
+        <view v-if="isEditMode" class="edit-mode">
+          <textarea
+            v-model="editContent"
+            class="content-editor"
+            placeholder="编辑章节内容..."
+            :maxlength="-1"
+          />
+          <view class="edit-actions">
+            <button class="edit-btn cancel" @click="cancelEdit">取消</button>
+            <button class="edit-btn save" @click="saveContent" :disabled="savingContent">
+              {{ savingContent ? '保存中...' : '保存' }}
+            </button>
+          </view>
+        </view>
+        <!-- 查看模式 -->
+        <rich-text v-else :nodes="renderedContent" class="chapter-text"></rich-text>
       </view>
-      
-      <!-- 章和节显示提示 -->
-      <view v-if="chapterLevel !== 3" class="chapter-body">
+
+      <!-- 没有内容的非叶节点显示提示 -->
+      <view v-else-if="!isLeafNode && !chapterContent" class="chapter-body">
         <view class="level-hint-large">
           <text class="hint-icon-large">📖</text>
           <text class="hint-text-large">这是一个{{ chapterLevel === 1 ? '章' : '节' }},没有具体内容</text>
-          <text class="hint-subtext">请点击下方的具体小节查看内容</text>
+          <text v-if="chapterLevel === 2" class="hint-subtext">请点击下方的具体小节查看内容</text>
         </view>
       </view>
 
       <!-- 底部操作区 -->
       <view class="chapter-actions">
-        <!-- 提示:章和节没有内容,只有小节才有 -->
-        <view v-if="chapterLevel !== 3" class="level-hint">
+        <!-- 提示:非叶节点显示提示 -->
+        <view v-if="!isLeafNode" class="level-hint">
           <text class="hint-icon">💡</text>
           <text class="hint-text">这是一个{{ chapterLevel === 1 ? '章' : '节' }},请点击具体小节查看内容和生成音频</text>
         </view>
-        
-        <!-- 只有小节(level=3)才能生成音频 -->
+
+        <!-- 叶节点才能生成音频 -->
         <button
-          v-if="chapterLevel === 3 && chapterStatus === 'completed' && !chapterAudioUrl"
+          v-if="isLeafNode && chapterStatus === 'completed' && !chapterAudioUrl"
           class="action-btn audio-btn"
           :disabled="generatingAudio"
           @click="handleGenerateAudio"
@@ -60,7 +102,7 @@
           {{ generatingAudio ? '生成中...' : '🎵 生成音频' }}
         </button>
         <button
-          v-if="chapterStatus === 'completed' && chapterAudioUrl && !chapterVideoUrl"
+          v-if="isLeafNode && chapterStatus === 'completed' && chapterAudioUrl && !chapterVideoUrl"
           class="action-btn video-btn"
           :disabled="generatingVideo"
           @click="handleGenerateVideo"
@@ -72,35 +114,16 @@
         <view v-if="chapterAudioUrl" class="audio-player">
           <view class="audio-player-header">
             <text class="audio-player-title">🎵 音频播放</text>
+            <view v-if="isLeafNode && chapterStatus === 'completed'" class="regenerate-btn" @click="handleRegenerateAudio">
+              <text>{{ regeneratingAudio ? '生成中...' : '🔄 重新生成' }}</text>
+            </view>
           </view>
           <button class="play-btn" @click="toggleAudio">
             ▶ 播放音频
           </button>
         </view>
 
-        <!-- 公开/取消公开按钮 -->
-        <view v-if="chapterAudioUrl" class="public-toggle">
-          <view class="public-toggle-header">
-            <text class="public-toggle-title">📢 公开设置</text>
-            <view class="public-toggle-status">
-              <text :class="chapterIsPublic ? 'status-public' : 'status-private'">
-                {{ chapterIsPublic ? '已公开' : '未公开' }}
-              </text>
-            </view>
-          </view>
-          <view class="public-toggle-desc">
-            <text>{{ chapterIsPublic ? '公开后其他用户可在首页听到此音频' : '仅自己可见,公开后其他用户可在首页听到' }}</text>
-          </view>
-          <button 
-            class="public-toggle-btn" 
-            :class="chapterIsPublic ? 'btn-cancel' : 'btn-public'"
-            :disabled="togglingPublic"
-            @click="togglePublic"
-          >
-            {{ togglingPublic ? '处理中...' : (chapterIsPublic ? '🔒 取消公开' : '🌐 公开到首页') }}
-          </button>
-        </view>
-
+        
         <!-- 视频播放器按钮 -->
         <view v-if="chapterVideoUrl" class="video-player">
           <view class="video-player-header">
@@ -135,20 +158,20 @@
         </view>
 
         <!-- 发布到平台按钮 -->
-        <view v-if="chapterAudioUrl || chapterVideoUrl" class="publish-section">
+        <!-- <view v-if="chapterAudioUrl || chapterVideoUrl" class="publish-section">
           <view class="publish-header">
             <text class="publish-title">🚀 发布到平台</text>
           </view>
           <view class="publish-desc">
             <text>{{ chapterVideoUrl ? '将视频发布到抖音、B站、快手等平台' : '将音频发布到喜马拉雅、蜻蜓FM等平台' }}</text>
           </view>
-          <button 
+          <button
             class="publish-btn"
             @click="goToPublish"
           >
             📤 发布{{ chapterVideoUrl ? '视频' : '音频' }}
           </button>
-        </view>
+        </view> -->
       </view>
 
       <!-- 视频播放弹窗 -->
@@ -209,15 +232,18 @@
 
 <script setup lang="ts">
 import { ref, computed, onMounted, onUnmounted } from 'vue';
-import { onShow } from '@dcloudio/uni-app';
+import { onShow, onLoad } from '@dcloudio/uni-app';
 import { marked } from 'marked';
 import katex from 'katex';
 import 'katex/dist/katex.min.css';
 import { useAudioStore } from '../../store/audio';
 import * as api from '../../utils/book-generator-api';
-import { get, put } from '../../utils/request';
+import { get, put, post } from '../../utils/request';
 import type { Book, Chapter } from '../../utils/book-generator-api';
 
+// scroll-top 用于避免 scrollTop 错误
+const scrollTop = ref(0);
+
 // 配置 marked
 marked.setOptions({
   gfm: true,
@@ -262,7 +288,17 @@ function renderContent(content: string): string {
 
 // 渲染后的内容
 const renderedContent = computed(() => {
-  return renderContent(chapterContent.value);
+  const content = chapterContent.value;
+  if (!content) return '';
+  try {
+    const html = renderContent(content);
+    console.log('[ChapterDetail] renderedContent computed, html length:', html?.length);
+    return html;
+  } catch (e) {
+    console.error('[ChapterDetail] renderedContent 渲染失败:', e);
+    // fallback: 直接显示原始文本(做简单换行处理)
+    return content.replace(/\n/g, '<br>');
+  }
 });
 
 const audioStore = useAudioStore();
@@ -279,6 +315,17 @@ const chapterVideoUrl = ref<string>('');
 const chapterIsPublic = ref<boolean>(false);
 const chapterId = ref<number>(0);
 const chapterLevel = ref<number>(1); // 章节层级:1=章, 2=节, 3=小节
+// 叶节点ID列表(用于判断当前章节是否为叶节点)
+const leafChapterIds = ref<string[]>([]);
+// 计算属性:判断当前章节是否为叶节点(没有子节点的节点)
+const isLeafNode = computed(() => {
+  return leafChapterIds.value.includes(String(chapterId.value));
+});
+
+// 编辑模式状态
+const isEditMode = ref(false);
+const editContent = ref('');
+const savingContent = ref(false);
 
 // 章节列表(用于上下章导航)
 const chapters = ref<Chapter[]>([]);
@@ -288,7 +335,12 @@ const nextChapter = ref<Chapter | null>(null);
 // 加载状态
 const generatingAudio = ref(false);
 const generatingVideo = ref(false);
+const regeneratingContent = ref(false);
+const regeneratingAudio = ref(false);
 const togglingPublic = ref(false);
+const needsReload = ref(false);
+const isLoading = ref(false);
+const loadError = ref<string>('');
 
 // 音频播放状态
 const isAudioPlaying = ref(false);
@@ -302,16 +354,20 @@ const showVideoModal = ref(false);
 
 // 从 URL 获取参数
 function getParamsFromUrl(): { bookId: string; chapterNumber: number } | null {
+  console.log('[ChapterDetail] getParamsFromUrl called, window.location.hash:', typeof window !== 'undefined' ? window.location.hash : 'N/A');
   if (typeof window !== 'undefined') {
     const hash = window.location.hash;
+    // H5 hash 路由格式: #/pages/book-generator/chapter-detail?bookId=114&chapter=3237
     const match = hash.match(/bookId=([^&]+)&chapter=(\d+)/);
     if (match) {
+      console.log('[ChapterDetail] getParamsFromUrl found params:', match[1], match[2]);
       return {
         bookId: decodeURIComponent(match[1]),
         chapterNumber: parseInt(match[2], 10)
       };
     }
   }
+  console.log('[ChapterDetail] getParamsFromUrl found no params');
   return null;
 }
 
@@ -504,62 +560,117 @@ async function togglePublic() {
 
 // 加载书籍和章节信息
 async function loadChapter() {
+  // 如果已经有 bookId 和 chapterId,直接加载
+  if (bookId.value && chapterId.value) {
+    console.log('[ChapterDetail] loadChapter: using existing params, bookId:', bookId.value, 'chapterId:', chapterId.value);
+    if (!loadingChapter) {
+      await loadChapterWithParams(bookId.value, chapterId.value);
+    }
+    return;
+  }
+
+  // H5 端从 URL hash 获取参数
   const params = getParamsFromUrl();
-  if (!params) {
-    uni.showToast({ title: '参数错误', icon: 'none' });
+  if (params) {
+    bookId.value = params.bookId;
+    chapterId.value = params.chapterNumber;
+    await loadChapterWithParams(params.bookId, params.chapterNumber);
     return;
   }
 
-  bookId.value = params.bookId;
+  console.log('[ChapterDetail] loadChapter: no params, bookId:', bookId.value, 'chapterId:', chapterId.value);
+  uni.showToast({ title: '参数错误', icon: 'none' });
+}
 
-  try {
-    const book: Book = await api.getBook(params.bookId);
-    chapters.value = book.chapters || [];
+// App 端通过 onLoad 参数加载章节
+let loadingChapter = false;
+async function loadChapterWithParams(id: string, chapterIdParam: number) {
+  if (loadingChapter) return;
+  loadingChapter = true;
+  isLoading.value = true;
+  loadError.value = '';
 
-    // 使用ID来查找章节(参数中的chapter现在是数据库ID)
-    const chapter = book.chapters.find((c) => c.id === String(params.chapterNumber));
-    if (chapter) {
-      chapterId.value = parseInt(chapter.id);
-      chapterNumber.value = chapter.number;
-      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 || '';
-      chapterLevel.value = chapter.level || 1;
-      
-      console.log('[Chapter Detail] 加载章节:', {
-        id: chapter.id,
-        number: chapter.number,
-        title: chapter.title,
-        level: chapter.level,
-        audioUrl: chapter.audioUrl,
-        videoUrl: chapter.videoUrl,
-        status: chapter.status
-      });
-      
-      // 从 API 获取公开状态
-      if (chapter.audioUrl && chapterId.value) {
-        try {
-          const audioDetail = await get<{ isPublic?: boolean }>(`/player/audio/${chapterId.value}`);
-          if (audioDetail) {
-            chapterIsPublic.value = audioDetail.isPublic || false;
-          }
-        } catch (e) {
-          console.log('获取公开状态失败:', e);
-        }
+  console.log('[ChapterDetail] loadChapterWithParams called, bookId:', id, 'chapterIdParam:', chapterIdParam);
+  bookId.value = id;
+
+  try {
+    // 优先使用全局数据(从列表页跳转)
+    const navData = (globalThis as any).__navBookData as Book | undefined;
+    if (navData && navData.id === id) {
+      chapters.value = navData.chapters || [];
+      const parentIds = new Set(chapters.value.map((c: Chapter) => c.parentId));
+      leafChapterIds.value = chapters.value
+        .filter((c: Chapter) => !parentIds.has(c.id))
+        .map((c: Chapter) => c.id);
+      const chapter = chapters.value.find((c) => String(c.id) === String(chapterIdParam));
+      if (chapter) {
+        chapterId.value = parseInt(chapter.id);
+        chapterNumber.value = chapter.number;
+        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 || '';
+        chapterLevel.value = chapter.level || 1;
       }
+      updatePrevNextChapters();
+      isLoading.value = false;
+      loadingChapter = false;
+      return;
+    }
 
-      if (typeof document !== 'undefined') {
-        document.title = `${chapterTitle.value} - 书籍详情`;
+    // 直接获取章节详情(只返回当前章节,不返回整书内容)
+    uni.showToast({ title: '正在加载...', icon: 'loading', duration: 10000 });
+    const chapterData = await get<any>(`/book-generator/albums/chapters/${chapterIdParam}`);
+    uni.hideToast();
+    uni.hideToast();
+    console.log('[ChapterDetail] chapter loaded via API:', chapterData);
+
+    if (!chapterData) {
+      throw new Error('章节数据为空');
+    }
+
+    chapterId.value = parseInt(chapterData.id);
+    chapterNumber.value = chapterData.number;
+    chapterTitle.value = chapterData.title;
+    chapterContent.value = chapterData.content || '章节内容正在生成中...';
+    chapterWordCount.value = chapterData.wordCount || 0;
+    chapterStatus.value = chapterData.status;
+    chapterAudioUrl.value = chapterData.audioUrl || '';
+    chapterVideoUrl.value = chapterData.videoUrl || '';
+    chapterLevel.value = chapterData.level || 1;
+
+    // 获取章节列表(用于上下章导航),不需要章节内容
+    const book = await api.getBook(id);
+    chapters.value = book.chapters || [];
+    const parentIds = new Set(chapters.value.map((c: Chapter) => c.parentId));
+    leafChapterIds.value = chapters.value
+      .filter((c: Chapter) => !parentIds.has(c.id))
+      .map((c: Chapter) => c.id);
+
+    // 从 API 获取公开状态
+    if (chapterData.audioUrl && chapterId.value) {
+      try {
+        const audioDetail = await get<{ isPublic?: boolean }>(`/player/audio/${chapterId.value}`);
+        if (audioDetail) {
+          chapterIsPublic.value = audioDetail.isPublic || false;
+        }
+      } catch (e) {
+        console.log('获取公开状态失败:', e);
       }
     }
 
     updatePrevNextChapters();
   } catch (e) {
-    console.error('加载章节失败:', e);
-    uni.showToast({ title: '加载失败', icon: 'none' });
+    uni.hideToast();
+    const errMsg = e instanceof Error ? e.message : '网络请求失败';
+    console.error('[ChapterDetail] 加载章节失败, bookId:', id, 'chapterIdParam:', chapterIdParam, 'error:', errMsg);
+    loadError.value = errMsg;
+    uni.showToast({ title: '加载失败', icon: 'none', duration: 2000 });
+  } finally {
+    loadingChapter = false;
+    isLoading.value = false;
   }
 }
 
@@ -571,6 +682,11 @@ function updatePrevNextChapters() {
   nextChapter.value = currentIndex < chapters.value.length - 1 ? chapters.value[currentIndex + 1] : null;
 }
 
+function retryLoad() {
+  console.log('[ChapterDetail] retryLoad');
+  loadChapterWithParams(bookId.value, chapterId.value);
+}
+
 function goBack() {
   // H5 环境下通过 URL hash 直接访问,没有 uni 页面栈,使用 history.back
   if (typeof window !== 'undefined' && window.history.length > 1) {
@@ -583,6 +699,55 @@ function goBack() {
   }
 }
 
+// 编辑功能
+function toggleEditMode() {
+  if (isEditMode.value) {
+    // 退出编辑模式
+    cancelEdit();
+  } else {
+    // 进入编辑模式
+    isEditMode.value = true;
+    editContent.value = chapterContent.value;
+  }
+}
+
+function cancelEdit() {
+  isEditMode.value = false;
+  editContent.value = '';
+}
+
+async function saveContent() {
+  if (!editContent.value.trim()) {
+    uni.showToast({ title: '内容不能为空', icon: 'none' });
+    return;
+  }
+
+  savingContent.value = true;
+  try {
+    uni.showLoading({ title: '保存中...', mask: true });
+    
+    await post(`/book-generator/langgraph/books/${bookId.value}/chapters/${chapterId.value}/content`, {
+      content: editContent.value,
+    });
+    
+    // 更新本地状态
+    chapterContent.value = editContent.value;
+    chapterWordCount.value = editContent.value.length;
+    
+    uni.hideLoading();
+    uni.showToast({ title: '保存成功', icon: 'success' });
+    
+    // 退出编辑模式
+    isEditMode.value = false;
+  } catch (error) {
+    uni.hideLoading();
+    console.error('[SaveContent] 保存失败:', error);
+    uni.showToast({ title: '保存失败', icon: 'none' });
+  } finally {
+    savingContent.value = false;
+  }
+}
+
 function goToChapter(chapter: Chapter) {
   uni.redirectTo({
     url: `/pages/book-generator/chapter-detail?bookId=${encodeURIComponent(bookId.value)}&chapter=${chapter.id}`
@@ -607,7 +772,7 @@ async function handleGenerateAudio() {
 async function handleGenerateVideo() {
   generatingVideo.value = true;
   try {
-    await api.generateChapterVideo(bookId.value, chapterNumber.value);
+    await api.generateChapterVideo(bookId.value, chapterId.value);
     uni.showToast({ title: '视频生成任务已启动', icon: 'none', duration: 2000 });
     setTimeout(async () => {
       await loadChapter();
@@ -619,12 +784,101 @@ async function handleGenerateVideo() {
   }
 }
 
+async function handleRegenerateContent() {
+  if (!chapterId.value || regeneratingContent.value) return;
+
+  uni.showModal({
+    title: '确认重新生成',
+    content: '确定要重新生成该章节内容吗?这将清除当前内容并重新生成。',
+    confirmText: '重新生成',
+    cancelText: '取消',
+    success: async (res) => {
+      if (res.confirm) {
+        regeneratingContent.value = true;
+        try {
+          await api.regenerateChapterContent(bookId.value, chapterId.value);
+          uni.showToast({ title: '重新生成任务已启动', icon: 'none', duration: 2000 });
+          setTimeout(async () => {
+            await loadChapter();
+          }, 3000);
+        } catch (e: any) {
+          uni.showToast({ title: e.message || '重新生成失败', icon: 'none' });
+        } finally {
+          regeneratingContent.value = false;
+        }
+      }
+    }
+  });
+}
+
+async function handleRegenerateAudio() {
+  if (!chapterId.value || regeneratingAudio.value) return;
+
+  uni.showModal({
+    title: '确认重新生成音频',
+    content: '确定要重新生成该章节音频吗?这将清除当前音频并重新生成。',
+    confirmText: '重新生成',
+    cancelText: '取消',
+    success: async (res) => {
+      if (res.confirm) {
+        regeneratingAudio.value = true;
+        try {
+          await api.regenerateChapterAudio(bookId.value, chapterId.value);
+          uni.showToast({ title: '音频重新生成任务已启动', icon: 'none', duration: 2000 });
+          setTimeout(async () => {
+            await loadChapter();
+          }, 3000);
+        } catch (e: any) {
+          uni.showToast({ title: e.message || '重新生成失败', icon: 'none' });
+        } finally {
+          regeneratingAudio.value = false;
+        }
+      }
+    }
+  });
+}
+
 onMounted(() => {
-  loadChapter();
+  console.log('[ChapterDetail] onMounted, bookId:', bookId.value, 'chapterId:', chapterId.value);
+  // 如果 onLoad 已经处理了初始化(App环境或H5环境),这里不再重复调用
+  // 只有在 bookId 有值但还没加载的情况下才调用
+  if (!loadingChapter && !initialLoadDone) {
+    loadChapter();
+  }
 });
 
+let initialLoadDone = false;
+
 onShow(() => {
-  loadChapter();
+  // onShow 只需要刷新状态,不需要重新加载数据
+  console.log('[ChapterDetail] onShow, bookId:', bookId.value, 'chapterId:', chapterId.value);
+});
+
+onLoad((query: any) => {
+  console.log('[ChapterDetail] onLoad, query:', query);
+  // H5 环境下从 URL hash 获取参数
+  // #ifdef H5
+  if (typeof window !== 'undefined') {
+    const hash = window.location.hash;
+    const match = hash.match(/bookId=([^&]+)&chapter=(\d+)/);
+    if (match) {
+      bookId.value = decodeURIComponent(match[1]);
+      chapterId.value = parseInt(match[2], 10);
+      console.log('[ChapterDetail] onLoad set params from hash, bookId:', bookId.value, 'chapterId:', chapterId.value);
+      initialLoadDone = true;
+      loadChapter();
+      return;
+    }
+  }
+  // #endif
+  // App 使用 onLoad 获取参数
+  if (query?.bookId && query?.chapter) {
+    bookId.value = query.bookId;
+    chapterId.value = parseInt(query.chapter);
+    loadChapter();
+  } else {
+    console.log('[ChapterDetail] onLoad: missing params', query);
+  }
 });
 </script>
 
@@ -708,6 +962,75 @@ onShow(() => {
 .audio-badge { background: rgba(16, 185, 129, 0.1); color: #059669; }
 .video-badge { background: rgba(245, 158, 11, 0.1); color: #f5576c; }
 
+.article-badge {
+  display: inline-block;
+  padding: 8rpx 20rpx;
+  background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+  color: white;
+  border-radius: 16rpx;
+  font-size: 24rpx;
+  font-weight: 500;
+  margin-bottom: 24rpx;
+}
+
+.edit-toggle-btn {
+  padding: 6rpx 16rpx;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+}
+
+.regenerate-btn {
+  padding: 6rpx 16rpx;
+  background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+  color: white;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+}
+
+.edit-mode {
+  padding: 24rpx;
+}
+
+.content-editor {
+  width: 100%;
+  min-height: 600rpx;
+  padding: 24rpx;
+  background: #f9fafb;
+  border: 2rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  line-height: 1.8;
+}
+
+.edit-actions {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 24rpx;
+}
+
+.edit-btn {
+  flex: 1;
+  padding: 24rpx;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+}
+
+.edit-btn.cancel {
+  background: #f3f4f6;
+  color: #6b7280;
+}
+
+.edit-btn.save {
+  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  color: white;
+}
+
+.edit-btn.save[disabled] {
+  opacity: 0.5;
+}
+
 .chapter-body {
   background: #ffffff;
   padding: 32rpx;
@@ -861,6 +1184,15 @@ onShow(() => {
   margin-bottom: 16rpx;
 }
 
+.retry-btn {
+  margin-top: 32rpx;
+  padding: 16rpx 48rpx;
+  background: #4f46e5;
+  color: #fff;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+}
+
 .hint-subtext {
   font-size: 26rpx;
   color: #999;
@@ -908,7 +1240,12 @@ onShow(() => {
   border: 1px solid rgba(245, 158, 11, 0.2);
 }
 
-.audio-player-header, .video-player-header { margin-bottom: 16rpx; }
+.audio-player-header, .video-player-header {
+  margin-bottom: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
 .audio-player-title { font-size: 28rpx; font-weight: 600; color: #059669; }
 .video-player-title { font-size: 28rpx; font-weight: 600; color: #f5576c; }