From 18db6a8cc6b7dde2820974b8b906b16eca4059b9 Mon Sep 17 00:00:00 2001 From: wangwuww111 <2816108629@qq.com> Date: Mon, 9 Mar 2026 14:15:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84AI=E6=94=B9=E5=86=99?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E7=AE=B1=E5=8A=9F=E8=83=BD=20-=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=87=8D=E5=A4=B4=E6=B8=B8=E7=8E=A9=E3=80=81=E8=AF=84?= =?UTF-8?q?=E5=88=86=E3=80=81=E6=95=B0=E6=8D=AE=E5=88=B7=E6=96=B0=E7=AD=89?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/data/StoryManager.js | 114 +++- client/js/main.js | 60 ++ client/js/scenes/EndingScene.js | 12 +- client/js/scenes/ProfileScene.js | 191 ++++-- client/js/scenes/StoryScene.js | 612 +++++++++++++++++- .../app/__pycache__/database.cpython-310.pyc | Bin 880 -> 909 bytes server/app/__pycache__/main.cpython-310.pyc | Bin 1382 -> 1441 bytes server/app/database.py | 3 + server/app/main.py | 3 +- .../models/__pycache__/story.cpython-310.pyc | Bin 2579 -> 3691 bytes server/app/models/story.py | 43 +- .../__pycache__/drafts.cpython-310.pyc | Bin 0 -> 7889 bytes .../routers/__pycache__/story.cpython-310.pyc | Bin 8400 -> 8400 bytes server/app/routers/drafts.py | 344 ++++++++++ .../services/__pycache__/ai.cpython-310.pyc | Bin 16175 -> 18003 bytes server/app/services/ai.py | 66 +- server/sql/schema_v2.sql | 36 ++ 17 files changed, 1385 insertions(+), 99 deletions(-) create mode 100644 server/app/routers/__pycache__/drafts.cpython-310.pyc create mode 100644 server/app/routers/drafts.py diff --git a/client/js/data/StoryManager.js b/client/js/data/StoryManager.js index 4c5d81b..6341045 100644 --- a/client/js/data/StoryManager.js +++ b/client/js/data/StoryManager.js @@ -1,7 +1,7 @@ /** * 故事数据管理器 */ -import { get, post } from '../utils/http'; +import { get, post, request } from '../utils/http'; export default class StoryManager { constructor() { @@ -128,6 +128,7 @@ export default class StoryManager { */ resetStory() { this.currentNodeKey = 'start'; + this.pathHistory = []; // 清空路径历史 } /** @@ -156,38 +157,117 @@ export default class StoryManager { } /** - * AI改写中间章节,生成新的剧情分支 - * @returns {Object|null} 成功返回新节点,失败返回 null(不改变当前状态) + * AI改写中间章节,异步提交到草稿箱 + * @returns {Object|null} 成功返回草稿ID,失败返回 null */ - async rewriteBranch(storyId, prompt, userId) { + async rewriteBranchAsync(storyId, prompt, userId) { try { + // 先标记之前的未读草稿为已读,避免轮询弹出之前的通知 + await this.markAllDraftsRead(userId); + const currentNode = this.getCurrentNode(); - const result = await post(`/stories/${storyId}/rewrite-branch`, { + const result = await post(`/drafts`, { userId: userId, + storyId: storyId, currentNodeKey: this.currentNodeKey, pathHistory: this.pathHistory, currentContent: currentNode?.content || '', prompt: prompt - }, { timeout: 300000 }); // 5分钟超时,AI生成需要较长时间 + }, { timeout: 30000 }); - // 检查是否有有效的 nodes - if (result && result.nodes) { - // AI 成功,将新分支合并到当前故事中 - Object.assign(this.currentStory.nodes, result.nodes); - // 跳转到新分支的入口节点 - this.currentNodeKey = result.entryNodeKey || 'branch_1'; - return this.getCurrentNode(); + if (result && result.draftId) { + return result; } - - // AI 失败,返回 null - console.log('AI服务不可用:', result?.error || '未知错误'); return null; } catch (error) { - console.error('AI改写分支失败:', error?.errMsg || error?.message || JSON.stringify(error)); + console.error('AI改写提交失败:', error?.errMsg || error?.message || JSON.stringify(error)); return null; } } + /** + * 获取用户草稿列表 + */ + async getDrafts(userId) { + try { + const result = await get(`/drafts?userId=${userId}`); + return result || []; + } catch (error) { + console.error('获取草稿列表失败:', error); + return []; + } + } + + /** + * 检查是否有新完成的草稿 + */ + async checkNewDrafts(userId) { + try { + const result = await get(`/drafts/check-new?userId=${userId}`); + return result || { hasNew: false, count: 0, drafts: [] }; + } catch (error) { + console.error('检查新草稿失败:', error); + return { hasNew: false, count: 0, drafts: [] }; + } + } + + /** + * 批量标记所有未读草稿为已读 + */ + async markAllDraftsRead(userId) { + try { + await request({ url: `/drafts/batch-read?userId=${userId}`, method: 'PUT' }); + return true; + } catch (error) { + console.error('批量标记已读失败:', error); + return false; + } + } + + /** + * 获取草稿详情 + */ + async getDraftDetail(draftId) { + try { + const result = await get(`/drafts/${draftId}`); + return result; + } catch (error) { + console.error('获取草稿详情失败:', error); + return null; + } + } + + /** + * 删除草稿 + */ + async deleteDraft(draftId, userId) { + try { + const result = await request({ + url: `/drafts/${draftId}?userId=${userId}`, + method: 'DELETE' + }); + return true; + } catch (error) { + console.error('删除草稿失败:', error); + return false; + } + } + + /** + * 从草稿加载并播放 AI 生成的内容 + */ + loadDraftContent(draft) { + if (!draft || !draft.aiNodes) return null; + + // 将 AI 生成的节点合并到当前故事 + if (this.currentStory) { + Object.assign(this.currentStory.nodes, draft.aiNodes); + this.currentNodeKey = draft.entryNodeKey || 'branch_1'; + return this.getCurrentNode(); + } + return null; + } + /** * AI续写故事 */ diff --git a/client/js/main.js b/client/js/main.js index ae8507f..a9dddca 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -59,6 +59,11 @@ export default class Main { // 设置分享 this.setupShare(); + + // 启动草稿检查(仅登录用户) + if (this.userManager.isLoggedIn) { + this.startDraftChecker(); + } } catch (error) { console.error('[Main] 初始化失败:', error); this.hideLoading(); @@ -111,6 +116,61 @@ export default class Main { }); } + // 启动草稿检查定时器 + startDraftChecker() { + // 避免重复启动 + if (this.draftCheckTimer) return; + + console.log('[Main] 启动草稿检查定时器'); + + // 每30秒检查一次 + this.draftCheckTimer = setInterval(async () => { + try { + if (!this.userManager.isLoggedIn) return; + + const result = await this.storyManager.checkNewDrafts(this.userManager.userId); + + if (result && result.hasNew && result.count > 0) { + console.log('[Main] 检测到新草稿:', result.count); + + // 先标记为已读,避免重复弹窗 + await this.storyManager.markAllDraftsRead(this.userManager.userId); + + // 弹窗通知 + wx.showModal({ + title: 'AI改写完成', + content: `您有 ${result.count} 个新的AI改写已完成,是否前往查看?`, + confirmText: '查看', + cancelText: '稍后', + success: (res) => { + if (res.confirm) { + // 跳转到个人中心的草稿箱 tab + this.sceneManager.switchScene('profile', { tab: 1 }); + } else { + // 点击稍后,如果当前在个人中心页面则刷新草稿列表 + const currentScene = this.sceneManager.currentScene; + if (currentScene && currentScene.refreshDrafts) { + currentScene.refreshDrafts(); + } + } + } + }); + } + } catch (e) { + console.warn('[Main] 草稿检查失败:', e); + } + }, 30000); + } + + // 停止草稿检查定时器 + stopDraftChecker() { + if (this.draftCheckTimer) { + clearInterval(this.draftCheckTimer); + this.draftCheckTimer = null; + console.log('[Main] 停止草稿检查定时器'); + } + } + // 设置分享 setupShare() { wx.showShareMenu({ diff --git a/client/js/scenes/EndingScene.js b/client/js/scenes/EndingScene.js index 99bad3a..b0c9913 100644 --- a/client/js/scenes/EndingScene.js +++ b/client/js/scenes/EndingScene.js @@ -8,6 +8,7 @@ export default class EndingScene extends BaseScene { super(main, params); this.storyId = params.storyId; this.ending = params.ending; + this.draftId = params.draftId || null; // 保存草稿ID console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending)); this.showButtons = false; this.fadeIn = 0; @@ -844,7 +845,16 @@ export default class EndingScene extends BaseScene { handleReplay() { this.main.storyManager.resetStory(); - this.main.sceneManager.switchScene('story', { storyId: this.storyId }); + + // 如果是从草稿进入的,重头游玩时保留草稿上下文 + if (this.draftId) { + this.main.sceneManager.switchScene('story', { + storyId: this.storyId, + draftId: this.draftId + }); + } else { + this.main.sceneManager.switchScene('story', { storyId: this.storyId }); + } } handleLike() { diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 487b3f2..91f9ae7 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -6,9 +6,9 @@ import BaseScene from './BaseScene'; export default class ProfileScene extends BaseScene { constructor(main, params) { super(main, params); - // Tab: 0我的作品 1草稿箱 2收藏 3游玩记录 - this.currentTab = 0; - this.tabs = ['作品', '草稿', '收藏', '记录']; + // Tab: 0我的作品 1AI草稿 2收藏 3游玩记录 + this.currentTab = params.tab || 0; // 支持传入初始tab + this.tabs = ['作品', 'AI草稿', '收藏', '记录']; // 数据 this.myWorks = []; @@ -40,8 +40,10 @@ export default class ProfileScene extends BaseScene { async loadData() { if (this.main.userManager.isLoggedIn) { try { + const userId = this.main.userManager.userId; this.myWorks = await this.main.userManager.getMyWorks?.() || []; - this.drafts = await this.main.userManager.getDrafts?.() || []; + // 加载 AI 改写草稿 + this.drafts = await this.main.storyManager.getDrafts(userId) || []; this.collections = await this.main.userManager.getCollections() || []; this.progress = await this.main.userManager.getProgress() || []; @@ -57,6 +59,19 @@ export default class ProfileScene extends BaseScene { this.calculateMaxScroll(); } + // 刷新草稿列表 + async refreshDrafts() { + if (this.main.userManager.isLoggedIn) { + try { + const userId = this.main.userManager.userId; + this.drafts = await this.main.storyManager.getDrafts(userId) || []; + this.calculateMaxScroll(); + } catch (e) { + console.error('刷新草稿失败:', e); + } + } + } + getCurrentList() { switch (this.currentTab) { case 0: return this.myWorks; @@ -366,50 +381,85 @@ export default class ProfileScene extends BaseScene { ctx.fill(); // AI标签 - if (item.source === 'ai') { - ctx.fillStyle = '#a855f7'; - this.roundRect(ctx, x + 8, y + 8, 28, 16, 8); - ctx.fill(); - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 9px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('AI', x + 22, y + 19); - } + ctx.fillStyle = '#a855f7'; + this.roundRect(ctx, x + 8, y + 8, 28, 16, 8); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('AI', x + 22, y + 19); const textX = x + 88; + // 标题(故事标题-改写) ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(this.truncateText(ctx, item.title || '未命名草稿', w - 180), textX, y + 25); + ctx.fillText(this.truncateText(ctx, item.title || item.storyTitle || 'AI改写', w - 180), textX, y + 25); - ctx.fillStyle = 'rgba(255,255,255,0.4)'; + // 状态标签 + const statusMap = { + 'pending': { text: '等待中', color: '#888888' }, + 'processing': { text: '生成中', color: '#f59e0b' }, + 'completed': { text: '已完成', color: '#22c55e' }, + 'failed': { text: '失败', color: '#ef4444' } + }; + const status = statusMap[item.status] || statusMap['pending']; + const titleWidth = ctx.measureText(this.truncateText(ctx, item.title || item.storyTitle || 'AI改写', w - 180)).width; + const statusW = ctx.measureText(status.text).width + 12; + ctx.fillStyle = status.color + '33'; + this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9); + ctx.fill(); + ctx.fillStyle = status.color; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(status.text, textX + titleWidth + 8 + statusW / 2, y + 24); + + // 改写指令 + ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '11px sans-serif'; - ctx.fillText(`创建于 ${item.created_at || '刚刚'}`, textX, y + 48); - ctx.fillText(`${item.node_count || 0} 个节点`, textX + 100, y + 48); + ctx.textAlign = 'left'; + const promptText = item.userPrompt ? `"「${item.userPrompt}」"` : ''; + ctx.fillText(this.truncateText(ctx, promptText, w - 100), textX, y + 48); + + // 时间 + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = '10px sans-serif'; + ctx.fillText(item.createdAt || '', textX, y + 68); + + // 未读标记 + if (!item.isRead && item.status === 'completed') { + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.arc(x + w - 20, y + 20, 5, 0, Math.PI * 2); + ctx.fill(); + } // 按钮 const btnY = y + 62; - const btns = [{ text: '继续编辑', primary: true }, { text: '删除', primary: false }]; - let btnX = textX; - btns.forEach((btn) => { - const btnW = btn.primary ? 65 : 45; - if (btn.primary) { - const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY); - btnGradient.addColorStop(0, '#a855f7'); - btnGradient.addColorStop(1, '#ec4899'); - ctx.fillStyle = btnGradient; - } else { - ctx.fillStyle = 'rgba(255,255,255,0.1)'; - } - this.roundRect(ctx, btnX, btnY, btnW, 26, 13); + + // 删除按钮(所有状态都显示) + ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'; + this.roundRect(ctx, x + w - 55, btnY, 45, 24, 12); + ctx.fill(); + ctx.fillStyle = '#ef4444'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('删除', x + w - 32, btnY + 16); + + // 播放按钮(仅已完成状态) + if (item.status === 'completed') { + const btnGradient = ctx.createLinearGradient(textX, btnY, textX + 65, btnY); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, textX + 120, btnY, 60, 24, 12); ctx.fill(); ctx.fillStyle = '#ffffff'; - ctx.font = btn.primary ? 'bold 11px sans-serif' : '11px sans-serif'; + ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(btn.text, btnX + btnW / 2, btnY + 17); - btnX += btnW + 8; - }); + ctx.fillText('播放', textX + 150, btnY + 16); + } } renderSimpleCard(ctx, item, x, y, w, h, index) { @@ -545,6 +595,11 @@ export default class ProfileScene extends BaseScene { this.currentTab = rect.index; this.scrollY = 0; this.calculateMaxScroll(); + + // 切换到 AI 草稿 tab 时刷新数据 + if (rect.index === 1) { + this.refreshDrafts(); + } } return; } @@ -569,22 +624,82 @@ export default class ProfileScene extends BaseScene { const startY = 250; const cardH = this.currentTab <= 1 ? 100 : 85; const gap = 10; + const padding = 12; + const cardW = this.screenWidth - padding * 2; const adjustedY = y + this.scrollY; const index = Math.floor((adjustedY - startY) / (cardH + gap)); if (index >= 0 && index < list.length) { const item = list[index]; - const storyId = item.story_id || item.id; + const storyId = item.story_id || item.storyId || item.id; + + // 计算卡片内的相对位置 + const cardY = startY + index * (cardH + gap) - this.scrollY; + const relativeY = y - cardY; + + // AI草稿 Tab 的按钮检测 + if (this.currentTab === 1) { + const btnY = 62; + const btnH = 24; + + // 检测删除按钮点击(右侧) + const deleteBtnX = padding + cardW - 55; + if (x >= deleteBtnX && x <= deleteBtnX + 45 && relativeY >= btnY && relativeY <= btnY + btnH) { + this.confirmDeleteDraft(item, index); + return; + } + + // 检测播放按钮点击(左侧,仅已完成状态) + if (item.status === 'completed') { + const playBtnX = padding + 88 + 120; + if (x >= playBtnX && x <= playBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) { + this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + return; + } + } + + // 点击卡片其他区域 + if (item.status === 'completed') { + this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + } else if (item.status === 'failed') { + wx.showToast({ title: 'AI改写失败', icon: 'none' }); + } else { + wx.showToast({ title: '正在生成中,请稍后', icon: 'none' }); + } + return; + } if (this.currentTab >= 2) { // 收藏/记录 - 跳转播放 this.main.sceneManager.switchScene('story', { storyId }); - } else if (this.currentTab === 1) { - // 草稿 - 跳转编辑(暂用AI创作) - this.main.sceneManager.switchScene('aiCreate', { draftId: item.id }); } // 作品Tab的按钮操作需要更精确判断,暂略 } } + + // 确认删除草稿 + confirmDeleteDraft(item, index) { + wx.showModal({ + title: '删除草稿', + content: `确定要删除「${item.title || 'AI改写'}」吗?`, + confirmText: '删除', + confirmColor: '#ef4444', + cancelText: '取消', + success: async (res) => { + if (res.confirm) { + const userId = this.main.userManager.userId; + const success = await this.main.storyManager.deleteDraft(item.id, userId); + if (success) { + // 从列表中移除 + this.drafts.splice(index, 1); + this.calculateMaxScroll(); + wx.showToast({ title: '删除成功', icon: 'success' }); + } else { + wx.showToast({ title: '删除失败', icon: 'none' }); + } + } + } + }); + } } diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index a8a9bc6..6004866 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -7,6 +7,7 @@ export default class StoryScene extends BaseScene { constructor(main, params) { super(main, params); this.storyId = params.storyId; + this.draftId = params.draftId || null; // 草稿ID this.aiContent = params.aiContent || null; // AI改写内容 this.story = null; this.currentNode = null; @@ -31,6 +32,18 @@ export default class StoryScene extends BaseScene { this.sceneColors = this.generateSceneColors(); // AI改写相关 this.isAIRewriting = false; + // 剧情回顾模式 + this.isRecapMode = false; + this.recapData = null; + this.recapScrollY = 0; + this.recapMaxScrollY = 0; + this.recapBtnRect = null; + this.recapReplayBtnRect = null; + this.recapCardRects = []; + // 重头游玩模式 + this.isReplayMode = false; + this.replayPath = []; + this.replayPathIndex = 0; } // 根据场景生成氛围色 @@ -46,6 +59,52 @@ export default class StoryScene extends BaseScene { } async init() { + // 如果是从Draft加载,先获取草稿详情,进入回顾模式 + if (this.draftId) { + this.main.showLoading('加载AI改写内容...'); + + const draft = await this.main.storyManager.getDraftDetail(this.draftId); + + if (draft && draft.aiNodes && draft.storyId) { + // 先加载原故事 + this.story = await this.main.storyManager.loadStoryDetail(draft.storyId); + + if (this.story) { + this.setThemeByCategory(this.story.category); + + // 将AI生成的节点合并到故事中 + Object.assign(this.story.nodes, draft.aiNodes); + + // 获取 AI 入口节点的内容 + const entryKey = draft.entryNodeKey || 'branch_1'; + const aiEntryNode = draft.aiNodes[entryKey]; + + // 保存回顾数据,包含 AI 内容 + this.recapData = { + pathHistory: draft.pathHistory || [], + userPrompt: draft.userPrompt || '', + entryNodeKey: entryKey, + aiContent: aiEntryNode // 保存 AI 入口节点内容 + }; + + // 同时保存到 aiContent,方便后续访问 + this.aiContent = aiEntryNode; + + // 进入回顾模式 + this.isRecapMode = true; + this.calculateRecapScroll(); + + this.main.hideLoading(); + return; + } + } + + this.main.hideLoading(); + this.main.showError('草稿加载失败'); + this.main.sceneManager.switchScene('home'); + return; + } + // 如果是AI改写内容,直接播放 if (this.aiContent) { this.story = this.main.storyManager.currentStory; @@ -63,6 +122,10 @@ export default class StoryScene extends BaseScene { // 重新开始,使用已有数据 this.story = existingStory; this.setThemeByCategory(this.story.category); + + // 重置到起点并清空历史 + this.main.storyManager.resetStory(); + this.currentNode = this.main.storyManager.getCurrentNode(); if (this.currentNode) { this.startTypewriter(this.currentNode.content); @@ -108,8 +171,341 @@ export default class StoryScene extends BaseScene { this.sceneColors = themes[category] || this.sceneColors; } + // 计算回顾页面滚动范围 + calculateRecapScroll() { + if (!this.recapData) return; + const itemHeight = 90; + const headerHeight = 120; + const promptHeight = 80; + const buttonHeight = 80; + const contentHeight = headerHeight + this.recapData.pathHistory.length * itemHeight + promptHeight + buttonHeight; + this.recapMaxScrollY = Math.max(0, contentHeight - this.screenHeight + 40); + } + + // 渲染剧情回顾页面 + renderRecapPage(ctx) { + // 背景 + const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight); + gradient.addColorStop(0, this.sceneColors.bg1); + gradient.addColorStop(1, this.sceneColors.bg2); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, this.screenWidth, this.screenHeight); + + // 返回按钮 + ctx.fillStyle = '#ffffff'; + ctx.font = '16px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('‹ 返回', 15, 35); + + // 标题 + ctx.textAlign = 'center'; + ctx.font = 'bold 18px sans-serif'; + ctx.fillStyle = this.sceneColors.accent; + ctx.fillText('📖 剧情回顾', this.screenWidth / 2, 35); + + // 故事标题 + if (this.story) { + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '13px sans-serif'; + ctx.fillText(this.story.title, this.screenWidth / 2, 60); + } + + // 内容区域裁剪(调整起点避免被标题挡住) + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 70, this.screenWidth, this.screenHeight - 150); + ctx.clip(); + + const padding = 16; + let y = 100 - this.recapScrollY; + const pathHistory = this.recapData?.pathHistory || []; + + // 保存卡片位置用于点击检测 + this.recapCardRects = []; + + // 计算可用文字宽度 + const maxTextWidth = this.screenWidth - padding * 2 - 50; + + // 绘制每个路径项 + pathHistory.forEach((item, index) => { + if (y > 50 && y < this.screenHeight - 80) { + // 卡片背景 + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, 80, 12); + ctx.fill(); + + // 序号圆圈 + ctx.fillStyle = this.sceneColors.accent; + ctx.beginPath(); + ctx.arc(padding + 20, y + 28, 12, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(`${index + 1}`, padding + 20, y + 32); + + // 内容摘要(限制宽度) + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + const contentText = this.truncateTextByWidth(ctx, item.content || '', maxTextWidth - 40); + ctx.fillText(contentText, padding + 40, y + 28); + + // 选择(限制宽度) + ctx.fillStyle = this.sceneColors.accent; + ctx.font = '11px sans-serif'; + const choiceText = `→ ${this.truncateTextByWidth(ctx, item.choice || '', maxTextWidth - 60)}`; + ctx.fillText(choiceText, padding + 40, y + 52); + + // 点击提示图标 + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('›', this.screenWidth - padding - 12, y + 40); + + // 保存卡片区域 + this.recapCardRects.push({ + x: padding, + y: y + this.recapScrollY, + width: this.screenWidth - padding * 2, + height: 80, + index: index, + item: item + }); + } + y += 90; + }); + + // 空状态 + if (pathHistory.length === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('没有历史记录', this.screenWidth / 2, y + 30); + y += 60; + } + + // AI改写指令(可点击查看详情) + this.recapPromptRect = null; + if (y > 40 && y < this.screenHeight - 30) { + ctx.fillStyle = 'rgba(168, 85, 247, 0.15)'; + this.roundRect(ctx, padding, y + 10, this.screenWidth - padding * 2, 60, 12); + ctx.fill(); + + ctx.fillStyle = '#a855f7'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('✨ AI改写指令', padding + 12, y + 32); + + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '11px sans-serif'; + const promptText = this.truncateTextByWidth(ctx, this.recapData?.userPrompt || '无', maxTextWidth - 30); + ctx.fillText(`「${promptText}」`, padding + 12, y + 52); + + // 点击提示 + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('›', this.screenWidth - padding - 12, y + 42); + + // 保存点击区域 + this.recapPromptRect = { + x: padding, + y: y + 10 + this.recapScrollY, + width: this.screenWidth - padding * 2, + height: 60 + }; + } + y += 80; + + ctx.restore(); + + // 底部按钮区域(固定位置,两个按钮) + const btnY = this.screenHeight - 70; + const btnH = 42; + const btnGap = 12; + const btnW = (this.screenWidth - padding * 2 - btnGap) / 2; + + // 左边按钮:重头游玩 + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + this.roundRect(ctx, padding, btnY, btnW, btnH, 21); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1; + this.roundRect(ctx, padding, btnY, btnW, btnH, 21); + ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('🔄 重头游玩', padding + btnW / 2, btnY + 26); + + // 右边按钮:开始新剧情 + const btn2X = padding + btnW + btnGap; + const btnGradient = ctx.createLinearGradient(btn2X, btnY, btn2X + btnW, btnY); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, btn2X, btnY, btnW, btnH, 21); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText('新剧情 →', btn2X + btnW / 2, btnY + 26); + + // 保存按钮区域 + this.recapReplayBtnRect = { x: padding, y: btnY, width: btnW, height: btnH }; + this.recapBtnRect = { x: btn2X, y: btnY, width: btnW, height: btnH }; + + // 滚动提示 + if (this.recapMaxScrollY > 0) { + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + if (this.recapScrollY < this.recapMaxScrollY - 10) { + ctx.fillText('↓ 上滑查看更多', this.screenWidth / 2, btnY - 15); + } + } + } + + // 开始播放AI改写内容(从回顾模式退出) + startAIContent() { + if (!this.recapData) return; + + this.isRecapMode = false; + this.main.storyManager.currentNodeKey = this.recapData.entryNodeKey || 'branch_1'; + this.currentNode = this.main.storyManager.getCurrentNode(); + + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + } + } + + // 显示历史项详情 + showRecapDetail(item, index) { + const content = item.content || '无内容'; + const choice = item.choice || '无选择'; + + wx.showModal({ + title: `第 ${index + 1} 幕`, + content: `【剧情】\n${content}\n\n【你的选择】\n${choice}`, + showCancel: false, + confirmText: '关闭' + }); + } + + // 显示AI改写指令详情 + showPromptDetail() { + const prompt = this.recapData?.userPrompt || '无'; + wx.showModal({ + title: '✨ AI改写指令', + content: prompt, + showCancel: false, + confirmText: '关闭' + }); + } + + // 根据宽度截断文字 + truncateTextByWidth(ctx, text, maxWidth) { + if (!text) return ''; + if (ctx.measureText(text).width <= maxWidth) return text; + let t = text; + while (t.length > 0 && ctx.measureText(t + '...').width > maxWidth) { + t = t.slice(0, -1); + } + return t + '...'; + } + + // 重头游玩(自动快进到AI改写点) + startReplayMode() { + if (!this.recapData) return; + + this.isRecapMode = false; + this.isReplayMode = true; + this.replayPathIndex = 0; + this.replayPath = this.recapData.pathHistory || []; + + // 从 start 节点开始 + this.main.storyManager.currentNodeKey = 'start'; + this.main.storyManager.pathHistory = []; + this.currentNode = this.main.storyManager.getCurrentNode(); + + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + } + } + + // 自动选择回放路径中的选项 + autoSelectReplayChoice() { + if (!this.isReplayMode || this.replayPathIndex >= this.replayPath.length) { + // 回放结束,进入AI改写内容 + this.isReplayMode = false; + this.enterAIContent(); + return; + } + + // 找到对应的选项并自动选择 + const currentPath = this.replayPath[this.replayPathIndex]; + const currentNode = this.main.storyManager.getCurrentNode(); + + if (currentNode && currentNode.choices) { + const choiceIndex = currentNode.choices.findIndex(c => c.text === currentPath.choice); + if (choiceIndex >= 0) { + this.replayPathIndex++; + this.main.storyManager.selectChoice(choiceIndex); + this.currentNode = this.main.storyManager.getCurrentNode(); + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + } + return; + } + } + + // 找不到匹配的选项,直接进入AI内容 + this.isReplayMode = false; + this.enterAIContent(); + } + + // 进入AI改写内容 + enterAIContent() { + console.log('进入AI改写内容'); + + // AI 节点已经合并到 story.nodes 中,使用 storyManager 来管理 + const entryKey = this.recapData?.entryNodeKey || 'branch_1'; + + // 检查节点是否存在 + if (this.story && this.story.nodes && this.story.nodes[entryKey]) { + this.main.storyManager.currentNodeKey = entryKey; + this.currentNode = this.main.storyManager.getCurrentNode(); + + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + return; + } + } + + // 节点不存在,显示错误 + console.error('AI入口节点不存在:', entryKey); + wx.showModal({ + title: '内容加载失败', + content: 'AI改写内容未找到', + showCancel: false, + confirmText: '返回', + success: () => { + this.main.sceneManager.switchScene('home'); + } + }); + } + startTypewriter(text) { - this.targetText = text || ''; + let content = text || ''; + + // 回放模式下,过滤掉结局提示(因为后面还有AI改写内容) + if (this.isReplayMode) { + content = content.replace(/【达成结局[::][^】]*】/g, '').trim(); + } + + this.targetText = content; this.displayText = ''; this.charIndex = 0; this.isTyping = true; @@ -145,6 +541,12 @@ export default class StoryScene extends BaseScene { } render(ctx) { + // 如果是回顾模式,渲染回顾页面 + if (this.isRecapMode) { + this.renderRecapPage(ctx); + return; + } + // 1. 绘制场景背景 this.renderSceneBackground(ctx); @@ -418,7 +820,7 @@ export default class StoryScene extends BaseScene { renderChoices(ctx) { if (!this.currentNode || !this.currentNode.choices) return; - const choices = this.currentNode.choices; + let choices = this.currentNode.choices; const choiceHeight = 50; const choiceMargin = 10; const padding = 20; @@ -428,18 +830,37 @@ export default class StoryScene extends BaseScene { ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58); - // 提示文字 - ctx.fillStyle = '#ffffff'; - ctx.font = '14px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('请做出选择', this.screenWidth / 2, startY); + // 回放模式下的处理 + let replayChoice = null; + if (this.isReplayMode && this.replayPathIndex < this.replayPath.length) { + const previousChoice = this.replayPath[this.replayPathIndex]?.choice; + replayChoice = choices.find(c => c.text === previousChoice); + + // 提示文字 + ctx.fillStyle = this.sceneColors.accent; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('📍 你之前选择的是:', this.screenWidth / 2, startY); + + // 只显示之前选过的选项 + if (replayChoice) { + choices = [replayChoice]; + } + } else { + // 正常模式提示文字 + ctx.fillStyle = '#ffffff'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('请做出选择', this.screenWidth / 2, startY); + } choices.forEach((choice, index) => { const y = startY + 25 + index * (choiceHeight + choiceMargin); const isSelected = index === this.selectedChoice; + const isReplayItem = this.isReplayMode && replayChoice && choice.text === replayChoice.text; // 选项背景 - if (isSelected) { + if (isSelected || isReplayItem) { const gradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y); gradient.addColorStop(0, this.sceneColors.accent); gradient.addColorStop(1, this.sceneColors.accent + 'aa'); @@ -451,7 +872,7 @@ export default class StoryScene extends BaseScene { ctx.fill(); // 选项边框 - ctx.strokeStyle = isSelected ? this.sceneColors.accent : 'rgba(255,255,255,0.2)'; + ctx.strokeStyle = (isSelected || isReplayItem) ? this.sceneColors.accent : 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5; this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25); ctx.stroke(); @@ -462,6 +883,13 @@ export default class StoryScene extends BaseScene { ctx.textAlign = 'center'; ctx.fillText(choice.text, this.screenWidth / 2, y + 30); + // 回放模式下显示点击继续提示 + if (isReplayItem) { + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = '11px sans-serif'; + ctx.fillText('点击继续 ›', this.screenWidth / 2, y + 45); + } + // 锁定图标 if (choice.isLocked) { ctx.fillStyle = '#ffd700'; @@ -512,6 +940,14 @@ export default class StoryScene extends BaseScene { this.lastTouchY = touch.clientY; this.hasMoved = false; + // 回顾模式下的滚动 + if (this.isRecapMode) { + if (touch.clientY > 75) { + this.isDragging = true; + } + return; + } + // 判断是否在对话框区域 const boxY = this.screenHeight * 0.42; if (touch.clientY > boxY) { @@ -522,6 +958,20 @@ export default class StoryScene extends BaseScene { onTouchMove(e) { const touch = e.touches[0]; + // 回顾模式下的滚动 + if (this.isRecapMode && this.isDragging) { + const deltaY = this.lastTouchY - touch.clientY; + if (Math.abs(deltaY) > 2) { + this.hasMoved = true; + } + if (this.recapMaxScrollY > 0) { + this.recapScrollY += deltaY; + this.recapScrollY = Math.max(0, Math.min(this.recapScrollY, this.recapMaxScrollY)); + } + this.lastTouchY = touch.clientY; + return; + } + // 滑动对话框内容 if (this.isDragging) { const deltaY = this.lastTouchY - touch.clientY; @@ -548,6 +998,57 @@ export default class StoryScene extends BaseScene { return; } + // 回顾模式下的点击处理 + if (this.isRecapMode) { + // 返回按钮 + if (y < 60 && x < 80) { + this.main.sceneManager.switchScene('profile', { tab: 1 }); + return; + } + + // 重头游玩按钮 + if (this.recapReplayBtnRect) { + const btn = this.recapReplayBtnRect; + if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { + this.startReplayMode(); + return; + } + } + + // 开始新剧情按钮 + if (this.recapBtnRect) { + const btn = this.recapBtnRect; + if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { + this.startAIContent(); + return; + } + } + + // 历史项卡片点击(显示详情) + if (this.recapCardRects) { + const adjustedY = y + this.recapScrollY; + for (const rect of this.recapCardRects) { + if (x >= rect.x && x <= rect.x + rect.width && + adjustedY >= rect.y && adjustedY <= rect.y + rect.height) { + this.showRecapDetail(rect.item, rect.index); + return; + } + } + } + + // AI改写指令点击(显示完整指令) + if (this.recapPromptRect) { + const adjustedY = y + this.recapScrollY; + const rect = this.recapPromptRect; + if (x >= rect.x && x <= rect.x + rect.width && + adjustedY >= rect.y && adjustedY <= rect.y + rect.height) { + this.showPromptDetail(); + return; + } + } + return; + } + // 返回按钮 if (y < 60 && x < 80) { this.main.sceneManager.switchScene('home'); @@ -584,46 +1085,94 @@ export default class StoryScene extends BaseScene { console.log('AI改写内容:', JSON.stringify(this.aiContent)); this.main.sceneManager.switchScene('ending', { storyId: this.storyId, + draftId: this.draftId, ending: { name: this.aiContent.ending_name, type: this.aiContent.ending_type, content: this.aiContent.content, - score: 100 + score: this.aiContent.ending_score || 80 } }); return; } - // 检查是否是结局 - if (this.main.storyManager.isEnding()) { + // 检查是否是结局(回放模式下跳过,因为要进入AI改写内容) + if (!this.isReplayMode && this.main.storyManager.isEnding()) { this.main.sceneManager.switchScene('ending', { storyId: this.storyId, + draftId: this.draftId, ending: this.main.storyManager.getEndingInfo() }); return; } + // 回放模式下,如果到达原结局或没有选项,进入AI改写内容 + if (this.isReplayMode) { + const currentNode = this.main.storyManager.getCurrentNode(); + if (!currentNode || !currentNode.choices || currentNode.choices.length === 0 || currentNode.is_ending) { + // 回放结束,进入AI改写内容 + this.isReplayMode = false; + this.enterAIContent(); + return; + } + } + // 显示选项 if (this.currentNode && this.currentNode.choices && this.currentNode.choices.length > 0) { + // 回放模式下也显示选项,但只显示之前选过的 this.showChoices = true; + } else if (this.currentNode && (!this.currentNode.choices || this.currentNode.choices.length === 0)) { + // 没有选项的节点,检查是否是死胡同(故事数据问题) + console.log('当前节点没有选项:', this.main.storyManager.currentNodeKey, this.currentNode); + + // 如果有 AI 改写内容,跳转到 AI 内容 + if (this.recapData && this.recapData.entryNodeKey) { + this.main.storyManager.currentNodeKey = this.recapData.entryNodeKey; + this.currentNode = this.main.storyManager.getCurrentNode(); + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + } + return; + } + + // 否则当作结局处理 + wx.showModal({ + title: '故事结束', + content: '当前剧情已结束', + showCancel: false, + confirmText: '返回', + success: () => { + this.main.sceneManager.switchScene('home'); + } + }); } return; } // 选项点击 if (this.showChoices && this.currentNode && this.currentNode.choices) { - const choices = this.currentNode.choices; const choiceHeight = 50; const choiceMargin = 10; const padding = 20; const startY = this.screenHeight * 0.42 + 55; - for (let i = 0; i < choices.length; i++) { - const choiceY = startY + i * (choiceHeight + choiceMargin); + // 回放模式下只有一个选项 + if (this.isReplayMode && this.replayPathIndex < this.replayPath.length) { + const choiceY = startY; if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) { - this.handleChoiceSelect(i); + this.autoSelectReplayChoice(); return; } + } else { + // 正常模式 + const choices = this.currentNode.choices; + for (let i = 0; i < choices.length; i++) { + const choiceY = startY + i * (choiceHeight + choiceMargin); + if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) { + this.handleChoiceSelect(i); + return; + } + } } } } @@ -689,24 +1238,24 @@ export default class StoryScene extends BaseScene { placeholderText: '输入你的改写指令,如"让主角暴富"', success: (res) => { if (res.confirm && res.content) { - this.doAIRewrite(res.content); + this.doAIRewriteAsync(res.content); } } }); } /** - * 执行AI改写 + * 异步提交AI改写到草稿箱 */ - async doAIRewrite(prompt) { + async doAIRewriteAsync(prompt) { if (this.isAIRewriting) return; this.isAIRewriting = true; - this.main.showLoading('AI正在改写剧情...'); + this.main.showLoading('正在提交...'); try { const userId = this.main.userManager.userId || 0; - const newNode = await this.main.storyManager.rewriteBranch( + const result = await this.main.storyManager.rewriteBranchAsync( this.storyId, prompt, userId @@ -714,26 +1263,25 @@ export default class StoryScene extends BaseScene { this.main.hideLoading(); - if (newNode) { - // 成功获取新分支,开始播放 - this.currentNode = newNode; - this.startTypewriter(newNode.content); - wx.showToast({ - title: '改写成功!', - icon: 'success', - duration: 1500 + if (result && result.draftId) { + // 提交成功 + wx.showModal({ + title: '提交成功', + content: 'AI正在后台生成中,完成后会通知您。\n您可以继续播放当前故事。', + showCancel: false, + confirmText: '知道了' }); } else { - // AI 失败,继续原故事 + // 提交失败 wx.showToast({ - title: 'AI暂时不可用,继续原故事', + title: '提交失败,请重试', icon: 'none', duration: 2000 }); } } catch (error) { this.main.hideLoading(); - console.error('AI改写出错:', error); + console.error('AI改写提交出错:', error); wx.showToast({ title: '网络错误,请重试', icon: 'none', diff --git a/server/app/__pycache__/database.cpython-310.pyc b/server/app/__pycache__/database.cpython-310.pyc index 745b1a54f0d712a7c25ff90c3eafcd4b51f0a842..19332463bd3eb32124e8a7fc127e5ea7ef0e3084 100644 GIT binary patch delta 101 zcmeys*2~VD&&$ij00iGAtj!FY$SccOFi|^BkT;4im93c}ia&)rg{OrfN?_x($Bc}M zldG7d83iX#V`>tQ5=|_w%u9|hPAx9Z%+HHYOH3}wFRGj@!K}xqG1;A2m5qg+jhTlT E0ICBU(EtDd delta 71 zcmeBW|G>tZ&&$ij00iQ*mS?g|g#h1dJ!qdVK#lLaZV@5`~$#qQ9 ai~^HqF*Qv#X4Yd=o1Da~%EHFZ!wdjx9}txQ diff --git a/server/app/__pycache__/main.cpython-310.pyc b/server/app/__pycache__/main.cpython-310.pyc index 6d66227ded17126d64ff1a38cf9a9a57159ac7f2..a956491477c3210f3d41719fbc088711e7560cb2 100644 GIT binary patch delta 554 zcmY+Ay>1gh5XW!#_I&62h(BUGA0z}hO)P;B2t)xwh=L*w1)@12olc&`_Sxp5-E{#C zNTx|~N}}yRNTh=zNIU`JHTD4#O5OnGtcZkN&3|Wpn%VzqPyOQ{%A?4)aGf5WjQ$)& zo75*q*B`x|25~?v?#`_^~rQLYe4fq79d-O)&=|Afc7zg4y?Sl=1)o7)TB8Mc=T zWh#PC@)UaO#!V8)`$=MSm%XS97bjy+a-~ME2GZz#cDvFno!a^*yMOD_drP=*_8v@g bm`(UYQo24tVU}ly1UGHz8_t&AbvFJ2Dw~S( delta 519 zcmYk1&ubGw6vt;~C)v&ZNH+c5YOxeS0$voPpi)G7>Q&mqAS@y8ne8U&%FG7wq!6z; zl(|(3?MZtQ{{{byy?N;$&|@jZx1re1SGexdHmvU@S+h2T_D|^L{?N6aLUHWTBlGn}Ks=qVc zr0v%1W9LO{lg>txiLbuUu}z(=x2kT6ZZS4WxhKy&mN-9^y8y2JVna<~bp!5V8GGd$i^`KKEp$m`g55RruHP<{$6Pch&#^ diff --git a/server/app/database.py b/server/app/database.py index c5275f1..02df7da 100644 --- a/server/app/database.py +++ b/server/app/database.py @@ -23,6 +23,9 @@ AsyncSessionLocal = sessionmaker( expire_on_commit=False ) +# 后台任务使用的会话工厂 +async_session_factory = AsyncSessionLocal + # 基类 Base = declarative_base() diff --git a/server/app/main.py b/server/app/main.py index 53e92bd..f3c95ea 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import get_settings -from app.routers import story, user +from app.routers import story, user, drafts settings = get_settings() @@ -29,6 +29,7 @@ app.add_middleware( # 注册路由 app.include_router(story.router, prefix="/api/stories", tags=["故事"]) app.include_router(user.router, prefix="/api/user", tags=["用户"]) +app.include_router(drafts.router, prefix="/api", tags=["草稿箱"]) @app.get("/") diff --git a/server/app/models/__pycache__/story.cpython-310.pyc b/server/app/models/__pycache__/story.cpython-310.pyc index 4d921857ad8886ddbca08534a071cb4b19c506dc..ef618f18449c93c39982f5dddca5370a8b20a471 100644 GIT binary patch delta 1845 zcma)7O>7%Q6yEW!y}R~$y>{HVv`O>RQep~0l+uqB}mC>0V&k%*QH91uNJYLO6#Nc>0$u81qQJww_Gj+{90-fRdY@&Q}(`F-==yqP!i z-g8p_eZmgQ<)Q+gFFyF(8*?`73G&s(vF0!^sTmY$ktmEX)h`7WwWO^1<-n%4l=FTi zaHu2Yf?uW8mlUQm*Qwyv%IiHm_bcVZ}a}sW9hO zMPZIK8E1vI(yVTLIZMoqU(P+5YA^N%L8`sneznyksU^7Y#$MQqmc2f3ZE4W!;_nM? z#9N!ki6CTh^G>cxh?RV!&eTf~YMSqbj7RqraSE6?mHeTe&f6fClBU)qcCx5XZuT{k zkg4Qm{%GC-)nCb-{Om#XJ^pN&ITuAruX8q@InklBiYv=qK!#PoaBbKFf4F`6p5mH zb6urHp64Z2S|iM2<@Fr3fJ324*_OJ>j;;dS*|w`nodY`blcTl&tQ*_2t`5Aws;oQ& z!+k);d1H<6#`XPjn@6NMy2_4l0Qf<0i^rJ&d6FOcbn3RkYQU$FZy#@l&$6*KLJu<= z3csEf+nB8KO$JrCg$tLrKK$;tcR$Kr<-zT{bZBMy%<$c!t3u|uF^IT`Mr5~m4xD85 z+CT|wxFxgQ`x8<{7O~i=-{z5#@3*T?hft{K33Ht4xUWocoYD}EAz+V9t-c#Cca}YQ zUg~rQLhxQJ3$e4pS4AF;D*sG(!(I&Pv?N_Zq6Ph!8m^ZWF10}?t9S@C1*_9i9Io(Q z)B$rQOxzUHN61`z20fR#5TWPaXkWEs8* zTeM(A*vYb7wR7WSoH)c%VeI_YGM2MoUf?R|BXh~3d3>0i8n24l(GB1AyURRSoexDI zH{Uf0df6sY%kB5)nH#%rLCY6qSe$Ac{v)#fD_a)!h3qnLyFJI3r%#RNgYXC&`1~yP ZiwK*#raYm-8R2UXM|J8>qvq6&zX4LUmV5vJ delta 779 zcma)(L2J}d5XUo3vdPP4<8FzrXm{JKb!{lDSY0i4t)3J`mV&n+EOB387n7!asY1Q1 zpa-wZ0}oz2O1*g4lYRm}Lypo*@$T7~R}q9BOyI}-XI^IVpZ9Y?`d(J4I2u0f51+;_ zovF6~ucvFlQ_whYJz=R6Ix04ja_WYzip`{wdZ7o}1C5us^;Y9n?7h*$GpKFOP&1{g zs2yHLT~@lr-6vX5nf_P?OPX+3Zo|@S4+Ox)+0k4Lz>&Z7me25a3Ueppaekoj*@FOE^dH2{i&Bj+MMXk~)eT zh(i!9d9PHzwn&*JL_b&*mq@G-E)&{>D}*_MPFR)iN}o4rg<_p>oj?vm6H!>hQ8pgN z`5zmUSB7p<)Ic<4YF$MW=tq9DK0;51_Uc60@$&sl91PSVI%6(w(Kj22`+`g!e6nfj|6REDYRX@d*9muCD$5^vF(@+!j6R&*EWt?EYA_v^m_*C&;b diff --git a/server/app/models/story.py b/server/app/models/story.py index 350e6f3..6a73bf0 100644 --- a/server/app/models/story.py +++ b/server/app/models/story.py @@ -1,10 +1,11 @@ """ 故事相关ORM模型 """ -from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey +from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey, Enum, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base +import enum class Story(Base): @@ -64,3 +65,43 @@ class StoryChoice(Base): created_at = Column(TIMESTAMP, server_default=func.now()) node = relationship("StoryNode", back_populates="choices") + + +class DraftStatus(enum.Enum): + """草稿状态枚举""" + pending = "pending" + processing = "processing" + completed = "completed" + failed = "failed" + + +class StoryDraft(Base): + """AI改写草稿表""" + __tablename__ = "story_drafts" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False) + title = Column(String(100), default="") + + # 用户输入 + path_history = Column(JSON, default=None) # 用户之前的选择路径 + current_node_key = Column(String(50), default="") + current_content = Column(Text, default="") + user_prompt = Column(String(500), nullable=False) + + # AI生成结果 + ai_nodes = Column(JSON, default=None) # AI生成的新节点 + entry_node_key = Column(String(50), default="") + tokens_used = Column(Integer, default=0) + + # 状态 + status = Column(Enum(DraftStatus), default=DraftStatus.pending) + error_message = Column(String(500), default="") + is_read = Column(Boolean, default=False) # 用户是否已查看 + + created_at = Column(TIMESTAMP, server_default=func.now()) + completed_at = Column(TIMESTAMP, default=None) + + # 关联 + story = relationship("Story") diff --git a/server/app/routers/__pycache__/drafts.cpython-310.pyc b/server/app/routers/__pycache__/drafts.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce96bfa7dd9ffe672e30c60654eb8974afd43020 GIT binary patch literal 7889 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 0, + "count": len(unread_drafts), + "drafts": [ + { + "id": d.id, + "title": d.title, + "userPrompt": d.user_prompt + } + for d in unread_drafts[:3] # 最多返回3个 + ] + } + } + + +@router.get("/{draft_id}") +async def get_draft_detail( + draft_id: int, + db: AsyncSession = Depends(get_db) +): + """获取草稿详情""" + result = await db.execute( + select(StoryDraft, Story) + .join(Story, StoryDraft.story_id == Story.id) + .where(StoryDraft.id == draft_id) + ) + + row = result.first() + if not row: + raise HTTPException(status_code=404, detail="草稿不存在") + + draft, story = row + + # 标记为已读 + if not draft.is_read: + draft.is_read = True + await db.commit() + + return { + "code": 0, + "data": { + "id": draft.id, + "storyId": draft.story_id, + "storyTitle": story.title, + "storyCategory": story.category, + "title": draft.title, + "pathHistory": draft.path_history, + "currentNodeKey": draft.current_node_key, + "currentContent": draft.current_content, + "userPrompt": draft.user_prompt, + "aiNodes": draft.ai_nodes, + "entryNodeKey": draft.entry_node_key, + "tokensUsed": draft.tokens_used, + "status": draft.status.value if draft.status else "pending", + "errorMessage": draft.error_message, + "createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "", + "completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None + } + } + + +@router.delete("/{draft_id}") +async def delete_draft( + draft_id: int, + userId: int, + db: AsyncSession = Depends(get_db) +): + """删除草稿""" + result = await db.execute( + select(StoryDraft).where( + StoryDraft.id == draft_id, + StoryDraft.user_id == userId + ) + ) + + draft = result.scalar_one_or_none() + if not draft: + raise HTTPException(status_code=404, detail="草稿不存在") + + await db.delete(draft) + await db.commit() + + return {"code": 0, "message": "删除成功"} + + +@router.put("/{draft_id}/read") +async def mark_draft_read( + draft_id: int, + db: AsyncSession = Depends(get_db) +): + """标记草稿为已读""" + await db.execute( + update(StoryDraft) + .where(StoryDraft.id == draft_id) + .values(is_read=True) + ) + await db.commit() + + return {"code": 0, "message": "已标记为已读"} + + +@router.put("/batch-read") +async def mark_all_drafts_read( + userId: int, + db: AsyncSession = Depends(get_db) +): + """批量标记所有未读草稿为已读""" + await db.execute( + update(StoryDraft) + .where( + StoryDraft.user_id == userId, + StoryDraft.is_read == False + ) + .values(is_read=True) + ) + await db.commit() + + return {"code": 0, "message": "已全部标记为已读"} diff --git a/server/app/services/__pycache__/ai.cpython-310.pyc b/server/app/services/__pycache__/ai.cpython-310.pyc index 6401d73ba1a810223ef60874659a5983f24356aa..800729bce9e13ef6dc2d1c7840f55b797dbf9566 100644 GIT binary patch delta 1302 zcmbu7%}*0S7{-~!uQUNP0ws+#>q25Q-MS@(wglvWU*Rt>F_G}mkOtEtg@^}j3lv&D zOU5V?AZdbN5L$3S`>me*13Y;1Af4Uqsfmd((TlUY-L)j32Pboy=Xu}xz0XYa@uz7} z?kX$Ol;EW|MjU-TJLNx46Ll5Pd8(GZd?rr7mdZBJ2z`~!zydRsW)JeS*~BcLglNZ0 z(j>RyL;g3pi5X$dmrwcjz4Mx{3x0W%Ty!oQ^K#2!6d328M1{9elo{g}$Ix~ft?r_C z{#-c8EkxyJ!sG})5+~Ja4NVpcvRlwaGJHBhQVw^A-Pz-C_EJsc;J}a_yWy|1tsbQa zg=YBmZPH9zNwoDWpPJ65AwRo~g6rI)E2Ez@+6TeT0#Lz?nx92>s!?>F)V;{wBC56#2E~4b|aETPk zhHO#0laxG7hr8P~V5dkSI)*{=sQ^Qp7$%csN$oGRBdbOWQPPW#Z{K<7P7y~V$pQaud}ZBae$etWkAd-!0Si%hB>3q#u^k7nN+ zLgRiEk&=q+@vDh!W=ec_s@vsctE$ojj80onBA7pFl zNCVF4&pH@;6KP}MZSA>YI?QEI1C<9i8jXLysEs=OmcfVpcKlx{JoOO{`T3hjv&5mq zSBOB&=u+|9{S)No5!WanO16jNDlm-)V3f>7nBq_J-!O&7M<6ROR)+mtBOC*F+4uUN z$B8PyrfN4zK|9-fF>w+MvDB641lVL#rdY3JHG)+Oid~P delta 250 zcmV`hmGi_f#ZOY<%Wgj zuDRuihUTud=cKyizKid?nzQH@!U6*>E-sTV86A^{8%mQ)7%`I^96Ai|yofX{IOVOE z@4ShVRvaX=`yc280WP!TB>Dvc#|tx3i@@E&>A50khRU^#KCv0kh0NtOEfFvw1{70Rae;x