5 Commits

27 changed files with 3032 additions and 229 deletions

View File

@@ -1,7 +1,7 @@
/** /**
* 故事数据管理器 * 故事数据管理器
*/ */
import { get, post } from '../utils/http'; import { get, post, request } from '../utils/http';
export default class StoryManager { export default class StoryManager {
constructor() { constructor() {
@@ -9,6 +9,7 @@ export default class StoryManager {
this.currentStory = null; this.currentStory = null;
this.currentNodeKey = 'start'; this.currentNodeKey = 'start';
this.categories = []; this.categories = [];
this.pathHistory = []; // 记录用户走过的路径
} }
/** /**
@@ -56,6 +57,7 @@ export default class StoryManager {
try { try {
this.currentStory = await get(`/stories/${storyId}`); this.currentStory = await get(`/stories/${storyId}`);
this.currentNodeKey = 'start'; this.currentNodeKey = 'start';
this.pathHistory = []; // 重置路径历史
// 记录游玩次数 // 记录游玩次数
await post(`/stories/${storyId}/play`); await post(`/stories/${storyId}/play`);
@@ -85,6 +87,14 @@ export default class StoryManager {
} }
const choice = currentNode.choices[choiceIndex]; const choice = currentNode.choices[choiceIndex];
// 记录路径历史
this.pathHistory.push({
nodeKey: this.currentNodeKey,
content: currentNode.content,
choice: choice.text
});
this.currentNodeKey = choice.nextNodeKey; this.currentNodeKey = choice.nextNodeKey;
return this.getCurrentNode(); return this.getCurrentNode();
@@ -118,6 +128,7 @@ export default class StoryManager {
*/ */
resetStory() { resetStory() {
this.currentNodeKey = 'start'; this.currentNodeKey = 'start';
this.pathHistory = []; // 清空路径历史
} }
/** /**
@@ -145,6 +156,170 @@ export default class StoryManager {
} }
} }
/**
* AI改写结局异步提交到草稿箱
*/
async rewriteEndingAsync(storyId, ending, prompt, userId) {
try {
// 先标记之前的未读草稿为已读
await this.markAllDraftsRead(userId);
const result = await post(`/drafts/ending`, {
userId: userId,
storyId: storyId,
endingName: ending?.name || '未知结局',
endingContent: ending?.content || '',
prompt: prompt
}, { timeout: 30000 });
if (result && result.draftId) {
return result;
}
return null;
} catch (error) {
console.error('AI改写结局提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
return null;
}
}
/**
* AI续写结局异步提交到草稿箱
*/
async continueEndingAsync(storyId, ending, prompt, userId) {
try {
// 先标记之前的未读草稿为已读
await this.markAllDraftsRead(userId);
const result = await post(`/drafts/continue-ending`, {
userId: userId,
storyId: storyId,
endingName: ending?.name || '未知结局',
endingContent: ending?.content || '',
prompt: prompt
}, { timeout: 30000 });
if (result && result.draftId) {
return result;
}
return null;
} catch (error) {
console.error('AI续写结局提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
return null;
}
}
/**
* AI改写中间章节异步提交到草稿箱
* @returns {Object|null} 成功返回草稿ID失败返回 null
*/
async rewriteBranchAsync(storyId, prompt, userId) {
try {
// 先标记之前的未读草稿为已读,避免轮询弹出之前的通知
await this.markAllDraftsRead(userId);
const currentNode = this.getCurrentNode();
const result = await post(`/drafts`, {
userId: userId,
storyId: storyId,
currentNodeKey: this.currentNodeKey,
pathHistory: this.pathHistory,
currentContent: currentNode?.content || '',
prompt: prompt
}, { timeout: 30000 });
if (result && result.draftId) {
return result;
}
return null;
} catch (error) {
console.error('AI改写提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
return null;
}
}
/**
* 获取用户草稿列表
*/
async getDrafts(userId) {
try {
const result = await get(`/drafts?userId=${userId}`);
return result || [];
} catch (error) {
console.error('获取草稿列表失败:', error);
return [];
}
}
/**
* 检查是否有新完成的草稿
*/
async checkNewDrafts(userId) {
try {
const result = await get(`/drafts/check-new?userId=${userId}`);
return result || { hasNew: false, count: 0, drafts: [] };
} catch (error) {
console.error('检查新草稿失败:', error);
return { hasNew: false, count: 0, drafts: [] };
}
}
/**
* 批量标记所有未读草稿为已读
*/
async markAllDraftsRead(userId) {
try {
await request({ url: `/drafts/batch-read?userId=${userId}`, method: 'PUT' });
return true;
} catch (error) {
console.error('批量标记已读失败:', error);
return false;
}
}
/**
* 获取草稿详情
*/
async getDraftDetail(draftId) {
try {
const result = await get(`/drafts/${draftId}`);
return result;
} catch (error) {
console.error('获取草稿详情失败:', error);
return null;
}
}
/**
* 删除草稿
*/
async deleteDraft(draftId, userId) {
try {
const result = await request({
url: `/drafts/${draftId}?userId=${userId}`,
method: 'DELETE'
});
return true;
} catch (error) {
console.error('删除草稿失败:', error);
return false;
}
}
/**
* 从草稿加载并播放 AI 生成的内容
*/
loadDraftContent(draft) {
if (!draft || !draft.aiNodes) return null;
// 将 AI 生成的节点合并到当前故事
if (this.currentStory) {
Object.assign(this.currentStory.nodes, draft.aiNodes);
this.currentNodeKey = draft.entryNodeKey || 'branch_1';
return this.getCurrentNode();
}
return null;
}
/** /**
* AI续写故事 * AI续写故事
*/ */

View File

@@ -59,6 +59,11 @@ export default class Main {
// 设置分享 // 设置分享
this.setupShare(); this.setupShare();
// 启动草稿检查(仅登录用户)
if (this.userManager.isLoggedIn) {
this.startDraftChecker();
}
} catch (error) { } catch (error) {
console.error('[Main] 初始化失败:', error); console.error('[Main] 初始化失败:', error);
this.hideLoading(); this.hideLoading();
@@ -111,6 +116,61 @@ export default class Main {
}); });
} }
// 启动草稿检查定时器
startDraftChecker() {
// 避免重复启动
if (this.draftCheckTimer) return;
console.log('[Main] 启动草稿检查定时器');
// 每30秒检查一次
this.draftCheckTimer = setInterval(async () => {
try {
if (!this.userManager.isLoggedIn) return;
const result = await this.storyManager.checkNewDrafts(this.userManager.userId);
if (result && result.hasNew && result.count > 0) {
console.log('[Main] 检测到新草稿:', result.count);
// 先标记为已读,避免重复弹窗
await this.storyManager.markAllDraftsRead(this.userManager.userId);
// 弹窗通知
wx.showModal({
title: 'AI改写完成',
content: `您有 ${result.count} 个新的AI改写已完成是否前往查看`,
confirmText: '查看',
cancelText: '稍后',
success: (res) => {
if (res.confirm) {
// 跳转到个人中心的草稿箱 tab
this.sceneManager.switchScene('profile', { tab: 1 });
} else {
// 点击稍后,如果当前在个人中心页面则刷新草稿列表
const currentScene = this.sceneManager.currentScene;
if (currentScene && currentScene.refreshDrafts) {
currentScene.refreshDrafts();
}
}
}
});
}
} catch (e) {
console.warn('[Main] 草稿检查失败:', e);
}
}, 30000);
}
// 停止草稿检查定时器
stopDraftChecker() {
if (this.draftCheckTimer) {
clearInterval(this.draftCheckTimer);
this.draftCheckTimer = null;
console.log('[Main] 停止草稿检查定时器');
}
}
// 设置分享 // 设置分享
setupShare() { setupShare() {
wx.showShareMenu({ wx.showShareMenu({

View File

@@ -71,8 +71,10 @@ export default class ChapterScene extends BaseScene {
const cardHeight = 85; const cardHeight = 85;
const gap = 12; const gap = 12;
const headerHeight = 80; const headerHeight = 80;
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight; const bottomPadding = 50; // 底部留出空间
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20); const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight + bottomPadding;
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight);
console.log('[ChapterScene] nodeList长度:', this.nodeList.length, 'contentHeight:', contentHeight, 'screenHeight:', this.screenHeight, 'maxScrollY:', this.maxScrollY);
} }
update() {} update() {}
@@ -173,9 +175,17 @@ export default class ChapterScene extends BaseScene {
if (this.maxScrollY > 0) { if (this.maxScrollY > 0) {
const scrollBarHeight = 50; const scrollBarHeight = 50;
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20); const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillStyle = 'rgba(255,255,255,0.4)';
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5); this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 5, scrollBarHeight, 2.5);
ctx.fill(); ctx.fill();
// 如果还没滚动到底部,显示提示
if (this.scrollY < this.maxScrollY - 10) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 15);
}
} }
} }

View File

@@ -8,6 +8,7 @@ export default class EndingScene extends BaseScene {
super(main, params); super(main, params);
this.storyId = params.storyId; this.storyId = params.storyId;
this.ending = params.ending; this.ending = params.ending;
this.draftId = params.draftId || null; // 保存草稿ID
console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending)); console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending));
this.showButtons = false; this.showButtons = false;
this.fadeIn = 0; this.fadeIn = 0;
@@ -19,6 +20,11 @@ export default class EndingScene extends BaseScene {
this.rewritePrompt = ''; this.rewritePrompt = '';
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢']; this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
this.selectedTag = -1; this.selectedTag = -1;
// AI续写面板
this.showContinuePanel = false;
this.continuePrompt = '';
this.continueTags = ['故事未完', '新的冒险', '多年以后', '意外转折', '番外篇'];
this.selectedContinueTag = -1;
// 改写历史 // 改写历史
this.rewriteHistory = []; this.rewriteHistory = [];
this.currentHistoryIndex = -1; this.currentHistoryIndex = -1;
@@ -84,6 +90,10 @@ export default class EndingScene extends BaseScene {
if (this.showRewritePanel) { if (this.showRewritePanel) {
this.renderRewritePanel(ctx); this.renderRewritePanel(ctx);
} }
// AI续写面板
if (this.showContinuePanel) {
this.renderContinuePanel(ctx);
}
} }
renderBackground(ctx) { renderBackground(ctx) {
@@ -256,15 +266,17 @@ export default class EndingScene extends BaseScene {
const buttonHeight = 38; const buttonHeight = 38;
const buttonMargin = 8; const buttonMargin = 8;
const startY = this.screenHeight - 220; const startY = this.screenHeight - 220;
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
// AI改写按钮(带配额提示 // AI改写按钮和AI续写按钮第一行
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
const aiBtnText = remaining > 0 ? '✨ AI改写结局' : '⚠️ 次数不足'; const rewriteBtnText = remaining > 0 ? '✨ AI改写' : '⚠️ 次数不足';
this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, aiBtnText, ['#a855f7', '#ec4899']); const continueBtnText = remaining > 0 ? '📖 AI续写' : '⚠️ 次数不足';
this.renderGradientButton(ctx, padding, startY, buttonWidth, buttonHeight, rewriteBtnText, ['#a855f7', '#ec4899']);
this.renderGradientButton(ctx, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight, continueBtnText, ['#10b981', '#059669']);
// 分享按钮 // 分享按钮
const row2Y = startY + buttonHeight + buttonMargin; const row2Y = startY + buttonHeight + buttonMargin;
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']); this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']);
// 章节选择按钮 // 章节选择按钮
@@ -502,6 +514,183 @@ export default class EndingScene extends BaseScene {
this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight }; this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
} }
renderContinuePanel(ctx) {
const padding = 20;
const panelWidth = this.screenWidth - padding * 2;
const panelHeight = 450;
const panelX = padding;
const panelY = (this.screenHeight - panelHeight) / 2;
// 遮罩层
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 面板背景渐变
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
panelGradient.addColorStop(0, '#0d2818');
panelGradient.addColorStop(1, '#0a1a10');
ctx.fillStyle = panelGradient;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.fill();
// 面板边框渐变
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
borderGradient.addColorStop(0, '#10b981');
borderGradient.addColorStop(1, '#059669');
ctx.strokeStyle = borderGradient;
ctx.lineWidth = 2;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.stroke();
// 标题栏
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('📖 AI续写结局', this.screenWidth / 2, panelY + 35);
// 配额提示
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
ctx.fillStyle = remaining > 0 ? 'rgba(255,255,255,0.6)' : 'rgba(255,100,100,0.8)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`剩余次数:${remaining}`, panelX + panelWidth - 15, panelY + 35);
// 副标题
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('从当前结局出发AI将为你续写新的剧情分支', this.screenWidth / 2, panelY + 58);
// 分隔线
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75);
lineGradient.addColorStop(0, 'transparent');
lineGradient.addColorStop(0.5, 'rgba(16,185,129,0.5)');
lineGradient.addColorStop(1, 'transparent');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(panelX + 20, panelY + 75);
ctx.lineTo(panelX + panelWidth - 20, panelY + 75);
ctx.stroke();
// 快捷标签标题
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('快捷选择:', panelX + 15, panelY + 105);
// 快捷标签
const tagStartX = panelX + 15;
const tagY = panelY + 120;
const tagHeight = 32;
const tagGap = 8;
let currentX = tagStartX;
let currentY = tagY;
this.continueTagRects = [];
this.continueTags.forEach((tag, index) => {
ctx.font = '12px sans-serif';
const tagWidth = ctx.measureText(tag).width + 24;
// 换行
if (currentX + tagWidth > panelX + panelWidth - 15) {
currentX = tagStartX;
currentY += tagHeight + tagGap;
}
// 标签背景
const isSelected = index === this.selectedContinueTag;
if (isSelected) {
const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
tagGradient.addColorStop(0, '#10b981');
tagGradient.addColorStop(1, '#059669');
ctx.fillStyle = tagGradient;
} else {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
}
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
ctx.fill();
// 标签边框
ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
ctx.stroke();
// 标签文字
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21);
// 存储标签位置
this.continueTagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index });
currentX += tagWidth + tagGap;
});
// 自定义输入提示
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('或自定义输入:', panelX + 15, panelY + 215);
// 输入框背景
const inputY = panelY + 230;
const inputHeight = 45;
ctx.fillStyle = 'rgba(255,255,255,0.08)';
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
ctx.stroke();
// 输入框文字或占位符
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
if (this.continuePrompt) {
ctx.fillStyle = '#ffffff';
ctx.fillText(this.continuePrompt, panelX + 28, inputY + 28);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillText('点击输入你的续写想法...', panelX + 28, inputY + 28);
}
// 按钮
const btnY = panelY + panelHeight - 70;
const btnWidth = (panelWidth - 50) / 2;
const btnHeight = 44;
// 取消按钮
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28);
// 确认按钮
const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY);
confirmGradient.addColorStop(0, '#10b981');
confirmGradient.addColorStop(1, '#059669');
ctx.fillStyle = confirmGradient;
this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('📖 开始续写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28);
// 存储按钮区域
this.continueCancelBtnRect = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight };
this.continueConfirmBtnRect = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight };
this.continueInputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
}
// 圆角矩形 // 圆角矩形
roundRect(ctx, x, y, width, height, radius) { roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath(); ctx.beginPath();
@@ -609,6 +798,12 @@ export default class EndingScene extends BaseScene {
return; return;
} }
// 如果续写面板打开,优先处理
if (this.showContinuePanel) {
this.handleContinuePanelTouch(x, y);
return;
}
if (!this.showButtons) return; if (!this.showButtons) return;
const padding = 15; const padding = 15;
@@ -617,12 +812,18 @@ export default class EndingScene extends BaseScene {
const startY = this.screenHeight - 220; const startY = this.screenHeight - 220;
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2; const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
// AI改写按钮 // AI改写按钮(左)
if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) { if (this.isInRect(x, y, padding, startY, buttonWidth, buttonHeight)) {
this.handleAIRewrite(); this.handleAIRewrite();
return; return;
} }
// AI续写按钮
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight)) {
this.handleAIContinue();
return;
}
// 分享按钮 // 分享按钮
const row2Y = startY + buttonHeight + buttonMargin; const row2Y = startY + buttonHeight + buttonMargin;
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) { if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
@@ -645,6 +846,9 @@ export default class EndingScene extends BaseScene {
// 返回首页 // 返回首页
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) { if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) {
// 清除当前故事状态
this.main.storyManager.currentStory = null;
this.main.storyManager.currentNodeKey = 'start';
this.main.sceneManager.switchScene('home'); this.main.sceneManager.switchScene('home');
return; return;
} }
@@ -692,6 +896,20 @@ export default class EndingScene extends BaseScene {
this.selectedTag = -1; this.selectedTag = -1;
} }
handleAIContinue() {
// 检查配额
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
if (remaining <= 0) {
this.showQuotaModal();
return;
}
// 显示AI续写面板
this.showContinuePanel = true;
this.continuePrompt = '';
this.selectedContinueTag = -1;
}
handleRewritePanelTouch(x, y) { handleRewritePanelTouch(x, y) {
// 点击标签 // 点击标签
if (this.tagRects) { if (this.tagRects) {
@@ -730,6 +948,44 @@ export default class EndingScene extends BaseScene {
return false; return false;
} }
handleContinuePanelTouch(x, y) {
// 点击标签
if (this.continueTagRects) {
for (const tag of this.continueTagRects) {
if (this.isInRect(x, y, tag.x, tag.y, tag.width, tag.height)) {
this.selectedContinueTag = tag.index;
this.continuePrompt = this.continueTags[tag.index];
return true;
}
}
}
// 点击输入框
if (this.continueInputRect && this.isInRect(x, y, this.continueInputRect.x, this.continueInputRect.y, this.continueInputRect.width, this.continueInputRect.height)) {
this.showContinueInput();
return true;
}
// 点击取消
if (this.continueCancelBtnRect && this.isInRect(x, y, this.continueCancelBtnRect.x, this.continueCancelBtnRect.y, this.continueCancelBtnRect.width, this.continueCancelBtnRect.height)) {
this.showContinuePanel = false;
return true;
}
// 点击确认
if (this.continueConfirmBtnRect && this.isInRect(x, y, this.continueConfirmBtnRect.x, this.continueConfirmBtnRect.y, this.continueConfirmBtnRect.width, this.continueConfirmBtnRect.height)) {
if (this.continuePrompt) {
this.showContinuePanel = false;
this.callAIContinue(this.continuePrompt);
} else {
wx.showToast({ title: '请选择或输入续写方向', icon: 'none' });
}
return true;
}
return false;
}
showCustomInput() { showCustomInput() {
wx.showModal({ wx.showModal({
title: '输入改写想法', title: '输入改写想法',
@@ -745,6 +1001,21 @@ export default class EndingScene extends BaseScene {
}); });
} }
showContinueInput() {
wx.showModal({
title: '输入续写想法',
editable: true,
placeholderText: '例如:主角开启新的冒险',
content: this.continuePrompt,
success: (res) => {
if (res.confirm && res.content) {
this.continuePrompt = res.content;
this.selectedContinueTag = -1;
}
}
});
}
async callAIRewrite(prompt) { async callAIRewrite(prompt) {
// 检查配额 // 检查配额
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
@@ -754,68 +1025,94 @@ export default class EndingScene extends BaseScene {
} }
this.isRewriting = true; this.isRewriting = true;
this.rewriteProgress = 0;
// 显示加载动画 // 显示加载动画
wx.showLoading({ wx.showLoading({
title: 'AI创作中...', title: '提交中...',
mask: true mask: true
}); });
// 模拟进度条效果
const progressInterval = setInterval(() => {
this.rewriteProgress += Math.random() * 20;
if (this.rewriteProgress > 90) this.rewriteProgress = 90;
}, 500);
try { try {
const result = await this.main.storyManager.rewriteEnding( const userId = this.main.userManager.userId || 1;
const result = await this.main.storyManager.rewriteEndingAsync(
this.storyId, this.storyId,
this.ending, this.ending,
prompt prompt,
userId
); );
clearInterval(progressInterval);
this.rewriteProgress = 100;
wx.hideLoading(); wx.hideLoading();
if (result && result.content) { if (result && result.draftId) {
// 记录改写历史
this.rewriteHistory.push({
prompt: prompt,
content: result.content,
timestamp: Date.now()
});
this.currentHistoryIndex = this.rewriteHistory.length - 1;
// 扣除配额 // 扣除配额
this.aiQuota.used += 1; this.aiQuota.used += 1;
// 成功提示 // 提交成功提示
wx.showToast({ wx.showModal({
title: '改写成功', title: '提交成功',
icon: 'success', content: 'AI正在后台生成新结局完成后会通知您。\n您可以在草稿箱中查看。',
duration: 1500 showCancel: false,
confirmText: '知道了'
}); });
// 延迟跳转到故事场景播放新内容
setTimeout(() => {
this.main.sceneManager.switchScene('story', {
storyId: this.storyId,
aiContent: result
});
}, 1500);
} else { } else {
wx.showToast({ title: '改写失败,请重试', icon: 'none' }); wx.showToast({ title: '提交失败,请重试', icon: 'none' });
} }
} catch (error) { } catch (error) {
clearInterval(progressInterval);
wx.hideLoading(); wx.hideLoading();
console.error('改写失败:', error); console.error('改写失败:', error);
wx.showToast({ title: error.message || '网络错误', icon: 'none' }); wx.showToast({ title: error.message || '网络错误', icon: 'none' });
} finally { } finally {
this.isRewriting = false; this.isRewriting = false;
this.rewriteProgress = 0; }
}
async callAIContinue(prompt) {
// 检查配额
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
if (remaining <= 0) {
this.showQuotaModal();
return;
}
this.isRewriting = true;
// 显示加载动画
wx.showLoading({
title: '提交中...',
mask: true
});
try {
const userId = this.main.userManager.userId || 1;
const result = await this.main.storyManager.continueEndingAsync(
this.storyId,
this.ending,
prompt,
userId
);
wx.hideLoading();
if (result && result.draftId) {
// 扣除配额
this.aiQuota.used += 1;
// 提交成功提示
wx.showModal({
title: '提交成功',
content: 'AI正在后台生成续写剧情完成后会通知您。\n您可以在草稿箱中查看。',
showCancel: false,
confirmText: '知道了'
});
} else {
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
}
} catch (error) {
wx.hideLoading();
console.error('续写失败:', error);
wx.showToast({ title: error.message || '网络错误', icon: 'none' });
} finally {
this.isRewriting = false;
} }
} }
@@ -844,7 +1141,16 @@ export default class EndingScene extends BaseScene {
handleReplay() { handleReplay() {
this.main.storyManager.resetStory(); this.main.storyManager.resetStory();
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
// 如果是从草稿进入的,重头游玩时保留草稿上下文
if (this.draftId) {
this.main.sceneManager.switchScene('story', {
storyId: this.storyId,
draftId: this.draftId
});
} else {
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
}
} }
handleLike() { handleLike() {

View File

@@ -6,9 +6,9 @@ import BaseScene from './BaseScene';
export default class ProfileScene extends BaseScene { export default class ProfileScene extends BaseScene {
constructor(main, params) { constructor(main, params) {
super(main, params); super(main, params);
// Tab: 0我的作品 1草稿 2收藏 3游玩记录 // Tab: 0我的作品 1AI草稿 2收藏 3游玩记录
this.currentTab = 0; this.currentTab = params.tab || 0; // 支持传入初始tab
this.tabs = ['作品', '草稿', '收藏', '记录']; this.tabs = ['作品', 'AI草稿', '收藏', '记录'];
// 数据 // 数据
this.myWorks = []; this.myWorks = [];
@@ -40,8 +40,10 @@ export default class ProfileScene extends BaseScene {
async loadData() { async loadData() {
if (this.main.userManager.isLoggedIn) { if (this.main.userManager.isLoggedIn) {
try { try {
const userId = this.main.userManager.userId;
this.myWorks = await this.main.userManager.getMyWorks?.() || []; this.myWorks = await this.main.userManager.getMyWorks?.() || [];
this.drafts = await this.main.userManager.getDrafts?.() || []; // 加载 AI 改写草稿
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
this.collections = await this.main.userManager.getCollections() || []; this.collections = await this.main.userManager.getCollections() || [];
this.progress = await this.main.userManager.getProgress() || []; this.progress = await this.main.userManager.getProgress() || [];
@@ -57,6 +59,19 @@ export default class ProfileScene extends BaseScene {
this.calculateMaxScroll(); this.calculateMaxScroll();
} }
// 刷新草稿列表
async refreshDrafts() {
if (this.main.userManager.isLoggedIn) {
try {
const userId = this.main.userManager.userId;
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
this.calculateMaxScroll();
} catch (e) {
console.error('刷新草稿失败:', e);
}
}
}
getCurrentList() { getCurrentList() {
switch (this.currentTab) { switch (this.currentTab) {
case 0: return this.myWorks; case 0: return this.myWorks;
@@ -366,50 +381,85 @@ export default class ProfileScene extends BaseScene {
ctx.fill(); ctx.fill();
// AI标签 // AI标签
if (item.source === 'ai') { ctx.fillStyle = '#a855f7';
ctx.fillStyle = '#a855f7'; this.roundRect(ctx, x + 8, y + 8, 28, 16, 8);
this.roundRect(ctx, x + 8, y + 8, 28, 16, 8); ctx.fill();
ctx.fill(); ctx.fillStyle = '#ffffff';
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 9px sans-serif';
ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center';
ctx.textAlign = 'center'; ctx.fillText('AI', x + 22, y + 19);
ctx.fillText('AI', x + 22, y + 19);
}
const textX = x + 88; const textX = x + 88;
// 标题(故事标题-改写)
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif'; ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(this.truncateText(ctx, item.title || '未命名草稿', w - 180), textX, y + 25); ctx.fillText(this.truncateText(ctx, item.title || item.storyTitle || 'AI改写', w - 180), textX, y + 25);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; // 状态标签
const statusMap = {
'pending': { text: '等待中', color: '#888888' },
'processing': { text: '生成中', color: '#f59e0b' },
'completed': { text: '已完成', color: '#22c55e' },
'failed': { text: '失败', color: '#ef4444' }
};
const status = statusMap[item.status] || statusMap['pending'];
const titleWidth = ctx.measureText(this.truncateText(ctx, item.title || item.storyTitle || 'AI改写', w - 180)).width;
const statusW = ctx.measureText(status.text).width + 12;
ctx.fillStyle = status.color + '33';
this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9);
ctx.fill();
ctx.fillStyle = status.color;
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(status.text, textX + titleWidth + 8 + statusW / 2, y + 24);
// 改写指令
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px sans-serif'; ctx.font = '11px sans-serif';
ctx.fillText(`创建于 ${item.created_at || '刚刚'}`, textX, y + 48); ctx.textAlign = 'left';
ctx.fillText(`${item.node_count || 0} 个节点`, textX + 100, y + 48); const promptText = item.userPrompt ? `"「${item.userPrompt}」"` : '';
ctx.fillText(this.truncateText(ctx, promptText, w - 100), textX, y + 48);
// 时间
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '10px sans-serif';
ctx.fillText(item.createdAt || '', textX, y + 68);
// 未读标记
if (!item.isRead && item.status === 'completed') {
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.arc(x + w - 20, y + 20, 5, 0, Math.PI * 2);
ctx.fill();
}
// 按钮 // 按钮
const btnY = y + 62; const btnY = y + 62;
const btns = [{ text: '继续编辑', primary: true }, { text: '删除', primary: false }];
let btnX = textX; // 删除按钮(所有状态都显示)
btns.forEach((btn) => { ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
const btnW = btn.primary ? 65 : 45; this.roundRect(ctx, x + w - 55, btnY, 45, 24, 12);
if (btn.primary) { ctx.fill();
const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY); ctx.fillStyle = '#ef4444';
btnGradient.addColorStop(0, '#a855f7'); ctx.font = '11px sans-serif';
btnGradient.addColorStop(1, '#ec4899'); ctx.textAlign = 'center';
ctx.fillStyle = btnGradient; ctx.fillText('删除', x + w - 32, btnY + 16);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.1)'; // 播放按钮(仅已完成状态)
} if (item.status === 'completed') {
this.roundRect(ctx, btnX, btnY, btnW, 26, 13); const btnGradient = ctx.createLinearGradient(textX, btnY, textX + 65, btnY);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, textX + 120, btnY, 60, 24, 12);
ctx.fill(); ctx.fill();
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = btn.primary ? 'bold 11px sans-serif' : '11px sans-serif'; ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(btn.text, btnX + btnW / 2, btnY + 17); ctx.fillText('播放', textX + 150, btnY + 16);
btnX += btnW + 8; }
});
} }
renderSimpleCard(ctx, item, x, y, w, h, index) { renderSimpleCard(ctx, item, x, y, w, h, index) {
@@ -545,6 +595,11 @@ export default class ProfileScene extends BaseScene {
this.currentTab = rect.index; this.currentTab = rect.index;
this.scrollY = 0; this.scrollY = 0;
this.calculateMaxScroll(); this.calculateMaxScroll();
// 切换到 AI 草稿 tab 时刷新数据
if (rect.index === 1) {
this.refreshDrafts();
}
} }
return; return;
} }
@@ -569,22 +624,82 @@ export default class ProfileScene extends BaseScene {
const startY = 250; const startY = 250;
const cardH = this.currentTab <= 1 ? 100 : 85; const cardH = this.currentTab <= 1 ? 100 : 85;
const gap = 10; const gap = 10;
const padding = 12;
const cardW = this.screenWidth - padding * 2;
const adjustedY = y + this.scrollY; const adjustedY = y + this.scrollY;
const index = Math.floor((adjustedY - startY) / (cardH + gap)); const index = Math.floor((adjustedY - startY) / (cardH + gap));
if (index >= 0 && index < list.length) { if (index >= 0 && index < list.length) {
const item = list[index]; const item = list[index];
const storyId = item.story_id || item.id; const storyId = item.story_id || item.storyId || item.id;
// 计算卡片内的相对位置
const cardY = startY + index * (cardH + gap) - this.scrollY;
const relativeY = y - cardY;
// AI草稿 Tab 的按钮检测
if (this.currentTab === 1) {
const btnY = 62;
const btnH = 24;
// 检测删除按钮点击(右侧)
const deleteBtnX = padding + cardW - 55;
if (x >= deleteBtnX && x <= deleteBtnX + 45 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.confirmDeleteDraft(item, index);
return;
}
// 检测播放按钮点击(左侧,仅已完成状态)
if (item.status === 'completed') {
const playBtnX = padding + 88 + 120;
if (x >= playBtnX && x <= playBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
return;
}
}
// 点击卡片其他区域
if (item.status === 'completed') {
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
} else if (item.status === 'failed') {
wx.showToast({ title: 'AI改写失败', icon: 'none' });
} else {
wx.showToast({ title: '正在生成中,请稍后', icon: 'none' });
}
return;
}
if (this.currentTab >= 2) { if (this.currentTab >= 2) {
// 收藏/记录 - 跳转播放 // 收藏/记录 - 跳转播放
this.main.sceneManager.switchScene('story', { storyId }); this.main.sceneManager.switchScene('story', { storyId });
} else if (this.currentTab === 1) {
// 草稿 - 跳转编辑暂用AI创作
this.main.sceneManager.switchScene('aiCreate', { draftId: item.id });
} }
// 作品Tab的按钮操作需要更精确判断暂略 // 作品Tab的按钮操作需要更精确判断暂略
} }
} }
// 确认删除草稿
confirmDeleteDraft(item, index) {
wx.showModal({
title: '删除草稿',
content: `确定要删除「${item.title || 'AI改写'}」吗?`,
confirmText: '删除',
confirmColor: '#ef4444',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
const userId = this.main.userManager.userId;
const success = await this.main.storyManager.deleteDraft(item.id, userId);
if (success) {
// 从列表中移除
this.drafts.splice(index, 1);
this.calculateMaxScroll();
wx.showToast({ title: '删除成功', icon: 'success' });
} else {
wx.showToast({ title: '删除失败', icon: 'none' });
}
}
}
});
}
} }

View File

@@ -7,6 +7,7 @@ export default class StoryScene extends BaseScene {
constructor(main, params) { constructor(main, params) {
super(main, params); super(main, params);
this.storyId = params.storyId; this.storyId = params.storyId;
this.draftId = params.draftId || null; // 草稿ID
this.aiContent = params.aiContent || null; // AI改写内容 this.aiContent = params.aiContent || null; // AI改写内容
this.story = null; this.story = null;
this.currentNode = null; this.currentNode = null;
@@ -29,6 +30,20 @@ export default class StoryScene extends BaseScene {
// 场景图相关 // 场景图相关
this.sceneImage = null; this.sceneImage = null;
this.sceneColors = this.generateSceneColors(); this.sceneColors = this.generateSceneColors();
// AI改写相关
this.isAIRewriting = false;
// 剧情回顾模式
this.isRecapMode = false;
this.recapData = null;
this.recapScrollY = 0;
this.recapMaxScrollY = 0;
this.recapBtnRect = null;
this.recapReplayBtnRect = null;
this.recapCardRects = [];
// 重头游玩模式
this.isReplayMode = false;
this.replayPath = [];
this.replayPathIndex = 0;
} }
// 根据场景生成氛围色 // 根据场景生成氛围色
@@ -44,6 +59,52 @@ export default class StoryScene extends BaseScene {
} }
async init() { async init() {
// 如果是从Draft加载先获取草稿详情进入回顾模式
if (this.draftId) {
this.main.showLoading('加载AI改写内容...');
const draft = await this.main.storyManager.getDraftDetail(this.draftId);
if (draft && draft.aiNodes && draft.storyId) {
// 先加载原故事
this.story = await this.main.storyManager.loadStoryDetail(draft.storyId);
if (this.story) {
this.setThemeByCategory(this.story.category);
// 将AI生成的节点合并到故事中
Object.assign(this.story.nodes, draft.aiNodes);
// 获取 AI 入口节点的内容
const entryKey = draft.entryNodeKey || 'branch_1';
const aiEntryNode = draft.aiNodes[entryKey];
// 保存回顾数据,包含 AI 内容
this.recapData = {
pathHistory: draft.pathHistory || [],
userPrompt: draft.userPrompt || '',
entryNodeKey: entryKey,
aiContent: aiEntryNode // 保存 AI 入口节点内容
};
// 同时保存到 aiContent方便后续访问
this.aiContent = aiEntryNode;
// 进入回顾模式
this.isRecapMode = true;
this.calculateRecapScroll();
this.main.hideLoading();
return;
}
}
this.main.hideLoading();
this.main.showError('草稿加载失败');
this.main.sceneManager.switchScene('home');
return;
}
// 如果是AI改写内容直接播放 // 如果是AI改写内容直接播放
if (this.aiContent) { if (this.aiContent) {
this.story = this.main.storyManager.currentStory; this.story = this.main.storyManager.currentStory;
@@ -61,6 +122,10 @@ export default class StoryScene extends BaseScene {
// 重新开始,使用已有数据 // 重新开始,使用已有数据
this.story = existingStory; this.story = existingStory;
this.setThemeByCategory(this.story.category); this.setThemeByCategory(this.story.category);
// 重置到起点并清空历史
this.main.storyManager.resetStory();
this.currentNode = this.main.storyManager.getCurrentNode(); this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) { if (this.currentNode) {
this.startTypewriter(this.currentNode.content); this.startTypewriter(this.currentNode.content);
@@ -106,8 +171,343 @@ export default class StoryScene extends BaseScene {
this.sceneColors = themes[category] || this.sceneColors; this.sceneColors = themes[category] || this.sceneColors;
} }
// 计算回顾页面滚动范围
calculateRecapScroll() {
if (!this.recapData) return;
const itemHeight = 90;
const headerHeight = 120;
const promptHeight = 80;
const buttonHeight = 80;
const contentHeight = headerHeight + this.recapData.pathHistory.length * itemHeight + promptHeight + buttonHeight;
this.recapMaxScrollY = Math.max(0, contentHeight - this.screenHeight + 40);
}
// 渲染剧情回顾页面
renderRecapPage(ctx) {
// 背景
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
gradient.addColorStop(0, this.sceneColors.bg1);
gradient.addColorStop(1, this.sceneColors.bg2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 返回按钮
ctx.fillStyle = '#ffffff';
ctx.font = '16px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(' 返回', 15, 35);
// 标题
ctx.textAlign = 'center';
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = this.sceneColors.accent;
ctx.fillText('📖 剧情回顾', this.screenWidth / 2, 35);
// 故事标题
if (this.story) {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.fillText(this.story.title, this.screenWidth / 2, 60);
}
// 内容区域裁剪(调整起点避免被标题挡住)
ctx.save();
ctx.beginPath();
ctx.rect(0, 70, this.screenWidth, this.screenHeight - 150);
ctx.clip();
const padding = 16;
let y = 100 - this.recapScrollY;
const rawPathHistory = this.recapData?.pathHistory;
// 兼容结局续写pathHistory 是对象而非数组)
const pathHistory = Array.isArray(rawPathHistory) ? rawPathHistory : [];
// 保存卡片位置用于点击检测
this.recapCardRects = [];
// 计算可用文字宽度
const maxTextWidth = this.screenWidth - padding * 2 - 50;
// 绘制每个路径项
pathHistory.forEach((item, index) => {
if (y > 50 && y < this.screenHeight - 80) {
// 卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.06)';
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, 80, 12);
ctx.fill();
// 序号圆圈
ctx.fillStyle = this.sceneColors.accent;
ctx.beginPath();
ctx.arc(padding + 20, y + 28, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${index + 1}`, padding + 20, y + 32);
// 内容摘要(限制宽度)
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
const contentText = this.truncateTextByWidth(ctx, item.content || '', maxTextWidth - 40);
ctx.fillText(contentText, padding + 40, y + 28);
// 选择(限制宽度)
ctx.fillStyle = this.sceneColors.accent;
ctx.font = '11px sans-serif';
const choiceText = `${this.truncateTextByWidth(ctx, item.choice || '', maxTextWidth - 60)}`;
ctx.fillText(choiceText, padding + 40, y + 52);
// 点击提示图标
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('', this.screenWidth - padding - 12, y + 40);
// 保存卡片区域
this.recapCardRects.push({
x: padding,
y: y + this.recapScrollY,
width: this.screenWidth - padding * 2,
height: 80,
index: index,
item: item
});
}
y += 90;
});
// 空状态
if (pathHistory.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('没有历史记录', this.screenWidth / 2, y + 30);
y += 60;
}
// AI改写指令可点击查看详情
this.recapPromptRect = null;
if (y > 40 && y < this.screenHeight - 30) {
ctx.fillStyle = 'rgba(168, 85, 247, 0.15)';
this.roundRect(ctx, padding, y + 10, this.screenWidth - padding * 2, 60, 12);
ctx.fill();
ctx.fillStyle = '#a855f7';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('✨ AI改写指令', padding + 12, y + 32);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '11px sans-serif';
const promptText = this.truncateTextByWidth(ctx, this.recapData?.userPrompt || '无', maxTextWidth - 30);
ctx.fillText(`${promptText}`, padding + 12, y + 52);
// 点击提示
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('', this.screenWidth - padding - 12, y + 42);
// 保存点击区域
this.recapPromptRect = {
x: padding,
y: y + 10 + this.recapScrollY,
width: this.screenWidth - padding * 2,
height: 60
};
}
y += 80;
ctx.restore();
// 底部按钮区域(固定位置,两个按钮)
const btnY = this.screenHeight - 70;
const btnH = 42;
const btnGap = 12;
const btnW = (this.screenWidth - padding * 2 - btnGap) / 2;
// 左边按钮:重头游玩
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, padding, btnY, btnW, btnH, 21);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
this.roundRect(ctx, padding, btnY, btnW, btnH, 21);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🔄 重头游玩', padding + btnW / 2, btnY + 26);
// 右边按钮:开始新剧情
const btn2X = padding + btnW + btnGap;
const btnGradient = ctx.createLinearGradient(btn2X, btnY, btn2X + btnW, btnY);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, btn2X, btnY, btnW, btnH, 21);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('新剧情 →', btn2X + btnW / 2, btnY + 26);
// 保存按钮区域
this.recapReplayBtnRect = { x: padding, y: btnY, width: btnW, height: btnH };
this.recapBtnRect = { x: btn2X, y: btnY, width: btnW, height: btnH };
// 滚动提示
if (this.recapMaxScrollY > 0) {
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
if (this.recapScrollY < this.recapMaxScrollY - 10) {
ctx.fillText('↓ 上滑查看更多', this.screenWidth / 2, btnY - 15);
}
}
}
// 开始播放AI改写内容从回顾模式退出
startAIContent() {
if (!this.recapData) return;
this.isRecapMode = false;
this.main.storyManager.currentNodeKey = this.recapData.entryNodeKey || 'branch_1';
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
}
// 显示历史项详情
showRecapDetail(item, index) {
const content = item.content || '无内容';
const choice = item.choice || '无选择';
wx.showModal({
title: `${index + 1}`,
content: `【剧情】\n${content}\n\n【你的选择】\n${choice}`,
showCancel: false,
confirmText: '关闭'
});
}
// 显示AI改写指令详情
showPromptDetail() {
const prompt = this.recapData?.userPrompt || '无';
wx.showModal({
title: '✨ AI改写指令',
content: prompt,
showCancel: false,
confirmText: '关闭'
});
}
// 根据宽度截断文字
truncateTextByWidth(ctx, text, maxWidth) {
if (!text) return '';
if (ctx.measureText(text).width <= maxWidth) return text;
let t = text;
while (t.length > 0 && ctx.measureText(t + '...').width > maxWidth) {
t = t.slice(0, -1);
}
return t + '...';
}
// 重头游玩自动快进到AI改写点
startReplayMode() {
if (!this.recapData) return;
this.isRecapMode = false;
this.isReplayMode = true;
this.replayPathIndex = 0;
this.replayPath = this.recapData.pathHistory || [];
// 从 start 节点开始
this.main.storyManager.currentNodeKey = 'start';
this.main.storyManager.pathHistory = [];
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
}
// 自动选择回放路径中的选项
autoSelectReplayChoice() {
if (!this.isReplayMode || this.replayPathIndex >= this.replayPath.length) {
// 回放结束进入AI改写内容
this.isReplayMode = false;
this.enterAIContent();
return;
}
// 找到对应的选项并自动选择
const currentPath = this.replayPath[this.replayPathIndex];
const currentNode = this.main.storyManager.getCurrentNode();
if (currentNode && currentNode.choices) {
const choiceIndex = currentNode.choices.findIndex(c => c.text === currentPath.choice);
if (choiceIndex >= 0) {
this.replayPathIndex++;
this.main.storyManager.selectChoice(choiceIndex);
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
return;
}
}
// 找不到匹配的选项直接进入AI内容
this.isReplayMode = false;
this.enterAIContent();
}
// 进入AI改写内容
enterAIContent() {
console.log('进入AI改写内容');
// AI 节点已经合并到 story.nodes 中,使用 storyManager 来管理
const entryKey = this.recapData?.entryNodeKey || 'branch_1';
// 检查节点是否存在
if (this.story && this.story.nodes && this.story.nodes[entryKey]) {
this.main.storyManager.currentNodeKey = entryKey;
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
return;
}
}
// 节点不存在,显示错误
console.error('AI入口节点不存在:', entryKey);
wx.showModal({
title: '内容加载失败',
content: 'AI改写内容未找到',
showCancel: false,
confirmText: '返回',
success: () => {
this.main.sceneManager.switchScene('home');
}
});
}
startTypewriter(text) { startTypewriter(text) {
this.targetText = text || ''; let content = text || '';
// 回放模式下过滤掉结局提示因为后面还有AI改写内容
if (this.isReplayMode) {
content = content.replace(/【达成结局[:][^】]*】/g, '').trim();
}
this.targetText = content;
this.displayText = ''; this.displayText = '';
this.charIndex = 0; this.charIndex = 0;
this.isTyping = true; this.isTyping = true;
@@ -143,6 +543,12 @@ export default class StoryScene extends BaseScene {
} }
render(ctx) { render(ctx) {
// 如果是回顾模式,渲染回顾页面
if (this.isRecapMode) {
this.renderRecapPage(ctx);
return;
}
// 1. 绘制场景背景 // 1. 绘制场景背景
this.renderSceneBackground(ctx); this.renderSceneBackground(ctx);
@@ -246,16 +652,34 @@ export default class StoryScene extends BaseScene {
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.font = 'bold 15px sans-serif'; ctx.font = 'bold 15px sans-serif';
ctx.fillStyle = this.sceneColors.accent; ctx.fillStyle = this.sceneColors.accent;
const title = this.story.title.length > 10 ? this.story.title.substring(0, 10) + '...' : this.story.title; const title = this.story.title.length > 8 ? this.story.title.substring(0, 8) + '...' : this.story.title;
ctx.fillText(title, this.screenWidth / 2, 35); ctx.fillText(title, this.screenWidth / 2, 35);
} }
// 进度指示 // AI改写按钮对话框上方右侧
ctx.textAlign = 'right'; const boxY = this.screenHeight * 0.42;
const btnW = 70;
const btnH = 30;
const btnX = this.screenWidth - btnW - 15;
const btnY = boxY - btnH - 12;
// 按钮背景
if (this.isAIRewriting) {
ctx.fillStyle = 'rgba(255,255,255,0.2)';
} else {
const gradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY);
gradient.addColorStop(0, '#a855f7');
gradient.addColorStop(1, '#ec4899');
ctx.fillStyle = gradient;
}
this.roundRect(ctx, btnX, btnY, btnW, btnH, 15);
ctx.fill();
// 按钮文字
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif'; ctx.font = '12px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'center';
const progress = this.main.storyManager.getProgress ? this.main.storyManager.getProgress() : ''; ctx.fillText(this.isAIRewriting ? '✨ 生成中...' : '✨ AI改写', btnX + btnW / 2, btnY + 20);
ctx.fillText(progress, this.screenWidth - 15, 35);
} }
renderDialogBox(ctx) { renderDialogBox(ctx) {
@@ -300,14 +724,14 @@ export default class StoryScene extends BaseScene {
const textY = boxY + 65; const textY = boxY + 65;
const lineHeight = 26; const lineHeight = 26;
const maxWidth = this.screenWidth - padding * 2; const maxWidth = this.screenWidth - padding * 2;
const visibleHeight = boxHeight - 105; // 可见区域高度 const visibleHeight = boxHeight - 90; // 增加可见区域高度
ctx.font = '15px sans-serif'; ctx.font = '15px sans-serif';
const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth); const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth);
const totalTextHeight = allLines.length * lineHeight; const totalTextHeight = allLines.length * lineHeight;
// 计算最大滚动距离 // 计算最大滚动距离
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight); this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight + 30);
// 自动滚动到最新内容(打字时) // 自动滚动到最新内容(打字时)
if (this.isTyping) { if (this.isTyping) {
@@ -334,9 +758,17 @@ export default class StoryScene extends BaseScene {
if (this.maxScrollY > 0) { if (this.maxScrollY > 0) {
const scrollBarHeight = 40; const scrollBarHeight = 40;
const scrollBarY = boxY + 55 + (this.textScrollY / this.maxScrollY) * (visibleHeight - scrollBarHeight); const scrollBarY = boxY + 55 + (this.textScrollY / this.maxScrollY) * (visibleHeight - scrollBarHeight);
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillStyle = 'rgba(255,255,255,0.3)';
this.roundRect(ctx, this.screenWidth - 6, scrollBarY, 3, scrollBarHeight, 1.5); this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 4, scrollBarHeight, 2);
ctx.fill(); ctx.fill();
// 如果还没滚动到底部,显示滚动提示
if (this.textScrollY < this.maxScrollY - 10 && !this.isTyping) {
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 25);
}
} }
// 打字机光标 // 打字机光标
@@ -391,7 +823,7 @@ export default class StoryScene extends BaseScene {
renderChoices(ctx) { renderChoices(ctx) {
if (!this.currentNode || !this.currentNode.choices) return; if (!this.currentNode || !this.currentNode.choices) return;
const choices = this.currentNode.choices; let choices = this.currentNode.choices;
const choiceHeight = 50; const choiceHeight = 50;
const choiceMargin = 10; const choiceMargin = 10;
const padding = 20; const padding = 20;
@@ -401,18 +833,37 @@ export default class StoryScene extends BaseScene {
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58); ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58);
// 提示文字 // 回放模式下的处理
ctx.fillStyle = '#ffffff'; let replayChoice = null;
ctx.font = '14px sans-serif'; if (this.isReplayMode && this.replayPathIndex < this.replayPath.length) {
ctx.textAlign = 'center'; const previousChoice = this.replayPath[this.replayPathIndex]?.choice;
ctx.fillText('请做出选择', this.screenWidth / 2, startY); replayChoice = choices.find(c => c.text === previousChoice);
// 提示文字
ctx.fillStyle = this.sceneColors.accent;
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('📍 你之前选择的是:', this.screenWidth / 2, startY);
// 只显示之前选过的选项
if (replayChoice) {
choices = [replayChoice];
}
} else {
// 正常模式提示文字
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('请做出选择', this.screenWidth / 2, startY);
}
choices.forEach((choice, index) => { choices.forEach((choice, index) => {
const y = startY + 25 + index * (choiceHeight + choiceMargin); const y = startY + 25 + index * (choiceHeight + choiceMargin);
const isSelected = index === this.selectedChoice; const isSelected = index === this.selectedChoice;
const isReplayItem = this.isReplayMode && replayChoice && choice.text === replayChoice.text;
// 选项背景 // 选项背景
if (isSelected) { if (isSelected || isReplayItem) {
const gradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y); const gradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
gradient.addColorStop(0, this.sceneColors.accent); gradient.addColorStop(0, this.sceneColors.accent);
gradient.addColorStop(1, this.sceneColors.accent + 'aa'); gradient.addColorStop(1, this.sceneColors.accent + 'aa');
@@ -424,7 +875,7 @@ export default class StoryScene extends BaseScene {
ctx.fill(); ctx.fill();
// 选项边框 // 选项边框
ctx.strokeStyle = isSelected ? this.sceneColors.accent : 'rgba(255,255,255,0.2)'; ctx.strokeStyle = (isSelected || isReplayItem) ? this.sceneColors.accent : 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25); this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
ctx.stroke(); ctx.stroke();
@@ -435,6 +886,13 @@ export default class StoryScene extends BaseScene {
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(choice.text, this.screenWidth / 2, y + 30); ctx.fillText(choice.text, this.screenWidth / 2, y + 30);
// 回放模式下显示点击继续提示
if (isReplayItem) {
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px sans-serif';
ctx.fillText('点击继续 ', this.screenWidth / 2, y + 45);
}
// 锁定图标 // 锁定图标
if (choice.isLocked) { if (choice.isLocked) {
ctx.fillStyle = '#ffd700'; ctx.fillStyle = '#ffd700';
@@ -485,6 +943,14 @@ export default class StoryScene extends BaseScene {
this.lastTouchY = touch.clientY; this.lastTouchY = touch.clientY;
this.hasMoved = false; this.hasMoved = false;
// 回顾模式下的滚动
if (this.isRecapMode) {
if (touch.clientY > 75) {
this.isDragging = true;
}
return;
}
// 判断是否在对话框区域 // 判断是否在对话框区域
const boxY = this.screenHeight * 0.42; const boxY = this.screenHeight * 0.42;
if (touch.clientY > boxY) { if (touch.clientY > boxY) {
@@ -495,14 +961,30 @@ export default class StoryScene extends BaseScene {
onTouchMove(e) { onTouchMove(e) {
const touch = e.touches[0]; const touch = e.touches[0];
// 滑动对话框内容 // 回顾模式下的滚动
if (this.isDragging && this.maxScrollY > 0) { if (this.isRecapMode && this.isDragging) {
const deltaY = this.lastTouchY - touch.clientY; const deltaY = this.lastTouchY - touch.clientY;
if (Math.abs(deltaY) > 2) { if (Math.abs(deltaY) > 2) {
this.hasMoved = true; this.hasMoved = true;
} }
this.textScrollY += deltaY; if (this.recapMaxScrollY > 0) {
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY)); this.recapScrollY += deltaY;
this.recapScrollY = Math.max(0, Math.min(this.recapScrollY, this.recapMaxScrollY));
}
this.lastTouchY = touch.clientY;
return;
}
// 滑动对话框内容
if (this.isDragging) {
const deltaY = this.lastTouchY - touch.clientY;
if (Math.abs(deltaY) > 2) {
this.hasMoved = true;
}
if (this.maxScrollY > 0) {
this.textScrollY += deltaY;
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY));
}
this.lastTouchY = touch.clientY; this.lastTouchY = touch.clientY;
} }
} }
@@ -519,12 +1001,76 @@ export default class StoryScene extends BaseScene {
return; return;
} }
// 回顾模式下的点击处理
if (this.isRecapMode) {
// 返回按钮
if (y < 60 && x < 80) {
this.main.sceneManager.switchScene('profile', { tab: 1 });
return;
}
// 重头游玩按钮
if (this.recapReplayBtnRect) {
const btn = this.recapReplayBtnRect;
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) {
this.startReplayMode();
return;
}
}
// 开始新剧情按钮
if (this.recapBtnRect) {
const btn = this.recapBtnRect;
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) {
this.startAIContent();
return;
}
}
// 历史项卡片点击(显示详情)
if (this.recapCardRects) {
const adjustedY = y + this.recapScrollY;
for (const rect of this.recapCardRects) {
if (x >= rect.x && x <= rect.x + rect.width &&
adjustedY >= rect.y && adjustedY <= rect.y + rect.height) {
this.showRecapDetail(rect.item, rect.index);
return;
}
}
}
// AI改写指令点击显示完整指令
if (this.recapPromptRect) {
const adjustedY = y + this.recapScrollY;
const rect = this.recapPromptRect;
if (x >= rect.x && x <= rect.x + rect.width &&
adjustedY >= rect.y && adjustedY <= rect.y + rect.height) {
this.showPromptDetail();
return;
}
}
return;
}
// 返回按钮 // 返回按钮
if (y < 60 && x < 80) { if (y < 60 && x < 80) {
this.main.sceneManager.switchScene('home'); this.main.sceneManager.switchScene('home');
return; return;
} }
// AI改写按钮点击对话框上方右侧
const boxY = this.screenHeight * 0.42;
const btnW = 70;
const btnH = 30;
const btnX = this.screenWidth - btnW - 15;
const btnY = boxY - btnH - 12;
if (y >= btnY && y <= btnY + btnH && x >= btnX && x <= btnX + btnW) {
if (!this.isAIRewriting) {
this.showAIRewriteInput();
}
return;
}
// 加速打字 // 加速打字
if (this.isTyping) { if (this.isTyping) {
this.displayText = this.targetText; this.displayText = this.targetText;
@@ -543,46 +1089,94 @@ export default class StoryScene extends BaseScene {
console.log('AI改写内容:', JSON.stringify(this.aiContent)); console.log('AI改写内容:', JSON.stringify(this.aiContent));
this.main.sceneManager.switchScene('ending', { this.main.sceneManager.switchScene('ending', {
storyId: this.storyId, storyId: this.storyId,
draftId: this.draftId,
ending: { ending: {
name: this.aiContent.ending_name, name: this.aiContent.ending_name,
type: this.aiContent.ending_type, type: this.aiContent.ending_type,
content: this.aiContent.content, content: this.aiContent.content,
score: 100 score: this.aiContent.ending_score || 80
} }
}); });
return; return;
} }
// 检查是否是结局 // 检查是否是结局回放模式下跳过因为要进入AI改写内容
if (this.main.storyManager.isEnding()) { if (!this.isReplayMode && this.main.storyManager.isEnding()) {
this.main.sceneManager.switchScene('ending', { this.main.sceneManager.switchScene('ending', {
storyId: this.storyId, storyId: this.storyId,
draftId: this.draftId,
ending: this.main.storyManager.getEndingInfo() ending: this.main.storyManager.getEndingInfo()
}); });
return; return;
} }
// 回放模式下如果到达原结局或没有选项进入AI改写内容
if (this.isReplayMode) {
const currentNode = this.main.storyManager.getCurrentNode();
if (!currentNode || !currentNode.choices || currentNode.choices.length === 0 || currentNode.is_ending) {
// 回放结束进入AI改写内容
this.isReplayMode = false;
this.enterAIContent();
return;
}
}
// 显示选项 // 显示选项
if (this.currentNode && this.currentNode.choices && this.currentNode.choices.length > 0) { if (this.currentNode && this.currentNode.choices && this.currentNode.choices.length > 0) {
// 回放模式下也显示选项,但只显示之前选过的
this.showChoices = true; this.showChoices = true;
} else if (this.currentNode && (!this.currentNode.choices || this.currentNode.choices.length === 0)) {
// 没有选项的节点,检查是否是死胡同(故事数据问题)
console.log('当前节点没有选项:', this.main.storyManager.currentNodeKey, this.currentNode);
// 如果有 AI 改写内容,跳转到 AI 内容
if (this.recapData && this.recapData.entryNodeKey) {
this.main.storyManager.currentNodeKey = this.recapData.entryNodeKey;
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
return;
}
// 否则当作结局处理
wx.showModal({
title: '故事结束',
content: '当前剧情已结束',
showCancel: false,
confirmText: '返回',
success: () => {
this.main.sceneManager.switchScene('home');
}
});
} }
return; return;
} }
// 选项点击 // 选项点击
if (this.showChoices && this.currentNode && this.currentNode.choices) { if (this.showChoices && this.currentNode && this.currentNode.choices) {
const choices = this.currentNode.choices;
const choiceHeight = 50; const choiceHeight = 50;
const choiceMargin = 10; const choiceMargin = 10;
const padding = 20; const padding = 20;
const startY = this.screenHeight * 0.42 + 55; const startY = this.screenHeight * 0.42 + 55;
for (let i = 0; i < choices.length; i++) { // 回放模式下只有一个选项
const choiceY = startY + i * (choiceHeight + choiceMargin); if (this.isReplayMode && this.replayPathIndex < this.replayPath.length) {
const choiceY = startY;
if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) { if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) {
this.handleChoiceSelect(i); this.autoSelectReplayChoice();
return; return;
} }
} else {
// 正常模式
const choices = this.currentNode.choices;
for (let i = 0; i < choices.length; i++) {
const choiceY = startY + i * (choiceHeight + choiceMargin);
if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) {
this.handleChoiceSelect(i);
return;
}
}
} }
} }
} }
@@ -638,6 +1232,70 @@ export default class StoryScene extends BaseScene {
ctx.closePath(); ctx.closePath();
} }
/**
* 显示AI改写输入框
*/
showAIRewriteInput() {
wx.showModal({
title: 'AI改写剧情',
editable: true,
placeholderText: '输入你的改写指令,如"让主角暴富"',
success: (res) => {
if (res.confirm && res.content) {
this.doAIRewriteAsync(res.content);
}
}
});
}
/**
* 异步提交AI改写到草稿箱
*/
async doAIRewriteAsync(prompt) {
if (this.isAIRewriting) return;
this.isAIRewriting = true;
this.main.showLoading('正在提交...');
try {
const userId = this.main.userManager.userId || 0;
const result = await this.main.storyManager.rewriteBranchAsync(
this.storyId,
prompt,
userId
);
this.main.hideLoading();
if (result && result.draftId) {
// 提交成功
wx.showModal({
title: '提交成功',
content: 'AI正在后台生成中完成后会通知您。\n您可以继续播放当前故事。',
showCancel: false,
confirmText: '知道了'
});
} else {
// 提交失败
wx.showToast({
title: '提交失败,请重试',
icon: 'none',
duration: 2000
});
}
} catch (error) {
this.main.hideLoading();
console.error('AI改写提交出错:', error);
wx.showToast({
title: '网络错误,请重试',
icon: 'none',
duration: 2000
});
} finally {
this.isAIRewriting = false;
}
}
destroy() { destroy() {
if (this.main.userManager.isLoggedIn && this.story) { if (this.main.userManager.isLoggedIn && this.story) {
this.main.userManager.saveProgress( this.main.userManager.saveProgress(

View File

@@ -3,7 +3,7 @@
*/ */
// API基础地址开发环境 // API基础地址开发环境
const BASE_URL = 'http://localhost:3000/api'; const BASE_URL = 'https://express-0a1p-230010-4-1408549115.sh.run.tcloudbase.com/api';
/** /**
* 发送HTTP请求 * 发送HTTP请求

View File

@@ -1,5 +1,5 @@
{ {
"appid": "wx27be06bc3365e84b", "appid": "wx772e2f0fbc498020",
"compileType": "game", "compileType": "game",
"projectname": "stardom-story", "projectname": "stardom-story",
"setting": { "setting": {
@@ -42,5 +42,7 @@
"ignore": [], "ignore": [],
"include": [] "include": []
}, },
"editorSetting": {} "editorSetting": {},
"libVersion": "3.14.3",
"isGameTourist": false
} }

View File

@@ -23,6 +23,9 @@ AsyncSessionLocal = sessionmaker(
expire_on_commit=False expire_on_commit=False
) )
# 后台任务使用的会话工厂
async_session_factory = AsyncSessionLocal
# 基类 # 基类
Base = declarative_base() Base = declarative_base()

View File

@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings from app.config import get_settings
from app.routers import story, user from app.routers import story, user, drafts
settings = get_settings() settings = get_settings()
@@ -29,6 +29,7 @@ app.add_middleware(
# 注册路由 # 注册路由
app.include_router(story.router, prefix="/api/stories", tags=["故事"]) app.include_router(story.router, prefix="/api/stories", tags=["故事"])
app.include_router(user.router, prefix="/api/user", tags=["用户"]) app.include_router(user.router, prefix="/api/user", tags=["用户"])
app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
@app.get("/") @app.get("/")

View File

@@ -1,10 +1,11 @@
""" """
故事相关ORM模型 故事相关ORM模型
""" """
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey, Enum, JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.database import Base from app.database import Base
import enum
class Story(Base): class Story(Base):
@@ -64,3 +65,43 @@ class StoryChoice(Base):
created_at = Column(TIMESTAMP, server_default=func.now()) created_at = Column(TIMESTAMP, server_default=func.now())
node = relationship("StoryNode", back_populates="choices") node = relationship("StoryNode", back_populates="choices")
class DraftStatus(enum.Enum):
"""草稿状态枚举"""
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class StoryDraft(Base):
"""AI改写草稿表"""
__tablename__ = "story_drafts"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
title = Column(String(100), default="")
# 用户输入
path_history = Column(JSON, default=None) # 用户之前的选择路径
current_node_key = Column(String(50), default="")
current_content = Column(Text, default="")
user_prompt = Column(String(500), nullable=False)
# AI生成结果
ai_nodes = Column(JSON, default=None) # AI生成的新节点
entry_node_key = Column(String(50), default="")
tokens_used = Column(Integer, default=0)
# 状态
status = Column(Enum(DraftStatus), default=DraftStatus.pending)
error_message = Column(String(500), default="")
is_read = Column(Boolean, default=False) # 用户是否已查看
created_at = Column(TIMESTAMP, server_default=func.now())
completed_at = Column(TIMESTAMP, default=None)
# 关联
story = relationship("Story")

Binary file not shown.

View File

@@ -0,0 +1,620 @@
"""
草稿箱路由 - AI异步改写功能
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from app.database import get_db
from app.models.story import Story, StoryDraft, DraftStatus
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
# ============ 请求/响应模型 ============
class PathHistoryItem(BaseModel):
nodeKey: str
content: str
choice: str
class CreateDraftRequest(BaseModel):
userId: int
storyId: int
currentNodeKey: str
pathHistory: List[PathHistoryItem]
currentContent: str
prompt: str
class CreateEndingDraftRequest(BaseModel):
"""结局改写请求"""
userId: int
storyId: int
endingName: str
endingContent: str
prompt: str
class ContinueEndingDraftRequest(BaseModel):
"""结局续写请求"""
userId: int
storyId: int
endingName: str
endingContent: str
prompt: str
class DraftResponse(BaseModel):
id: int
storyId: int
storyTitle: str
title: str
userPrompt: str
status: str
isRead: bool
createdAt: str
completedAt: Optional[str] = None
class Config:
from_attributes = True
# ============ 后台任务 ============
async def process_ai_rewrite(draft_id: int):
"""后台异步处理AI改写"""
from app.database import async_session_factory
from app.services.ai import ai_service
async with async_session_factory() as db:
try:
# 获取草稿
result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
draft = result.scalar_one_or_none()
if not draft:
return
# 更新状态为处理中
draft.status = DraftStatus.processing
await db.commit()
# 获取故事信息
story_result = await db.execute(select(Story).where(Story.id == draft.story_id))
story = story_result.scalar_one_or_none()
if not story:
draft.status = DraftStatus.failed
draft.error_message = "故事不存在"
draft.completed_at = datetime.now()
await db.commit()
return
# 转换路径历史格式
path_history = draft.path_history or []
# 调用AI服务
ai_result = await ai_service.rewrite_branch(
story_title=story.title,
story_category=story.category or "未知",
path_history=path_history,
current_content=draft.current_content or "",
user_prompt=draft.user_prompt
)
if ai_result and ai_result.get("nodes"):
# 成功
draft.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1")
draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-改写"
else:
# 失败
draft.status = DraftStatus.failed
draft.error_message = "AI服务暂时不可用"
draft.completed_at = datetime.now()
await db.commit()
except Exception as e:
print(f"[process_ai_rewrite] 异常: {e}")
import traceback
traceback.print_exc()
# 更新失败状态
try:
draft.status = DraftStatus.failed
draft.error_message = str(e)[:500]
draft.completed_at = datetime.now()
await db.commit()
except:
pass
async def process_ai_rewrite_ending(draft_id: int):
"""后台异步处理AI改写结局"""
from app.database import async_session_factory
from app.services.ai import ai_service
import json
import re
async with async_session_factory() as db:
try:
# 获取草稿
result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
draft = result.scalar_one_or_none()
if not draft:
return
# 更新状态为处理中
draft.status = DraftStatus.processing
await db.commit()
# 获取故事信息
story_result = await db.execute(select(Story).where(Story.id == draft.story_id))
story = story_result.scalar_one_or_none()
if not story:
draft.status = DraftStatus.failed
draft.error_message = "故事不存在"
draft.completed_at = datetime.now()
await db.commit()
return
# 从 path_history 获取结局信息
ending_info = draft.path_history or {}
ending_name = ending_info.get("endingName", "未知结局")
ending_content = ending_info.get("endingContent", "")
# 调用AI服务改写结局
ai_result = await ai_service.rewrite_ending(
story_title=story.title,
story_category=story.category or "未知",
ending_name=ending_name,
ending_content=ending_content,
user_prompt=draft.user_prompt
)
if ai_result and ai_result.get("content"):
content = ai_result["content"]
new_ending_name = f"{ending_name}AI改写"
# 尝试解析 JSON 格式的返回
try:
json_match = re.search(r'\{[^{}]*"ending_name"[^{}]*"content"[^{}]*\}', content, re.DOTALL)
if json_match:
parsed = json.loads(json_match.group())
new_ending_name = parsed.get("ending_name", new_ending_name)
content = parsed.get("content", content)
else:
parsed = json.loads(content)
new_ending_name = parsed.get("ending_name", new_ending_name)
content = parsed.get("content", content)
except (json.JSONDecodeError, AttributeError):
pass
# 成功 - 存储为单节点结局格式
draft.status = DraftStatus.completed
draft.ai_nodes = [{
"nodeKey": "ending_rewrite",
"content": content,
"speaker": "旁白",
"isEnding": True,
"endingName": new_ending_name,
"endingType": "rewrite"
}]
draft.entry_node_key = "ending_rewrite"
draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-{new_ending_name}"
else:
draft.status = DraftStatus.failed
draft.error_message = "AI服务暂时不可用"
draft.completed_at = datetime.now()
await db.commit()
except Exception as e:
print(f"[process_ai_rewrite_ending] 异常: {e}")
import traceback
traceback.print_exc()
try:
draft.status = DraftStatus.failed
draft.error_message = str(e)[:500]
draft.completed_at = datetime.now()
await db.commit()
except:
pass
async def process_ai_continue_ending(draft_id: int):
"""后台异步处理AI续写结局"""
from app.database import async_session_factory
from app.services.ai import ai_service
async with async_session_factory() as db:
try:
# 获取草稿
result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
draft = result.scalar_one_or_none()
if not draft:
return
# 更新状态为处理中
draft.status = DraftStatus.processing
await db.commit()
# 获取故事信息
story_result = await db.execute(select(Story).where(Story.id == draft.story_id))
story = story_result.scalar_one_or_none()
if not story:
draft.status = DraftStatus.failed
draft.error_message = "故事不存在"
draft.completed_at = datetime.now()
await db.commit()
return
# 从 path_history 获取结局信息
ending_info = draft.path_history or {}
ending_name = ending_info.get("endingName", "未知结局")
ending_content = ending_info.get("endingContent", "")
# 调用AI服务续写结局
ai_result = await ai_service.continue_ending(
story_title=story.title,
story_category=story.category or "未知",
ending_name=ending_name,
ending_content=ending_content,
user_prompt=draft.user_prompt
)
if ai_result and ai_result.get("nodes"):
# 成功 - 存储多节点分支格式
draft.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1")
draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-{ending_name}续写"
else:
draft.status = DraftStatus.failed
draft.error_message = "AI服务暂时不可用"
draft.completed_at = datetime.now()
await db.commit()
except Exception as e:
print(f"[process_ai_continue_ending] 异常: {e}")
import traceback
traceback.print_exc()
try:
draft.status = DraftStatus.failed
draft.error_message = str(e)[:500]
draft.completed_at = datetime.now()
await db.commit()
except:
pass
# ============ API 路由 ============
@router.post("")
async def create_draft(
request: CreateDraftRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""提交AI改写任务异步处理"""
if not request.prompt:
raise HTTPException(status_code=400, detail="请输入改写指令")
# 获取故事信息
result = await db.execute(select(Story).where(Story.id == request.storyId))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 转换路径历史
path_history = [
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
for item in request.pathHistory
]
# 创建草稿记录
draft = StoryDraft(
user_id=request.userId,
story_id=request.storyId,
title=f"{story.title}-改写",
path_history=path_history,
current_node_key=request.currentNodeKey,
current_content=request.currentContent,
user_prompt=request.prompt,
status=DraftStatus.pending
)
db.add(draft)
await db.commit()
await db.refresh(draft)
# 添加后台任务
background_tasks.add_task(process_ai_rewrite, draft.id)
return {
"code": 0,
"data": {
"draftId": draft.id,
"message": "已提交AI正在生成中..."
}
}
@router.post("/ending")
async def create_ending_draft(
request: CreateEndingDraftRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""提交AI改写结局任务异步处理"""
if not request.prompt:
raise HTTPException(status_code=400, detail="请输入改写指令")
# 获取故事信息
result = await db.execute(select(Story).where(Story.id == request.storyId))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 创建草稿记录,将结局信息存在 path_history
draft = StoryDraft(
user_id=request.userId,
story_id=request.storyId,
title=f"{story.title}-结局改写",
path_history={"endingName": request.endingName, "endingContent": request.endingContent},
current_node_key="ending",
current_content=request.endingContent,
user_prompt=request.prompt,
status=DraftStatus.pending
)
db.add(draft)
await db.commit()
await db.refresh(draft)
# 添加后台任务
background_tasks.add_task(process_ai_rewrite_ending, draft.id)
return {
"code": 0,
"data": {
"draftId": draft.id,
"message": "已提交AI正在生成新结局..."
}
}
@router.post("/continue-ending")
async def create_continue_ending_draft(
request: ContinueEndingDraftRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""提交AI续写结局任务异步处理"""
if not request.prompt:
raise HTTPException(status_code=400, detail="请输入续写指令")
# 获取故事信息
result = await db.execute(select(Story).where(Story.id == request.storyId))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 创建草稿记录,将结局信息存在 path_history
draft = StoryDraft(
user_id=request.userId,
story_id=request.storyId,
title=f"{story.title}-结局续写",
path_history={"endingName": request.endingName, "endingContent": request.endingContent},
current_node_key="ending",
current_content=request.endingContent,
user_prompt=request.prompt,
status=DraftStatus.pending
)
db.add(draft)
await db.commit()
await db.refresh(draft)
# 添加后台任务
background_tasks.add_task(process_ai_continue_ending, draft.id)
return {
"code": 0,
"data": {
"draftId": draft.id,
"message": "已提交AI正在续写故事..."
}
}
@router.get("")
async def get_drafts(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""获取用户的草稿列表"""
result = await db.execute(
select(StoryDraft, Story.title.label("story_title"))
.join(Story, StoryDraft.story_id == Story.id)
.where(StoryDraft.user_id == userId)
.order_by(StoryDraft.created_at.desc())
)
drafts = []
for row in result:
draft = row[0]
story_title = row[1]
drafts.append({
"id": draft.id,
"storyId": draft.story_id,
"storyTitle": story_title,
"title": draft.title,
"userPrompt": draft.user_prompt,
"status": draft.status.value if draft.status else "pending",
"isRead": draft.is_read,
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
})
return {"code": 0, "data": drafts}
@router.get("/check-new")
async def check_new_drafts(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""检查是否有新完成的草稿(用于弹窗通知)"""
result = await db.execute(
select(StoryDraft)
.where(
StoryDraft.user_id == userId,
StoryDraft.status == DraftStatus.completed,
StoryDraft.is_read == False
)
)
unread_drafts = result.scalars().all()
return {
"code": 0,
"data": {
"hasNew": len(unread_drafts) > 0,
"count": len(unread_drafts),
"drafts": [
{
"id": d.id,
"title": d.title,
"userPrompt": d.user_prompt
}
for d in unread_drafts[:3] # 最多返回3个
]
}
}
@router.get("/{draft_id}")
async def get_draft_detail(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取草稿详情"""
result = await db.execute(
select(StoryDraft, Story)
.join(Story, StoryDraft.story_id == Story.id)
.where(StoryDraft.id == draft_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="草稿不存在")
draft, story = row
# 标记为已读
if not draft.is_read:
draft.is_read = True
await db.commit()
return {
"code": 0,
"data": {
"id": draft.id,
"storyId": draft.story_id,
"storyTitle": story.title,
"storyCategory": story.category,
"title": draft.title,
"pathHistory": draft.path_history,
"currentNodeKey": draft.current_node_key,
"currentContent": draft.current_content,
"userPrompt": draft.user_prompt,
"aiNodes": draft.ai_nodes,
"entryNodeKey": draft.entry_node_key,
"tokensUsed": draft.tokens_used,
"status": draft.status.value if draft.status else "pending",
"errorMessage": draft.error_message,
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
}
}
@router.delete("/{draft_id}")
async def delete_draft(
draft_id: int,
userId: int,
db: AsyncSession = Depends(get_db)
):
"""删除草稿"""
result = await db.execute(
select(StoryDraft).where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId
)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
await db.delete(draft)
await db.commit()
return {"code": 0, "message": "删除成功"}
@router.put("/{draft_id}/read")
async def mark_draft_read(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""标记草稿为已读"""
await db.execute(
update(StoryDraft)
.where(StoryDraft.id == draft_id)
.values(is_read=True)
)
await db.commit()
return {"code": 0, "message": "已标记为已读"}
@router.put("/batch-read")
async def mark_all_drafts_read(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""批量标记所有未读草稿为已读"""
await db.execute(
update(StoryDraft)
.where(
StoryDraft.user_id == userId,
StoryDraft.is_read == False
)
.values(is_read=True)
)
await db.commit()
return {"code": 0, "message": "已全部标记为已读"}

View File

@@ -5,7 +5,7 @@ import random
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func, distinct from sqlalchemy import select, update, func, distinct
from typing import Optional from typing import Optional, List
from pydantic import BaseModel from pydantic import BaseModel
from app.database import get_db from app.database import get_db
@@ -25,6 +25,20 @@ class RewriteRequest(BaseModel):
prompt: str prompt: str
class PathHistoryItem(BaseModel):
nodeKey: str
content: str = ""
choice: str = ""
class RewriteBranchRequest(BaseModel):
userId: int
currentNodeKey: str
pathHistory: List[PathHistoryItem]
currentContent: str
prompt: str
# ========== API接口 ========== # ========== API接口 ==========
@router.get("") @router.get("")
@@ -268,3 +282,59 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
"ending_type": "rewrite" "ending_type": "rewrite"
} }
} }
@router.post("/{story_id}/rewrite-branch")
async def ai_rewrite_branch(
story_id: int,
request: RewriteBranchRequest,
db: AsyncSession = Depends(get_db)
):
"""AI改写中间章节生成新的剧情分支"""
if not request.prompt:
raise HTTPException(status_code=400, detail="请输入改写指令")
# 获取故事信息
result = await db.execute(select(Story).where(Story.id == story_id))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 将 Pydantic 模型转换为字典列表
path_history = [
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
for item in request.pathHistory
]
# 调用 AI 服务
from app.services.ai import ai_service
ai_result = await ai_service.rewrite_branch(
story_title=story.title,
story_category=story.category or "未知",
path_history=path_history,
current_content=request.currentContent,
user_prompt=request.prompt
)
if ai_result and ai_result.get("nodes"):
return {
"code": 0,
"data": {
"nodes": ai_result["nodes"],
"entryNodeKey": ai_result.get("entryNodeKey", "branch_1"),
"tokensUsed": ai_result.get("tokens_used", 0)
}
}
# AI 服务不可用时,返回空结果(不使用兜底模板)
return {
"code": 0,
"data": {
"nodes": None,
"entryNodeKey": None,
"tokensUsed": 0,
"error": "AI服务暂时不可用"
}
}

Binary file not shown.

View File

@@ -2,8 +2,10 @@
AI服务封装模块 AI服务封装模块
支持多种AI提供商DeepSeek, OpenAI, Claude, 通义千问 支持多种AI提供商DeepSeek, OpenAI, Claude, 通义千问
""" """
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
import httpx import httpx
import json
import re
class AIService: class AIService:
def __init__(self): def __init__(self):
@@ -13,11 +15,14 @@ class AIService:
self.enabled = settings.ai_service_enabled self.enabled = settings.ai_service_enabled
self.provider = settings.ai_provider self.provider = settings.ai_provider
print(f"[AI服务初始化] enabled={self.enabled}, provider={self.provider}")
# 根据提供商初始化配置 # 根据提供商初始化配置
if self.provider == "deepseek": if self.provider == "deepseek":
self.api_key = settings.deepseek_api_key self.api_key = settings.deepseek_api_key
self.base_url = settings.deepseek_base_url self.base_url = settings.deepseek_base_url
self.model = settings.deepseek_model self.model = settings.deepseek_model
print(f"[AI服务初始化] DeepSeek配置: api_key={self.api_key[:20] + '...' if self.api_key else 'None'}, base_url={self.base_url}, model={self.model}")
elif self.provider == "openai": elif self.provider == "openai":
self.api_key = settings.openai_api_key self.api_key = settings.openai_api_key
self.base_url = settings.openai_base_url self.base_url = settings.openai_base_url
@@ -86,6 +91,532 @@ class AIService:
return None return None
async def rewrite_branch(
self,
story_title: str,
story_category: str,
path_history: List[Dict[str, str]],
current_content: str,
user_prompt: str
) -> Optional[Dict[str, Any]]:
"""
AI改写中间章节生成新的剧情分支
"""
print(f"\n[rewrite_branch] ========== 开始调用 ==========")
print(f"[rewrite_branch] story_title={story_title}, category={story_category}")
print(f"[rewrite_branch] user_prompt={user_prompt}")
print(f"[rewrite_branch] path_history长度={len(path_history)}")
print(f"[rewrite_branch] current_content长度={len(current_content)}")
print(f"[rewrite_branch] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
if not self.enabled or not self.api_key:
print(f"[rewrite_branch] 服务未启用或API Key为空返回None")
return None
# 构建路径历史文本
path_text = ""
for i, item in enumerate(path_history, 1):
path_text += f"{i}段:{item.get('content', '')}\n"
if item.get('choice'):
path_text += f" → 用户选择:{item['choice']}\n"
# 构建系统提示词
system_prompt = """你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
【任务】
请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。
【写作要求】
1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
5. 严格符合用户的改写意图,围绕用户指令展开剧情
6. 保持原故事的人物性格、语言风格和世界观
7. 对话要自然生动,描写要有画面感
【关于结局 - 极其重要!】
★★★ 每一条分支路径的尽头必须是结局节点 ★★★
- 结局节点必须设置 "is_ending": true
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
- 结局名称 4-8 字,体现剧情走向
- 如果有2个选项分支最终必须有2个不同的结局
- 不允许出现没有结局的"死胡同"节点
- 每个结局必须有 "ending_score" 评分0-100
- good 好结局80-100分
- bad 坏结局20-50分
- neutral 中立结局50-70分
- special 特殊结局70-90分
【内容分段示例】
"content": "他的声音在耳边响起,像是一阵温柔的风。\n\n\"我喜欢你。\"他说,目光坚定地看着你。\n\n你的心跳漏了一拍,一时间不知该如何回应。"
【输出格式】严格JSON不要有任何额外文字
{
"nodes": {
"branch_1": {
"content": "新剧情第一段150-300字...",
"speaker": "旁白",
"choices": [
{"text": "选项A5-15字", "nextNodeKey": "branch_2a"},
{"text": "选项B5-15字", "nextNodeKey": "branch_2b"}
]
},
"branch_2a": {
"content": "...",
"speaker": "旁白",
"choices": [
{"text": "选项C", "nextNodeKey": "branch_ending_good"},
{"text": "选项D", "nextNodeKey": "branch_ending_bad"}
]
},
"branch_2b": {
"content": "...",
"speaker": "旁白",
"choices": [
{"text": "选项E", "nextNodeKey": "branch_ending_neutral"},
{"text": "选项F", "nextNodeKey": "branch_ending_special"}
]
},
"branch_ending_good": {
"content": "好结局内容200-400字...\n\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "good",
"ending_score": 90
},
"branch_ending_bad": {
"content": "坏结局内容...\n\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "bad",
"ending_score": 40
},
"branch_ending_neutral": {
"content": "中立结局...\n\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "neutral",
"ending_score": 60
},
"branch_ending_special": {
"content": "特殊结局...\n\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "special",
"ending_score": 80
}
},
"entryNodeKey": "branch_1"
}"""
# 构建用户提示词
user_prompt_text = f"""【原故事信息】
故事标题:{story_title}
故事分类:{story_category}
【用户已走过的剧情】
{path_text}
【当前节点】
{current_content}
【用户改写指令】
{user_prompt}
请创作新的剧情分支输出JSON格式"""
print(f"[rewrite_branch] 提示词构建完成开始调用AI...")
print(f"[rewrite_branch] provider={self.provider}")
try:
result = None
if self.provider == "openai":
print(f"[rewrite_branch] 调用 OpenAI...")
result = await self._call_openai_long(system_prompt, user_prompt_text)
elif self.provider == "claude":
print(f"[rewrite_branch] 调用 Claude...")
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
elif self.provider == "qwen":
print(f"[rewrite_branch] 调用 Qwen...")
result = await self._call_qwen_long(system_prompt, user_prompt_text)
elif self.provider == "deepseek":
print(f"[rewrite_branch] 调用 DeepSeek...")
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
print(f"[rewrite_branch] AI调用完成result存在={result is not None}")
if result and result.get("content"):
print(f"[rewrite_branch] AI返回内容长度={len(result.get('content', ''))}")
print(f"[rewrite_branch] AI返回内容前500字: {result.get('content', '')[:500]}")
# 解析JSON响应
parsed = self._parse_branch_json(result["content"])
print(f"[rewrite_branch] JSON解析结果: parsed存在={parsed is not None}")
if parsed:
parsed["tokens_used"] = result.get("tokens_used", 0)
print(f"[rewrite_branch] 成功! nodes数量={len(parsed.get('nodes', {}))}, tokens={parsed.get('tokens_used')}")
return parsed
else:
print(f"[rewrite_branch] JSON解析失败!")
else:
print(f"[rewrite_branch] AI返回为空或无content")
return None
except Exception as e:
print(f"[rewrite_branch] 异常: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
async def continue_ending(
self,
story_title: str,
story_category: str,
ending_name: str,
ending_content: str,
user_prompt: str
) -> Optional[Dict[str, Any]]:
"""
AI续写结局从结局开始续写新的剧情分支
"""
print(f"\n[continue_ending] ========== 开始调用 ==========")
print(f"[continue_ending] story_title={story_title}, category={story_category}")
print(f"[continue_ending] ending_name={ending_name}")
print(f"[continue_ending] user_prompt={user_prompt}")
print(f"[continue_ending] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
if not self.enabled or not self.api_key:
print(f"[continue_ending] 服务未启用或API Key为空返回None")
return None
# 构建系统提示词
system_prompt = """你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。
【任务】
请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。
【写作要求】
1. 第一个节点必须紧密衔接原结局,像是结局之后自然发生的事
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔包含场景描写、人物对话、心理活动
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
5. 严格符合用户的续写意图,围绕用户指令展开剧情
6. 保持原故事的人物性格、语言风格和世界观
7. 对话要自然生动,描写要有画面感
【关于新结局 - 极其重要!】
★★★ 每一条分支路径的尽头必须是新结局节点 ★★★
- 结局节点必须设置 "is_ending": true
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
- 结局名称 4-8 字,体现剧情走向
- 如果有2个选项分支最终必须有2个不同的结局
- 每个结局必须有 "ending_score" 评分0-100
【输出格式】严格JSON不要有任何额外文字
{
"nodes": {
"continue_1": {
"content": "续写剧情第一段150-300字...",
"speaker": "旁白",
"choices": [
{"text": "选项A5-15字", "nextNodeKey": "continue_2a"},
{"text": "选项B5-15字", "nextNodeKey": "continue_2b"}
]
},
"continue_2a": {
"content": "...",
"speaker": "旁白",
"choices": [
{"text": "选项C", "nextNodeKey": "continue_ending_good"},
{"text": "选项D", "nextNodeKey": "continue_ending_bad"}
]
},
"continue_ending_good": {
"content": "新好结局内容200-400字...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "新结局名称",
"ending_type": "good",
"ending_score": 90
},
"continue_ending_bad": {
"content": "新坏结局内容...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "新结局名称",
"ending_type": "bad",
"ending_score": 40
}
},
"entryNodeKey": "continue_1"
}"""
# 构建用户提示词
user_prompt_text = f"""【原故事信息】
故事标题:{story_title}
故事分类:{story_category}
【已达成的结局】
结局名称:{ending_name}
结局内容:{ending_content[:800]}
【用户续写指令】
{user_prompt}
请从这个结局开始续写新的剧情分支输出JSON格式"""
print(f"[continue_ending] 提示词构建完成开始调用AI...")
try:
result = None
if self.provider == "openai":
result = await self._call_openai_long(system_prompt, user_prompt_text)
elif self.provider == "claude":
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
elif self.provider == "qwen":
result = await self._call_qwen_long(system_prompt, user_prompt_text)
elif self.provider == "deepseek":
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
print(f"[continue_ending] AI调用完成result存在={result is not None}")
if result and result.get("content"):
print(f"[continue_ending] AI返回内容长度={len(result.get('content', ''))}")
# 解析JSON响应复用 rewrite_branch 的解析方法)
parsed = self._parse_branch_json(result["content"])
print(f"[continue_ending] JSON解析结果: parsed存在={parsed is not None}")
if parsed:
parsed["tokens_used"] = result.get("tokens_used", 0)
print(f"[continue_ending] 成功! nodes数量={len(parsed.get('nodes', {}))}")
return parsed
else:
print(f"[continue_ending] JSON解析失败!")
return None
except Exception as e:
print(f"[continue_ending] 异常: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
def _parse_branch_json(self, content: str) -> Optional[Dict]:
"""解析AI返回的分支JSON"""
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
# 移除 markdown 代码块标记
clean_content = content.strip()
if clean_content.startswith('```'):
# 移除开头的 ```json 或 ```
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
# 移除结尾的 ```
clean_content = re.sub(r'\s*```$', '', clean_content)
try:
# 尝试直接解析
result = json.loads(clean_content)
print(f"[_parse_branch_json] 直接解析成功!")
return result
except json.JSONDecodeError as e:
print(f"[_parse_branch_json] 直接解析失败: {e}")
# 尝试提取JSON块
try:
# 匹配 { ... } 结构
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
if brace_match:
json_str = brace_match.group(0)
print(f"[_parse_branch_json] 找到花括号块,尝试解析...")
try:
result = json.loads(json_str)
print(f"[_parse_branch_json] 花括号块解析成功!")
return result
except json.JSONDecodeError as e:
print(f"[_parse_branch_json] 花括号块解析失败: {e}")
# 打印错误位置附近的内容
error_pos = e.pos if hasattr(e, 'pos') else 0
start = max(0, error_pos - 100)
end = min(len(json_str), error_pos + 100)
print(f"[_parse_branch_json] 错误位置附近内容: ...{json_str[start:end]}...")
# 尝试修复不完整的 JSON
print(f"[_parse_branch_json] 尝试修复不完整的JSON...")
fixed_json = self._try_fix_incomplete_json(json_str)
if fixed_json:
print(f"[_parse_branch_json] JSON修复成功!")
return fixed_json
except Exception as e:
print(f"[_parse_branch_json] 提取解析异常: {e}")
print(f"[_parse_branch_json] 所有解析方法都失败了")
return None
def _try_fix_incomplete_json(self, json_str: str) -> Optional[Dict]:
"""尝试修复不完整的JSON被截断的情况"""
try:
# 找到已完成的节点,截断不完整的部分
# 查找最后一个完整的节点(以 } 结尾,后面跟着逗号或闭括号)
# 先找到 "nodes": { 的位置
nodes_match = re.search(r'"nodes"\s*:\s*\{', json_str)
if not nodes_match:
return None
nodes_start = nodes_match.end()
# 找所有完整的 branch 节点
branch_pattern = r'"branch_\w+"\s*:\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
branches = list(re.finditer(branch_pattern, json_str[nodes_start:]))
if not branches:
return None
# 取最后一个完整的节点的结束位置
last_complete_end = nodes_start + branches[-1].end()
# 构建修复后的 JSON
# 截取到最后一个完整节点,然后补全结构
truncated = json_str[:last_complete_end]
# 补全 JSON 结构
fixed = truncated + '\n },\n "entryNodeKey": "branch_1"\n}'
print(f"[_try_fix_incomplete_json] 修复后的JSON长度: {len(fixed)}")
result = json.loads(fixed)
# 验证结果结构
if "nodes" in result and len(result["nodes"]) > 0:
print(f"[_try_fix_incomplete_json] 修复后节点数: {len(result['nodes'])}")
return result
except Exception as e:
print(f"[_try_fix_incomplete_json] 修复失败: {e}")
return None
async def _call_deepseek_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用 DeepSeek API (长文本版本)"""
print(f"[_call_deepseek_long] 开始调用...")
print(f"[_call_deepseek_long] base_url={self.base_url}")
print(f"[_call_deepseek_long] model={self.model}")
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.85,
"max_tokens": 6000 # 增加输出长度确保JSON完整
}
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")
print(f"[_call_deepseek_long] user_prompt长度={len(user_prompt)}")
async with httpx.AsyncClient(timeout=300.0) as client:
try:
print(f"[_call_deepseek_long] 发送请求到 {url}...")
response = await client.post(url, headers=headers, json=data)
print(f"[_call_deepseek_long] 响应状态码: {response.status_code}")
response.raise_for_status()
result = response.json()
print(f"[_call_deepseek_long] 响应JSON keys: {result.keys()}")
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"]["content"]
tokens = result.get("usage", {}).get("total_tokens", 0)
print(f"[_call_deepseek_long] 成功! content长度={len(content)}, tokens={tokens}")
return {"content": content.strip(), "tokens_used": tokens}
else:
print(f"[_call_deepseek_long] 响应异常无choices: {result}")
return None
except httpx.HTTPStatusError as e:
print(f"[_call_deepseek_long] HTTP错误: {e.response.status_code} - {e.response.text}")
return None
except httpx.TimeoutException as e:
print(f"[_call_deepseek_long] 请求超时: {e}")
return None
except Exception as e:
print(f"[_call_deepseek_long] 其他错误: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
async def _call_openai_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用OpenAI API (长文本版本)"""
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.8,
"max_tokens": 2000
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
tokens = result["usage"]["total_tokens"]
return {"content": content.strip(), "tokens_used": tokens}
async def _call_qwen_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用通义千问API (长文本版本)"""
url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": self.model,
"input": {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
},
"parameters": {
"result_format": "message",
"temperature": 0.8,
"max_tokens": 2000
}
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
result = response.json()
content = result["output"]["choices"][0]["message"]["content"]
tokens = result.get("usage", {}).get("total_tokens", 0)
return {"content": content.strip(), "tokens_used": tokens}
async def _call_openai(self, system_prompt: str, user_prompt: str) -> Optional[Dict]: async def _call_openai(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用OpenAI API""" """调用OpenAI API"""
url = f"{self.base_url}/chat/completions" url = f"{self.base_url}/chat/completions"

View File

@@ -0,0 +1,29 @@
"""添加测试用户"""
import os
import pymysql
from pathlib import Path
SERVER_DIR = Path(__file__).parent.parent
env_file = SERVER_DIR / '.env'
if env_file.exists():
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ.setdefault(key.strip(), value.strip())
conn = pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', 3306)),
user=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', ''),
database='stardom_story',
charset='utf8mb4'
)
cur = conn.cursor()
cur.execute("INSERT IGNORE INTO users (id, openid, nickname) VALUES (1, 'test_user', '测试用户')")
conn.commit()
print('测试用户创建成功')
cur.close()
conn.close()

View File

@@ -9,12 +9,25 @@ from pathlib import Path
# 获取当前脚本所在目录 # 获取当前脚本所在目录
SQL_DIR = Path(__file__).parent SQL_DIR = Path(__file__).parent
# 数据库配置(从环境变量或默认值) # 从.env文件读取配置
def load_env():
env_file = SQL_DIR.parent / '.env'
config = {}
if env_file.exists():
for line in env_file.read_text(encoding='utf-8').splitlines():
if '=' in line and not line.startswith('#'):
k, v = line.split('=', 1)
config[k.strip()] = v.strip()
return config
env_config = load_env()
# 数据库配置(优先从.env读取
DB_CONFIG = { DB_CONFIG = {
'host': os.getenv('DB_HOST', 'localhost'), 'host': env_config.get('DB_HOST', os.getenv('DB_HOST', 'localhost')),
'port': int(os.getenv('DB_PORT', 3306)), 'port': int(env_config.get('DB_PORT', os.getenv('DB_PORT', 3306))),
'user': os.getenv('DB_USER', 'root'), 'user': env_config.get('DB_USER', os.getenv('DB_USER', 'root')),
'password': os.getenv('DB_PASSWORD', '123456'), 'password': env_config.get('DB_PASSWORD', os.getenv('DB_PASSWORD', '')),
'charset': 'utf8mb4' 'charset': 'utf8mb4'
} }

74
server/sql/rebuild_db.py Normal file
View File

@@ -0,0 +1,74 @@
"""删库重建脚本"""
import os
import sys
import pymysql
from pathlib import Path
SQL_DIR = Path(__file__).parent
SERVER_DIR = SQL_DIR.parent
# 加载 .env 文件
env_file = SERVER_DIR / '.env'
if env_file.exists():
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ.setdefault(key.strip(), value.strip())
DB_CONFIG = {
'host': os.getenv('DB_HOST', 'localhost'),
'port': int(os.getenv('DB_PORT', 3306)),
'user': os.getenv('DB_USER', 'root'),
'password': os.getenv('DB_PASSWORD', '123456'),
'charset': 'utf8mb4'
}
def read_sql_file(filename):
with open(SQL_DIR / filename, 'r', encoding='utf-8') as f:
return f.read()
def execute_sql(cursor, sql, desc):
print(f'{desc}...')
for stmt in [s.strip() for s in sql.split(';') if s.strip()]:
try:
cursor.execute(stmt)
except pymysql.Error as e:
if e.args[0] not in [1007, 1050]:
print(f' 警告: {e.args[1]}')
print(f' {desc}完成!')
def rebuild():
print('=' * 50)
print('星域故事汇 - 删库重建')
print('=' * 50)
conn = pymysql.connect(**DB_CONFIG)
cur = conn.cursor()
# 删库
print('删除旧数据库...')
cur.execute('DROP DATABASE IF EXISTS stardom_story')
conn.commit()
print(' 删除完成!')
# 重建
schema_sql = read_sql_file('schema.sql')
execute_sql(cur, schema_sql, '创建数据库表结构')
conn.commit()
seed1 = read_sql_file('seed_stories_part1.sql')
execute_sql(cur, seed1, '导入种子数据第1部分')
conn.commit()
seed2 = read_sql_file('seed_stories_part2.sql')
execute_sql(cur, seed2, '导入种子数据第2部分')
conn.commit()
print('\n数据库重建完成!')
cur.close()
conn.close()
if __name__ == '__main__':
rebuild()

View File

@@ -1,107 +1,158 @@
-- 星域故事汇数据库初始化脚本 -- ============================================
-- 创建数据库 -- 星域故事汇数据库结构
CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 基于实际数据库导出共7张表
-- ============================================
CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE stardom_story; USE stardom_story;
-- 故事主表 -- ============================================
CREATE TABLE IF NOT EXISTS stories ( -- 1. 用户表
id INT PRIMARY KEY AUTO_INCREMENT, -- ============================================
title VARCHAR(100) NOT NULL COMMENT '故事标题', CREATE TABLE IF NOT EXISTS `users` (
cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL', `id` INT NOT NULL AUTO_INCREMENT,
description TEXT COMMENT '故事简介', `openid` VARCHAR(100) NOT NULL COMMENT '微信openid',
author_id INT DEFAULT 0 COMMENT '作者ID0表示官方', `nickname` VARCHAR(100) DEFAULT '' COMMENT '昵称',
category VARCHAR(50) NOT NULL COMMENT '故事分类', `avatar_url` VARCHAR(255) DEFAULT '' COMMENT '头像URL',
play_count INT DEFAULT 0 COMMENT '游玩次数', `gender` TINYINT DEFAULT 0 COMMENT '性别0未知 1男 2女',
like_count INT DEFAULT 0 COMMENT '点赞', `total_play_count` INT DEFAULT 0 COMMENT '总游玩次',
is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选', `total_endings` INT DEFAULT 0 COMMENT '解锁结局数',
status TINYINT DEFAULT 1 COMMENT '状态0下架 1上架', `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`),
INDEX idx_category (category), UNIQUE KEY `openid` (`openid`),
INDEX idx_featured (is_featured), KEY `idx_openid` (`openid`)
INDEX idx_play_count (play_count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表';
-- 故事节点表
CREATE TABLE IF NOT EXISTS story_nodes (
id INT PRIMARY KEY AUTO_INCREMENT,
story_id INT NOT NULL COMMENT '故事ID',
node_key VARCHAR(50) NOT NULL COMMENT '节点唯一标识',
content TEXT NOT NULL COMMENT '节点内容文本',
speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色名',
background_image VARCHAR(255) DEFAULT '' COMMENT '背景图URL',
character_image VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL',
bgm VARCHAR(255) DEFAULT '' COMMENT '背景音乐',
is_ending BOOLEAN DEFAULT FALSE COMMENT '是否为结局节点',
ending_name VARCHAR(100) DEFAULT '' COMMENT '结局名称',
ending_score INT DEFAULT 0 COMMENT '结局评分',
ending_type VARCHAR(20) DEFAULT '' COMMENT '结局类型good/bad/normal/hidden',
sort_order INT DEFAULT 0 COMMENT '排序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_story_id (story_id),
INDEX idx_node_key (story_id, node_key),
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表';
-- 故事选项表
CREATE TABLE IF NOT EXISTS story_choices (
id INT PRIMARY KEY AUTO_INCREMENT,
node_id INT NOT NULL COMMENT '所属节点ID',
story_id INT NOT NULL COMMENT '故事ID冗余便于查询',
text VARCHAR(200) NOT NULL COMMENT '选项文本',
next_node_key VARCHAR(50) NOT NULL COMMENT '下一个节点key',
sort_order INT DEFAULT 0 COMMENT '排序',
is_locked BOOLEAN DEFAULT FALSE COMMENT '是否锁定(需广告解锁)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_node_id (node_id),
INDEX idx_story_id (story_id),
FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表';
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid',
nickname VARCHAR(100) DEFAULT '' COMMENT '昵称',
avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像URL',
gender TINYINT DEFAULT 0 COMMENT '性别0未知 1男 2女',
total_play_count INT DEFAULT 0 COMMENT '总游玩次数',
total_endings INT DEFAULT 0 COMMENT '解锁结局数',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_openid (openid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 用户进度表 -- ============================================
CREATE TABLE IF NOT EXISTS user_progress ( -- 2. 故事主表
id INT PRIMARY KEY AUTO_INCREMENT, -- ============================================
user_id INT NOT NULL COMMENT '用户ID', CREATE TABLE IF NOT EXISTS `stories` (
story_id INT NOT NULL COMMENT '故事ID', `id` INT NOT NULL AUTO_INCREMENT,
current_node_key VARCHAR(50) DEFAULT 'start' COMMENT '当前节点', `title` VARCHAR(100) NOT NULL COMMENT '故事标题',
is_completed BOOLEAN DEFAULT FALSE COMMENT '是否完成', `cover_url` VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
ending_reached VARCHAR(100) DEFAULT '' COMMENT '达成的结局', `description` TEXT COMMENT '故事简介',
is_liked BOOLEAN DEFAULT FALSE COMMENT '是否点赞', `author_id` INT DEFAULT 0 COMMENT '作者ID0表示官方',
is_collected BOOLEAN DEFAULT FALSE COMMENT '是否收藏', `category` VARCHAR(50) NOT NULL COMMENT '故事分类',
play_count INT DEFAULT 1 COMMENT '游玩次数', `play_count` INT DEFAULT 0 COMMENT '游玩次数',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `like_count` INT DEFAULT 0 COMMENT '点赞数',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `is_featured` TINYINT(1) DEFAULT 0 COMMENT '是否精选',
UNIQUE KEY uk_user_story (user_id, story_id), `status` TINYINT DEFAULT 1 COMMENT '状态0下架 1上架',
INDEX idx_user_id (user_id), `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_story_id (story_id), `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (`id`),
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE KEY `idx_category` (`category`),
KEY `idx_featured` (`is_featured`),
KEY `idx_play_count` (`play_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表';
-- ============================================
-- 3. 故事节点表
-- ============================================
CREATE TABLE IF NOT EXISTS `story_nodes` (
`id` INT NOT NULL AUTO_INCREMENT,
`story_id` INT NOT NULL COMMENT '故事ID',
`node_key` VARCHAR(50) NOT NULL COMMENT '节点唯一标识',
`content` TEXT NOT NULL COMMENT '节点内容文本',
`speaker` VARCHAR(50) DEFAULT '' COMMENT '说话角色名',
`background_image` VARCHAR(255) DEFAULT '' COMMENT '背景图URL',
`character_image` VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL',
`bgm` VARCHAR(255) DEFAULT '' COMMENT '背景音乐',
`is_ending` TINYINT(1) DEFAULT 0 COMMENT '是否为结局节点',
`ending_name` VARCHAR(100) DEFAULT '' COMMENT '结局名称',
`ending_score` INT DEFAULT 0 COMMENT '结局评分',
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型good/bad/normal/hidden',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_story_id` (`story_id`),
KEY `idx_node_key` (`story_id`, `node_key`),
CONSTRAINT `story_nodes_ibfk_1` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表';
-- ============================================
-- 4. 故事选项表
-- ============================================
CREATE TABLE IF NOT EXISTS `story_choices` (
`id` INT NOT NULL AUTO_INCREMENT,
`node_id` INT NOT NULL COMMENT '所属节点ID',
`story_id` INT NOT NULL COMMENT '故事ID冗余便于查询',
`text` VARCHAR(200) NOT NULL COMMENT '选项文本',
`next_node_key` VARCHAR(50) NOT NULL COMMENT '下一个节点key',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`is_locked` TINYINT(1) DEFAULT 0 COMMENT '是否锁定(需广告解锁)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_node_id` (`node_id`),
KEY `idx_story_id` (`story_id`),
CONSTRAINT `story_choices_ibfk_1` FOREIGN KEY (`node_id`) REFERENCES `story_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表';
-- ============================================
-- 5. 用户进度表
-- ============================================
CREATE TABLE IF NOT EXISTS `user_progress` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`story_id` INT NOT NULL COMMENT '故事ID',
`current_node_key` VARCHAR(50) DEFAULT 'start' COMMENT '当前节点',
`is_completed` TINYINT(1) DEFAULT 0 COMMENT '是否完成',
`ending_reached` VARCHAR(100) DEFAULT '' COMMENT '达成的结局',
`is_liked` TINYINT(1) DEFAULT 0 COMMENT '是否点赞',
`is_collected` TINYINT(1) DEFAULT 0 COMMENT '是否收藏',
`play_count` INT DEFAULT 1 COMMENT '游玩次数',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_story` (`user_id`, `story_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_story_id` (`story_id`),
CONSTRAINT `user_progress_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `user_progress_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表';
-- 用户结局收集表 -- ============================================
CREATE TABLE IF NOT EXISTS user_endings ( -- 6. 用户结局收集表
id INT PRIMARY KEY AUTO_INCREMENT, -- ============================================
user_id INT NOT NULL, CREATE TABLE IF NOT EXISTS `user_endings` (
story_id INT NOT NULL, `id` INT NOT NULL AUTO_INCREMENT,
ending_name VARCHAR(100) NOT NULL, `user_id` INT NOT NULL,
ending_score INT DEFAULT 0, `story_id` INT NOT NULL,
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `ending_name` VARCHAR(100) NOT NULL,
UNIQUE KEY uk_user_ending (user_id, story_id, ending_name), `ending_score` INT DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, `unlocked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_ending` (`user_id`, `story_id`, `ending_name`),
KEY `story_id` (`story_id`),
CONSTRAINT `user_endings_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `user_endings_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户结局收集表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户结局收集表';
-- ============================================
-- 7. AI改写草稿表
-- ============================================
CREATE TABLE IF NOT EXISTS `story_drafts` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`story_id` INT NOT NULL COMMENT '原故事ID',
`title` VARCHAR(100) DEFAULT '' COMMENT '草稿标题',
`path_history` JSON DEFAULT NULL COMMENT '用户之前的选择路径',
`current_node_key` VARCHAR(50) DEFAULT '' COMMENT '改写起始节点',
`current_content` TEXT COMMENT '当前节点内容',
`user_prompt` VARCHAR(500) NOT NULL COMMENT '用户改写指令',
`ai_nodes` JSON DEFAULT NULL COMMENT 'AI生成的新节点',
`entry_node_key` VARCHAR(50) DEFAULT '' COMMENT '入口节点',
`tokens_used` INT DEFAULT 0 COMMENT '消耗token数',
`status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
`error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因',
`is_read` TINYINT(1) DEFAULT 0 COMMENT '用户是否已查看',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_story` (`story_id`),
KEY `idx_status` (`status`),
KEY `idx_user_unread` (`user_id`, `is_read`),
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改写草稿表';

View File

@@ -564,3 +564,39 @@ CREATE TABLE sensitive_words (
UNIQUE KEY uk_word (word), UNIQUE KEY uk_word (word),
INDEX idx_category (category) INDEX idx_category (category)
) ENGINE=InnoDB COMMENT='敏感词表'; ) ENGINE=InnoDB COMMENT='敏感词表';
-- ============================================
-- 九、AI改写草稿箱
-- ============================================
-- AI改写草稿表
CREATE TABLE story_drafts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
story_id BIGINT NOT NULL COMMENT '原故事ID',
title VARCHAR(100) DEFAULT '' COMMENT '草稿标题',
-- 用户输入
path_history JSON COMMENT '用户之前的选择路径',
current_node_key VARCHAR(50) DEFAULT '' COMMENT '改写起始节点',
current_content TEXT COMMENT '当前节点内容',
user_prompt VARCHAR(500) NOT NULL COMMENT '用户改写指令',
-- AI生成结果
ai_nodes JSON COMMENT 'AI生成的新节点',
entry_node_key VARCHAR(50) DEFAULT '' COMMENT '入口节点',
tokens_used INT DEFAULT 0 COMMENT '消耗token数',
-- 状态
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
error_message VARCHAR(500) DEFAULT '' COMMENT '失败原因',
is_read BOOLEAN DEFAULT FALSE COMMENT '用户是否已查看',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL COMMENT '完成时间',
INDEX idx_user (user_id),
INDEX idx_story (story_id),
INDEX idx_status (status),
INDEX idx_user_unread (user_id, is_read)
) ENGINE=InnoDB COMMENT='AI改写草稿表';

View File

@@ -1,7 +1,15 @@
-- 10个种子故事数据 -- 种子数据:测试用户 + 10个故事
USE stardom_story; USE stardom_story;
-- ============================================
-- 测试用户
-- ============================================
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(1, 'test_user', '测试用户', '', 0, 0, 0);
-- ============================================
-- 1. 都市言情:《总裁的替身新娘》 -- 1. 都市言情:《总裁的替身新娘》
-- ============================================
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
(1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE); (1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE);