|
|
@@ -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; }
|
|
|
|