feat: 游玩记录多版本功能 - 支持多版本记录存储和回放 - 相同路径自动去重只保留最新 - 版本列表支持删除功能 - AI草稿箱游玩不记录历史 - iOS日期格式兼容修复

This commit is contained in:
wangwuww111
2026-03-10 12:44:55 +08:00
parent 9948ccba8f
commit baf7dd1e2b
8 changed files with 693 additions and 35 deletions

View File

@@ -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;
}
}
}

View File

@@ -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() {

View File

@@ -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' });
}
}
}
});
}
}

View File

@@ -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,10 +550,35 @@ export default class StoryScene extends BaseScene {
}
}
// 找不到匹配的选项直接进入AI内容
// 找不到匹配的选项
console.log('[ReplayMode] 找不到匹配选项');
this.isReplayMode = false;
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改写内容
enterAIContent() {
@@ -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;
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;
}
}

View File

@@ -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 };

View File

@@ -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'),
)

View File

@@ -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": "删除成功"}

View File

@@ -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='游玩记录表';