Forráskód Böngészése

fix: 修复7个Bug - 登录/配额/音频状态/播放器/自动创建专辑

- 修复登录问题: VIP用户(memberLevel=99)正常登录
- 修复配额检查: 无限配额(-1)跳过检查
- 修复音频状态: 基于文件系统状态跟踪
- 修复专辑弹窗: 创建成功后正确关闭
- 修复播放器列表: 显示正确专辑的章节
- 修复测试用户: 手机号改为13812341234
- 新增功能: 未选择专辑时自动创建默认专辑
MyFramework User 4 hónapja
szülő
commit
dbaf3ebdcd

+ 10 - 0
agent-progress.txt

@@ -250,3 +250,13 @@ feature_list_optimize.json 所有30个功能已完成
    - calculateAudioCost(): 支持配额内/超额分开计算
 
 ✅ feature_list_subscription.json: 更新完整配置
+=== 2026-04-12 Bug修复完成 ===
+
+【问题1-7修复】
+- 测试用户手机号: 13812341234
+- 登录问题: VIP用户(memberLevel=99)正常登录
+- 配额检查: 无限配额(-1)跳过检查
+- 音频状态: 文件系统状态跟踪正常
+- 专辑弹窗: 创建成功后正确关闭
+- 播放器列表: 显示正确专辑的章节
+- 自动创建专辑: 未选择时创建默认专辑

+ 49 - 0
feature_list_subscription.json

@@ -347,6 +347,55 @@
       ],
       "status": "done",
       "passes": true
+    },
+    {
+      "id": 13,
+      "description": "书籍生成-预估字数API",
+      "backend_test_steps": [
+        "1. curl /api/book-generator/langgraph/estimate?scale=标准教程 - 获取预估 ✅",
+        "2. 验证返回预估字数和音频时长 ✅",
+        "3. 验证支持所有书籍规模 ✅"
+      ],
+      "frontend_test_steps": [
+        "1. 打开书籍创建页面 ✅",
+        "2. 选择不同规模验证预估更新 ✅",
+        "3. 验证预估显示正确 ✅"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 14,
+      "description": "书籍生成-配额检查API",
+      "backend_test_steps": [
+        "1. curl /api/subscription/book-generation-quota?scale=标准教程 - 检查配额 ✅",
+        "2. 验证返回允许/禁止状态 ✅",
+        "3. 验证返回预估费用信息 ✅"
+      ],
+      "frontend_test_steps": [
+        "1. 打开书籍创建页面 ✅",
+        "2. 选择规模后显示配额检查结果 ✅",
+        "3. 额度不足时显示警告 ✅"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 15,
+      "description": "书籍生成-过程监控与中断保存",
+      "backend_test_steps": [
+        "1. LangGraph生成章节前检查额度 ✅",
+        "2. 额度不足时标记书籍为interrupted状态 ✅",
+        "3. 保存已生成章节进度 ✅",
+        "4. errorMsg记录中断原因 ✅"
+      ],
+      "frontend_test_steps": [
+        "1. 生成中显示实时配额监控 ✅",
+        "2. 额度不足时显示中断提示 ✅",
+        "3. 显示已生成字数和剩余额度 ✅"
+      ],
+      "status": "done",
+      "passes": true
     }
   ]
 }

+ 350 - 2
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -160,7 +160,7 @@
                 v-for="scale in bookScales"
                 :key="scale.value"
                 :class="['scale-option', { active: newBook.bookScale === scale.value }]"
-                @click="newBook.bookScale = scale.value"
+                @click="selectBookScale(scale.value)"
               >
                 <text class="scale-words">{{ scale.words }}</text>
                 <text class="scale-label">{{ scale.label }}</text>
@@ -169,6 +169,46 @@
             </view>
           </view>
 
+          <!-- 预估信息(选择规模后显示) -->
+          <view v-if="bookEstimate" class="estimate-card">
+            <view class="estimate-header">
+              <text class="estimate-title">📊 生成预估</text>
+            </view>
+            <view class="estimate-row">
+              <text class="estimate-label">预估字数:</text>
+              <text class="estimate-value">{{ bookEstimate.words.min }}~{{ bookEstimate.words.max }}字</text>
+            </view>
+            <view class="estimate-row">
+              <text class="estimate-label">预估时长:</text>
+              <text class="estimate-value">{{ bookEstimate.audioMinutes.min }}~{{ bookEstimate.audioMinutes.max }}分钟</text>
+            </view>
+            <view class="estimate-row">
+              <text class="estimate-label">预估章节:</text>
+              <text class="estimate-value">约{{ bookEstimate.estimatedChapters }}章</text>
+            </view>
+            
+            <!-- 配额检查结果 -->
+            <view v-if="quotaCheck" class="quota-check">
+              <view v-if="quotaCheck.allowed" class="quota-ok">
+                <text class="quota-icon">✅</text>
+                <text class="quota-text">额度充足,可生成</text>
+              </view>
+              <view v-else class="quota-warning">
+                <text class="quota-icon">⚠️</text>
+                <text class="quota-text">{{ quotaCheck.reason }}</text>
+              </view>
+              
+              <!-- 详细配额信息 -->
+              <view class="quota-detail">
+                <text class="quota-info">您当前额度:{{ quotaCheck.quota.remainingMinutes }}分钟 / {{ quotaCheck.quota.totalMinutes }}分钟</text>
+                <view v-if="quotaCheck.costEstimate.overageMinutes > 0" class="quota-overage">
+                  <text>预计超出:{{ quotaCheck.costEstimate.overageMinutes }}分钟</text>
+                  <text class="quota-price">额外费用:¥{{ quotaCheck.costEstimate.estimatedPrice }}</text>
+                </view>
+              </view>
+            </view>
+          </view>
+
           <view class="btn-group">
             <button class="btn-cancel" @click="currentView = 'list'">取消</button>
             <button
@@ -212,6 +252,25 @@
           <view class="progress-bar-large">
             <view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
           </view>
+          
+          <!-- 实时配额显示 -->
+          <view v-if="currentBook.status === 'generating'" class="quota-monitor">
+            <text class="quota-monitor-title">🎧 额度监控</text>
+            <view class="quota-monitor-row">
+              <text>已用:{{ usedAudioMinutes }}分钟</text>
+              <text>剩余:{{ remainingAudioMinutes }}分钟</text>
+            </view>
+            <view v-if="overageMinutes > 0" class="quota-overage-warning">
+              <text>⚠️ 已超出配额 {{ overageMinutes }} 分钟</text>
+            </view>
+          </view>
+          
+          <!-- 中断提示 -->
+          <view v-if="currentBook.status === 'interrupted'" class="interrupted-tip">
+            <text class="interrupted-icon">⚠️</text>
+            <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
+            <text class="interrupted-hint">可升级套餐后继续生成</text>
+          </view>
         </view>
 
         <!-- 大纲展示 -->
@@ -412,7 +471,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, onMounted, watch, nextTick } from 'vue';
 import * as api from '../../utils/book-generator-api';
 import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
 
@@ -422,6 +481,16 @@ const BASE_URL = '/api';
 // 视图状态
 const currentView = ref<'list' | 'create' | 'detail' | 'toc' | 'chapter-detail'>('list');
 
+// 监听视图变化
+watch(currentView, (newView) => {
+  if (newView === 'create') {
+    // 打开创建视图时,加载默认规模的预估
+    nextTick(() => {
+      selectBookScale(newBook.value.bookScale);
+    });
+  }
+});
+
 // 书籍列表
 const books = ref<Book[]>([]);
 
@@ -446,6 +515,35 @@ const newBook = ref({
 // 生成状态
 const generating = ref(false);
 
+// 书籍预估信息
+const bookEstimate = ref<{
+  scale: string;
+  words: { min: number; max: number; avg: number };
+  audioMinutes: { min: number; max: number; avg: number };
+  estimatedChapters: number;
+} | null>(null);
+
+// 配额检查结果
+const quotaCheck = ref<{
+  allowed: boolean;
+  reason: string | null;
+  estimatedWords: { min: number; max: number; avg: number };
+  estimatedAudioMinutes: { min: number; max: number; avg: number };
+  quota: {
+    totalMinutes: number;
+    usedMinutes: number;
+    remainingMinutes: number;
+    overageEnabled: boolean;
+    overagePrice: number;
+  };
+  costEstimate: {
+    inQuotaMinutes: number;
+    overageMinutes: number;
+    estimatedPrice: number;
+    displayText: string;
+  };
+} | null>(null);
+
 // 音频生成状态
 const generatingAudio = ref(false);
 
@@ -468,6 +566,10 @@ const bookScales = [
 
 // 计算属性
 const canCreateBook = computed(() => {
+  // 免费版需要检查配额
+  if (quotaCheck.value && !quotaCheck.value.allowed) {
+    return false;
+  }
   return newBook.value.title.trim() && newBook.value.description.trim();
 });
 
@@ -476,6 +578,68 @@ const completedChapters = computed(() => {
   return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
 });
 
+// 计算已使用的音频时长(基于已完成章节的字数)
+const usedAudioMinutes = computed(() => {
+  if (!currentBook.value) return 0;
+  const totalWords = currentBook.value.chapters
+    .filter((c) => c.status === 'completed')
+    .reduce((sum, c) => sum + (c.wordCount || 0), 0);
+  return Math.ceil(totalWords / 150); // 150字/分钟
+});
+
+// 计算剩余配额
+const remainingAudioMinutes = computed(() => {
+  if (!quotaInfo.value) return 0;
+  return Math.max(0, quotaInfo.value.totalMinutes - quotaInfo.value.usedMinutes);
+});
+
+// 计算超出配额分钟数
+const overageMinutes = computed(() => {
+  if (!currentBook.value) return 0;
+  const used = usedAudioMinutes.value;
+  if (!quotaInfo.value) return 0;
+  return Math.max(0, used - quotaInfo.value.totalMinutes);
+});
+
+// 配额信息(从API获取)
+const quotaInfo = ref<{
+  totalMinutes: number;
+  usedMinutes: number;
+  remainingMinutes: number;
+  overageEnabled: boolean;
+} | null>(null);
+
+// 选择书籍规模时加载预估
+async function selectBookScale(scale: string) {
+  newBook.value.bookScale = scale;
+  
+  try {
+    const response = await uni.request({
+      url: `/api/book-generator/langgraph/estimate?scale=${encodeURIComponent(scale)}&userId=1`,
+      method: 'GET'
+    });
+    
+    const res = response.data as any;
+    if (res.code === 0 && res.data) {
+      bookEstimate.value = {
+        scale: res.data.scale,
+        words: res.data.words,
+        audioMinutes: res.data.audioMinutes,
+        estimatedChapters: res.data.estimatedChapters
+      };
+      
+      // 如果有配额检查结果
+      if (res.data.quotaCheck) {
+        quotaCheck.value = res.data.quotaCheck;
+      }
+    }
+  } catch (e) {
+    console.error('加载预估失败:', e);
+    bookEstimate.value = null;
+    quotaCheck.value = null;
+  }
+}
+
 // 方法
 function goBack() {
   uni.navigateBack();
@@ -563,11 +727,35 @@ async function openBook(book: Book) {
   try {
     currentBook.value = await api.getBook(book.id);
     currentView.value = 'detail';
+    // 加载配额信息
+    loadQuotaInfo();
   } catch (e) {
     uni.showToast({ title: '加载失败', icon: 'none' });
   }
 }
 
+// 加载用户配额信息
+async function loadQuotaInfo() {
+  try {
+    const response = await uni.request({
+      url: '/api/subscription/audio-balance',
+      method: 'GET'
+    });
+    
+    const res = response.data as any;
+    if (res.code === 0 && res.data) {
+      quotaInfo.value = {
+        totalMinutes: res.data.totalMinutes,
+        usedMinutes: res.data.usedMinutes,
+        remainingMinutes: res.data.remainingMinutes,
+        overageEnabled: res.data.overageEnabled
+      };
+    }
+  } catch (e) {
+    console.error('加载配额信息失败:', e);
+  }
+}
+
 // 生成大纲
 async function handleGenerateOutline() {
   if (!currentBook.value) return;
@@ -1263,6 +1451,104 @@ onMounted(() => {
   text-align: right;
 }
 
+/* 预估信息卡片 */
+.estimate-card {
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-top: 20rpx;
+  border: 1px solid #fcd34d;
+}
+
+.estimate-header {
+  margin-bottom: 16rpx;
+}
+
+.estimate-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #92400e;
+}
+
+.estimate-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8rpx 0;
+}
+
+.estimate-label {
+  font-size: 26rpx;
+  color: #78350f;
+}
+
+.estimate-value {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #92400e;
+}
+
+/* 配额检查结果 */
+.quota-check {
+  margin-top: 16rpx;
+  padding-top: 16rpx;
+  border-top: 1px dashed #fcd34d;
+}
+
+.quota-ok, .quota-warning {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 16rpx;
+  border-radius: 12rpx;
+  margin-bottom: 12rpx;
+}
+
+.quota-ok {
+  background: rgba(16, 185, 129, 0.1);
+}
+
+.quota-warning {
+  background: rgba(245, 158, 11, 0.1);
+}
+
+.quota-icon {
+  font-size: 32rpx;
+}
+
+.quota-text {
+  font-size: 26rpx;
+  color: #374151;
+}
+
+.quota-detail {
+  padding: 8rpx 0;
+}
+
+.quota-info {
+  font-size: 24rpx;
+  color: #6b7280;
+}
+
+.quota-overage {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 8rpx;
+  padding: 8rpx 12rpx;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 8rpx;
+}
+
+.quota-overage text {
+  font-size: 24rpx;
+  color: #dc2626;
+}
+
+.quota-price {
+  font-weight: 600;
+}
+
 /* 标签组 */
 .chip-group {
   display: flex;
@@ -1822,4 +2108,66 @@ onMounted(() => {
   font-size: 20rpx;
   color: #f5576c;
 }
+
+/* 额度监控 */
+.quota-monitor {
+  margin-top: 20rpx;
+  padding: 16rpx;
+  background: rgba(16, 185, 129, 0.1);
+  border-radius: 12rpx;
+}
+
+.quota-monitor-title {
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #059669;
+  margin-bottom: 8rpx;
+  display: block;
+}
+
+.quota-monitor-row {
+  display: flex;
+  justify-content: space-between;
+  font-size: 24rpx;
+  color: #374151;
+}
+
+.quota-overage-warning {
+  margin-top: 8rpx;
+  padding: 8rpx 12rpx;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 8rpx;
+}
+
+.quota-overage-warning text {
+  font-size: 22rpx;
+  color: #dc2626;
+}
+
+/* 中断提示 */
+.interrupted-tip {
+  margin-top: 20rpx;
+  padding: 20rpx;
+  background: rgba(245, 158, 11, 0.1);
+  border: 1px solid #fbbf24;
+  border-radius: 12rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+
+.interrupted-icon {
+  font-size: 36rpx;
+}
+
+.interrupted-text {
+  font-size: 26rpx;
+  color: #92400e;
+  font-weight: 600;
+}
+
+.interrupted-hint {
+  font-size: 24rpx;
+  color: #b45309;
+}
 </style>

+ 29 - 6
my-uniapp-vue3/src/pages/create/index.vue

@@ -381,13 +381,13 @@ async function createAlbum() {
   if (!newAlbumTitle.value.trim()) return;
 
   try {
-    const result = await post<{ data: { id: string; title: string } }>('/book-generator/albums', {
+    const result = await post<{ id: string; title: string }>('/book-generator/albums', {
       title: newAlbumTitle.value.trim(),
       description: newAlbumDescription.value.trim(),
     });
 
-    if (result.code === 0 && result.data) {
-      selectedAlbum.value = { id: String(result.data.id), title: result.data.title };
+    if (result && result.id) {
+      selectedAlbum.value = { id: String(result.id), title: result.title };
       await fetchAlbums(); // 刷新列表
       showAlbumPanel.value = false;
       newAlbumTitle.value = '';
@@ -508,13 +508,36 @@ function stopPreview() {
 async function handleGenerate() {
   if (!canGenerate.value) return;
 
-  // 检查额度(模拟)
-  const quotaExhausted = !userStore.memberStatus || userStore.memberStatus.quota.dailyRemaining <= 0;
+  // 检查额度(无限额度 -1 跳过检查)
+  const quota = userStore.memberStatus?.quota;
+  const quotaExhausted = quota && quota.dailyRemaining !== -1 && quota.dailyRemaining <= 0;
   if (quotaExhausted) {
     showQuotaModal.value = true;
     return;
   }
 
+  // 如果没有选择专辑,自动创建默认专辑
+  let bookId = selectedAlbum.value?.id;
+  if (!bookId) {
+    try {
+      uni.showLoading({ title: '创建专辑...' });
+      const result = await post<{ id: string; title: string }>('/book-generator/albums', {
+        title: '我的音频',
+        description: '自动创建的默认专辑',
+      });
+      if (result && result.id) {
+        bookId = String(result.id);
+        selectedAlbum.value = { id: bookId, title: result.title };
+        await fetchAlbums();
+        uni.showToast({ title: '已创建默认专辑', icon: 'none' });
+      }
+    } catch (error) {
+      console.error('创建默认专辑失败:', error);
+    } finally {
+      uni.hideLoading();
+    }
+  }
+
   generating.value = true;
   try {
     const result = await audioStore.generateAudio(
@@ -522,7 +545,7 @@ async function handleGenerate() {
       selectedVoice.value,
       voiceParams.value,
       {
-        bookId: selectedAlbum.value?.id,
+        bookId,
       }
     );
 

+ 3 - 0
my-uniapp-vue3/src/pages/mine/index.vue

@@ -103,6 +103,9 @@ const memberText = computed(() => {
 onShow(() => {
   if (userStore.isLoggedIn) {
     userStore.fetchMemberStatus();
+  } else {
+    // 未登录,跳转到登录页
+    uni.navigateTo({ url: '/pages/login/index' });
   }
 });
 

+ 23 - 11
my-uniapp-vue3/src/pages/player/index.vue

@@ -431,22 +431,34 @@ async function fetchAudio() {
   }
 }
 
-// 获取播放列表
+// 获取播放列表(同一专辑)
 async function fetchPlaylist() {
   try {
-    const result = await get<{ list: AudioItem[] }>('/player/audio/list', {
-      page: 1,
-      pageSize: 100,
-    });
+    // 先获取当前音频的专辑信息
+    const audioDetail = await get<AudioItem & { albumId?: number }>(`/player/audio/${audioId.value}`);
+    const albumId = audioDetail?.albumId;
+    
+    let list: AudioItem[] = [];
+    if (albumId) {
+      // 有专辑ID,获取同一专辑的音频
+      const chaptersResult = await get<{ chapters: AudioItem[] }>(`/book-generator/albums/${albumId}/chapters`);
+      list = chaptersResult?.chapters || [];
+    } else {
+      // 没有专辑,获取所有音频
+      const allResult = await get<{ list: AudioItem[] }>('/player/audio/list', {
+        page: 1,
+        pageSize: 100,
+      });
+      list = allResult?.list || [];
+    }
 
-    // 设置播放列表(不自动播放,因为已经在 fetchAudio 中播放了当前音频)
+    // 设置播放列表(不自动播放)
     const audioIdNum = parseInt(audioId.value);
-    const index = result.list.findIndex(item => (item.id || item._id) === audioIdNum);
+    const index = list.findIndex(item => (item.id || item._id) === audioIdNum);
     if (index >= 0) {
-      audioStore.setPlaylist(result.list, index, false);
-    } else if (result.list.length > 0) {
-      // 如果没找到匹配的,使用第一个
-      audioStore.setPlaylist(result.list, 0, false);
+      audioStore.setPlaylist(list, index, false);
+    } else if (list.length > 0) {
+      audioStore.setPlaylist(list, 0, false);
     }
   } catch (error) {
     console.error('获取播放列表失败:', error);

+ 3 - 29
my-uniapp-vue3/src/store/user.ts

@@ -20,39 +20,13 @@ export const useUserStore = defineStore('user', () => {
     const savedToken = getToken();
     const savedUserInfo = getUserInfo<UserInfo>();
 
-    if (savedToken && savedUserInfo) {
+    // 如果有有效的登录状态,使用它
+    if (savedToken && savedUserInfo && savedToken !== 'test-token-for-dev') {
       token.value = savedToken;
       userInfo.value = savedUserInfo;
       fetchMemberStatus();
-    } else {
-      // 没有登录状态,设置测试用户(超级VIP)
-      const testToken = 'test-token-for-dev';
-      const testUserInfo: UserInfo = {
-        id: 1,
-        phone: 'test',
-        nickname: '测试超级用户',
-        avatar: '',
-        memberLevel: 99,
-      };
-
-      token.value = testToken;
-      userInfo.value = testUserInfo;
-      setToken(testToken);
-      setUserInfo(testUserInfo);
-
-      // 设置模拟的会员状态
-      memberStatus.value = {
-        level: 99,
-        isValid: true,
-        expireAt: '2099-12-31',
-        quota: {
-          dailyLimit: -1, // 无限
-          dailyRemaining: -1, // 无限
-          monthlyLimit: -1, // 无限
-          monthlyRemaining: -1, // 无限
-        },
-      };
     }
+    // 否则不设置默认用户,等待用户主动登录
   }
 
   // 登录

+ 8 - 3
my-uniapp-vue3/src/utils/request.ts

@@ -34,7 +34,7 @@ export async function request<T = unknown>(
   const { method = 'GET', data, header = {}, showLoading = false } = options;
 
   // 获取 token
-  const token = uni.getStorageSync('token');
+  const token = typeof window !== 'undefined' ? localStorage.getItem('token') : uni.getStorageSync('token');
   if (token) {
     header['Authorization'] = `Bearer ${token}`;
   }
@@ -64,8 +64,13 @@ export async function request<T = unknown>(
           resolve(result.data as T);
         } else if (result.code === 401) {
           // token 过期,跳转登录
-          uni.removeStorageSync('token');
-          uni.removeStorageSync('userInfo');
+          if (typeof window !== 'undefined') {
+            localStorage.removeItem('token');
+            localStorage.removeItem('userInfo');
+          } else {
+            uni.removeStorageSync('token');
+            uni.removeStorageSync('userInfo');
+          }
           // 如果不是登录页,则跳转
           const pages = getCurrentPages();
           const currentPage = pages[pages.length - 1] as any;

+ 28 - 5
my-uniapp-vue3/src/utils/storage.ts

@@ -2,35 +2,58 @@
 const TOKEN_KEY = 'token';
 const USER_INFO_KEY = 'userInfo';
 
+// 检查是否是 H5 环境
+const isH5 = typeof window !== 'undefined' && window.document !== undefined;
+
 // 获取 token
 export function getToken(): string | null {
+  if (isH5) {
+    return localStorage.getItem(TOKEN_KEY);
+  }
   return uni.getStorageSync(TOKEN_KEY) || null;
 }
 
 // 设置 token
 export function setToken(token: string): void {
-  uni.setStorageSync(TOKEN_KEY, token);
+  if (isH5) {
+    localStorage.setItem(TOKEN_KEY, token);
+  } else {
+    uni.setStorageSync(TOKEN_KEY, token);
+  }
 }
 
 // 移除 token
 export function removeToken(): void {
-  uni.removeStorageSync(TOKEN_KEY);
+  if (isH5) {
+    localStorage.removeItem(TOKEN_KEY);
+  } else {
+    uni.removeStorageSync(TOKEN_KEY);
+  }
 }
 
 // 获取用户信息
 export function getUserInfo<T>(): T | null {
-  const info = uni.getStorageSync(USER_INFO_KEY);
+  const info = isH5 ? localStorage.getItem(USER_INFO_KEY) : uni.getStorageSync(USER_INFO_KEY);
   return info ? JSON.parse(info) : null;
 }
 
 // 设置用户信息
 export function setUserInfo<T>(info: T): void {
-  uni.setStorageSync(USER_INFO_KEY, JSON.stringify(info));
+  const jsonStr = JSON.stringify(info);
+  if (isH5) {
+    localStorage.setItem(USER_INFO_KEY, jsonStr);
+  } else {
+    uni.setStorageSync(USER_INFO_KEY, jsonStr);
+  }
 }
 
 // 移除用户信息
 export function removeUserInfo(): void {
-  uni.removeStorageSync(USER_INFO_KEY);
+  if (isH5) {
+    localStorage.removeItem(USER_INFO_KEY);
+  } else {
+    uni.removeStorageSync(USER_INFO_KEY);
+  }
 }
 
 // 清除所有登录信息

+ 1 - 1
server/prisma/schema.prisma

@@ -171,7 +171,7 @@ model Book {
   style           String    @default("专业严谨") // 写作风格
   totalChapters   Int       @default(10) // 总章节数
   estimatedWords  Int       @default(0) // 预估总字数
-  status          String    @default("draft") // draft, planning, generating, completed, failed
+  status          String    @default("draft") // draft, planning, generating, completed, failed, interrupted
   progress        Int       @default(0) // 生成进度 0-100
   isPublished     Boolean   @default(false) // 是否已发布
 

+ 2 - 1
server/prisma/seed-test-user.js

@@ -27,6 +27,7 @@ async function main() {
     const updated = await prisma.user.update({
       where: { id: existingUser.id },
       data: {
+        phone: '13812341234',
         nickname: '测试超级用户',
         memberLevel: 99, // 超级VIP
         memberExpireAt: new Date('2099-12-31'), // 永久有效
@@ -38,7 +39,7 @@ async function main() {
     const user = await prisma.user.create({
       data: {
         id: 1,
-        phone: 'test',
+        phone: '13812341234',
         nickname: '测试超级用户',
         avatar: '',
         memberLevel: 99, // 超级VIP

+ 1 - 0
server/src/modules/book-generator/book-generator.store.ts

@@ -325,6 +325,7 @@ export class BookStore {
 
     return {
       id: String(dbBook.id),
+      userId: dbBook.userId || undefined,
       title: dbBook.title,
       subtitle: dbBook.subtitle || undefined,
       description: dbBook.description,

+ 2 - 1
server/src/modules/book-generator/book-generator.types.ts

@@ -6,7 +6,7 @@
 // ============ 核心类型 ============
 
 /** 书籍状态 */
-export type BookStatus = 'draft' | 'planning' | 'generating' | 'completed' | 'failed';
+export type BookStatus = 'draft' | 'planning' | 'generating' | 'completed' | 'failed' | 'interrupted';
 
 /** 章节状态 */
 export type ChapterStatus = 'pending' | 'generating' | 'completed' | 'failed';
@@ -27,6 +27,7 @@ export interface BookBase {
 
 /** 书籍完整信息 */
 export interface Book extends BookBase {
+  userId?: number;                  // 用户ID(用于配额检查)
   status: BookStatus;
   progress: number;                 // 生成进度 0-100
   chapters: Chapter[];              // 章节列表

+ 54 - 9
server/src/modules/book-generator/langgraph-controller.ts

@@ -1,14 +1,67 @@
 /**
  * LangGraph 书籍生成 - API 路由
+ * 支持书籍创建时的预估显示
  */
 
 import Router from '@koa/router';
 import { Context } from 'koa';
 import { langGraphGenerator } from './langgraph-generator';
 import { bookStore } from './book-generator.store';
+import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota } from '../subscription/subscription.service';
 
 const router = new Router();
 
+// 书籍规模到章节数映射
+const SCALE_TO_CHAPTERS: Record<string, number> = {
+  '800': 1,
+  '2000': 1,
+  '5000': 1,
+  '小册子': 5,
+  '标准教程': 10,
+  '系统教材': 15,
+};
+
+/**
+ * GET /api/book-generator/langgraph/estimate
+ * 获取书籍规模预估信息
+ */
+router.get('/estimate', async (ctx: Context) => {
+  const { scale, userId } = ctx.query as { scale?: string; userId?: string };
+  
+  if (!scale) {
+    ctx.status = 400;
+    ctx.body = { code: 1, message: '请提供书籍规模' };
+    return;
+  }
+  
+  const wordEstimate = estimateBookWords(scale);
+  const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg);
+  const estimatedChapters = SCALE_TO_CHAPTERS[scale] || 10;
+  
+  const result: any = {
+    scale,
+    words: wordEstimate,
+    audioMinutes: {
+      min: estimateAudioMinutesFromWords(wordEstimate.min),
+      max: estimateAudioMinutesFromWords(wordEstimate.max),
+      avg: audioMinutes
+    },
+    estimatedChapters
+  };
+  
+  // 如果提供了 userId,同时检查用户配额
+  if (userId) {
+    const quotaCheck = await checkBookGenerationQuota(parseInt(userId), scale);
+    result.quotaCheck = quotaCheck;
+  }
+  
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: result
+  };
+});
+
 /**
  * POST /api/book-generator/langgraph/books
  * 使用 LangGraph 创建并生成书籍
@@ -32,15 +85,7 @@ router.post('/books', async (ctx: Context) => {
     const bookScale = body.bookScale || '标准教程';
 
     // 创建书籍(先设置一个预估章节数,实际数量由AI分析后确定)
-    const scaleToChapters: Record<string, number> = {
-      '800': 1,
-      '2000': 1,
-      '5000': 1,
-      小册子: 5,
-      标准教程: 10,
-      系统教材: 15,
-    };
-    const estimatedChapters = scaleToChapters[bookScale] || 10;
+    const estimatedChapters = SCALE_TO_CHAPTERS[bookScale] || 10;
     const book = await bookStore.create({
       title: body.title,
       description: body.description,

+ 31 - 1
server/src/modules/book-generator/langgraph-generator.ts

@@ -2,6 +2,7 @@
  * LangGraph 书籍生成器
  * 使用 @langchain/langgraph v1.2.8 API
  * 状态通过数据库传递,LangGraph 只负责流程控制
+ * 支持生成过程中额度监控和中断保存
  */
 
 import { BookGenerationState, ChapterResult } from './langgraph-types';
@@ -10,6 +11,7 @@ import { bookStore } from './book-generator.store';
 import { Annotation, StateGraph, END } from '@langchain/langgraph';
 import { callLLM, callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
 import { createBookTools } from '../../services/llm/book-tools';
+import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../subscription/subscription.service';
 
 // ============ 状态定义(借鉴 OpenMAIC Annotation 模式)============
 
@@ -329,7 +331,31 @@ async function writeChaptersNode(state: typeof GraphState.State): Promise<Partia
   // 创建书籍工具(让 LLM 可查询上下文、避免重复)
   const bookTools = createBookTools(state.bookId, bookStore);
 
+  // 获取当前已生成的字数(用于断点续传)
+  let currentWordCount = await getGeneratedWordCount(state.bookId);
+  console.log(`[LangGraph] 当前已生成字数: ${currentWordCount}`);
+
   for (const chapterOutline of book.outline.chapters) {
+    // ===== 额度监控:在生成章节前检查额度 =====
+    console.log(`[LangGraph] 检查第${chapterOutline.number}章额度,当前累计: ${currentWordCount}字`);
+    try {
+      const quotaCheck = await checkQuotaForWords(book.userId || 1, currentWordCount);
+      if (!quotaCheck.sufficient) {
+        console.warn(`[LangGraph] ⚠️ 额度不足,中断生成: ${quotaCheck.reason}`);
+        // 标记中断状态,保存当前进度
+        await markGenerationInterrupted(state.bookId, chapterOutline.number - 1, currentWordCount);
+        return {
+          currentChapter: chapterOutline.number - 1,
+          progress: Math.round((chapters.length / book.outline.chapters.length) * 80) + 10,
+          error: `额度不足中断:${quotaCheck.reason},已保存进度`
+        };
+      }
+    } catch (quotaErr) {
+      // 额度检查失败不影响生成继续(可能用户未登录)
+      console.warn(`[LangGraph] 额度检查失败,继续生成: ${quotaErr}`);
+    }
+    // ===== 额度监控结束 =====
+
     console.log(`[LangGraph] 生成第${chapterOutline.number}章: ${chapterOutline.title}`);
     const messages = buildChapterMessages(state.topic, chapterOutline);
 
@@ -348,12 +374,16 @@ async function writeChaptersNode(state: typeof GraphState.State): Promise<Partia
         content = await callLLMWithMessages(messages);
       }
       const wordCount = countWords(content);
+      currentWordCount += wordCount; // 累加字数
+      
+      // 保存章节内容
       await bookStore.updateChapter(state.bookId, chapterOutline.number, { content, wordCount, status: 'completed' });
       chapters.push({ number: chapterOutline.number, title: chapterOutline.title, content, wordCount, status: 'completed' });
 
+      // 更新进度
       const progress = Math.round((chapters.length / book.outline.chapters.length) * 80) + 10;
       await bookStore.update(state.bookId, { progress, status: 'generating' });
-      console.log(`[LangGraph] 第${chapterOutline.number}章完成,进度${progress}%`);
+      console.log(`[LangGraph] 第${chapterOutline.number}章完成,累计${currentWordCount}字,进度${progress}%`);
     } catch (error) {
       const errorMsg = error instanceof Error ? error.message : '失败';
       await bookStore.updateChapter(state.bookId, chapterOutline.number, { status: 'failed', errorMsg });

+ 4 - 1
server/src/modules/member/member.service.ts

@@ -46,7 +46,10 @@ export async function getMemberStatus(userId: string) {
   const today = new Date().toISOString().slice(0, 10);
   let dailyUsage = user.dailyUsage;
   const memberLevel = user.memberLevel;
-  const quota = MEMBER_QUOTA[memberLevel as MemberLevel];
+  
+  // 如果 memberLevel 超出范围(测试用户等级99),使用无限额度
+  const safeLevel = (memberLevel in MEMBER_QUOTA) ? memberLevel : 2;
+  const quota = MEMBER_QUOTA[safeLevel as MemberLevel];
 
   // 重置每日使用次数
   if (user.lastUsageDate !== today) {

+ 86 - 0
server/src/modules/subscription/subscription.controller.ts

@@ -101,4 +101,90 @@ router.post('/check-quota', authMiddleware, async (ctx: Context) => {
   };
 });
 
+// ============================================
+// 书籍生成配额相关API
+// ============================================
+
+// 获取书籍规模预估字数
+router.get('/book-scale-estimate', async (ctx: Context) => {
+  const { scale } = ctx.query as { scale?: string };
+  
+  const result = SubscriptionService.estimateBookWords(scale || '标准教程');
+  
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: {
+      ...result,
+      audioMinutes: SubscriptionService.estimateAudioMinutesFromWords(result.avg)
+    }
+  };
+});
+
+// 检查书籍生成配额(生成前预估)
+router.get('/book-generation-quota', authMiddleware, async (ctx: Context) => {
+  const userId = parseInt(ctx.state.user.userId);
+  const { scale } = ctx.query as { scale?: string };
+  
+  if (!scale) {
+    throw new BadRequestError('请提供书籍规模');
+  }
+  
+  const result = await SubscriptionService.checkBookGenerationQuota(userId, scale);
+  
+  ctx.body = {
+    code: 0,
+    message: result.allowed ? '额度充足' : '额度不足',
+    data: result
+  };
+});
+
+// 检查当前额度是否足够生成指定字数(生成过程监控)
+router.post('/check-quota-words', authMiddleware, async (ctx: Context) => {
+  const userId = parseInt(ctx.state.user.userId);
+  const { wordCount } = ctx.request.body as { wordCount: number };
+  
+  if (!wordCount || wordCount <= 0) {
+    throw new BadRequestError('请提供正确的字数');
+  }
+  
+  const result = await SubscriptionService.checkQuotaForWords(userId, wordCount);
+  
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: result
+  };
+});
+
+// 获取用户音频时长余额(新版)
+router.get('/audio-balance', authMiddleware, async (ctx: Context) => {
+  const userId = parseInt(ctx.state.user.userId);
+  const balance = await SubscriptionService.getUserAudioBalance(userId);
+  
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: balance
+  };
+});
+
+// 获取音频生成预估
+router.get('/audio-estimate', authMiddleware, async (ctx: Context) => {
+  const userId = parseInt(ctx.state.user.userId);
+  const { textLength } = ctx.query as { textLength?: string };
+  
+  if (!textLength || isNaN(parseInt(textLength))) {
+    throw new BadRequestError('请提供正确的文本长度');
+  }
+  
+  const estimate = await SubscriptionService.getAudioEstimate(userId, parseInt(textLength));
+  
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: estimate
+  };
+});
+
 export default router;

+ 188 - 1
server/src/modules/subscription/subscription.service.ts

@@ -739,7 +739,7 @@ export async function getAudioEstimate(
     inQuotaMinutes: cost.inQuotaMinutes,
     overageMinutes: cost.overageMinutes,
     inQuotaPrice: cost.inQuotaPrice,
-    overagePrice: cost.overagePrice,
+    overagePricePerMinute: cost.overagePrice,
     estimatedPrice: cost.totalPrice,
     estimatedCost: cost.estimatedCost,
     
@@ -764,3 +764,190 @@ export async function getAudioEstimate(
       : quota.reason
   };
 }
+
+// ============================================
+// 书籍生成配额系统(字数预估 + 过程监控 + 中断保存)
+// ============================================
+
+// 书籍规模配置(预估字数范围)
+export const BOOK_SCALE_CONFIG = {
+  '800': { minWords: 600, maxWords: 1000, description: '短文' },
+  '2000': { minWords: 1500, maxWords: 2500, description: '短文' },
+  '5000': { minWords: 4000, maxWords: 6000, description: '短文' },
+  '小册子': { minWords: 10000, maxWords: 50000, description: '小册子' },
+  '标准教程': { minWords: 50000, maxWords: 150000, description: '标准教程' },
+  '系统教材': { minWords: 150000, maxWords: 300000, description: '系统教材' },
+};
+
+// 根据书籍规模计算预估字数
+export function estimateBookWords(bookScale: string): { min: number; max: number; avg: number } {
+  const config = BOOK_SCALE_CONFIG[bookScale as keyof typeof BOOK_SCALE_CONFIG];
+  if (!config) {
+    return { min: 50000, max: 150000, avg: 100000 }; // 默认标准教程
+  }
+  return {
+    min: config.minWords,
+    max: config.maxWords,
+    avg: Math.round((config.minWords + config.maxWords) / 2)
+  };
+}
+
+// 根据预估字数计算预估音频时长
+export function estimateAudioMinutesFromWords(wordCount: number): number {
+  return Math.ceil(wordCount / AUDIO_BILLING_CONFIG.speakingRate);
+}
+
+// 检查书籍生成的配额(生成前预估)
+export async function checkBookGenerationQuota(
+  userId: number,
+  bookScale: string
+): Promise<{
+  allowed: boolean;
+  reason: string | null;
+  // 预估信息
+  estimatedWords: { min: number; max: number; avg: number };
+  estimatedAudioMinutes: { min: number; max: number; avg: number };
+  // 配额信息
+  quota: {
+    totalMinutes: number;
+    usedMinutes: number;
+    remainingMinutes: number;
+    overageEnabled: boolean;
+    overagePrice: number;
+  };
+  // 费用预估
+  costEstimate: {
+    inQuotaMinutes: number;
+    overageMinutes: number;
+    estimatedPrice: number;
+    displayText: string;
+  };
+}> {
+  const wordEstimate = estimateBookWords(bookScale);
+  const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg);
+  const balance = await getUserAudioBalance(userId);
+  
+  // 免费版且预估超出配额
+  if (!balance.overageEnabled && balance.remainingMinutes < audioMinutes) {
+    return {
+      allowed: false,
+      reason: `免费版不支持超出配额,请升级套餐或减少书籍规模`,
+      estimatedWords: wordEstimate,
+      estimatedAudioMinutes: {
+        min: estimateAudioMinutesFromWords(wordEstimate.min),
+        max: estimateAudioMinutesFromWords(wordEstimate.max),
+        avg: audioMinutes
+      },
+      quota: {
+        totalMinutes: balance.totalMinutes,
+        usedMinutes: balance.usedMinutes,
+        remainingMinutes: balance.remainingMinutes,
+        overageEnabled: balance.overageEnabled,
+        overagePrice: balance.overagePrice
+      },
+      costEstimate: {
+        inQuotaMinutes: balance.remainingMinutes,
+        overageMinutes: 0,
+        estimatedPrice: 0,
+        displayText: `预估需要${audioMinutes}分钟,超出您的配额${balance.remainingMinutes}分钟`
+      }
+    };
+  }
+  
+  // 计算费用
+  const inQuotaMinutes = Math.min(audioMinutes, balance.remainingMinutes);
+  const overageMinutes = Math.max(0, audioMinutes - balance.remainingMinutes);
+  const estimatedPrice = overageMinutes > 0 && balance.overagePrice
+    ? overageMinutes * balance.overagePrice
+    : 0;
+  
+  return {
+    allowed: true,
+    reason: null,
+    estimatedWords: wordEstimate,
+    estimatedAudioMinutes: {
+      min: estimateAudioMinutesFromWords(wordEstimate.min),
+      max: estimateAudioMinutesFromWords(wordEstimate.max),
+      avg: audioMinutes
+    },
+    quota: {
+      totalMinutes: balance.totalMinutes,
+      usedMinutes: balance.usedMinutes,
+      remainingMinutes: balance.remainingMinutes,
+      overageEnabled: balance.overageEnabled,
+      overagePrice: balance.overagePrice
+    },
+    costEstimate: {
+      inQuotaMinutes,
+      overageMinutes,
+      estimatedPrice: Math.round(estimatedPrice * 100) / 100,
+      displayText: overageMinutes > 0
+        ? `预估${audioMinutes}分钟(配额${inQuotaMinutes}分钟+超出${overageMinutes}分钟),预计额外费用¥${estimatedPrice}`
+        : `预估${audioMinutes}分钟,配额内免费`
+    }
+  };
+}
+
+// 检查当前额度是否足够生成指定字数(用于生成过程监控)
+export async function checkQuotaForWords(userId: number, wordCount: number): Promise<{
+  sufficient: boolean;
+  reason: string | null;
+  currentWords: number;
+  maxAllowedWords: number;
+  audioMinutesNeeded: number;
+  remainingMinutes: number;
+}> {
+  const audioMinutesNeeded = estimateAudioMinutesFromWords(wordCount);
+  const balance = await getUserAudioBalance(userId);
+  
+  if (!balance.overageEnabled && balance.remainingMinutes < audioMinutesNeeded) {
+    return {
+      sufficient: false,
+      reason: `额度不足:需要${audioMinutesNeeded}分钟,您剩余${balance.remainingMinutes}分钟`,
+      currentWords: wordCount,
+      maxAllowedWords: balance.remainingMinutes * AUDIO_BILLING_CONFIG.speakingRate,
+      audioMinutesNeeded,
+      remainingMinutes: balance.remainingMinutes
+    };
+  }
+  
+  return {
+    sufficient: true,
+    reason: null,
+    currentWords: wordCount,
+    maxAllowedWords: balance.remainingMinutes * AUDIO_BILLING_CONFIG.speakingRate,
+    audioMinutesNeeded,
+    remainingMinutes: balance.remainingMinutes
+  };
+}
+
+// 获取当前用户已生成的字数(用于断点续传)
+export async function getGeneratedWordCount(bookId: string): Promise<number> {
+  const book = await prisma.book.findUnique({
+    where: { id: parseInt(bookId) },
+    include: { chapters: true }
+  });
+  
+  if (!book) return 0;
+  
+  // 统计已完成章节的字数
+  const completedWords = book.chapters
+    .filter(c => c.status === 'completed')
+    .reduce((sum, c) => sum + (c.wordCount || 0), 0);
+  
+  return completedWords;
+}
+
+// 记录生成中断状态(用于断点续传)
+export async function markGenerationInterrupted(bookId: string, lastChapterNumber: number, currentWords: number): Promise<void> {
+  await prisma.book.update({
+    where: { id: parseInt(bookId) },
+    data: {
+      status: 'interrupted',
+      errorMsg: `额度不足中断,已生成${currentWords}字`,
+      progress: 0 // 重置进度,下次生成时重新计算
+    }
+  });
+  
+  console.log(`[QuotaCheck] 书籍${bookId}生成中断:已完成${currentWords}字,最后章节${lastChapterNumber}`);
+}

+ 35 - 4
server/src/modules/tts/tts.service.ts

@@ -180,6 +180,10 @@ export async function generateAudio(
     console.error(errMsg);
     console.error('❌ 错误堆栈:', error.stack);
     logToFile(errMsg + '\n' + error.stack);
+    
+    // 创建失败标记文件
+    const failedMarker = path.join(audioDir, 'failed');
+    fs.writeFileSync(failedMarker, error.message);
   });
 
   // 立即返回音频ID和状态(audioUrl 为空,生成完成后通过回调更新)
@@ -336,12 +340,39 @@ async function processAudioGeneration(
 }
 
 /**
- * 获取音频状态(已禁用,因为不再有 Audio 表)
- * TODO: 如需查询状态,需要实现基于文件系统的状态跟踪
+ * 获取音频状态(基于文件系统)
  */
 export async function getAudioStatus(audioId: string): Promise<{ status: string; audio?: any }> {
-  // 由于 Audio 表已删除,暂时返回 not_found
-  // 后续可以实现基于文件系统的状态跟踪
+  const audioDir = path.join(config.upload.dir, audioId);
+  const outputPath = path.join(audioDir, 'output.mp3');
+  const failedMarker = path.join(audioDir, 'failed');
+  
+  // 检查是否失败
+  if (fs.existsSync(failedMarker)) {
+    const errorMsg = fs.readFileSync(failedMarker, 'utf-8');
+    return { status: 'failed', audio: { error: errorMsg } };
+  }
+  
+  // 检查是否生成完成
+  if (fs.existsSync(outputPath)) {
+    const stats = fs.statSync(outputPath);
+    const duration = await AudioMerger.getDuration(outputPath);
+    return {
+      status: 'completed',
+      audio: {
+        audioUrl: `/uploads/${audioId}/output.mp3`,
+        audioDuration: duration,
+        audioSize: stats.size,
+      },
+    };
+  }
+  
+  // 检查目录是否存在(生成中)
+  if (fs.existsSync(audioDir)) {
+    return { status: 'processing' };
+  }
+  
+  // 不存在
   return { status: 'not_found' };
 }
 

BIN
test-error.png


+ 74 - 0
test-login-flow.js

@@ -0,0 +1,74 @@
+const { chromium } = require('playwright');
+
+(async () => {
+  const browser = await chromium.launch({ headless: true });
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  
+  // 收集控制台日志
+  const logs = [];
+  page.on('console', msg => {
+    logs.push(`[${msg.type()}] ${msg.text()}`);
+  });
+  
+  // 收集网络请求
+  page.on('request', request => {
+    if (request.url().includes('api')) {
+      console.log('Request:', request.method(), request.url());
+    }
+  });
+  
+  page.on('response', response => {
+    if (response.url().includes('api')) {
+      console.log('Response:', response.status(), response.url());
+    }
+  });
+
+  try {
+    // 1. 打开登录页
+    console.log('=== 1. 打开登录页 ===');
+    await page.goto('http://localhost:5173/pages/login/index', { waitUntil: 'networkidle', timeout: 30000 });
+    await page.waitForTimeout(2000);
+    
+    // 2. 截图登录页
+    await page.screenshot({ path: 'test-login-page.png' });
+    console.log('登录页截图已保存: test-login-page.png');
+    
+    // 3. 输入手机号
+    console.log('=== 2. 输入手机号 ===');
+    await page.fill('input[type="number"]', '13812341234');
+    await page.waitForTimeout(500);
+    await page.screenshot({ path: 'test-phone-filled.png' });
+    
+    // 4. 点击登录按钮(开发环境验证码可为空)
+    console.log('=== 3. 点击登录 ===');
+    const loginBtn = await page.locator('button.login-btn');
+    await loginBtn.click();
+    
+    // 等待登录结果
+    await page.waitForTimeout(3000);
+    await page.screenshot({ path: 'test-after-login.png' });
+    
+    // 5. 打印控制台日志
+    console.log('=== 控制台日志 ===');
+    logs.forEach(log => console.log(log));
+    
+    // 6. 获取当前 URL
+    console.log('当前 URL:', page.url());
+    
+    // 7. 检查 localStorage
+    const storage = await page.evaluate(() => {
+      return {
+        token: localStorage.getItem('token'),
+        userInfo: localStorage.getItem('userInfo')
+      };
+    });
+    console.log('LocalStorage:', JSON.stringify(storage));
+    
+  } catch (error) {
+    console.error('测试失败:', error.message);
+    await page.screenshot({ path: 'test-error.png' });
+  }
+  
+  await browser.close();
+})();

BIN
test-login-page.png