From 4a69bf2711ec9c110646860e6b8c47e2e709b532 Mon Sep 17 00:00:00 2001 From: wangwuww111 <2816108629@qq.com> Date: Fri, 13 Mar 2026 12:28:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=B8=E7=8E=A9=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8C=BA=E5=88=86=E5=8E=9F=E6=95=85=E4=BA=8B?= =?UTF-8?q?=E5=92=8CAI=E8=8D=89=E7=A8=BF=EF=BC=8C=E5=B7=B2=E4=B8=8B?= =?UTF-8?q?=E6=9E=B6=E8=8D=89=E7=A8=BF=E6=98=BE=E7=A4=BA=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/data/UserManager.js | 26 ++- client/js/scenes/EndingScene.js | 56 +++++- client/js/scenes/ProfileScene.js | 287 +++++++++++++++++++++++-------- server/app/models/story.py | 1 + server/app/models/user.py | 1 + server/app/routers/drafts.py | 39 +++++ server/app/routers/user.py | 136 ++++++++++++--- server/sql/schema.sql | 2 + 8 files changed, 449 insertions(+), 99 deletions(-) diff --git a/client/js/data/UserManager.js b/client/js/data/UserManager.js index c63d465..b867fb8 100644 --- a/client/js/data/UserManager.js +++ b/client/js/data/UserManager.js @@ -203,6 +203,29 @@ export default class UserManager { }); } + /** + * 收藏草稿 + */ + async collectDraft(draftId, isCollected) { + if (!this.isLoggedIn) return; + await put(`/drafts/${draftId}/collect`, null, { + params: { userId: this.userId, isCollected } + }); + } + + /** + * 获取草稿收藏状态 + */ + async getDraftCollectStatus(draftId) { + if (!this.isLoggedIn) return false; + try { + const res = await get(`/drafts/${draftId}/collect-status`, { userId: this.userId }); + return res?.isCollected || false; + } catch (e) { + return false; + } + } + /** * 获取收藏列表 */ @@ -323,12 +346,13 @@ export default class UserManager { /** * 保存游玩记录 */ - async savePlayRecord(storyId, endingName, endingType, pathHistory) { + async savePlayRecord(storyId, endingName, endingType, pathHistory, draftId = null) { if (!this.isLoggedIn) return null; try { return await post('/user/play-record', { userId: this.userId, storyId, + draftId, // AI草稿ID,原故事为null endingName, endingType: endingType || '', pathHistory: pathHistory || [] diff --git a/client/js/scenes/EndingScene.js b/client/js/scenes/EndingScene.js index 3abfb8b..871ad98 100644 --- a/client/js/scenes/EndingScene.js +++ b/client/js/scenes/EndingScene.js @@ -43,9 +43,48 @@ export default class EndingScene extends BaseScene { this.showButtons = true; }, 1500); - // 保存游玩记录(回放模式和AI草稿不保存) - if (!this.isReplay && !this.draftId) { + // 保存游玩记录 + this.checkAndSavePlayRecord(); + + // 加载收藏状态 + this.loadCollectStatus(); + } + + async checkAndSavePlayRecord() { + // 回放模式不保存 + if (this.isReplay) return; + + // 原故事:直接保存 + if (!this.draftId) { this.savePlayRecord(); + return; + } + + // AI草稿:检查是否已发布,已发布才保存 + try { + const userId = this.main.userManager.userId; + const drafts = await this.main.storyManager.getDrafts(userId) || []; + const draft = drafts.find(d => d.id === this.draftId); + if (draft?.publishedToCenter) { + this.savePlayRecord(); + } + } catch (e) { + console.error('检查草稿发布状态失败:', e); + } + } + + async loadCollectStatus() { + try { + if (this.draftId) { + // AI草稿:获取草稿收藏状态 + this.isCollected = await this.main.userManager.getDraftCollectStatus(this.draftId); + } else { + // 原故事:获取故事收藏状态 + const progress = await this.main.userManager.getProgress(this.storyId); + this.isCollected = progress?.isCollected || false; + } + } catch (e) { + console.error('加载收藏状态失败:', e); } } @@ -56,12 +95,13 @@ export default class EndingScene extends BaseScene { const endingName = this.ending?.name || '未知结局'; const endingType = this.ending?.type || ''; - // 调用保存接口 + // 调用保存接口(传入 draftId 区分原故事和AI草稿) await this.main.userManager.savePlayRecord( this.storyId, endingName, endingType, - pathHistory + pathHistory, + this.draftId // AI草稿ID,原故事为null ); console.log('游玩记录保存成功'); } catch (e) { @@ -1193,7 +1233,13 @@ export default class EndingScene extends BaseScene { handleCollect() { this.isCollected = !this.isCollected; - this.main.userManager.collectStory(this.storyId, this.isCollected); + if (this.draftId) { + // AI草稿:收藏草稿 + this.main.userManager.collectDraft(this.draftId, this.isCollected); + } else { + // 原故事:收藏故事 + this.main.userManager.collectStory(this.storyId, this.isCollected); + } } // 启动草稿完成轮询(每5秒检查一次,持续2分钟) diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 6795820..764f824 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -73,18 +73,21 @@ export default class ProfileScene extends BaseScene { if (this.main.userManager.isLoggedIn) { try { const userId = this.main.userManager.userId; - this.myWorks = await this.main.userManager.getMyWorks?.() || []; + // 加载已发布到创作中心的作品(改写+续写) + const publishedRewrites = await this.main.userManager.getPublishedDrafts('rewrite') || []; + const publishedContinues = await this.main.userManager.getPublishedDrafts('continue') || []; + this.myWorks = [...publishedRewrites, ...publishedContinues]; // 加载 AI 改写草稿 this.drafts = await this.main.storyManager.getDrafts(userId) || []; this.collections = await this.main.userManager.getCollections() || []; // 加载游玩记录(故事列表) this.progress = await this.main.userManager.getPlayRecords() || []; - // 计算统计 + // 计算统计(作品数=已发布作品数) this.stats.works = this.myWorks.length; - this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.play_count || 0), 0); - this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.like_count || 0), 0); - this.stats.earnings = this.myWorks.reduce((sum, w) => sum + (w.earnings || 0), 0); + this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.playCount || 0), 0); + this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.likeCount || 0), 0); + this.stats.earnings = 0; // 暂无收益功能 } catch (e) { console.error('加载数据失败:', e); } @@ -333,25 +336,25 @@ export default class ProfileScene extends BaseScene { ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; - const emptyTexts = ['还没有发布作品,去创作吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录']; + const emptyTexts = ['还没有发布作品,去草稿箱发布吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录']; const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions') ? '该故事还没有游玩记录' : emptyTexts[this.currentTab]; ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50); - // 创作引导按钮 + // 作品Tab引导按钮 - 跳转到草稿箱 if (this.currentTab === 0) { const btnY = listStartY + 80; const btnGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, btnY, this.screenWidth / 2 + 50, btnY); btnGradient.addColorStop(0, '#a855f7'); btnGradient.addColorStop(1, '#ec4899'); ctx.fillStyle = btnGradient; - this.roundRect(ctx, this.screenWidth / 2 - 50, btnY, 100, 36, 18); + this.roundRect(ctx, this.screenWidth / 2 - 55, btnY, 110, 36, 18); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 13px sans-serif'; - ctx.fillText('✨ 开始创作', this.screenWidth / 2, btnY + 23); - this.createBtnRect = { x: this.screenWidth / 2 - 50, y: btnY, width: 100, height: 36 }; + ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 23); + this.createBtnRect = { x: this.screenWidth / 2 - 55, y: btnY, width: 110, height: 36 }; } ctx.restore(); @@ -431,7 +434,10 @@ export default class ProfileScene extends BaseScene { // 渲染单条游玩记录版本卡片 renderRecordVersionCard(ctx, item, x, y, w, h, index) { - ctx.fillStyle = 'rgba(255,255,255,0.05)'; + const isUnpublished = item.draftId && item.isPublished === false; + + // 已下架的卡片背景更暗 + ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)'; this.roundRect(ctx, x, y, w, h, 12); ctx.fill(); @@ -441,34 +447,49 @@ export default class ProfileScene extends BaseScene { const circleR = 18; const colors = this.getGradientColors(index); const circleGradient = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR); - circleGradient.addColorStop(0, colors[0]); - circleGradient.addColorStop(1, colors[1]); + circleGradient.addColorStop(0, isUnpublished ? 'rgba(128,128,128,0.5)' : colors[0]); + circleGradient.addColorStop(1, isUnpublished ? 'rgba(96,96,96,0.5)' : colors[1]); ctx.fillStyle = circleGradient; ctx.beginPath(); ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2); ctx.fill(); // 序号 - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${index + 1}`, circleX, circleY + 5); const textX = x + 65; + const maxTextWidth = w - 200; // 留出按钮空间 - // 结局名称 - ctx.fillStyle = '#ffffff'; + // 结局名称(只显示结局,不显示草稿标题) + ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; const endingLabel = `结局:${item.endingName || '未知结局'}`; - ctx.fillText(this.truncateText(ctx, endingLabel, w - 150), textX, y + 28); + ctx.fillText(this.truncateText(ctx, endingLabel, maxTextWidth - 60), textX, y + 28); + + // 已下架标签(固定在结局名称右边) + if (isUnpublished) { + ctx.fillStyle = 'rgba(239, 68, 68, 0.3)'; + this.roundRect(ctx, x + w - 185, y + 15, 44, 18, 9); + ctx.fill(); + ctx.fillStyle = '#ef4444'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('已下架', x + w - 163, y + 27); + } - // 游玩时间 - ctx.fillStyle = 'rgba(255,255,255,0.45)'; + // 游玩时间(如果是草稿,显示草稿标题) + ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.45)'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; - const timeText = item.createdAt ? this.formatDateTime(item.createdAt) : ''; - ctx.fillText(timeText, textX, y + 52); + let subText = item.createdAt ? this.formatDateTime(item.createdAt) : ''; + if (item.draftTitle) { + subText = this.truncateText(ctx, item.draftTitle, 100) + ' · ' + subText; + } + ctx.fillText(subText, textX, y + 52); // 删除按钮 ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'; @@ -481,8 +502,8 @@ export default class ProfileScene extends BaseScene { // 回放按钮 const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28); - btnGradient.addColorStop(0, '#ff6b6b'); - btnGradient.addColorStop(1, '#ffd700'); + btnGradient.addColorStop(0, isUnpublished ? '#888888' : '#ff6b6b'); + btnGradient.addColorStop(1, isUnpublished ? '#666666' : '#ffd700'); ctx.fillStyle = btnGradient; this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13); ctx.fill(); @@ -526,10 +547,12 @@ export default class ProfileScene extends BaseScene { this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10); ctx.fill(); + // 类型标签 + const typeText = item.draftType === 'continue' ? '续写' : '改写'; ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3); + ctx.fillText(typeText, x + 8 + coverW / 2, y + 8 + coverH / 2 + 3); const textX = x + 88; const maxW = w - 100; @@ -538,46 +561,55 @@ export default class ProfileScene extends BaseScene { ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(this.truncateText(ctx, item.title || '未命名', maxW - 60), textX, y + 25); + const title = item.title || item.storyTitle || '未命名'; + ctx.fillText(this.truncateText(ctx, title, maxW - 60), textX, y + 25); - // 审核状态标签 - const statusMap = { - 0: { text: '草稿', color: '#888888' }, - 1: { text: '审核中', color: '#f59e0b' }, - 2: { text: '已发布', color: '#22c55e' }, - 3: { text: '已下架', color: '#ef4444' }, - 4: { text: '被拒绝', color: '#ef4444' } - }; - const status = statusMap[item.status] || statusMap[0]; - const statusW = ctx.measureText(status.text).width + 12; - ctx.fillStyle = status.color + '33'; - this.roundRect(ctx, textX + ctx.measureText(this.truncateText(ctx, item.title || '未命名', maxW - 60)).width + 8, y + 12, statusW, 18, 9); + // 已发布标签 + const statusText = '已发布'; + const statusW = ctx.measureText(statusText).width + 12; + ctx.fillStyle = 'rgba(34, 197, 94, 0.2)'; + const titleWidth = ctx.measureText(this.truncateText(ctx, title, maxW - 60)).width; + this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9); ctx.fill(); - ctx.fillStyle = status.color; + ctx.fillStyle = '#22c55e'; ctx.font = 'bold 10px sans-serif'; - ctx.fillText(status.text, textX + ctx.measureText(this.truncateText(ctx, item.title || '未命名', maxW - 60)).width + 8 + statusW / 2, y + 24); + ctx.textAlign = 'center'; + ctx.fillText(statusText, textX + titleWidth + 8 + statusW / 2, y + 24); - // 数据统计 + // 原故事标题 ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(`▶ ${this.formatNumber(item.play_count || 0)}`, textX, y + 50); - ctx.fillText(`♥ ${this.formatNumber(item.like_count || 0)}`, textX + 55, y + 50); - ctx.fillText(`💰 ${(item.earnings || 0).toFixed(1)}`, textX + 105, y + 50); + ctx.fillText(`原故事: ${item.storyTitle || ''}`, textX, y + 48); - // 操作按钮 - const btnY = y + 65; - const btns = ['编辑', '数据']; - btns.forEach((btn, i) => { - const btnX = textX + i * 55; - ctx.fillStyle = 'rgba(255,255,255,0.1)'; - this.roundRect(ctx, btnX, btnY, 48, 24, 12); - ctx.fill(); - ctx.fillStyle = 'rgba(255,255,255,0.7)'; - ctx.font = '11px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(btn, btnX + 24, btnY + 16); - }); + // 创建时间 + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = '10px sans-serif'; + ctx.fillText(item.createdAt || '', textX, y + 68); + + // 按钮区域 + const btnY = y + 55; + + // 取消发布按钮 + ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'; + this.roundRect(ctx, x + w - 125, btnY, 60, 26, 13); + ctx.fill(); + ctx.fillStyle = '#ef4444'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('取消发布', x + w - 95, btnY + 17); + + // 播放按钮 + const btnGradient = ctx.createLinearGradient(x + w - 58, btnY, x + w - 10, btnY); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, x + w - 58, btnY, 48, 26, 13); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('播放', x + w - 34, btnY + 17); } renderDraftCard(ctx, item, x, y, w, h, index) { @@ -719,7 +751,7 @@ export default class ProfileScene extends BaseScene { ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; - // 记录Tab使用 storyTitle,收藏Tab使用 story_title + // 记录Tab使用 storyTitle,收藏Tab使用 storyTitle const title = item.storyTitle || item.story_title || item.title || '未知'; ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28); @@ -728,11 +760,14 @@ export default class ProfileScene extends BaseScene { if (this.currentTab === 3) { // 记录Tab:只显示记录数量 ctx.fillText(`${item.recordCount || 0} 条记录`, textX, y + 50); + } else if (this.currentTab === 2 && item.versionCount > 1) { + // 收藏Tab:显示版本数量 + ctx.fillText(`${item.versionCount} 个版本`, textX, y + 50); } else { ctx.fillText(item.category || '', textX, y + 50); } - // 查看按钮(记录Tab)/ 继续按钮(收藏Tab) + // 查看按钮(记录Tab/收藏Tab/作品Tab) const btnGradient = ctx.createLinearGradient(x + w - 58, y + 28, x + w - 10, y + 28); btnGradient.addColorStop(0, '#ff6b6b'); btnGradient.addColorStop(1, '#ffd700'); @@ -742,7 +777,7 @@ export default class ProfileScene extends BaseScene { ctx.fillStyle = '#ffffff'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(this.currentTab === 3 ? '查看' : '继续', x + w - 34, y + 45); + ctx.fillText('查看', x + w - 34, y + 45); } getGradientColors(index) { @@ -867,11 +902,14 @@ export default class ProfileScene extends BaseScene { } } - // 创作按钮 + // 前往草稿箱按钮 if (this.createBtnRect && this.currentTab === 0) { const btn = this.createBtnRect; if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { - this.main.sceneManager.switchScene('aiCreate'); + // 切换到草稿箱 Tab + this.currentTab = 1; + this.scrollY = 0; + this.calculateMaxScroll(); return; } } @@ -985,17 +1023,54 @@ export default class ProfileScene extends BaseScene { } if (this.currentTab === 2) { - // 收藏 - 跳转播放 - this.main.sceneManager.switchScene('story', { storyId }); + // 收藏 - 检查版本数量 + if (item.versionCount > 1) { + // 多版本:弹出选择框 + this.showVersionSelector(item); + } else if (item.versions && item.versions.length > 0) { + // 单版本:直接播放 + const version = item.versions[0]; + if (version.draftId) { + this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: version.draftId }); + } else { + this.main.sceneManager.switchScene('story', { storyId: item.storyId }); + } + } else { + // 兼容旧数据 + this.main.sceneManager.switchScene('story', { storyId }); + } + return; + } + + // 作品Tab - 点击按钮进入草稿播放或取消发布 + if (this.currentTab === 0) { + const btnY = 55; + const btnH = 26; + + // 检测取消发布按钮点击 + const unpublishBtnX = padding + cardW - 125; + if (x >= unpublishBtnX && x <= unpublishBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) { + this.confirmUnpublishWork(item, index); + return; + } + + // 检测播放按钮点击 + const playBtnX = padding + cardW - 58; + if (x >= playBtnX && x <= playBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) { + this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + return; + } + + // 点击卡片其他区域也进入播放 + this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); } - // 作品Tab的按钮操作需要更精确判断,暂略 } } // 显示故事的版本列表 async showStoryVersions(storyItem) { const storyId = storyItem.story_id || storyItem.storyId || storyItem.id; - const storyTitle = storyItem.story_title || storyItem.title || '未知故事'; + const storyTitle = storyItem.storyTitle || storyItem.story_title || storyItem.title || '未知故事'; try { wx.showLoading({ title: '加载中...' }); @@ -1022,11 +1097,13 @@ export default class ProfileScene extends BaseScene { async startRecordReplay(recordItem) { const recordId = recordItem.id; const storyId = this.selectedStoryInfo.id; + const draftId = recordItem.draftId; // AI草稿ID,原故事为null - // 进入故事场景,传入 playRecordId 参数 + // 进入故事场景,传入 playRecordId 参数(draftId用于回放草稿内容) this.main.sceneManager.switchScene('story', { storyId, - playRecordId: recordId + playRecordId: recordId, + draftId // 回放草稿内容 }); } @@ -1069,8 +1146,18 @@ export default class ProfileScene extends BaseScene { const success = await this.main.userManager.publishDraft(item.id); wx.hideLoading(); if (success) { - // 更新本地状态 + // 更新草稿箱状态 this.drafts[index].publishedToCenter = true; + // 同步添加到作品列表 + this.myWorks.push({ + id: item.id, + storyId: item.storyId, + storyTitle: item.storyTitle, + title: item.title, + draftType: item.draftType || 'rewrite', + createdAt: item.createdAt + }); + this.stats.works = this.myWorks.length; wx.showToast({ title: '发布成功', icon: 'success' }); } else { wx.showToast({ title: '发布失败', icon: 'none' }); @@ -1080,6 +1167,70 @@ export default class ProfileScene extends BaseScene { }); } + // 确认取消发布作品 + confirmUnpublishWork(item, index) { + wx.showModal({ + title: '取消发布', + content: `确定要将「${item.title || 'AI改写'}」从创作中心移除吗?草稿仍会保留在草稿箱中。`, + confirmText: '取消发布', + confirmColor: '#ef4444', + cancelText: '返回', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: '处理中...' }); + const success = await this.main.userManager.unpublishDraft(item.id); + wx.hideLoading(); + if (success) { + // 从作品列表中移除 + this.myWorks.splice(index, 1); + this.stats.works = this.myWorks.length; + this.calculateMaxScroll(); + // 同步更新草稿箱状态 + const draftIndex = this.drafts.findIndex(d => d.id === item.id); + if (draftIndex !== -1) { + this.drafts[draftIndex].publishedToCenter = false; + } + wx.showToast({ title: '已取消发布', icon: 'success' }); + } else { + wx.showToast({ title: '操作失败', icon: 'none' }); + } + } + } + }); + } + + // 显示版本选择弹窗(收藏Tab用) + showVersionSelector(item) { + const versions = item.versions || []; + const itemList = versions.map(v => { + if (v.type === 'original') { + return '原版故事'; + } else if (v.type === 'rewrite') { + return `AI改写: ${v.title}`; + } else if (v.type === 'continue') { + return `AI续写: ${v.title}`; + } + return v.title; + }); + + wx.showActionSheet({ + itemList, + success: (res) => { + const selectedVersion = versions[res.tapIndex]; + if (selectedVersion) { + if (selectedVersion.draftId) { + this.main.sceneManager.switchScene('story', { + storyId: item.storyId, + draftId: selectedVersion.draftId + }); + } else { + this.main.sceneManager.switchScene('story', { storyId: item.storyId }); + } + } + } + }); + } + // 确认删除游玩记录 confirmDeleteRecord(item, index) { wx.showModal({ diff --git a/server/app/models/story.py b/server/app/models/story.py index e072337..3e9db61 100644 --- a/server/app/models/story.py +++ b/server/app/models/story.py @@ -123,6 +123,7 @@ class StoryDraft(Base): is_read = Column(Boolean, default=False) # 用户是否已查看 published_to_center = Column(Boolean, default=False) # 是否发布到创作中心 draft_type = Column(String(20), default="rewrite") # 草稿类型: rewrite/continue/create + is_collected = Column(Boolean, default=False) # 用户是否收藏 created_at = Column(TIMESTAMP, server_default=func.now()) completed_at = Column(TIMESTAMP, default=None) diff --git a/server/app/models/user.py b/server/app/models/user.py index 3a08e4b..eace0d5 100644 --- a/server/app/models/user.py +++ b/server/app/models/user.py @@ -65,6 +65,7 @@ class PlayRecord(Base): 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) + draft_id = Column(Integer, default=None) # AI草稿ID,原故事为空 ending_name = Column(String(100), nullable=False) # 结局名称 ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite) path_history = Column(JSON, nullable=False) # 完整的选择路径 diff --git a/server/app/routers/drafts.py b/server/app/routers/drafts.py index 8258c92..64ef17b 100644 --- a/server/app/routers/drafts.py +++ b/server/app/routers/drafts.py @@ -747,3 +747,42 @@ async def unpublish_draft_from_center( return {"code": 0, "message": "已从创作中心移除"} + +@router.put("/{draft_id}/collect") +async def collect_draft( + draft_id: int, + userId: int, + isCollected: bool = True, + db: AsyncSession = Depends(get_db) +): + """收藏/取消收藏草稿""" + await db.execute( + update(StoryDraft) + .where( + StoryDraft.id == draft_id, + StoryDraft.user_id == userId + ) + .values(is_collected=isCollected) + ) + await db.commit() + + return {"code": 0, "message": "收藏成功" if isCollected else "取消收藏成功"} + + +@router.get("/{draft_id}/collect-status") +async def get_draft_collect_status( + draft_id: int, + userId: int, + db: AsyncSession = Depends(get_db) +): + """获取草稿收藏状态""" + result = await db.execute( + select(StoryDraft.is_collected) + .where( + StoryDraft.id == draft_id, + StoryDraft.user_id == userId + ) + ) + is_collected = result.scalar_one_or_none() + + return {"code": 0, "data": {"isCollected": is_collected or False}} diff --git a/server/app/routers/user.py b/server/app/routers/user.py index cde3cfc..5bdfc2d 100644 --- a/server/app/routers/user.py +++ b/server/app/routers/user.py @@ -52,6 +52,7 @@ class CollectRequest(BaseModel): class PlayRecordRequest(BaseModel): userId: int storyId: int + draftId: Optional[int] = None # AI草稿ID,原故事为空 endingName: str endingType: str = "" pathHistory: list @@ -364,24 +365,79 @@ async def toggle_collect(request: CollectRequest, db: AsyncSession = Depends(get @router.get("/collections") async def get_collections(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)): - """获取收藏列表""" - result = await db.execute( - select(Story) + """获取收藏列表(包含原故事和AI改写草稿)""" + from app.models.story import StoryDraft, DraftStatus + + # 查询收藏的原故事 + original_result = await db.execute( + select(Story, UserProgress.updated_at) .join(UserProgress, Story.id == UserProgress.story_id) .where(UserProgress.user_id == user_id, UserProgress.is_collected == True) - .order_by(UserProgress.updated_at.desc()) ) - stories = result.scalars().all() + original_stories = original_result.all() - data = [{ - "id": s.id, - "title": s.title, - "cover_url": s.cover_url, - "description": s.description, - "category": s.category, - "play_count": s.play_count, - "like_count": s.like_count - } for s in stories] + # 查询收藏的草稿 + draft_result = await db.execute( + select(StoryDraft, Story.title.label('story_title'), Story.category, Story.cover_url) + .join(Story, StoryDraft.story_id == Story.id) + .where( + StoryDraft.user_id == user_id, + StoryDraft.is_collected == True, + StoryDraft.status == DraftStatus.completed + ) + .order_by(StoryDraft.created_at.desc()) + ) + drafts = draft_result.all() + + # 按 story_id 分组 + collections = {} + + # 处理原故事 + for story, updated_at in original_stories: + story_id = story.id + if story_id not in collections: + collections[story_id] = { + "storyId": story_id, + "storyTitle": story.title, + "category": story.category, + "coverUrl": story.cover_url, + "versions": [] + } + collections[story_id]["versions"].append({ + "type": "original", + "id": story_id, + "title": story.title, + "draftId": None + }) + + # 处理草稿 + for row in drafts: + draft = row[0] + story_title = row[1] + category = row[2] + cover_url = row[3] + story_id = draft.story_id + + if story_id not in collections: + collections[story_id] = { + "storyId": story_id, + "storyTitle": story_title, + "category": category, + "coverUrl": cover_url, + "versions": [] + } + collections[story_id]["versions"].append({ + "type": draft.draft_type or "rewrite", + "id": draft.id, + "title": draft.title or f"{story_title}-{draft.draft_type}", + "draftId": draft.id + }) + + # 转换为列表,添加版本数量 + data = [] + for item in collections.values(): + item["versionCount"] = len(item["versions"]) + data.append(item) return {"code": 0, "data": data} @@ -511,11 +567,17 @@ async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depend """保存游玩记录(相同路径只保留最新)""" import json - # 查找该用户该故事的所有记录 - result = await db.execute( - select(PlayRecord) - .where(PlayRecord.user_id == request.userId, PlayRecord.story_id == request.storyId) + # 查找该用户该故事的所有记录(区分原故事和草稿) + query = select(PlayRecord).where( + PlayRecord.user_id == request.userId, + PlayRecord.story_id == request.storyId ) + if request.draftId: + query = query.where(PlayRecord.draft_id == request.draftId) + else: + query = query.where(PlayRecord.draft_id == None) + + result = await db.execute(query) existing_records = result.scalars().all() # 检查是否有相同路径的记录 @@ -530,6 +592,7 @@ async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depend record = PlayRecord( user_id=request.userId, story_id=request.storyId, + draft_id=request.draftId, ending_name=request.endingName, ending_type=request.endingType, path_history=request.pathHistory @@ -554,8 +617,10 @@ async def get_play_records( db: AsyncSession = Depends(get_db) ): """获取游玩记录列表""" + from app.models.story import StoryDraft + if story_id: - # 获取指定故事的记录 + # 获取指定故事的记录(包含草稿发布状态) result = await db.execute( select(PlayRecord) .where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id) @@ -563,12 +628,33 @@ async def get_play_records( ) records = result.scalars().all() - data = [{ - "id": r.id, - "endingName": r.ending_name, - "endingType": r.ending_type, - "createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else "" - } for r in records] + # 获取相关草稿的发布状态 + draft_ids = [r.draft_id for r in records if r.draft_id] + draft_status = {} + if draft_ids: + draft_result = await db.execute( + select(StoryDraft.id, StoryDraft.published_to_center, StoryDraft.title) + .where(StoryDraft.id.in_(draft_ids)) + ) + for d in draft_result.all(): + draft_status[d.id] = {"published": d.published_to_center, "title": d.title} + + data = [] + for r in records: + item = { + "id": r.id, + "draftId": r.draft_id, + "endingName": r.ending_name, + "endingType": r.ending_type, + "createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else "" + } + # 如果是草稿记录,添加发布状态 + if r.draft_id and r.draft_id in draft_status: + item["isPublished"] = draft_status[r.draft_id]["published"] + item["draftTitle"] = draft_status[r.draft_id]["title"] + else: + item["isPublished"] = True # 原故事视为"已发布" + data.append(item) else: # 获取所有玩过的故事(按故事分组,取最新一条) result = await db.execute( diff --git a/server/sql/schema.sql b/server/sql/schema.sql index 05cc637..04f6e36 100644 --- a/server/sql/schema.sql +++ b/server/sql/schema.sql @@ -148,6 +148,7 @@ CREATE TABLE IF NOT EXISTS `story_drafts` ( `is_read` TINYINT(1) DEFAULT 0 COMMENT '用户是否已查看', `published_to_center` TINYINT(1) DEFAULT 0 COMMENT '是否发布到创作中心', `draft_type` VARCHAR(20) DEFAULT 'rewrite' COMMENT '草稿类型: rewrite/continue/create', + `is_collected` TINYINT(1) DEFAULT 0 COMMENT '用户是否收藏', `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间', PRIMARY KEY (`id`), @@ -166,6 +167,7 @@ CREATE TABLE IF NOT EXISTS `play_records` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL COMMENT '用户ID', `story_id` INT NOT NULL COMMENT '故事ID', + `draft_id` INT DEFAULT NULL COMMENT 'AI草稿ID,原故事为空', `ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称', `ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型', `path_history` JSON NOT NULL COMMENT '完整的选择路径',