feat: 游玩记录多版本功能 - 支持多版本记录存储和回放 - 相同路径自动去重只保留最新 - 版本列表支持删除功能 - AI草稿箱游玩不记录历史 - iOS日期格式兼容修复
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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": "删除成功"}
|
||||
|
||||
@@ -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='游玩记录表';
|
||||
|
||||
Reference in New Issue
Block a user