feat: 完善AI改写草稿箱功能 - 修复重头游玩、评分、数据刷新等问题
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 故事数据管理器
|
||||
*/
|
||||
import { get, post } from '../utils/http';
|
||||
import { get, post, request } from '../utils/http';
|
||||
|
||||
export default class StoryManager {
|
||||
constructor() {
|
||||
@@ -128,6 +128,7 @@ export default class StoryManager {
|
||||
*/
|
||||
resetStory() {
|
||||
this.currentNodeKey = 'start';
|
||||
this.pathHistory = []; // 清空路径历史
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,38 +157,117 @@ export default class StoryManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* AI改写中间章节,生成新的剧情分支
|
||||
* @returns {Object|null} 成功返回新节点,失败返回 null(不改变当前状态)
|
||||
* AI改写中间章节,异步提交到草稿箱
|
||||
* @returns {Object|null} 成功返回草稿ID,失败返回 null
|
||||
*/
|
||||
async rewriteBranch(storyId, prompt, userId) {
|
||||
async rewriteBranchAsync(storyId, prompt, userId) {
|
||||
try {
|
||||
// 先标记之前的未读草稿为已读,避免轮询弹出之前的通知
|
||||
await this.markAllDraftsRead(userId);
|
||||
|
||||
const currentNode = this.getCurrentNode();
|
||||
const result = await post(`/stories/${storyId}/rewrite-branch`, {
|
||||
const result = await post(`/drafts`, {
|
||||
userId: userId,
|
||||
storyId: storyId,
|
||||
currentNodeKey: this.currentNodeKey,
|
||||
pathHistory: this.pathHistory,
|
||||
currentContent: currentNode?.content || '',
|
||||
prompt: prompt
|
||||
}, { timeout: 300000 }); // 5分钟超时,AI生成需要较长时间
|
||||
}, { timeout: 30000 });
|
||||
|
||||
// 检查是否有有效的 nodes
|
||||
if (result && result.nodes) {
|
||||
// AI 成功,将新分支合并到当前故事中
|
||||
Object.assign(this.currentStory.nodes, result.nodes);
|
||||
// 跳转到新分支的入口节点
|
||||
this.currentNodeKey = result.entryNodeKey || 'branch_1';
|
||||
return this.getCurrentNode();
|
||||
if (result && result.draftId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// AI 失败,返回 null
|
||||
console.log('AI服务不可用:', result?.error || '未知错误');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('AI改写分支失败:', error?.errMsg || error?.message || JSON.stringify(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续写故事
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,11 @@ export default class Main {
|
||||
|
||||
// 设置分享
|
||||
this.setupShare();
|
||||
|
||||
// 启动草稿检查(仅登录用户)
|
||||
if (this.userManager.isLoggedIn) {
|
||||
this.startDraftChecker();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Main] 初始化失败:', error);
|
||||
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() {
|
||||
wx.showShareMenu({
|
||||
|
||||
@@ -8,6 +8,7 @@ export default class EndingScene extends BaseScene {
|
||||
super(main, params);
|
||||
this.storyId = params.storyId;
|
||||
this.ending = params.ending;
|
||||
this.draftId = params.draftId || null; // 保存草稿ID
|
||||
console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending));
|
||||
this.showButtons = false;
|
||||
this.fadeIn = 0;
|
||||
@@ -844,7 +845,16 @@ export default class EndingScene extends BaseScene {
|
||||
|
||||
handleReplay() {
|
||||
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() {
|
||||
|
||||
@@ -6,9 +6,9 @@ import BaseScene from './BaseScene';
|
||||
export default class ProfileScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
// Tab: 0我的作品 1草稿箱 2收藏 3游玩记录
|
||||
this.currentTab = 0;
|
||||
this.tabs = ['作品', '草稿', '收藏', '记录'];
|
||||
// Tab: 0我的作品 1AI草稿 2收藏 3游玩记录
|
||||
this.currentTab = params.tab || 0; // 支持传入初始tab
|
||||
this.tabs = ['作品', 'AI草稿', '收藏', '记录'];
|
||||
|
||||
// 数据
|
||||
this.myWorks = [];
|
||||
@@ -40,8 +40,10 @@ export default class ProfileScene extends BaseScene {
|
||||
async loadData() {
|
||||
if (this.main.userManager.isLoggedIn) {
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
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.progress = await this.main.userManager.getProgress() || [];
|
||||
|
||||
@@ -57,6 +59,19 @@ export default class ProfileScene extends BaseScene {
|
||||
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() {
|
||||
switch (this.currentTab) {
|
||||
case 0: return this.myWorks;
|
||||
@@ -366,50 +381,85 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fill();
|
||||
|
||||
// AI标签
|
||||
if (item.source === 'ai') {
|
||||
ctx.fillStyle = '#a855f7';
|
||||
this.roundRect(ctx, x + 8, y + 8, 28, 16, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('AI', x + 22, y + 19);
|
||||
}
|
||||
ctx.fillStyle = '#a855f7';
|
||||
this.roundRect(ctx, x + 8, y + 8, 28, 16, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('AI', x + 22, y + 19);
|
||||
|
||||
const textX = x + 88;
|
||||
|
||||
// 标题(故事标题-改写)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
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.fillText(`创建于 ${item.created_at || '刚刚'}`, textX, y + 48);
|
||||
ctx.fillText(`${item.node_count || 0} 个节点`, textX + 100, y + 48);
|
||||
ctx.textAlign = 'left';
|
||||
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 btns = [{ text: '继续编辑', primary: true }, { text: '删除', primary: false }];
|
||||
let btnX = textX;
|
||||
btns.forEach((btn) => {
|
||||
const btnW = btn.primary ? 65 : 45;
|
||||
if (btn.primary) {
|
||||
const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
}
|
||||
this.roundRect(ctx, btnX, btnY, btnW, 26, 13);
|
||||
|
||||
// 删除按钮(所有状态都显示)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
this.roundRect(ctx, x + w - 55, btnY, 45, 24, 12);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('删除', x + w - 32, btnY + 16);
|
||||
|
||||
// 播放按钮(仅已完成状态)
|
||||
if (item.status === 'completed') {
|
||||
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.fillStyle = '#ffffff';
|
||||
ctx.font = btn.primary ? 'bold 11px sans-serif' : '11px sans-serif';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(btn.text, btnX + btnW / 2, btnY + 17);
|
||||
btnX += btnW + 8;
|
||||
});
|
||||
ctx.fillText('播放', textX + 150, btnY + 16);
|
||||
}
|
||||
}
|
||||
|
||||
renderSimpleCard(ctx, item, x, y, w, h, index) {
|
||||
@@ -545,6 +595,11 @@ export default class ProfileScene extends BaseScene {
|
||||
this.currentTab = rect.index;
|
||||
this.scrollY = 0;
|
||||
this.calculateMaxScroll();
|
||||
|
||||
// 切换到 AI 草稿 tab 时刷新数据
|
||||
if (rect.index === 1) {
|
||||
this.refreshDrafts();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -569,22 +624,82 @@ export default class ProfileScene extends BaseScene {
|
||||
const startY = 250;
|
||||
const cardH = this.currentTab <= 1 ? 100 : 85;
|
||||
const gap = 10;
|
||||
const padding = 12;
|
||||
const cardW = this.screenWidth - padding * 2;
|
||||
|
||||
const adjustedY = y + this.scrollY;
|
||||
const index = Math.floor((adjustedY - startY) / (cardH + gap));
|
||||
|
||||
if (index >= 0 && index < list.length) {
|
||||
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) {
|
||||
// 收藏/记录 - 跳转播放
|
||||
this.main.sceneManager.switchScene('story', { storyId });
|
||||
} else if (this.currentTab === 1) {
|
||||
// 草稿 - 跳转编辑(暂用AI创作)
|
||||
this.main.sceneManager.switchScene('aiCreate', { draftId: item.id });
|
||||
}
|
||||
// 作品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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export default class StoryScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.storyId = params.storyId;
|
||||
this.draftId = params.draftId || null; // 草稿ID
|
||||
this.aiContent = params.aiContent || null; // AI改写内容
|
||||
this.story = null;
|
||||
this.currentNode = null;
|
||||
@@ -31,6 +32,18 @@ export default class StoryScene extends BaseScene {
|
||||
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;
|
||||
}
|
||||
|
||||
// 根据场景生成氛围色
|
||||
@@ -46,6 +59,52 @@ export default class StoryScene extends BaseScene {
|
||||
}
|
||||
|
||||
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改写内容,直接播放
|
||||
if (this.aiContent) {
|
||||
this.story = this.main.storyManager.currentStory;
|
||||
@@ -63,6 +122,10 @@ export default class StoryScene extends BaseScene {
|
||||
// 重新开始,使用已有数据
|
||||
this.story = existingStory;
|
||||
this.setThemeByCategory(this.story.category);
|
||||
|
||||
// 重置到起点并清空历史
|
||||
this.main.storyManager.resetStory();
|
||||
|
||||
this.currentNode = this.main.storyManager.getCurrentNode();
|
||||
if (this.currentNode) {
|
||||
this.startTypewriter(this.currentNode.content);
|
||||
@@ -108,8 +171,341 @@ export default class StoryScene extends BaseScene {
|
||||
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 pathHistory = this.recapData?.pathHistory || [];
|
||||
|
||||
// 保存卡片位置用于点击检测
|
||||
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) {
|
||||
this.targetText = text || '';
|
||||
let content = text || '';
|
||||
|
||||
// 回放模式下,过滤掉结局提示(因为后面还有AI改写内容)
|
||||
if (this.isReplayMode) {
|
||||
content = content.replace(/【达成结局[::][^】]*】/g, '').trim();
|
||||
}
|
||||
|
||||
this.targetText = content;
|
||||
this.displayText = '';
|
||||
this.charIndex = 0;
|
||||
this.isTyping = true;
|
||||
@@ -145,6 +541,12 @@ export default class StoryScene extends BaseScene {
|
||||
}
|
||||
|
||||
render(ctx) {
|
||||
// 如果是回顾模式,渲染回顾页面
|
||||
if (this.isRecapMode) {
|
||||
this.renderRecapPage(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 绘制场景背景
|
||||
this.renderSceneBackground(ctx);
|
||||
|
||||
@@ -418,7 +820,7 @@ export default class StoryScene extends BaseScene {
|
||||
renderChoices(ctx) {
|
||||
if (!this.currentNode || !this.currentNode.choices) return;
|
||||
|
||||
const choices = this.currentNode.choices;
|
||||
let choices = this.currentNode.choices;
|
||||
const choiceHeight = 50;
|
||||
const choiceMargin = 10;
|
||||
const padding = 20;
|
||||
@@ -428,18 +830,37 @@ export default class StoryScene extends BaseScene {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58);
|
||||
|
||||
// 提示文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('请做出选择', this.screenWidth / 2, startY);
|
||||
// 回放模式下的处理
|
||||
let replayChoice = null;
|
||||
if (this.isReplayMode && this.replayPathIndex < this.replayPath.length) {
|
||||
const previousChoice = this.replayPath[this.replayPathIndex]?.choice;
|
||||
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) => {
|
||||
const y = startY + 25 + index * (choiceHeight + choiceMargin);
|
||||
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);
|
||||
gradient.addColorStop(0, this.sceneColors.accent);
|
||||
gradient.addColorStop(1, this.sceneColors.accent + 'aa');
|
||||
@@ -451,7 +872,7 @@ export default class StoryScene extends BaseScene {
|
||||
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;
|
||||
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
|
||||
ctx.stroke();
|
||||
@@ -462,6 +883,13 @@ export default class StoryScene extends BaseScene {
|
||||
ctx.textAlign = 'center';
|
||||
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) {
|
||||
ctx.fillStyle = '#ffd700';
|
||||
@@ -512,6 +940,14 @@ export default class StoryScene extends BaseScene {
|
||||
this.lastTouchY = touch.clientY;
|
||||
this.hasMoved = false;
|
||||
|
||||
// 回顾模式下的滚动
|
||||
if (this.isRecapMode) {
|
||||
if (touch.clientY > 75) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断是否在对话框区域
|
||||
const boxY = this.screenHeight * 0.42;
|
||||
if (touch.clientY > boxY) {
|
||||
@@ -522,6 +958,20 @@ export default class StoryScene extends BaseScene {
|
||||
onTouchMove(e) {
|
||||
const touch = e.touches[0];
|
||||
|
||||
// 回顾模式下的滚动
|
||||
if (this.isRecapMode && this.isDragging) {
|
||||
const deltaY = this.lastTouchY - touch.clientY;
|
||||
if (Math.abs(deltaY) > 2) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
if (this.recapMaxScrollY > 0) {
|
||||
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;
|
||||
@@ -548,6 +998,57 @@ export default class StoryScene extends BaseScene {
|
||||
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) {
|
||||
this.main.sceneManager.switchScene('home');
|
||||
@@ -584,46 +1085,94 @@ export default class StoryScene extends BaseScene {
|
||||
console.log('AI改写内容:', JSON.stringify(this.aiContent));
|
||||
this.main.sceneManager.switchScene('ending', {
|
||||
storyId: this.storyId,
|
||||
draftId: this.draftId,
|
||||
ending: {
|
||||
name: this.aiContent.ending_name,
|
||||
type: this.aiContent.ending_type,
|
||||
content: this.aiContent.content,
|
||||
score: 100
|
||||
score: this.aiContent.ending_score || 80
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是结局
|
||||
if (this.main.storyManager.isEnding()) {
|
||||
// 检查是否是结局(回放模式下跳过,因为要进入AI改写内容)
|
||||
if (!this.isReplayMode && this.main.storyManager.isEnding()) {
|
||||
this.main.sceneManager.switchScene('ending', {
|
||||
storyId: this.storyId,
|
||||
draftId: this.draftId,
|
||||
ending: this.main.storyManager.getEndingInfo()
|
||||
});
|
||||
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) {
|
||||
// 回放模式下也显示选项,但只显示之前选过的
|
||||
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;
|
||||
}
|
||||
|
||||
// 选项点击
|
||||
if (this.showChoices && this.currentNode && this.currentNode.choices) {
|
||||
const choices = this.currentNode.choices;
|
||||
const choiceHeight = 50;
|
||||
const choiceMargin = 10;
|
||||
const padding = 20;
|
||||
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) {
|
||||
this.handleChoiceSelect(i);
|
||||
this.autoSelectReplayChoice();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,24 +1238,24 @@ export default class StoryScene extends BaseScene {
|
||||
placeholderText: '输入你的改写指令,如"让主角暴富"',
|
||||
success: (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
this.doAIRewrite(res.content);
|
||||
this.doAIRewriteAsync(res.content);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行AI改写
|
||||
* 异步提交AI改写到草稿箱
|
||||
*/
|
||||
async doAIRewrite(prompt) {
|
||||
async doAIRewriteAsync(prompt) {
|
||||
if (this.isAIRewriting) return;
|
||||
|
||||
this.isAIRewriting = true;
|
||||
this.main.showLoading('AI正在改写剧情...');
|
||||
this.main.showLoading('正在提交...');
|
||||
|
||||
try {
|
||||
const userId = this.main.userManager.userId || 0;
|
||||
const newNode = await this.main.storyManager.rewriteBranch(
|
||||
const result = await this.main.storyManager.rewriteBranchAsync(
|
||||
this.storyId,
|
||||
prompt,
|
||||
userId
|
||||
@@ -714,26 +1263,25 @@ export default class StoryScene extends BaseScene {
|
||||
|
||||
this.main.hideLoading();
|
||||
|
||||
if (newNode) {
|
||||
// 成功获取新分支,开始播放
|
||||
this.currentNode = newNode;
|
||||
this.startTypewriter(newNode.content);
|
||||
wx.showToast({
|
||||
title: '改写成功!',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
if (result && result.draftId) {
|
||||
// 提交成功
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: 'AI正在后台生成中,完成后会通知您。\n您可以继续播放当前故事。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
});
|
||||
} else {
|
||||
// AI 失败,继续原故事
|
||||
// 提交失败
|
||||
wx.showToast({
|
||||
title: 'AI暂时不可用,继续原故事',
|
||||
title: '提交失败,请重试',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.main.hideLoading();
|
||||
console.error('AI改写出错:', error);
|
||||
console.error('AI改写提交出错:', error);
|
||||
wx.showToast({
|
||||
title: '网络错误,请重试',
|
||||
icon: 'none',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,9 @@ AsyncSessionLocal = sessionmaker(
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# 后台任务使用的会话工厂
|
||||
async_session_factory = AsyncSessionLocal
|
||||
|
||||
# 基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.routers import story, user
|
||||
from app.routers import story, user, drafts
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -29,6 +29,7 @@ app.add_middleware(
|
||||
# 注册路由
|
||||
app.include_router(story.router, prefix="/api/stories", tags=["故事"])
|
||||
app.include_router(user.router, prefix="/api/user", tags=["用户"])
|
||||
app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
Binary file not shown.
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
故事相关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.sql import func
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class Story(Base):
|
||||
@@ -64,3 +65,43 @@ class StoryChoice(Base):
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
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")
|
||||
|
||||
BIN
server/app/routers/__pycache__/drafts.cpython-310.pyc
Normal file
BIN
server/app/routers/__pycache__/drafts.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
344
server/app/routers/drafts.py
Normal file
344
server/app/routers/drafts.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
草稿箱路由 - 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 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
|
||||
|
||||
|
||||
# ============ 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.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": "已全部标记为已读"}
|
||||
Binary file not shown.
@@ -131,12 +131,24 @@ class AIService:
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 必须以结局收尾,结局内容要 200-400 字,分 2-3 段,有情感冲击力
|
||||
6. 严格符合用户的改写意图,围绕用户指令展开剧情
|
||||
7. 保持原故事的人物性格、语言风格和世界观
|
||||
8. 对话要自然生动,描写要有画面感
|
||||
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,不要有任何额外文字)
|
||||
@@ -153,14 +165,50 @@ class AIService:
|
||||
"branch_2a": {
|
||||
"content": "...",
|
||||
"speaker": "旁白",
|
||||
"choices": [...]
|
||||
"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字)...",
|
||||
"content": "好结局内容(200-400字)...\n\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称(4-8字)",
|
||||
"ending_type": "good"
|
||||
"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"
|
||||
|
||||
@@ -564,3 +564,39 @@ CREATE TABLE sensitive_words (
|
||||
UNIQUE KEY uk_word (word),
|
||||
INDEX idx_category (category)
|
||||
) 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改写草稿表';
|
||||
|
||||
Reference in New Issue
Block a user