feat: 完善AI改写草稿箱功能 - 修复重头游玩、评分、数据刷新等问题

This commit is contained in:
wangwuww111
2026-03-09 14:15:00 +08:00
parent bbdccfa843
commit 18db6a8cc6
17 changed files with 1385 additions and 99 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() {
@@ -128,6 +128,7 @@ export default class StoryManager {
*/ */
resetStory() { resetStory() {
this.currentNodeKey = 'start'; this.currentNodeKey = 'start';
this.pathHistory = []; // 清空路径历史
} }
/** /**
@@ -156,38 +157,117 @@ export default class StoryManager {
} }
/** /**
* AI改写中间章节生成新的剧情分支 * AI改写中间章节异步提交到草稿箱
* @returns {Object|null} 成功返回新节点,失败返回 null(不改变当前状态) * @returns {Object|null} 成功返回草稿ID,失败返回 null
*/ */
async rewriteBranch(storyId, prompt, userId) { async rewriteBranchAsync(storyId, prompt, userId) {
try { try {
// 先标记之前的未读草稿为已读,避免轮询弹出之前的通知
await this.markAllDraftsRead(userId);
const currentNode = this.getCurrentNode(); const currentNode = this.getCurrentNode();
const result = await post(`/stories/${storyId}/rewrite-branch`, { const result = await post(`/drafts`, {
userId: userId, userId: userId,
storyId: storyId,
currentNodeKey: this.currentNodeKey, currentNodeKey: this.currentNodeKey,
pathHistory: this.pathHistory, pathHistory: this.pathHistory,
currentContent: currentNode?.content || '', currentContent: currentNode?.content || '',
prompt: prompt prompt: prompt
}, { timeout: 300000 }); // 5分钟超时AI生成需要较长时间 }, { timeout: 30000 });
// 检查是否有有效的 nodes if (result && result.draftId) {
if (result && result.nodes) { return result;
// AI 成功,将新分支合并到当前故事中
Object.assign(this.currentStory.nodes, result.nodes);
// 跳转到新分支的入口节点
this.currentNodeKey = result.entryNodeKey || 'branch_1';
return this.getCurrentNode();
} }
// AI 失败,返回 null
console.log('AI服务不可用:', result?.error || '未知错误');
return null; return null;
} catch (error) { } catch (error) {
console.error('AI改写分支失败:', error?.errMsg || error?.message || JSON.stringify(error)); console.error('AI改写提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
return null; 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

@@ -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;
@@ -844,7 +845,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;
@@ -31,6 +32,18 @@ export default class StoryScene extends BaseScene {
this.sceneColors = this.generateSceneColors(); this.sceneColors = this.generateSceneColors();
// AI改写相关 // AI改写相关
this.isAIRewriting = false; 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() { 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;
@@ -63,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);
@@ -108,8 +171,341 @@ 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 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) { 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;
@@ -145,6 +541,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);
@@ -418,7 +820,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;
@@ -428,18 +830,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');
@@ -451,7 +872,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();
@@ -462,6 +883,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';
@@ -512,6 +940,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) {
@@ -522,6 +958,20 @@ export default class StoryScene extends BaseScene {
onTouchMove(e) { onTouchMove(e) {
const touch = e.touches[0]; 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) { if (this.isDragging) {
const deltaY = this.lastTouchY - touch.clientY; const deltaY = this.lastTouchY - touch.clientY;
@@ -548,6 +998,57 @@ 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');
@@ -584,46 +1085,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;
}
}
} }
} }
} }
@@ -689,24 +1238,24 @@ export default class StoryScene extends BaseScene {
placeholderText: '输入你的改写指令,如"让主角暴富"', placeholderText: '输入你的改写指令,如"让主角暴富"',
success: (res) => { success: (res) => {
if (res.confirm && res.content) { 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; if (this.isAIRewriting) return;
this.isAIRewriting = true; this.isAIRewriting = true;
this.main.showLoading('AI正在改写剧情...'); this.main.showLoading('正在提交...');
try { try {
const userId = this.main.userManager.userId || 0; const userId = this.main.userManager.userId || 0;
const newNode = await this.main.storyManager.rewriteBranch( const result = await this.main.storyManager.rewriteBranchAsync(
this.storyId, this.storyId,
prompt, prompt,
userId userId
@@ -714,26 +1263,25 @@ export default class StoryScene extends BaseScene {
this.main.hideLoading(); this.main.hideLoading();
if (newNode) { if (result && result.draftId) {
// 成功获取新分支,开始播放 // 提交成功
this.currentNode = newNode; wx.showModal({
this.startTypewriter(newNode.content); title: '提交成功',
wx.showToast({ content: 'AI正在后台生成中完成后会通知您。\n您可以继续播放当前故事。',
title: '改写成功!', showCancel: false,
icon: 'success', confirmText: '知道了'
duration: 1500
}); });
} else { } else {
// AI 失败,继续原故事 // 提交失败
wx.showToast({ wx.showToast({
title: 'AI暂时不可用继续原故事', title: '提交失败,请重试',
icon: 'none', icon: 'none',
duration: 2000 duration: 2000
}); });
} }
} catch (error) { } catch (error) {
this.main.hideLoading(); this.main.hideLoading();
console.error('AI改写出错:', error); console.error('AI改写提交出错:', error);
wx.showToast({ wx.showToast({
title: '网络错误,请重试', title: '网络错误,请重试',
icon: 'none', icon: 'none',

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,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": "已全部标记为已读"}

View File

@@ -131,12 +131,24 @@ class AIService:
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合) 2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动 3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果 4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
5. 必须以结局收尾,结局内容要 200-400 字,分 2-3 段,有情感冲击力 5. 严格符合用户的改写意图,围绕用户指令展开剧情
6. 严格符合用户的改写意图,围绕用户指令展开剧情 6. 保持原故事的人物性格、语言风格和世界观
7. 保持原故事的人物性格、语言风格和世界观 7. 对话要自然生动,描写要有画面感
8. 对话要自然生动,描写要有画面感
重要】内容分段示例: 关于结局 - 极其重要!】
★★★ 每一条分支路径的尽头必须是结局节点 ★★★
- 结局节点必须设置 "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你的心跳漏了一拍,一时间不知该如何回应。" "content": "他的声音在耳边响起,像是一阵温柔的风。\n\n\"我喜欢你。\"他说,目光坚定地看着你。\n\n你的心跳漏了一拍,一时间不知该如何回应。"
【输出格式】严格JSON不要有任何额外文字 【输出格式】严格JSON不要有任何额外文字
@@ -153,14 +165,50 @@ class AIService:
"branch_2a": { "branch_2a": {
"content": "...", "content": "...",
"speaker": "旁白", "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": { "branch_ending_good": {
"content": "好结局内容200-400字...", "content": "好结局内容200-400字...\n\n【达成结局xxx】",
"speaker": "旁白", "speaker": "旁白",
"is_ending": true, "is_ending": true,
"ending_name": "结局名称4-8字", "ending_name": "结局名称",
"ending_type": "good" "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" "entryNodeKey": "branch_1"

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改写草稿表';