Răsfoiți Sursa

fix: 修复前端 API 调用双层 /api 前缀导致 404

移除 '/api/player/recent' 和 '/api/subscription/audio-balance' 中的多余 '/api' 前缀,
因 BASE_URL 已包含 /api,避免路径拼接成 /api/api/...

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 luni în urmă
părinte
comite
60900dfc6f

+ 97 - 39
my-uniapp-vue3/src/pages/book-generator/chapter-detail.vue

@@ -104,14 +104,28 @@
         </view>
 
         <!-- 叶节点才能生成音频 -->
-        <button
-          v-if="isLeafNode && chapterGenStage === 'content_completed' && !chapterAudioUrl"
-          class="action-btn audio-btn"
-          :disabled="generatingAudio"
-          @click="handleGenerateAudio"
-        >
-          {{ generatingAudio ? '生成中...' : '🎵 生成音频' }}
-        </button>
+        <view v-if="isLeafNode && chapterGenStage === 'content_completed' && !chapterAudioUrl" class="audio-gen-area">
+          <button
+            v-if="hasEnoughQuota"
+            class="action-btn audio-btn"
+            :disabled="generatingAudio"
+            @click="handleGenerateAudio"
+          >
+            {{ generatingAudio ? '生成中...' : '🎵 生成音频' }}
+          </button>
+          <button
+            v-else
+            class="action-btn upgrade-btn"
+            @click="goToSubscription"
+          >
+            🚀 升级套餐
+          </button>
+          <view class="quota-info-inline">
+            <text class="quota-text">预估消耗 {{ estimatedAudioMinutes }} 分钟</text>
+            <text v-if="quotaInfo" class="quota-text">剩余 {{ quotaInfo.remainingMinutes }} 分钟</text>
+            <text v-if="!hasEnoughQuota" class="quota-warn">⚠️ 配额不足</text>
+          </view>
+        </view>
         <!-- 音频生成中状态 -->
         <button
           v-if="isLeafNode && chapterGenStage === 'audio_generating'"
@@ -283,7 +297,7 @@ import { useAudioStore } from '../../store/audio';
 import * as api from '../../utils/book-generator-api';
 import { get, put, post } from '../../utils/request';
 import type { Book, Chapter } from '../../utils/book-generator-api';
-import { wsManager } from '../../utils/websocket';
+import { useNotificationStore } from '../../store/notification';
 import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 
 // scroll-top 用于避免 scrollTop 错误
@@ -398,6 +412,50 @@ const loadError = ref<string>('');
 // 音频播放状态
 const isAudioPlaying = ref(false);
 const audioCurrentTime = ref('00:00');
+
+// 配额信息
+const quotaInfo = ref<{
+  totalMinutes: number;
+  usedMinutes: number;
+  remainingMinutes: number;
+  overageEnabled: boolean;
+} | null>(null);
+
+const estimatedAudioMinutes = computed(() => {
+  const words = chapterWordCount.value || 0;
+  return Math.max(1, Math.ceil(words / 150));
+});
+
+const hasEnoughQuota = computed(() => {
+  if (!quotaInfo.value) return true;
+  if (quotaInfo.value.overageEnabled) return true;
+  return quotaInfo.value.remainingMinutes >= estimatedAudioMinutes.value;
+});
+
+async function fetchQuotaInfo() {
+  try {
+    const res = await get<{
+      totalMinutes: number;
+      usedMinutes: number;
+      remainingMinutes: number;
+      overageEnabled: boolean;
+    }>('/subscription/audio-balance');
+    if (res) {
+      quotaInfo.value = {
+        totalMinutes: res.totalMinutes,
+        usedMinutes: res.usedMinutes,
+        remainingMinutes: res.remainingMinutes,
+        overageEnabled: res.overageEnabled,
+      };
+    }
+  } catch {
+    quotaInfo.value = null;
+  }
+}
+
+function goToSubscription() {
+  uni.navigateTo({ url: '/pages/subscription/index' });
+}
 const audioDuration = ref('00:00');
 
 // 轮询定时器 ID
@@ -836,6 +894,21 @@ function goToChapter(chapter: Chapter) {
 
 async function handleGenerateAudio() {
   if (generatingAudio.value) return;
+  // 配额预检
+  if (!hasEnoughQuota.value && quotaInfo.value) {
+    uni.showModal({
+      title: '配额不足',
+      content: `预估消耗 ${estimatedAudioMinutes.value} 分钟,剩余 ${quotaInfo.value.remainingMinutes} 分钟。是否前往升级套餐?`,
+      confirmText: '升级套餐',
+      cancelText: '取消',
+      success: (res) => {
+        if (res.confirm) {
+          goToSubscription();
+        }
+      },
+    });
+    return;
+  }
   uni.showModal({
     title: '确认生成音频',
     content: '确定要生成该章节音频吗?',
@@ -857,6 +930,9 @@ async function handleGenerateAudio() {
               }
               generatingAudio.value = false;
               await loadChapter();
+              // 写入通知
+              const notifStore = useNotificationStore();
+              notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${chapterTitle.value || '章节'}》音频已生成完毕`, bookId: bookId.value });
             }
           } catch (e) {
             console.error('轮询音频状态失败:', e);
@@ -894,6 +970,9 @@ async function handleGenerateVideo() {
               }
               generatingVideo.value = false;
               await loadChapter();
+              // 写入通知
+              const notifStore = useNotificationStore();
+              notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${chapterTitle.value || '章节'}》视频已生成完毕`, bookId: bookId.value });
             }
           } catch (e) {
             console.error('轮询视频状态失败:', e);
@@ -989,13 +1068,6 @@ function onTouchEnd(e: TouchEvent) {
 
 onMounted(() => {
   console.log('[ChapterDetail] onMounted, bookId:', bookId.value, 'chapterId:', chapterId.value);
-  // 连接 WebSocket 并订阅事件
-  wsManager.connect().then(() => {
-    wsManager.on('audio_generation_complete', handleAudioGenerationComplete);
-    wsManager.on('video_generation_complete', handleVideoGenerationComplete);
-  }).catch((e) => {
-    console.error('[ChapterDetail] WebSocket 连接失败:', e);
-  });
   // 如果还没有参数,从 URL 获取
   if (!bookId.value || !chapterId.value) {
     const params = getParamsFromUrl();
@@ -1008,6 +1080,8 @@ onMounted(() => {
   if (bookId.value && chapterId.value) {
     loadChapter();
   }
+  // 获取配额信息
+  fetchQuotaInfo();
 });
 
 onShow(() => {
@@ -1021,6 +1095,8 @@ onShow(() => {
   if (bookId.value && chapterId.value) {
     loadChapter();
   }
+  // 刷新配额信息
+  fetchQuotaInfo();
 });
 
 onLoad((query: any) => {
@@ -1034,31 +1110,8 @@ onLoad((query: any) => {
   // H5 环境由 onMounted 从 hash 获取参数
 });
 
-// WebSocket 事件处理 - 音频生成完成
-function handleAudioGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
-  if (data.bookId === bookId.value && data.chapterId === chapterId.value) {
-    generatingAudio.value = false;
-    audioStatus.value = data.status;
-    loadChapter();
-    uni.showToast({ title: data.status === 'completed' ? '音频生成完成' : '音频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
-  }
-}
-
-// WebSocket 事件处理 - 视频生成完成
-function handleVideoGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
-  if (data.bookId === bookId.value && data.chapterId === chapterId.value) {
-    generatingVideo.value = false;
-    videoStatus.value = data.status;
-    loadChapter();
-    uni.showToast({ title: data.status === 'completed' ? '视频生成完成' : '视频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
-  }
-}
-
 // 页面卸载时清理资源
 onUnmounted(() => {
-  // 清理 WebSocket 订阅
-  wsManager.off('audio_generation_complete', handleAudioGenerationComplete);
-  wsManager.off('video_generation_complete', handleVideoGenerationComplete);
   // 清理轮询定时器
   if (audioPollTimer) {
     clearInterval(audioPollTimer);
@@ -1410,6 +1463,11 @@ onUnmounted(() => {
 }
 
 .action-btn.audio-btn { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
+.action-btn.upgrade-btn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; }
+.audio-gen-area { display: flex; flex-direction: column; gap: 8rpx; }
+.quota-info-inline { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 8rpx 0; }
+.quota-text { font-size: 22rpx; color: #6b7280; }
+.quota-warn { font-size: 22rpx; color: #ef4444; font-weight: 600; }
 .action-btn.video-btn { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: #ffffff; }
 .action-btn[disabled] { opacity: 0.6; }
 

+ 226 - 9
my-uniapp-vue3/src/pages/book-generator/detail.vue

@@ -50,6 +50,10 @@
             <text class="stage-label">当前阶段:</text>
             <text class="stage-value">{{ getCurrentStage() }}</text>
           </view>
+          <view v-if="currentGeneratingChapter" class="current-chapter-info">
+            <text class="chapter-label">正在处理:</text>
+            <text class="chapter-name">第{{ currentGeneratingChapter.number }}{{ currentGeneratingChapter.levelLabel }} {{ currentGeneratingChapter.title }}</text>
+          </view>
           <view v-if="currentBook.error" class="status-error">
             <text class="error-icon">⚠️</text>
             <text class="error-text">{{ currentBook.error }}</text>
@@ -120,6 +124,26 @@
           </view>
         </view>
 
+        <!-- 章节搜索与折叠工具栏 -->
+        <view class="chapter-toolbar">
+          <view class="search-box">
+            <text class="search-icon-small">🔍</text>
+            <input
+              v-model="searchKeyword"
+              class="search-input"
+              placeholder="搜索章节标题、摘要..."
+              placeholder-class="search-placeholder-text"
+            />
+            <text v-if="searchKeyword" class="search-clear" @click="searchKeyword = ''">✕</text>
+          </view>
+          <view class="toggle-btn" @click="toggleExpandAll">
+            <text>{{ allExpanded ? '📁 折叠全部' : '📂 展开全部' }}</text>
+          </view>
+        </view>
+        <view v-if="searchKeyword && filteredChaptersLevel1.length === 0" class="search-empty">
+          <text>未找到匹配的章节</text>
+        </view>
+
         <!-- 前言 -->
         <view v-if="currentBook?.metadata?.foreword" class="content-item foreword" @click="viewForeword">
           <view class="content-item-left">
@@ -134,7 +158,7 @@
         <!-- 章节列表 -->
         <view class="content-tree-list">
           <view
-            v-for="chapter in chaptersLevel1"
+            v-for="chapter in filteredChaptersLevel1"
             :key="chapter.id"
             class="content-tree-item content-chapter"
           >
@@ -168,6 +192,8 @@
               </view>
             </view>
 
+            <!-- 节和小节包裹折叠控制 -->
+            <view v-show="allExpanded">
             <!-- 节 -->
             <view v-if="getChapterSectionsFromDB(chapter.id) && getChapterSectionsFromDB(chapter.id).length > 0" class="content-sections-list">
               <view
@@ -229,6 +255,7 @@
                 </view>
               </view>
             </view>
+            </view>
           </view>
         </view>
 
@@ -258,13 +285,28 @@
           </view>
           <!-- 音频生成按钮 - 内容生成完成后显示 -->
           <view v-if="isContentCompleted" class="btn-row">
-            <button
-              class="action-btn audio-btn"
-              :disabled="generatingAudio"
-              @click="handleGenerateAudio"
-            >
-              {{ generatingAudio ? '生成中...' : '🎵 生成全部音频' }}
-            </button>
+            <view class="btn-with-quota">
+              <button
+                v-if="hasEnoughQuota"
+                class="action-btn audio-btn"
+                :disabled="generatingAudio"
+                @click="handleGenerateAudio"
+              >
+                {{ generatingAudio ? '生成中...' : '🎵 生成全部音频' }}
+              </button>
+              <button
+                v-else
+                class="action-btn upgrade-btn"
+                @click="goToSubscription"
+              >
+                🚀 升级套餐
+              </button>
+              <view class="quota-info-inline">
+                <text class="quota-text">预估消耗 {{ estimatedAudioMinutes }} 分钟</text>
+                <text v-if="quotaInfo" class="quota-text">剩余 {{ quotaInfo.remainingMinutes }} 分钟</text>
+                <text v-if="!hasEnoughQuota" class="quota-warn">⚠️ 配额不足</text>
+              </view>
+            </view>
           </view>
         </view>
 
@@ -290,14 +332,19 @@
 import { ref, computed, onMounted, onUnmounted } from 'vue';
 import { onLoad, onShow } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
-import { post } from '../../utils/request';
+import { post, get } from '../../utils/request';
 import type { Book, Chapter } from '../../utils/book-generator-api';
+import { useNotificationStore } from '../../store/notification';
 import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 // 批量操作状态
 const isBatchMode = ref(false);
 const selectedChapterIds = ref<string[]>([]);
 const isAllSelected = ref(false);
 
+// 章节搜索与折叠
+const searchKeyword = ref('');
+const allExpanded = ref(true);
+
 // 当前书籍
 const currentBook = ref<Book | null>(null);
 
@@ -341,6 +388,38 @@ const chaptersLevel1 = computed(() => {
   return currentBook.value.chapters.filter((c) => c.level === 1);
 });
 
+// 当前正在生成的章节信息
+const currentGeneratingChapter = computed(() => {
+  if (!currentBook.value?.chapters) return null;
+  const generating = currentBook.value.chapters.find(
+    (c: any) => c.genStage === 'content_generating' || c.genStage === 'audio_generating' || c.genStage === 'video_generating' || c.contentStatus === 'generating'
+  );
+  if (!generating) return null;
+  const levelLabels = ['', '章', '节', '小节'];
+  const levelLabel = levelLabels[generating.level || 1] || '章';
+  return { number: generating.number, title: generating.title, levelLabel };
+});
+
+// 根据搜索关键词过滤章节
+const filteredChaptersLevel1 = computed(() => {
+  const chapters = chaptersLevel1.value;
+  if (!searchKeyword.value.trim()) return chapters;
+  const keyword = searchKeyword.value.trim().toLowerCase();
+  return chapters.filter(ch => {
+    if (ch.title?.toLowerCase().includes(keyword)) return true;
+    const outline = getChapterOutline(ch.number);
+    if (outline?.summary?.toLowerCase().includes(keyword)) return true;
+    if (outline?.keyPoints?.some((kp: string) => kp.toLowerCase().includes(keyword))) return true;
+    const sections = getChapterSectionsFromDB(ch.id);
+    if (sections.some((s: any) => s.title?.toLowerCase().includes(keyword) || s.summary?.toLowerCase().includes(keyword))) return true;
+    return false;
+  });
+});
+
+function toggleExpandAll() {
+  allExpanded.value = !allExpanded.value;
+}
+
 const usedAudioMinutes = computed(() => {
   if (!currentBook.value) return 0;
   const totalWords = currentBook.value.chapters
@@ -349,6 +428,51 @@ const usedAudioMinutes = computed(() => {
   return Math.ceil(totalWords / 150);
 });
 
+// 预估全部音频时长(基于所有叶子节点字数)
+const estimatedAudioMinutes = computed(() => {
+  if (!currentBook.value) return 0;
+  const leafLevel = getBookLeafLevel(currentBook.value);
+  const leafNodes = getBookLeafNodes(currentBook.value, leafLevel);
+  const totalWords = leafNodes.reduce((sum, c: any) => sum + (c.wordCount || 0), 0);
+  return Math.max(1, Math.ceil(totalWords / 150));
+});
+
+// 配额是否足够
+const hasEnoughQuota = computed(() => {
+  if (!quotaInfo.value) return true; // 未加载配额信息时不限制
+  const remaining = quotaInfo.value.remainingMinutes;
+  // 如果启用了超额,总是允许
+  if (quotaInfo.value.overageEnabled) return true;
+  return remaining >= estimatedAudioMinutes.value;
+});
+
+function goToSubscription() {
+  uni.navigateTo({ url: '/pages/subscription/index' });
+}
+
+// 获取用户音频配额信息
+async function fetchQuotaInfo() {
+  try {
+    const res = await get<{
+      totalMinutes: number;
+      usedMinutes: number;
+      remainingMinutes: number;
+      overageEnabled: boolean;
+    }>('/subscription/audio-balance');
+    if (res) {
+      quotaInfo.value = {
+        totalMinutes: res.totalMinutes,
+        usedMinutes: res.usedMinutes,
+        remainingMinutes: res.remainingMinutes,
+        overageEnabled: res.overageEnabled,
+      };
+    }
+  } catch {
+    // 未登录或获取失败时不显示配额信息
+    quotaInfo.value = null;
+  }
+}
+
 const remainingAudioMinutes = computed(() => {
   if (!quotaInfo.value) return 0;
   return Math.max(0, quotaInfo.value.totalMinutes - quotaInfo.value.usedMinutes);
@@ -533,6 +657,10 @@ function startPollingProgress(bookId: string) {
         currentBook.value = book;
         if (book.genStage === 'content_completed' || book.genStage === 'audio_completed' || book.genStage === 'video_completed') {
           uni.showToast({ title: '生成完成!', icon: 'success' });
+          // 写入通知
+          const notifStore = useNotificationStore();
+          const stageLabel = book.genStage === 'content_completed' ? '内容' : book.genStage === 'audio_completed' ? '音频' : '视频';
+          notifStore.add({ type: 'content_complete', title: `${stageLabel}生成完成`, message: `《${currentBook.value?.title || '书籍'}》${stageLabel}已生成完毕`, bookId });
         }
       }
     } catch (e) {
@@ -558,6 +686,9 @@ function startPollingAudioStatus(bookId: string) {
         generatingAudio.value = false;
         await loadBook(bookId);
         uni.showToast({ title: '音频生成完成', icon: 'success' });
+        // 写入通知
+        const notifStore = useNotificationStore();
+        notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${currentBook.value?.title || '书籍'}》音频已生成完毕`, bookId });
       }
     } catch (e) {
       console.error('轮询音频状态失败:', e);
@@ -582,6 +713,9 @@ function startPollingVideoStatus(bookId: string) {
         generatingVideo.value = false;
         await loadBook(bookId);
         uni.showToast({ title: '视频生成完成', icon: 'success' });
+        // 写入通知
+        const notifStore = useNotificationStore();
+        notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${currentBook.value?.title || '书籍'}》视频已生成完毕`, bookId });
       }
     } catch (e) {
       console.error('轮询视频状态失败:', e);
@@ -785,6 +919,8 @@ onMounted(() => {
     }
   }
   // #endif
+  // 获取配额信息
+  fetchQuotaInfo();
 });
 
 onShow(() => {
@@ -792,6 +928,8 @@ onShow(() => {
   if (currentBook.value?.id) {
     loadBook(currentBook.value.id);
   }
+  // 刷新配额信息
+  fetchQuotaInfo();
 });
 
 onUnmounted(() => {
@@ -887,6 +1025,9 @@ onUnmounted(() => {
 .status-stage { display: flex; align-items: center; padding: 16rpx; background: rgba(255, 255, 255, 0.2); border-radius: 12rpx; margin-bottom: 16rpx; }
 .stage-label { font-size: 26rpx; opacity: 0.9; margin-right: 8rpx; }
 .stage-value { font-size: 28rpx; font-weight: 600; }
+.current-chapter-info { display: flex; align-items: center; padding: 16rpx; background: rgba(255, 255, 255, 0.15); border-radius: 12rpx; margin-bottom: 16rpx; }
+.chapter-label { font-size: 24rpx; opacity: 0.8; margin-right: 8rpx; flex-shrink: 0; }
+.chapter-name { font-size: 26rpx; font-weight: 600; flex: 1; }
 .status-error { display: flex; align-items: flex-start; padding: 16rpx; background: rgba(239, 68, 68, 0.3); border-radius: 12rpx; margin-bottom: 16rpx; }
 .error-icon { font-size: 28rpx; margin-right: 12rpx; flex-shrink: 0; }
 .error-text { font-size: 24rpx; line-height: 1.6; flex: 1; }
@@ -906,6 +1047,77 @@ onUnmounted(() => {
 
 .outline-theme { font-size: 24rpx; color: #6b7280; }
 
+/* 章节搜索工具栏 */
+.chapter-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  margin-top: 20rpx;
+  margin-bottom: 8rpx;
+}
+
+.search-box {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background: #f3f4f6;
+  border-radius: 16rpx;
+  padding: 12rpx 20rpx;
+  border: 2rpx solid transparent;
+  transition: border-color 0.2s;
+}
+
+.search-box:focus-within {
+  border-color: #4f46e5;
+  background: #ffffff;
+}
+
+.search-icon-small {
+  font-size: 28rpx;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+}
+
+.search-input {
+  flex: 1;
+  font-size: 26rpx;
+  color: #1f2937;
+  background: transparent;
+}
+
+.search-placeholder-text {
+  color: #9ca3af;
+  font-size: 26rpx;
+}
+
+.search-clear {
+  font-size: 28rpx;
+  color: #9ca3af;
+  padding: 4rpx 8rpx;
+  flex-shrink: 0;
+}
+
+.toggle-btn {
+  flex-shrink: 0;
+  padding: 12rpx 20rpx;
+  background: #f3f4f6;
+  border-radius: 16rpx;
+  font-size: 24rpx;
+  color: #4f46e5;
+  white-space: nowrap;
+}
+
+.toggle-btn:active {
+  background: #e5e7eb;
+}
+
+.search-empty {
+  text-align: center;
+  padding: 40rpx;
+  color: #9ca3af;
+  font-size: 26rpx;
+}
+
 .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-left { display: flex; align-items: center; gap: 16rpx; }
@@ -973,6 +1185,11 @@ onUnmounted(() => {
 .action-btn[disabled] { opacity: 0.6; }
 .action-btn.langgraph-btn { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: #ffffff; }
 .action-btn.audio-btn { background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; }
+.action-btn.upgrade-btn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; }
+.btn-with-quota { display: flex; flex-direction: column; gap: 8rpx; width: 100%; }
+.quota-info-inline { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 8rpx 0; }
+.quota-text { font-size: 22rpx; color: #6b7280; }
+.quota-warn { font-size: 22rpx; color: #ef4444; font-weight: 600; }
 .generating-tip { text-align: center; padding: 20rpx; color: #6b7280; font-size: 26rpx; }
 
 .warning-tip { padding: 24rpx; background: #fef3c7; border-radius: 12rpx; margin-top: 16rpx; }

+ 221 - 22
my-uniapp-vue3/src/pages/index/index.vue

@@ -4,6 +4,11 @@
     <view class="search-bar" @click="goToSearch">
       <text class="search-icon">🔍</text>
       <text class="search-placeholder">搜索专辑...</text>
+      <!-- 通知铃铛 -->
+      <view class="notif-bell" @click.stop="showNotifications">
+        <text class="bell-icon">🔔</text>
+        <view v-if="notifStore.unreadCount > 0" class="bell-badge">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</view>
+      </view>
     </view>
 
     <!-- 骨架屏加载状态 -->
@@ -55,7 +60,34 @@
         </scroll-view>
       </view>
 
-      <view v-if="bookList.length === 0 && !loading" class="empty">
+      <!-- 冷启动引导(新用户无内容时) -->
+      <view v-if="bookList.length === 0 && !loading && recentAudios.length === 0" class="onboarding">
+        <view class="onboarding-card">
+          <text class="onboarding-icon">🎧</text>
+          <text class="onboarding-title">欢迎来到有声书 AI</text>
+          <text class="onboarding-desc">AI 为你创作专属有声书,从选题到配音全自动完成</text>
+          <button class="onboarding-btn" @click="quickCreateBook">
+            ✨ 创建我的第一本有声书
+          </button>
+        </view>
+        <view class="template-section">
+          <text class="template-title">💡 快速模板</text>
+          <view class="template-list">
+            <view
+              v-for="tpl in quickTemplates"
+              :key="tpl.title"
+              class="template-card"
+              @click="useQuickTemplate(tpl)"
+            >
+              <text class="template-icon">{{ tpl.icon }}</text>
+              <text class="template-name">{{ tpl.title }}</text>
+              <text class="template-desc">{{ tpl.desc }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view v-else-if="bookList.length === 0 && !loading" class="empty">
         <text class="empty-icon">📚</text>
         <text class="empty-text">暂无书籍</text>
         <text class="empty-hint">点击下方按钮创建第一本书籍</text>
@@ -114,7 +146,9 @@ import { onShow } from '@dcloudio/uni-app';
 import { getPublicBooks } from '../../utils/book-generator-api';
 import { get } from '../../utils/request';
 import { useUserStore } from '../../store/user';
+import { useNotificationStore } from '../../store/notification';
 import MiniPlayer from '../../components/MiniPlayer.vue';
+import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
 // scroll-top 用于避免 scrollTop 错误
 const scrollTop = ref(0);
@@ -150,35 +184,38 @@ interface RecentAudio {
 const recentAudios = ref<RecentAudio[]>([]);
 const loadingRecent = ref(false);
 const userStore = useUserStore();
+const notifStore = useNotificationStore();
 
-// 封面颜色映射
-const coverColors = [
-  ['#4f46e5', '#818cf8'],
-  ['#ef4444', '#f87171'],
-  ['#f97316', '#fb923c'],
-  ['#22c55e', '#4ade80'],
-  ['#3b82f6', '#60a5fa'],
-  ['#a855f7', '#c084fc'],
-  ['#ec4899', '#f472b6'],
-  ['#14b8a6', '#2dd4bf'],
+// 快速模板
+const quickTemplates = [
+  { icon: '🔬', title: '科普短文', desc: '用通俗语言解释一个科学概念,适合大众阅读', description: '用通俗易懂的语言,深入浅出地解释一个科学概念或自然现象。要求:逻辑清晰、例证生动、引发读者好奇心。' },
+  { icon: '🌙', title: '睡前故事', desc: '温馨治愈的短篇故事,适合睡前收听', description: '创作一个温馨治愈的短篇故事,主题围绕友情、成长或奇幻冒险。要求:情节温暖、语言优美、有情感共鸣,适合睡前收听。' },
+  { icon: '📝', title: '知识总结', desc: '系统梳理一个知识领域的核心要点', description: '系统梳理一个知识领域的核心概念、发展脉络和关键要点。要求:结构清晰、内容准确、重点突出,帮助读者快速建立知识框架。' },
 ];
 
+function quickCreateBook() {
+  uni.navigateTo({ url: '/pages/book-generator/interactive' });
+}
+
+function useQuickTemplate(tpl: { title: string; description: string }) {
+  // 将模板信息通过 globalData 传递到创建页
+  const app = getApp();
+  if (app) {
+    app.globalData = app.globalData || {};
+    app.globalData.quickTemplate = { title: tpl.title, description: tpl.description };
+  }
+  uni.navigateTo({ url: '/pages/book-generator/interactive' });
+}
+
+// 封面颜色映射
 // 获取封面渐变色
 function getCoverGradient(item: Book): string {
-  const colors = coverColors[item.id % coverColors.length] || coverColors[0];
-  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+  return getBookGradient(item.id);
 }
 
 // 获取最近收听封面渐变色
 function getRecentGradient(id: string | number): string {
-  const colors = coverColors[Number(id) % coverColors.length] || coverColors[0];
-  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
-}
-
-// 获取标题首字母作为装饰文字
-function getTitleLetter(title: string): string {
-  if (!title) return '?';
-  return title.charAt(0).toUpperCase();
+  return getBookGradient(Number(id));
 }
 
 // 获取书籍列表
@@ -212,7 +249,7 @@ async function fetchRecentAudios() {
 
   loadingRecent.value = true;
   try {
-    const res = await get<{ list: RecentAudio[] }>('/api/player/recent');
+    const res = await get<{ list: RecentAudio[] }>('/player/recent');
     recentAudios.value = res?.list || [];
   } catch (error) {
     console.error('获取最近收听失败:', error);
@@ -261,6 +298,30 @@ function goToSearch() {
   uni.navigateTo({ url: '/pages/search/index' });
 }
 
+// 显示通知列表
+function showNotifications() {
+  if (notifStore.notifications.length === 0) {
+    uni.showToast({ title: '暂无通知', icon: 'none' });
+    return;
+  }
+  const items = notifStore.notifications.slice(0, 5).map((n, i) => {
+    const prefix = n.read ? '' : '● ';
+    return `${prefix}${n.title}`;
+  });
+  uni.showActionSheet({
+    itemList: items.length > 0 ? items : ['暂无通知'],
+    success: (res) => {
+      const notif = notifStore.notifications[res.tapIndex];
+      if (notif) {
+        notifStore.markRead(notif.id);
+        if (notif.bookId) {
+          uni.navigateTo({ url: `/pages/book-generator/detail?id=${notif.bookId}` });
+        }
+      }
+    },
+  });
+}
+
 // 初始化
 onMounted(async () => {
   console.log('首页加载了, platform:', uni.getSystemInfoSync().platform);
@@ -308,10 +369,45 @@ onShow(() => {
 }
 
 .search-placeholder {
+  flex: 1;
   font-size: 26rpx;
   color: rgba(255, 255, 255, 0.8);
 }
 
+/* 通知铃铛 */
+.notif-bell {
+  position: relative;
+  width: 64rpx;
+  height: 64rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+
+.bell-icon {
+  font-size: 36rpx;
+}
+
+.bell-badge {
+  position: absolute;
+  top: 4rpx;
+  right: 0;
+  min-width: 32rpx;
+  height: 32rpx;
+  background: #ef4444;
+  color: #ffffff;
+  font-size: 20rpx;
+  font-weight: 600;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 6rpx;
+  border: 2rpx solid #ffffff;
+}
+
 /* 最近收听区域 */
 .recent-section {
   padding: 24rpx;
@@ -414,6 +510,109 @@ onShow(() => {
   color: #9ca3af;
 }
 
+/* 冷启动引导 */
+.onboarding {
+  padding: 32rpx 24rpx;
+}
+
+.onboarding-card {
+  background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  border-radius: 24rpx;
+  padding: 48rpx 32rpx;
+  text-align: center;
+  margin-bottom: 32rpx;
+}
+
+.onboarding-icon {
+  font-size: 80rpx;
+  display: block;
+  margin-bottom: 20rpx;
+}
+
+.onboarding-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #ffffff;
+  display: block;
+  margin-bottom: 12rpx;
+}
+
+.onboarding-desc {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.85);
+  display: block;
+  margin-bottom: 28rpx;
+  line-height: 1.5;
+}
+
+.onboarding-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 80rpx;
+  padding: 0 40rpx;
+  background: #ffffff;
+  color: #4f46e5;
+  font-size: 28rpx;
+  font-weight: 600;
+  border-radius: 40rpx;
+  border: none;
+}
+
+.onboarding-btn::after { border: none; }
+
+.template-section {
+  margin-bottom: 24rpx;
+}
+
+.template-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+  display: block;
+  margin-bottom: 16rpx;
+}
+
+.template-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.template-card {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 24rpx;
+  background: #ffffff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  transition: transform 0.1s;
+}
+
+.template-card:active {
+  transform: scale(0.98);
+}
+
+.template-icon {
+  font-size: 40rpx;
+  flex-shrink: 0;
+}
+
+.template-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+  flex-shrink: 0;
+  min-width: 120rpx;
+}
+
+.template-desc {
+  font-size: 24rpx;
+  color: #9ca3af;
+  flex: 1;
+}
+
 .album-grid {
   display: flex;
   flex-wrap: wrap;