From baf7dd1e2baa78004eda5668236c08d58a65fb4c Mon Sep 17 00:00:00 2001 From: wangwuww111 <2816108629@qq.com> Date: Tue, 10 Mar 2026 12:44:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=B8=E7=8E=A9=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=A4=9A=E7=89=88=E6=9C=AC=E5=8A=9F=E8=83=BD=20-=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E7=89=88=E6=9C=AC=E8=AE=B0=E5=BD=95=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=92=8C=E5=9B=9E=E6=94=BE=20-=20=E7=9B=B8=E5=90=8C?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=87=AA=E5=8A=A8=E5=8E=BB=E9=87=8D=E5=8F=AA?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=9C=80=E6=96=B0=20-=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81=E5=88=A0=E9=99=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20-=20AI=E8=8D=89=E7=A8=BF=E7=AE=B1=E6=B8=B8=E7=8E=A9?= =?UTF-8?q?=E4=B8=8D=E8=AE=B0=E5=BD=95=E5=8E=86=E5=8F=B2=20-=20iOS?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=A0=BC=E5=BC=8F=E5=85=BC=E5=AE=B9=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/data/UserManager.js | 64 ++++++- client/js/scenes/EndingScene.js | 28 +++- client/js/scenes/ProfileScene.js | 280 +++++++++++++++++++++++++++++-- client/js/scenes/StoryScene.js | 149 ++++++++++++++-- client/js/utils/http.js | 11 +- server/app/models/user.py | 20 ++- server/app/routers/user.py | 156 ++++++++++++++++- server/sql/schema.sql | 20 +++ 8 files changed, 693 insertions(+), 35 deletions(-) diff --git a/client/js/data/UserManager.js b/client/js/data/UserManager.js index bd877ea..ba9ce14 100644 --- a/client/js/data/UserManager.js +++ b/client/js/data/UserManager.js @@ -1,7 +1,7 @@ /** * 用户数据管理器 */ -import { get, post } from '../utils/http'; +import { get, post, del } from '../utils/http'; export default class UserManager { constructor() { @@ -191,4 +191,66 @@ export default class UserManager { return []; } } + + // ========== 游玩记录相关 ========== + + /** + * 保存游玩记录 + */ + async savePlayRecord(storyId, endingName, endingType, pathHistory) { + if (!this.isLoggedIn) return null; + try { + return await post('/user/play-record', { + userId: this.userId, + storyId, + endingName, + endingType: endingType || '', + pathHistory: pathHistory || [] + }); + } catch (e) { + console.error('保存游玩记录失败:', e); + return null; + } + } + + /** + * 获取游玩记录列表 + * @param {number} storyId - 可选,指定故事ID获取该故事的所有记录 + */ + async getPlayRecords(storyId = null) { + if (!this.isLoggedIn) return []; + try { + const params = { userId: this.userId }; + if (storyId) params.storyId = storyId; + return await get('/user/play-records', params); + } catch (e) { + console.error('获取游玩记录失败:', e); + return []; + } + } + + /** + * 获取单条记录详情 + */ + async getPlayRecordDetail(recordId) { + if (!this.isLoggedIn) return null; + try { + return await get(`/user/play-records/${recordId}`); + } catch (e) { + console.error('获取记录详情失败:', e); + return null; + } + } + + // 删除游玩记录 + async deletePlayRecord(recordId) { + if (!this.isLoggedIn) return false; + try { + await del(`/user/play-records/${recordId}`); + return true; + } catch (e) { + console.error('删除记录失败:', e); + return false; + } + } } diff --git a/client/js/scenes/EndingScene.js b/client/js/scenes/EndingScene.js index dc79b74..917e8e7 100644 --- a/client/js/scenes/EndingScene.js +++ b/client/js/scenes/EndingScene.js @@ -9,7 +9,8 @@ export default class EndingScene extends BaseScene { this.storyId = params.storyId; this.ending = params.ending; this.draftId = params.draftId || null; // 保存草稿ID - console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending)); + this.isReplay = params.isReplay || false; // 是否是回放模式 + console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending), ', isReplay:', this.isReplay); this.showButtons = false; this.fadeIn = 0; this.particles = []; @@ -41,6 +42,31 @@ export default class EndingScene extends BaseScene { setTimeout(() => { this.showButtons = true; }, 1500); + + // 保存游玩记录(回放模式和AI草稿不保存) + if (!this.isReplay && !this.draftId) { + this.savePlayRecord(); + } + } + + async savePlayRecord() { + try { + // 获取当前游玩路径 + const pathHistory = this.main.storyManager.pathHistory || []; + const endingName = this.ending?.name || '未知结局'; + const endingType = this.ending?.type || ''; + + // 调用保存接口 + await this.main.userManager.savePlayRecord( + this.storyId, + endingName, + endingType, + pathHistory + ); + console.log('游玩记录保存成功'); + } catch (e) { + console.error('保存游玩记录失败:', e); + } } async loadQuota() { diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 91f9ae7..926f105 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -16,6 +16,11 @@ export default class ProfileScene extends BaseScene { this.collections = []; this.progress = []; + // 记录版本列表相关状态 + this.recordViewMode = 'list'; // 'list' 故事列表 | 'versions' 版本列表 + this.selectedStoryRecords = []; // 选中故事的记录列表 + this.selectedStoryInfo = {}; // 选中故事的信息 + // 统计 this.stats = { works: 0, @@ -45,7 +50,8 @@ export default class ProfileScene extends BaseScene { // 加载 AI 改写草稿 this.drafts = await this.main.storyManager.getDrafts(userId) || []; this.collections = await this.main.userManager.getCollections() || []; - this.progress = await this.main.userManager.getProgress() || []; + // 加载游玩记录(故事列表) + this.progress = await this.main.userManager.getPlayRecords() || []; // 计算统计 this.stats.works = this.myWorks.length; @@ -77,7 +83,9 @@ export default class ProfileScene extends BaseScene { case 0: return this.myWorks; case 1: return this.drafts; case 2: return this.collections; - case 3: return this.progress; + case 3: + // 记录 Tab:根据视图模式返回不同列表 + return this.recordViewMode === 'versions' ? this.selectedStoryRecords : this.progress; default: return []; } } @@ -254,16 +262,26 @@ export default class ProfileScene extends BaseScene { ctx.rect(0, startY - 5, this.screenWidth, this.screenHeight - startY + 5); ctx.clip(); + // 记录 Tab 版本列表模式:显示返回按钮和标题 + if (this.currentTab === 3 && this.recordViewMode === 'versions') { + this.renderVersionListHeader(ctx, startY); + } + + const listStartY = (this.currentTab === 3 && this.recordViewMode === 'versions') ? startY + 45 : startY; + if (list.length === 0) { ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; const emptyTexts = ['还没有发布作品,去创作吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录']; - ctx.fillText(emptyTexts[this.currentTab], this.screenWidth / 2, startY + 50); + const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions') + ? '该故事还没有游玩记录' + : emptyTexts[this.currentTab]; + ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50); // 创作引导按钮 if (this.currentTab === 0) { - const btnY = startY + 80; + 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'); @@ -281,12 +299,14 @@ export default class ProfileScene extends BaseScene { } list.forEach((item, index) => { - const y = startY + index * (cardH + gap) - this.scrollY; - if (y > startY - cardH && y < this.screenHeight) { + const y = listStartY + index * (cardH + gap) - this.scrollY; + if (y > listStartY - cardH && y < this.screenHeight) { if (this.currentTab === 0) { this.renderWorkCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index); } else if (this.currentTab === 1) { this.renderDraftCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index); + } else if (this.currentTab === 3 && this.recordViewMode === 'versions') { + this.renderRecordVersionCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index); } else { this.renderSimpleCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index); } @@ -296,6 +316,112 @@ export default class ProfileScene extends BaseScene { ctx.restore(); } + // 渲染版本列表头部(返回按钮+故事标题) + renderVersionListHeader(ctx, startY) { + const headerY = startY - 5; + + // 返回按钮 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('‹ 返回', 15, headerY + 20); + this.versionBackBtnRect = { x: 5, y: headerY, width: 70, height: 35 }; + + // 故事标题 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + const title = this.selectedStoryInfo.title || '游玩记录'; + ctx.fillText(this.truncateText(ctx, title, this.screenWidth - 120), this.screenWidth / 2, headerY + 20); + + // 记录数量 + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(`${this.selectedStoryRecords.length} 条记录`, this.screenWidth - 15, headerY + 20); + } + + // 渲染单条游玩记录版本卡片 + renderRecordVersionCard(ctx, item, x, y, w, h, index) { + ctx.fillStyle = 'rgba(255,255,255,0.05)'; + this.roundRect(ctx, x, y, w, h, 12); + ctx.fill(); + + // 左侧序号圆圈 + const circleX = x + 30; + const circleY = y + h / 2; + 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]); + ctx.fillStyle = circleGradient; + ctx.beginPath(); + ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2); + ctx.fill(); + + // 序号 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(`${index + 1}`, circleX, circleY + 5); + + const textX = x + 65; + + // 结局名称 + ctx.fillStyle = '#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.fillStyle = '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); + + // 删除按钮 + ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'; + this.roundRect(ctx, x + w - 125, y + 28, 48, 26, 13); + ctx.fill(); + ctx.fillStyle = '#ef4444'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('删除', x + w - 101, y + 45); + + // 回放按钮 + const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28); + btnGradient.addColorStop(0, '#ff6b6b'); + btnGradient.addColorStop(1, '#ffd700'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('回放', x + w - 39, y + 45); + } + + // 格式化日期时间 + formatDateTime(dateStr) { + if (!dateStr) return ''; + try { + // iOS 兼容:将 "2026-03-10 11:51" 转换为 "2026-03-10T11:51:00" + const isoStr = dateStr.replace(' ', 'T'); + const date = new Date(isoStr); + if (isNaN(date.getTime())) return dateStr; + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours().toString().padStart(2, '0'); + const minute = date.getMinutes().toString().padStart(2, '0'); + return `${month}月${day}日 ${hour}:${minute}`; + } catch (e) { + return dateStr; + } + } + renderWorkCard(ctx, item, x, y, w, h, index) { // 卡片背景 ctx.fillStyle = 'rgba(255,255,255,0.05)'; @@ -486,20 +612,20 @@ export default class ProfileScene extends BaseScene { ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(this.truncateText(ctx, item.story_title || item.title || '未知', w - 150), textX, y + 28); + // 记录Tab使用 storyTitle,收藏Tab使用 story_title + const title = item.storyTitle || item.story_title || item.title || '未知'; + ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28); ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = '11px sans-serif'; - if (this.currentTab === 3 && item.is_completed) { - ctx.fillStyle = '#4ade80'; - ctx.fillText('✓ 已完成', textX, y + 50); - } else if (this.currentTab === 3) { - ctx.fillText('进行中...', textX, y + 50); + if (this.currentTab === 3) { + // 记录Tab:只显示记录数量 + ctx.fillText(`${item.recordCount || 0} 条记录`, textX, y + 50); } else { ctx.fillText(item.category || '', textX, y + 50); } - // 继续按钮 + // 查看按钮(记录Tab)/ 继续按钮(收藏Tab) const btnGradient = ctx.createLinearGradient(x + w - 58, y + 28, x + w - 10, y + 28); btnGradient.addColorStop(0, '#ff6b6b'); btnGradient.addColorStop(1, '#ffd700'); @@ -509,7 +635,7 @@ export default class ProfileScene extends BaseScene { ctx.fillStyle = '#ffffff'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText('继续', x + w - 34, y + 45); + ctx.fillText(this.currentTab === 3 ? '查看' : '继续', x + w - 34, y + 45); } getGradientColors(index) { @@ -594,6 +720,7 @@ export default class ProfileScene extends BaseScene { if (this.currentTab !== rect.index) { this.currentTab = rect.index; this.scrollY = 0; + this.recordViewMode = 'list'; // 切换 Tab 时重置记录视图模式 this.calculateMaxScroll(); // 切换到 AI 草稿 tab 时刷新数据 @@ -627,15 +754,29 @@ export default class ProfileScene extends BaseScene { const padding = 12; const cardW = this.screenWidth - padding * 2; + // 记录 Tab 版本列表模式下,检测返回按钮 + if (this.currentTab === 3 && this.recordViewMode === 'versions') { + if (this.versionBackBtnRect) { + const btn = this.versionBackBtnRect; + if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { + this.recordViewMode = 'list'; + this.scrollY = 0; + this.calculateMaxScroll(); + return; + } + } + } + + const listStartY = (this.currentTab === 3 && this.recordViewMode === 'versions') ? startY + 45 : startY; const adjustedY = y + this.scrollY; - const index = Math.floor((adjustedY - startY) / (cardH + gap)); + const index = Math.floor((adjustedY - listStartY) / (cardH + gap)); if (index >= 0 && index < list.length) { const item = list[index]; const storyId = item.story_id || item.storyId || item.id; // 计算卡片内的相对位置 - const cardY = startY + index * (cardH + gap) - this.scrollY; + const cardY = listStartY + index * (cardH + gap) - this.scrollY; const relativeY = y - cardY; // AI草稿 Tab 的按钮检测 @@ -670,14 +811,82 @@ export default class ProfileScene extends BaseScene { return; } - if (this.currentTab >= 2) { - // 收藏/记录 - 跳转播放 + // 记录 Tab 处理 + if (this.currentTab === 3) { + if (this.recordViewMode === 'list') { + // 故事列表模式:点击进入版本列表 + this.showStoryVersions(item); + } else { + // 版本列表模式 + const btnY = 28; + const btnH = 26; + + // 检测删除按钮点击 + const deleteBtnX = padding + cardW - 125; + if (x >= deleteBtnX && x <= deleteBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) { + this.confirmDeleteRecord(item, index); + return; + } + + // 检测回放按钮点击 + const replayBtnX = padding + cardW - 68; + if (x >= replayBtnX && x <= replayBtnX + 58 && relativeY >= btnY && relativeY <= btnY + btnH) { + this.startRecordReplay(item); + return; + } + + // 点击卡片其他区域也进入回放 + this.startRecordReplay(item); + } + return; + } + + if (this.currentTab === 2) { + // 收藏 - 跳转播放 this.main.sceneManager.switchScene('story', { storyId }); } // 作品Tab的按钮操作需要更精确判断,暂略 } } + // 显示故事的版本列表 + async showStoryVersions(storyItem) { + const storyId = storyItem.story_id || storyItem.storyId || storyItem.id; + const storyTitle = storyItem.story_title || storyItem.title || '未知故事'; + + try { + wx.showLoading({ title: '加载中...' }); + const records = await this.main.userManager.getPlayRecords(storyId); + wx.hideLoading(); + + if (records && records.length > 0) { + this.selectedStoryInfo = { id: storyId, title: storyTitle }; + this.selectedStoryRecords = records; + this.recordViewMode = 'versions'; + this.scrollY = 0; + this.calculateMaxScroll(); + } else { + wx.showToast({ title: '暂无游玩记录', icon: 'none' }); + } + } catch (e) { + wx.hideLoading(); + console.error('加载版本列表失败:', e); + wx.showToast({ title: '加载失败', icon: 'none' }); + } + } + + // 开始回放记录 + async startRecordReplay(recordItem) { + const recordId = recordItem.id; + const storyId = this.selectedStoryInfo.id; + + // 进入故事场景,传入 playRecordId 参数 + this.main.sceneManager.switchScene('story', { + storyId, + playRecordId: recordId + }); + } + // 确认删除草稿 confirmDeleteDraft(item, index) { wx.showModal({ @@ -702,4 +911,39 @@ export default class ProfileScene extends BaseScene { } }); } + + // 确认删除游玩记录 + confirmDeleteRecord(item, index) { + wx.showModal({ + title: '删除记录', + content: `确定要删除这条「${item.endingName || '未知结局'}」的记录吗?`, + confirmText: '删除', + confirmColor: '#ef4444', + cancelText: '取消', + success: async (res) => { + if (res.confirm) { + const success = await this.main.userManager.deletePlayRecord(item.id); + if (success) { + // 从版本列表中移除 + this.selectedStoryRecords.splice(index, 1); + this.calculateMaxScroll(); + wx.showToast({ title: '删除成功', icon: 'success' }); + + // 如果删光了,返回故事列表 + if (this.selectedStoryRecords.length === 0) { + this.recordViewMode = 'list'; + // 从 progress 列表中也移除该故事 + const storyId = this.selectedStoryInfo.id; + const idx = this.progress.findIndex(p => (p.story_id || p.storyId) === storyId); + if (idx >= 0) { + this.progress.splice(idx, 1); + } + } + } else { + wx.showToast({ title: '删除失败', icon: 'none' }); + } + } + } + }); + } } diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index dd73b9e..7714b4b 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -8,6 +8,7 @@ export default class StoryScene extends BaseScene { super(main, params); this.storyId = params.storyId; this.draftId = params.draftId || null; // 草稿ID + this.playRecordId = params.playRecordId || null; // 游玩记录ID(从记录回放) this.aiContent = params.aiContent || null; // AI改写内容 this.story = null; this.currentNode = null; @@ -42,6 +43,8 @@ export default class StoryScene extends BaseScene { this.recapCardRects = []; // 重头游玩模式 this.isReplayMode = false; + this.isRecordReplay = false; // 是否是从记录回放(区别AI改写回放) + this.recordReplayEnding = null; // 记录回放的结局信息 this.replayPath = []; this.replayPathIndex = 0; } @@ -59,6 +62,66 @@ export default class StoryScene extends BaseScene { } async init() { + // 如果是从记录回放 + if (this.playRecordId) { + this.main.showLoading('加载回放记录...'); + + try { + const record = await this.main.userManager.getPlayRecordDetail(this.playRecordId); + + if (record && record.pathHistory) { + // 加载故事 + this.story = await this.main.storyManager.loadStoryDetail(record.storyId); + + if (this.story) { + this.setThemeByCategory(this.story.category); + + // 设置记录回放模式 + this.isRecordReplay = true; + this.replayPath = record.pathHistory || []; + this.replayPathIndex = 0; + this.recordReplayEnding = { + name: record.endingName, + type: record.endingType + }; + + console.log('[RecordReplay] 开始记录回放, pathHistory长度:', this.replayPath.length); + + this.main.hideLoading(); + + // 如果 pathHistory 为空,说明用户在起始节点就到达了结局 + if (this.replayPath.length === 0) { + this.main.storyManager.currentNodeKey = 'start'; + this.currentNode = this.main.storyManager.getCurrentNode(); + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + } + return; + } + + this.isReplayMode = true; + + // 从 start 节点开始 + this.main.storyManager.currentNodeKey = 'start'; + this.main.storyManager.pathHistory = []; + this.currentNode = this.main.storyManager.getCurrentNode(); + + if (this.currentNode) { + this.startTypewriter(this.currentNode.content); + } + return; + } + } + } catch (e) { + console.error('加载回放记录失败:', e); + } + + this.main.hideLoading(); + this.main.showError('记录加载失败'); + this.main.sceneManager.switchScene('home'); + return; + } + // 如果是从Draft加载,先获取草稿详情,进入回顾模式 if (this.draftId) { this.main.showLoading('加载AI改写内容...'); @@ -423,10 +486,21 @@ export default class StoryScene extends BaseScene { if (!this.recapData) return; this.isRecapMode = false; - this.isReplayMode = true; this.replayPathIndex = 0; this.replayPath = this.recapData.pathHistory || []; + console.log('[ReplayMode] 开始回放, pathHistory长度:', this.replayPath.length); + + // 如果 pathHistory 为空,说明用户在起始节点就改写了,直接进入 AI 内容 + if (this.replayPath.length === 0) { + console.log('[ReplayMode] pathHistory为空,直接进入AI内容'); + this.isReplayMode = false; + this.enterAIContent(); + return; + } + + this.isReplayMode = true; + // 从 start 节点开始 this.main.storyManager.currentNodeKey = 'start'; this.main.storyManager.pathHistory = []; @@ -439,9 +513,20 @@ export default class StoryScene extends BaseScene { // 自动选择回放路径中的选项 autoSelectReplayChoice() { + console.log('[ReplayMode] autoSelectReplayChoice, index:', this.replayPathIndex, ', total:', this.replayPath.length); + if (!this.isReplayMode || this.replayPathIndex >= this.replayPath.length) { - // 回放结束,进入AI改写内容 + // 回放结束 + console.log('[ReplayMode] 回放结束'); this.isReplayMode = false; + + // 记录回放模式:进入结局页面 + if (this.isRecordReplay) { + this.finishRecordReplay(); + return; + } + + // AI改写回放模式:进入AI内容 this.enterAIContent(); return; } @@ -450,6 +535,8 @@ export default class StoryScene extends BaseScene { const currentPath = this.replayPath[this.replayPathIndex]; const currentNode = this.main.storyManager.getCurrentNode(); + console.log('[ReplayMode] 当前路径:', currentPath?.choice, ', 当前节点选项:', currentNode?.choices?.map(c => c.text)); + if (currentNode && currentNode.choices) { const choiceIndex = currentNode.choices.findIndex(c => c.text === currentPath.choice); if (choiceIndex >= 0) { @@ -463,9 +550,34 @@ export default class StoryScene extends BaseScene { } } - // 找不到匹配的选项,直接进入AI内容 + // 找不到匹配的选项 + console.log('[ReplayMode] 找不到匹配选项'); this.isReplayMode = false; - this.enterAIContent(); + + if (this.isRecordReplay) { + this.finishRecordReplay(); + } else { + this.enterAIContent(); + } + } + + // 完成记录回放,进入结局页面 + finishRecordReplay() { + console.log('[RecordReplay] 回放完成,进入结局页面'); + + // 获取结局信息 + const endingInfo = this.main.storyManager.getEndingInfo() || this.recordReplayEnding || {}; + + this.main.sceneManager.switchScene('ending', { + storyId: this.storyId, + ending: { + name: endingInfo.name || this.recordReplayEnding?.name || '未知结局', + type: endingInfo.type || this.recordReplayEnding?.type || '', + content: this.currentNode?.content || '', + score: endingInfo.score || 80 + }, + isReplay: true // 标记为回放模式,不重复保存记录 + }); } // 进入AI改写内容 @@ -502,8 +614,9 @@ export default class StoryScene extends BaseScene { startTypewriter(text) { let content = text || ''; - // 回放模式下,过滤掉结局提示(因为后面还有AI改写内容) - if (this.isReplayMode) { + // 回放模式下,过滤掉结局提示(因为后面还有内容) + // 但记录回放模式不过滤,因为要完整显示原结局 + if (this.isReplayMode && !this.isRecordReplay) { content = content.replace(/【达成结局[::][^】]*】/g, '').trim(); } @@ -1110,13 +1223,29 @@ export default class StoryScene extends BaseScene { return; } - // 回放模式下,如果到达原结局或没有选项,进入AI改写内容 + // 回放模式下,如果回放路径已用完或到达原结局 if (this.isReplayMode) { const currentNode = this.main.storyManager.getCurrentNode(); - if (!currentNode || !currentNode.choices || currentNode.choices.length === 0 || currentNode.is_ending) { - // 回放结束,进入AI改写内容 + // 检查回放路径是否已用完 + if (this.replayPathIndex >= this.replayPath.length) { + console.log('[ReplayMode] 回放路径已用完'); this.isReplayMode = false; - this.enterAIContent(); + if (this.isRecordReplay) { + this.finishRecordReplay(); + } else { + this.enterAIContent(); + } + return; + } + // 检查当前节点是否是结局或没有选项 + if (!currentNode || !currentNode.choices || currentNode.choices.length === 0 || currentNode.is_ending) { + console.log('[ReplayMode] 到达结局或无选项'); + this.isReplayMode = false; + if (this.isRecordReplay) { + this.finishRecordReplay(); + } else { + this.enterAIContent(); + } return; } } diff --git a/client/js/utils/http.js b/client/js/utils/http.js index 7c2b4a0..c28c539 100644 --- a/client/js/utils/http.js +++ b/client/js/utils/http.js @@ -3,7 +3,7 @@ */ // API基础地址(开发环境) -const BASE_URL = 'https://express-0a1p-230010-4-1408549115.sh.run.tcloudbase.com/api'; +const BASE_URL = 'https://express-fuvd-231535-4-1409819450.sh.run.tcloudbase.com/api'; /** * 发送HTTP请求 @@ -49,4 +49,11 @@ export function post(url, data, options = {}) { return request({ url, method: 'POST', data, ...options }); } -export default { request, get, post }; +/** + * DELETE请求 + */ +export function del(url, data) { + return request({ url, method: 'DELETE', data }); +} + +export default { request, get, post, del }; diff --git a/server/app/models/user.py b/server/app/models/user.py index 277650d..3a08e4b 100644 --- a/server/app/models/user.py +++ b/server/app/models/user.py @@ -1,7 +1,7 @@ """ 用户相关ORM模型 """ -from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint +from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint, JSON, Index from sqlalchemy.sql import func from app.database import Base @@ -56,3 +56,21 @@ class UserEnding(Base): __table_args__ = ( UniqueConstraint('user_id', 'story_id', 'ending_name', name='uk_user_ending'), ) + + +class PlayRecord(Base): + """游玩记录表 - 保存每次游玩的完整路径""" + __tablename__ = "play_records" + + 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) + ending_name = Column(String(100), nullable=False) # 结局名称 + ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite) + path_history = Column(JSON, nullable=False) # 完整的选择路径 + play_duration = Column(Integer, default=0) # 游玩时长(秒) + created_at = Column(TIMESTAMP, server_default=func.now()) + + __table_args__ = ( + Index('idx_user_story', 'user_id', 'story_id'), + ) diff --git a/server/app/routers/user.py b/server/app/routers/user.py index c7c8c62..da8ce0c 100644 --- a/server/app/routers/user.py +++ b/server/app/routers/user.py @@ -3,12 +3,12 @@ """ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update, func, text +from sqlalchemy import select, update, func, text, delete from typing import Optional from pydantic import BaseModel from app.database import get_db -from app.models.user import User, UserProgress, UserEnding +from app.models.user import User, UserProgress, UserEnding, PlayRecord from app.models.story import Story router = APIRouter() @@ -46,6 +46,14 @@ class CollectRequest(BaseModel): isCollected: bool +class PlayRecordRequest(BaseModel): + userId: int + storyId: int + endingName: str + endingType: str = "" + pathHistory: list + + # ========== API接口 ========== @router.post("/login") @@ -419,3 +427,147 @@ async def get_ai_quota(user_id: int = Query(..., alias="userId"), db: AsyncSessi "gift": 0 } } + + +# ========== 游玩记录 API ========== + +@router.post("/play-record") +async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depends(get_db)): + """保存游玩记录(相同路径只保留最新)""" + import json + + # 查找该用户该故事的所有记录 + result = await db.execute( + select(PlayRecord) + .where(PlayRecord.user_id == request.userId, PlayRecord.story_id == request.storyId) + ) + existing_records = result.scalars().all() + + # 检查是否有相同路径的记录 + new_path_str = json.dumps(request.pathHistory, sort_keys=True, ensure_ascii=False) + for old_record in existing_records: + old_path_str = json.dumps(old_record.path_history, sort_keys=True, ensure_ascii=False) + if old_path_str == new_path_str: + # 相同路径,删除旧记录 + await db.delete(old_record) + + # 创建新记录 + record = PlayRecord( + user_id=request.userId, + story_id=request.storyId, + ending_name=request.endingName, + ending_type=request.endingType, + path_history=request.pathHistory + ) + db.add(record) + await db.commit() + await db.refresh(record) + + return { + "code": 0, + "data": { + "recordId": record.id, + "message": "记录保存成功" + } + } + + +@router.get("/play-records") +async def get_play_records( + user_id: int = Query(..., alias="userId"), + story_id: Optional[int] = Query(None, alias="storyId"), + db: AsyncSession = Depends(get_db) +): + """获取游玩记录列表""" + if story_id: + # 获取指定故事的记录 + result = await db.execute( + select(PlayRecord) + .where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id) + .order_by(PlayRecord.created_at.desc()) + ) + 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] + else: + # 获取所有玩过的故事(按故事分组,取最新一条) + result = await db.execute( + select(PlayRecord, Story.title, Story.cover_url) + .join(Story, PlayRecord.story_id == Story.id) + .where(PlayRecord.user_id == user_id) + .order_by(PlayRecord.created_at.desc()) + ) + rows = result.all() + + # 按 story_id 分组,取每个故事的最新记录和记录数 + story_map = {} + for row in rows: + sid = row.PlayRecord.story_id + if sid not in story_map: + story_map[sid] = { + "storyId": sid, + "storyTitle": row.title, + "coverUrl": row.cover_url, + "latestEnding": row.PlayRecord.ending_name, + "latestTime": row.PlayRecord.created_at.strftime("%Y-%m-%d %H:%M") if row.PlayRecord.created_at else "", + "recordCount": 0 + } + story_map[sid]["recordCount"] += 1 + + data = list(story_map.values()) + + return {"code": 0, "data": data} + + +@router.get("/play-records/{record_id}") +async def get_play_record_detail( + record_id: int, + db: AsyncSession = Depends(get_db) +): + """获取单条记录详情""" + result = await db.execute( + select(PlayRecord, Story.title) + .join(Story, PlayRecord.story_id == Story.id) + .where(PlayRecord.id == record_id) + ) + row = result.first() + + if not row: + return {"code": 404, "message": "记录不存在"} + + record = row.PlayRecord + return { + "code": 0, + "data": { + "id": record.id, + "storyId": record.story_id, + "storyTitle": row.title, + "endingName": record.ending_name, + "endingType": record.ending_type, + "pathHistory": record.path_history, + "createdAt": record.created_at.strftime("%Y-%m-%d %H:%M") if record.created_at else "" + } + } + + +@router.delete("/play-records/{record_id}") +async def delete_play_record( + record_id: int, + db: AsyncSession = Depends(get_db) +): + """删除游玩记录""" + result = await db.execute(select(PlayRecord).where(PlayRecord.id == record_id)) + record = result.scalar_one_or_none() + + if not record: + return {"code": 404, "message": "记录不存在"} + + await db.delete(record) + await db.commit() + + return {"code": 0, "message": "删除成功"} diff --git a/server/sql/schema.sql b/server/sql/schema.sql index 43e8327..553f163 100644 --- a/server/sql/schema.sql +++ b/server/sql/schema.sql @@ -156,3 +156,23 @@ CREATE TABLE IF NOT EXISTS `story_drafts` ( CONSTRAINT `story_drafts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `story_drafts_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI改写草稿表'; + +-- ============================================ +-- 8. 游玩记录表 +-- ============================================ +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', + `ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称', + `ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型', + `path_history` JSON NOT NULL COMMENT '完整的选择路径', + `play_duration` INT DEFAULT 0 COMMENT '游玩时长(秒)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_story` (`user_id`, `story_id`), + KEY `idx_user` (`user_id`), + KEY `idx_story` (`story_id`), + CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表';