9 Commits

19 changed files with 3656 additions and 336 deletions

BIN
.gitignore vendored

Binary file not shown.

View File

@@ -345,7 +345,12 @@ export default class StoryManager {
*/
async createStory(params) {
try {
if (!params.userId) {
console.error('AI创作失败: 缺少userId');
return null;
}
const result = await post('/stories/ai-create', {
userId: params.userId,
genre: params.genre,
keywords: params.keywords,
protagonist: params.protagonist,

View File

@@ -1,7 +1,7 @@
/**
* 用户数据管理器
*/
import { get, post, del } from '../utils/http';
import { get, post, put, del } from '../utils/http';
export default class UserManager {
constructor() {
@@ -203,6 +203,29 @@ export default class UserManager {
});
}
/**
* 收藏草稿
*/
async collectDraft(draftId, isCollected) {
if (!this.isLoggedIn) return;
await put(`/drafts/${draftId}/collect`, null, {
params: { userId: this.userId, isCollected }
});
}
/**
* 获取草稿收藏状态
*/
async getDraftCollectStatus(draftId) {
if (!this.isLoggedIn) return false;
try {
const res = await get(`/drafts/${draftId}/collect-status`, { userId: this.userId });
return res?.isCollected || false;
} catch (e) {
return false;
}
}
/**
* 获取收藏列表
*/
@@ -271,17 +294,65 @@ export default class UserManager {
}
}
/**
* 获取已发布到创作中心的草稿
* @param {string} draftType - 草稿类型: rewrite/continue
*/
async getPublishedDrafts(draftType) {
if (!this.isLoggedIn) return [];
try {
console.log('[UserManager] 获取已发布草稿, userId:', this.userId, 'draftType:', draftType);
const res = await get('/drafts/published', { userId: this.userId, draftType });
console.log('[UserManager] 已发布草稿响应:', res);
return res || [];
} catch (e) {
console.error('获取已发布草稿失败:', e);
return [];
}
}
/**
* 发布草稿到创作中心
* @param {number} draftId - 草稿ID
*/
async publishDraft(draftId) {
if (!this.isLoggedIn) return false;
try {
await put(`/drafts/${draftId}/publish?userId=${this.userId}`);
return true;
} catch (e) {
console.error('发布草稿失败:', e);
return false;
}
}
/**
* 从创作中心取消发布
* @param {number} draftId - 草稿ID
*/
async unpublishDraft(draftId) {
if (!this.isLoggedIn) return false;
try {
await put(`/drafts/${draftId}/unpublish?userId=${this.userId}`);
return true;
} catch (e) {
console.error('取消发布失败:', e);
return false;
}
}
// ========== 游玩记录相关 ==========
/**
* 保存游玩记录
*/
async savePlayRecord(storyId, endingName, endingType, pathHistory) {
async savePlayRecord(storyId, endingName, endingType, pathHistory, draftId = null) {
if (!this.isLoggedIn) return null;
try {
return await post('/user/play-record', {
userId: this.userId,
storyId,
draftId, // AI草稿ID原故事为null
endingName,
endingType: endingType || '',
pathHistory: pathHistory || []

View File

@@ -2,12 +2,13 @@
* AI创作中心场景
*/
import BaseScene from './BaseScene';
import { get, post } from '../utils/http';
export default class AICreateScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.currentTab = 0; // 0:改写 1:续写 2:创作
this.tabs = ['AI改写', 'AI续写', 'AI创作'];
this.currentTab = 0; // 0:我的改写 1:我的续写 2:AI创作
this.tabs = ['我的改写', '我的续写', 'AI创作'];
// 滚动
this.scrollY = 0;
@@ -17,8 +18,8 @@ export default class AICreateScene extends BaseScene {
this.hasMoved = false;
// 用户数据
this.recentStories = [];
this.aiHistory = [];
this.publishedRewrites = []; // 已发布的改写作品
this.publishedContinues = []; // 已发布的续写作品
this.quota = { daily: 3, used: 0, purchased: 0 };
// 创作表单
@@ -29,13 +30,12 @@ export default class AICreateScene extends BaseScene {
conflict: ''
};
// 选中的故事(用于改写/续写)
this.selectedStory = null;
// 快捷标签
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘'];
this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除'];
this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战'];
// 创作确认面板
this.showCreatePanel = false;
this.createPanelBtns = {};
}
async init() {
@@ -44,10 +44,17 @@ export default class AICreateScene extends BaseScene {
async loadData() {
try {
// 加载最近游玩的故事
this.recentStories = await this.main.userManager.getRecentPlayed() || [];
// 加载AI创作历史
this.aiHistory = await this.main.userManager.getAIHistory() || [];
const userId = this.main.userManager.userId;
if (!userId) return;
// 加载已发布的改写作品
const rewriteRes = await this.main.userManager.getPublishedDrafts('rewrite');
this.publishedRewrites = rewriteRes || [];
// 加载已发布的续写作品
const continueRes = await this.main.userManager.getPublishedDrafts('continue');
this.publishedContinues = continueRes || [];
// 加载配额
const quotaData = await this.main.userManager.getAIQuota();
if (quotaData) this.quota = quotaData;
@@ -59,10 +66,15 @@ export default class AICreateScene extends BaseScene {
calculateMaxScroll() {
let contentHeight = 400;
if (this.currentTab === 0 || this.currentTab === 1) {
contentHeight = 300 + this.recentStories.length * 80;
if (this.currentTab === 0) {
contentHeight = 300 + this.publishedRewrites.length * 90;
} else if (this.currentTab === 1) {
contentHeight = 300 + this.publishedContinues.length * 90;
} else {
contentHeight = 600;
// AI创作Tab表单高度 + 已创作列表高度
const formHeight = 500;
const listHeight = this.createdStories ? this.createdStories.length * 90 : 0;
contentHeight = formHeight + listHeight + 100;
}
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 200);
}
@@ -75,6 +87,11 @@ export default class AICreateScene extends BaseScene {
this.renderQuotaBar(ctx);
this.renderTabs(ctx);
this.renderContent(ctx);
// 创作确认面板(最上层)
if (this.showCreatePanel) {
this.renderCreatePanel(ctx);
}
}
renderBackground(ctx) {
@@ -210,23 +227,10 @@ export default class AICreateScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('选择一个已玩过的故事AI帮你改写结局', this.screenWidth / 2, y + 25);
ctx.fillText('展示你从草稿箱发布的改写作品', this.screenWidth / 2, y + 25);
// 快捷标签
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('热门改写方向:', padding, y + 55);
const tagEndY = this.renderTags(ctx, this.rewriteTags, padding, y + 70, 'rewrite');
// 选择故事 - 位置根据标签高度动态调整
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('选择要改写的故事:', padding, tagEndY + 25);
this.renderStoryList(ctx, tagEndY + 40, 'rewrite');
// 作品列表
this.renderPublishedList(ctx, y + 50, this.publishedRewrites, 'rewrite');
}
renderContinueTab(ctx, startY) {
@@ -236,21 +240,10 @@ export default class AICreateScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('选择一个进行中的故事AI帮你续写剧情', this.screenWidth / 2, y + 25);
ctx.fillText('展示你从草稿箱发布的续写作品', this.screenWidth / 2, y + 25);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('续写方向:', padding, y + 55);
const tagEndY = this.renderTags(ctx, this.continueTags, padding, y + 70, 'continue');
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('选择要续写的故事:', padding, tagEndY + 25);
this.renderStoryList(ctx, tagEndY + 40, 'continue');
// 作品列表
this.renderPublishedList(ctx, y + 50, this.publishedContinues, 'continue');
}
renderCreateTab(ctx, startY) {
@@ -273,16 +266,21 @@ export default class AICreateScene extends BaseScene {
// 关键词输入
let currentY = tagEndY + 25;
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.fillText('故事关键词:', padding, currentY);
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords');
// 主角设定
currentY += 80;
ctx.textAlign = 'left';
ctx.fillText('主角设定:', padding, currentY);
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist');
// 核心冲突
currentY += 80;
ctx.textAlign = 'left';
ctx.fillText('核心冲突:', padding, currentY);
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict');
@@ -301,6 +299,120 @@ export default class AICreateScene extends BaseScene {
ctx.fillText('✨ 开始AI创作', this.screenWidth / 2, btnY + 32);
this.createBtnRect = { x: padding, y: btnY + this.scrollY, width: inputWidth, height: 50 };
// 提示文字
const tipY = btnY + 75;
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('创作完成后可在「个人中心 > 草稿箱」查看', this.screenWidth / 2, tipY);
}
renderCreatedList(ctx, startY, list) {
const padding = 15;
const cardWidth = this.screenWidth - padding * 2;
const cardHeight = 80;
const cardGap = 10;
this.createdItemRects = [];
list.forEach((item, index) => {
const y = startY + index * (cardHeight + cardGap);
// 卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.08)';
this.roundRect(ctx, padding, y, cardWidth, cardHeight, 12);
ctx.fill();
// 标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
const title = item.title || '未命名故事';
ctx.fillText(title.length > 15 ? title.substring(0, 15) + '...' : title, padding + 15, y + 25);
// 状态标签
const isPending = item.status === 'pending';
const isFailed = item.status === 'failed';
const isCompleted = item.status === 'completed';
const isPublished = item.published_to_center;
let statusText = '草稿';
let statusColor = '#fbbf24';
if (isPublished) {
statusText = '已发布';
statusColor = '#10b981';
} else if (isCompleted) {
statusText = '已完成';
statusColor = '#60a5fa';
} else if (isPending) {
statusText = '创作中...';
statusColor = '#a855f7';
} else if (isFailed) {
statusText = '失败';
statusColor = '#ef4444';
}
ctx.fillStyle = statusColor;
ctx.font = '12px sans-serif';
ctx.fillText(statusText, padding + 15, y + 50);
// 按钮(只有完成状态才能操作)
if (isCompleted) {
const btnWidth = 50;
const btnHeight = 28;
const btnGap = 8;
let btnX = this.screenWidth - padding - btnWidth - 10;
const btnY = y + (cardHeight - btnHeight) / 2;
// 阅读按钮
const readGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
readGradient.addColorStop(0, '#a855f7');
readGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = readGradient;
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 14);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('阅读', btnX + btnWidth / 2, btnY + 18);
this.createdItemRects.push({
x: btnX,
y: btnY + this.scrollY,
width: btnWidth,
height: btnHeight,
action: 'preview',
item: item
});
// 发布按钮(未发布时显示)
if (!isPublished) {
btnX = btnX - btnWidth - btnGap;
const pubGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
pubGradient.addColorStop(0, '#10b981');
pubGradient.addColorStop(1, '#059669');
ctx.fillStyle = pubGradient;
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 14);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('发布', btnX + btnWidth / 2, btnY + 18);
this.createdItemRects.push({
x: btnX,
y: btnY + this.scrollY,
width: btnWidth,
height: btnHeight,
action: 'publish',
item: item
});
}
}
});
}
renderTags(ctx, tags, startX, startY, type) {
@@ -388,6 +500,84 @@ export default class AICreateScene extends BaseScene {
this.inputRects[field] = { x, y: y + this.scrollY, width, height, field };
}
renderPublishedList(ctx, startY, items, type) {
const padding = 15;
const cardHeight = 80;
const cardGap = 12;
if (!this.publishedRects) this.publishedRects = {};
this.publishedRects[type] = [];
if (!items || items.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
const tipText = type === 'rewrite'
? '暂无改写作品,去草稿箱发布吧'
: '暂无续写作品,去草稿箱发布吧';
ctx.fillText(tipText, this.screenWidth / 2, startY + 40);
// 跳转草稿箱按钮
const btnY = startY + 70;
const btnWidth = 120;
const btnX = (this.screenWidth - btnWidth) / 2;
ctx.fillStyle = 'rgba(168, 85, 247, 0.3)';
this.roundRect(ctx, btnX, btnY, btnWidth, 36, 18);
ctx.fill();
ctx.fillStyle = '#a855f7';
ctx.font = '13px sans-serif';
ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 24);
this.gotoDraftsBtnRect = { x: btnX, y: btnY + this.scrollY, width: btnWidth, height: 36 };
return;
}
items.forEach((item, index) => {
const y = startY + index * (cardHeight + cardGap);
// 卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.06)';
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
ctx.fill();
// 标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
const title = item.title?.length > 15 ? item.title.substring(0, 15) + '...' : (item.title || '未命名作品');
ctx.fillText(title, padding + 15, y + 25);
// 原故事
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px sans-serif';
ctx.fillText(`原故事:${item.storyTitle || '未知'}`, padding + 15, y + 45);
// 创作时间
ctx.fillText(item.createdAt || '', padding + 15, y + 65);
// 阅读按钮
const btnX = this.screenWidth - padding - 70;
const btnGradient = ctx.createLinearGradient(btnX, y + 25, btnX + 60, y + 25);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, btnX, y + 25, 60, 30, 15);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('阅读', btnX + 30, y + 45);
this.publishedRects[type].push({
x: padding,
y: y + this.scrollY,
width: this.screenWidth - padding * 2,
height: cardHeight,
item,
btnRect: { x: btnX, y: y + 25 + this.scrollY, width: 60, height: 30 }
});
});
}
renderStoryList(ctx, startY, type) {
const padding = 15;
const cardHeight = 70;
@@ -487,6 +677,134 @@ export default class AICreateScene extends BaseScene {
}
}
renderCreatePanel(ctx) {
const padding = 20;
const panelWidth = this.screenWidth - padding * 2;
const panelHeight = 380;
const panelX = padding;
const panelY = (this.screenHeight - panelHeight) / 2;
// 遮罩层
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 面板背景渐变
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
panelGradient.addColorStop(0, '#1a1a3e');
panelGradient.addColorStop(1, '#0d0d1a');
ctx.fillStyle = panelGradient;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.fill();
// 面板边框渐变
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
borderGradient.addColorStop(0, '#a855f7');
borderGradient.addColorStop(1, '#ec4899');
ctx.strokeStyle = borderGradient;
ctx.lineWidth = 2;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.stroke();
// 标题栏
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('✨ 确认创作', this.screenWidth / 2, panelY + 35);
// 配额提示
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
ctx.fillStyle = remaining > 0 ? 'rgba(255,255,255,0.6)' : 'rgba(255,100,100,0.8)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`剩余次数:${remaining}`, panelX + panelWidth - 15, panelY + 35);
// 分隔线
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 55, panelX + panelWidth - 20, panelY + 55);
lineGradient.addColorStop(0, 'transparent');
lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)');
lineGradient.addColorStop(1, 'transparent');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(panelX + 20, panelY + 55);
ctx.lineTo(panelX + panelWidth - 20, panelY + 55);
ctx.stroke();
// 创作信息展示
let infoY = panelY + 85;
const lineHeight = 45;
const items = [
{ label: '题材', value: this.createForm.genre || '未选择' },
{ label: '关键词', value: this.createForm.keywords || '未填写' },
{ label: '主角设定', value: this.createForm.protagonist || '未填写' },
{ label: '核心冲突', value: this.createForm.conflict || '未填写' }
];
items.forEach((item, index) => {
const y = infoY + index * lineHeight;
// 标签
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(item.label + '', panelX + 20, y);
// 值
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
let displayValue = item.value;
if (displayValue.length > 18) {
displayValue = displayValue.substring(0, 18) + '...';
}
ctx.fillText(displayValue, panelX + 85, y);
});
// 消耗提示
ctx.fillStyle = 'rgba(255,200,100,0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('将消耗 1 次 AI 次数', this.screenWidth / 2, panelY + panelHeight - 85);
// 按钮区域
const btnWidth = (panelWidth - 50) / 2;
const btnHeight = 42;
const btnY = panelY + panelHeight - 60;
// 取消按钮
const cancelX = panelX + 15;
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, cancelX, btnY, btnWidth, btnHeight, 21);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
this.roundRect(ctx, cancelX, btnY, btnWidth, btnHeight, 21);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('取消', cancelX + btnWidth / 2, btnY + 27);
this.createPanelBtns.cancel = { x: cancelX, y: btnY, width: btnWidth, height: btnHeight };
// 确认按钮
const confirmX = panelX + panelWidth - btnWidth - 15;
const confirmGradient = ctx.createLinearGradient(confirmX, btnY, confirmX + btnWidth, btnY);
confirmGradient.addColorStop(0, '#a855f7');
confirmGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = confirmGradient;
this.roundRect(ctx, confirmX, btnY, btnWidth, btnHeight, 21);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('开始创作', confirmX + btnWidth / 2, btnY + 27);
this.createPanelBtns.confirm = { x: confirmX, y: btnY, width: btnWidth, height: btnHeight };
}
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
@@ -531,6 +849,12 @@ export default class AICreateScene extends BaseScene {
const x = touch.clientX;
const y = touch.clientY;
// 创作确认面板优先处理
if (this.showCreatePanel) {
this.handleCreatePanelTouch(x, y);
return;
}
// 返回按钮
if (y < 60 && x < 80) {
this.main.sceneManager.switchScene('home');
@@ -550,7 +874,6 @@ export default class AICreateScene extends BaseScene {
if (this.currentTab !== tab.index) {
this.currentTab = tab.index;
this.scrollY = 0;
this.selectedStory = null;
this.calculateMaxScroll();
}
return;
@@ -561,14 +884,34 @@ export default class AICreateScene extends BaseScene {
// 调整y坐标考虑滚动
const scrolledY = y + this.scrollY;
// 标签点击
if (this.tagRects) {
const tagType = this.currentTab === 0 ? 'rewrite' : this.currentTab === 1 ? 'continue' : 'genre';
const tags = this.tagRects[tagType];
// 前往草稿箱按钮
if (this.gotoDraftsBtnRect && this.isInRect(x, scrolledY, this.gotoDraftsBtnRect)) {
this.main.sceneManager.switchScene('drafts');
return;
}
// 已发布作品点击(改写/续写Tab
if (this.currentTab < 2 && this.publishedRects) {
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
const items = this.publishedRects[type];
if (items) {
for (const rect of items) {
// 阅读按钮点击
if (this.isInRect(x, scrolledY, rect.btnRect)) {
this.handleReadPublished(rect.item);
return;
}
}
}
}
// 标签点击只有创作Tab有标签
if (this.currentTab === 2 && this.tagRects) {
const tags = this.tagRects['genre'];
if (tags) {
for (const tag of tags) {
if (this.isInRect(x, scrolledY, tag)) {
this.handleTagSelect(tagType, tag);
this.handleTagSelect('genre', tag);
return;
}
}
@@ -586,26 +929,6 @@ export default class AICreateScene extends BaseScene {
}
}
// 故事列表点击
if (this.currentTab < 2 && this.storyRects) {
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
const stories = this.storyRects[type];
if (stories) {
for (const rect of stories) {
if (this.isInRect(x, scrolledY, rect)) {
this.selectedStory = rect.story;
return;
}
}
}
}
// 操作按钮
if (this.actionBtnRect && this.isInRect(x, scrolledY, this.actionBtnRect)) {
this.handleAction(this.actionBtnRect.type);
return;
}
// 创作按钮
if (this.currentTab === 2 && this.createBtnRect && this.isInRect(x, scrolledY, this.createBtnRect)) {
this.handleCreate();
@@ -613,6 +936,52 @@ export default class AICreateScene extends BaseScene {
}
}
handleReadPublished(item) {
// 跳转到故事场景播放AI改写/续写的内容
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
fromDrafts: true
});
}
handlePreviewCreated(item) {
// 跳转到故事场景播放AI创作的故事使用 draftId
this.main.sceneManager.switchScene('story', {
storyId: item.story_id,
draftId: item.id,
fromDrafts: true,
draftType: 'create' // 标记为AI创作类型
});
}
async handlePublishCreated(item) {
wx.showModal({
title: '确认发布',
content: `确定要发布《${item.title || '未命名故事'}》吗?\n发布后可在"我的作品"中查看`,
success: async (res) => {
if (res.confirm) {
wx.showLoading({ title: '发布中...', mask: true });
try {
const result = await post(`/stories/ai-create/${item.id}/publish`);
wx.hideLoading();
if (result && result.code === 0) {
wx.showToast({ title: '发布成功!', icon: 'success' });
// 刷新列表
this.loadData();
this.render();
} else {
wx.showToast({ title: result?.data?.message || '发布失败', icon: 'none' });
}
} catch (e) {
wx.hideLoading();
wx.showToast({ title: '发布失败', icon: 'none' });
}
}
}
});
}
isInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
@@ -620,10 +989,6 @@ export default class AICreateScene extends BaseScene {
handleTagSelect(type, tag) {
if (type === 'genre') {
this.createForm.genre = tag.value;
} else if (type === 'rewrite') {
this.selectedRewriteTag = tag.index;
} else if (type === 'continue') {
this.selectedContinueTag = tag.index;
}
}
@@ -762,10 +1127,10 @@ export default class AICreateScene extends BaseScene {
}
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
if (remaining < 5) {
if (remaining < 1) {
wx.showModal({
title: '次数不足',
content: 'AI创作需要5次配额,当前剩余' + remaining + '次',
content: 'AI创作需要1次配额,当前剩余' + remaining + '次',
confirmText: '获取更多',
success: (res) => {
if (res.confirm) this.showQuotaModal();
@@ -774,30 +1139,122 @@ export default class AICreateScene extends BaseScene {
return;
}
wx.showModal({
title: '确认创作',
content: `题材:${this.createForm.genre}\n关键词:${this.createForm.keywords}\n\n将消耗5次AI次数`,
success: async (res) => {
if (res.confirm) {
wx.showLoading({ title: 'AI创作中...', mask: true });
try {
// TODO: 实现完整创作API
const result = await this.main.storyManager.createStory(this.createForm);
wx.hideLoading();
if (result) {
this.quota.used += 5;
wx.showToast({ title: '创作成功!', icon: 'success' });
// 跳转到新故事
setTimeout(() => {
this.main.sceneManager.switchScene('story', { storyId: result.storyId });
}, 1500);
}
} catch (e) {
wx.hideLoading();
wx.showToast({ title: '创作失败', icon: 'none' });
}
}
// 显示创作确认面板
this.showCreatePanel = true;
}
handleCreatePanelTouch(x, y) {
// 点击取消
if (this.createPanelBtns.cancel && this.isInRect(x, y, this.createPanelBtns.cancel)) {
this.showCreatePanel = false;
return;
}
// 点击确认
if (this.createPanelBtns.confirm && this.isInRect(x, y, this.createPanelBtns.confirm)) {
this.showCreatePanel = false;
this.confirmCreate();
return;
}
}
async confirmCreate() {
wx.showLoading({ title: '提交中...', mask: true });
try {
const userId = this.main?.userManager?.userId;
if (!userId) {
wx.hideLoading();
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
});
const result = await this.main.storyManager.createStory({
...this.createForm,
userId: userId
});
wx.hideLoading();
const draftId = result?.data?.draftId || result?.draftId;
if (draftId) {
this.quota.used += 1;
// 显示提示框,用户可以选择等待或返回
wx.showModal({
title: '创作已提交',
content: 'AI正在创作故事预计需要1-2分钟完成后可在草稿箱查看',
confirmText: '等待结果',
cancelText: '返回',
success: (modalRes) => {
if (modalRes.confirm) {
// 用户选择等待显示loading并轮询
wx.showLoading({ title: 'AI创作中...', mask: true });
this.pollCreateStatus(draftId);
}
// 用户选择返回,后台继续创作,稍后可在草稿箱查看
}
});
} else {
wx.showToast({ title: '创作失败', icon: 'none' });
}
} catch (e) {
wx.hideLoading();
wx.showToast({ title: '创作失败', icon: 'none' });
}
}
/**
* 轮询AI创作状态
*/
async pollCreateStatus(draftId, retries = 0) {
const maxRetries = 60; // 最多等待5分钟每5秒检查一次
if (retries >= maxRetries) {
wx.hideLoading();
wx.showModal({
title: '创作超时',
content: '故事创作时间较长,请稍后在"AI创作"中查看',
showCancel: false
});
return;
}
try {
const res = await get(`/stories/ai-create/${draftId}/status`);
const status = res?.data || res; // 兼容两种格式
if (status && status.isCompleted) {
wx.hideLoading();
wx.showModal({
title: '创作成功!',
content: `故事《${status.title}》已保存到草稿箱`,
confirmText: '去查看',
cancelText: '继续创作',
success: (res) => {
if (res.confirm) {
// 刷新当前页面数据
this.loadData();
this.render();
}
}
});
} else if (status && (status.status === -1 || status.isFailed)) {
wx.hideLoading();
wx.showModal({
title: '创作失败',
content: status.errorMessage || '故事创作失败,请检查输入后重试',
showCancel: false
});
} else {
// 继续轮询
setTimeout(() => {
this.pollCreateStatus(draftId, retries + 1);
}, 5000);
}
} catch (e) {
console.error('轮询状态失败:', e);
// 请求失败,继续重试
setTimeout(() => {
this.pollCreateStatus(draftId, retries + 1);
}, 5000);
}
}
}

View File

@@ -43,9 +43,48 @@ export default class EndingScene extends BaseScene {
this.showButtons = true;
}, 1500);
// 保存游玩记录回放模式和AI草稿不保存
if (!this.isReplay && !this.draftId) {
// 保存游玩记录
this.checkAndSavePlayRecord();
// 加载收藏状态
this.loadCollectStatus();
}
async checkAndSavePlayRecord() {
// 回放模式不保存
if (this.isReplay) return;
// 原故事:直接保存
if (!this.draftId) {
this.savePlayRecord();
return;
}
// AI草稿检查是否已发布已发布才保存
try {
const userId = this.main.userManager.userId;
const drafts = await this.main.storyManager.getDrafts(userId) || [];
const draft = drafts.find(d => d.id === this.draftId);
if (draft?.publishedToCenter) {
this.savePlayRecord();
}
} catch (e) {
console.error('检查草稿发布状态失败:', e);
}
}
async loadCollectStatus() {
try {
if (this.draftId) {
// AI草稿获取草稿收藏状态
this.isCollected = await this.main.userManager.getDraftCollectStatus(this.draftId);
} else {
// 原故事:获取故事收藏状态
const progress = await this.main.userManager.getProgress(this.storyId);
this.isCollected = progress?.isCollected || false;
}
} catch (e) {
console.error('加载收藏状态失败:', e);
}
}
@@ -56,12 +95,13 @@ export default class EndingScene extends BaseScene {
const endingName = this.ending?.name || '未知结局';
const endingType = this.ending?.type || '';
// 调用保存接口
// 调用保存接口(传入 draftId 区分原故事和AI草稿
await this.main.userManager.savePlayRecord(
this.storyId,
endingName,
endingType,
pathHistory
pathHistory,
this.draftId // AI草稿ID原故事为null
);
console.log('游玩记录保存成功');
} catch (e) {
@@ -1193,7 +1233,13 @@ export default class EndingScene extends BaseScene {
handleCollect() {
this.isCollected = !this.isCollected;
this.main.userManager.collectStory(this.storyId, this.isCollected);
if (this.draftId) {
// AI草稿收藏草稿
this.main.userManager.collectDraft(this.draftId, this.isCollected);
} else {
// 原故事:收藏故事
this.main.userManager.collectStory(this.storyId, this.isCollected);
}
}
// 启动草稿完成轮询每5秒检查一次持续2分钟

View File

@@ -2,6 +2,7 @@
* 首页场景 - 支持UGC
*/
import BaseScene from './BaseScene';
import { getStaticUrl } from '../utils/http';
export default class HomeScene extends BaseScene {
constructor(main, params) {
@@ -13,6 +14,9 @@ export default class HomeScene extends BaseScene {
this.lastTouchY = 0;
this.scrollVelocity = 0;
// 封面图片缓存
this.coverImages = {};
// 底部Tab: 首页/发现/创作/我的
this.bottomTab = 0;
@@ -35,6 +39,23 @@ export default class HomeScene extends BaseScene {
async init() {
this.storyList = this.main.storyManager.storyList;
this.calculateMaxScroll();
// 预加载封面图片
this.preloadCoverImages();
}
/**
* 预加载故事封面图片
*/
preloadCoverImages() {
this.storyList.forEach(story => {
if (story.cover_url && !this.coverImages[story.id]) {
const img = wx.createImage();
img.onload = () => {
this.coverImages[story.id] = img;
};
img.src = getStaticUrl(story.cover_url);
}
});
}
getFilteredStories() {
@@ -248,18 +269,32 @@ export default class HomeScene extends BaseScene {
// 封面
const coverW = 80, coverH = height - 20;
const coverGradient = ctx.createLinearGradient(x + 10, y + 10, x + 10 + coverW, y + 10 + coverH);
const colors = this.getCategoryGradient(story.category);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 10, y + 10, coverW, coverH, 10);
ctx.fill();
const coverX = x + 10, coverY = y + 10;
// 尝试显示封面图片
const coverImg = this.coverImages[story.id];
if (coverImg) {
// 有图片,绘制图片
ctx.save();
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.clip();
ctx.drawImage(coverImg, coverX, coverY, coverW, coverH);
ctx.restore();
} else {
// 无图片,显示渐变占位
const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH);
const colors = this.getCategoryGradient(story.category);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(story.category || '故事', x + 10 + coverW / 2, y + 10 + coverH / 2 + 4);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(story.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 4);
}
const textX = x + 100;
const maxW = width - 115;

View File

@@ -2,6 +2,7 @@
* 个人中心场景 - 支持创作者功能
*/
import BaseScene from './BaseScene';
import { getStaticUrl } from '../utils/http';
export default class ProfileScene extends BaseScene {
constructor(main, params) {
@@ -40,6 +41,9 @@ export default class ProfileScene extends BaseScene {
this.lastTouchY = 0;
this.scrollVelocity = 0;
this.hasMoved = false;
// 封面图片缓存
this.coverImages = {}; // { url: Image对象 }
}
async init() {
@@ -69,27 +73,62 @@ export default class ProfileScene extends BaseScene {
}
}
// 加载封面图片
loadCoverImage(url) {
if (!url || this.coverImages[url] !== undefined) return;
// 标记为加载中
this.coverImages[url] = null;
const img = wx.createImage();
img.onload = () => {
this.coverImages[url] = img;
};
img.onerror = () => {
this.coverImages[url] = false; // 加载失败
};
// 使用 getStaticUrl 处理 URL与首页一致
img.src = getStaticUrl(url);
}
// 预加载当前列表的封面图片
preloadCoverImages() {
const list = this.getCurrentList();
list.forEach(item => {
const coverUrl = item.coverUrl || item.cover_url;
if (coverUrl) {
this.loadCoverImage(coverUrl);
}
});
}
async loadData() {
if (this.main.userManager.isLoggedIn) {
try {
const userId = this.main.userManager.userId;
this.myWorks = await this.main.userManager.getMyWorks?.() || [];
// 加载已发布到创作中心的作品(改写+续写+创作)
const publishedRewrites = await this.main.userManager.getPublishedDrafts('rewrite') || [];
const publishedContinues = await this.main.userManager.getPublishedDrafts('continue') || [];
const publishedCreates = await this.main.userManager.getPublishedDrafts('create') || [];
this.myWorks = [...publishedRewrites, ...publishedContinues, ...publishedCreates];
// 加载 AI 改写草稿
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
this.collections = await this.main.userManager.getCollections() || [];
// 加载游玩记录(故事列表)
this.progress = await this.main.userManager.getPlayRecords() || [];
// 计算统计
// 计算统计(作品数=已发布作品数)
this.stats.works = this.myWorks.length;
this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.play_count || 0), 0);
this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.like_count || 0), 0);
this.stats.earnings = this.myWorks.reduce((sum, w) => sum + (w.earnings || 0), 0);
this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.playCount || 0), 0);
this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.likeCount || 0), 0);
this.stats.earnings = 0; // 暂无收益功能
} catch (e) {
console.error('加载数据失败:', e);
}
}
this.calculateMaxScroll();
this.preloadCoverImages();
}
// 刷新草稿列表
@@ -333,25 +372,25 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
const emptyTexts = ['还没有发布作品,去创作吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录'];
const emptyTexts = ['还没有发布作品,去草稿箱发布吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录'];
const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions')
? '该故事还没有游玩记录'
: emptyTexts[this.currentTab];
ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50);
// 创作引导按钮
// 作品Tab引导按钮 - 跳转到草稿箱
if (this.currentTab === 0) {
const btnY = listStartY + 80;
const btnGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, btnY, this.screenWidth / 2 + 50, btnY);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, this.screenWidth / 2 - 50, btnY, 100, 36, 18);
this.roundRect(ctx, this.screenWidth / 2 - 55, btnY, 110, 36, 18);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px sans-serif';
ctx.fillText('✨ 开始创作', this.screenWidth / 2, btnY + 23);
this.createBtnRect = { x: this.screenWidth / 2 - 50, y: btnY, width: 100, height: 36 };
ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 23);
this.createBtnRect = { x: this.screenWidth / 2 - 55, y: btnY, width: 110, height: 36 };
}
ctx.restore();
@@ -431,7 +470,10 @@ export default class ProfileScene extends BaseScene {
// 渲染单条游玩记录版本卡片
renderRecordVersionCard(ctx, item, x, y, w, h, index) {
ctx.fillStyle = 'rgba(255,255,255,0.05)';
const isUnpublished = item.draftId && item.isPublished === false;
// 已下架的卡片背景更暗
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)';
this.roundRect(ctx, x, y, w, h, 12);
ctx.fill();
@@ -441,34 +483,49 @@ export default class ProfileScene extends BaseScene {
const circleR = 18;
const colors = this.getGradientColors(index);
const circleGradient = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR);
circleGradient.addColorStop(0, colors[0]);
circleGradient.addColorStop(1, colors[1]);
circleGradient.addColorStop(0, isUnpublished ? 'rgba(128,128,128,0.5)' : colors[0]);
circleGradient.addColorStop(1, isUnpublished ? 'rgba(96,96,96,0.5)' : colors[1]);
ctx.fillStyle = circleGradient;
ctx.beginPath();
ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2);
ctx.fill();
// 序号
ctx.fillStyle = '#ffffff';
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${index + 1}`, circleX, circleY + 5);
const textX = x + 65;
const maxTextWidth = w - 200; // 留出按钮空间
// 结局名称
ctx.fillStyle = '#ffffff';
// 结局名称(只显示结局,不显示草稿标题)
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
const endingLabel = `结局:${item.endingName || '未知结局'}`;
ctx.fillText(this.truncateText(ctx, endingLabel, w - 150), textX, y + 28);
ctx.fillText(this.truncateText(ctx, endingLabel, maxTextWidth - 60), textX, y + 28);
// 已下架标签(固定在结局名称右边)
if (isUnpublished) {
ctx.fillStyle = 'rgba(239, 68, 68, 0.3)';
this.roundRect(ctx, x + w - 185, y + 15, 44, 18, 9);
ctx.fill();
ctx.fillStyle = '#ef4444';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('已下架', x + w - 163, y + 27);
}
// 游玩时间
ctx.fillStyle = 'rgba(255,255,255,0.45)';
// 游玩时间(如果是草稿,显示草稿标题)
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.45)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
const timeText = item.createdAt ? this.formatDateTime(item.createdAt) : '';
ctx.fillText(timeText, textX, y + 52);
let subText = item.createdAt ? this.formatDateTime(item.createdAt) : '';
if (item.draftTitle) {
subText = this.truncateText(ctx, item.draftTitle, 100) + ' · ' + subText;
}
ctx.fillText(subText, textX, y + 52);
// 删除按钮
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
@@ -481,8 +538,8 @@ export default class ProfileScene extends BaseScene {
// 回放按钮
const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28);
btnGradient.addColorStop(0, '#ff6b6b');
btnGradient.addColorStop(1, '#ffd700');
btnGradient.addColorStop(0, isUnpublished ? '#888888' : '#ff6b6b');
btnGradient.addColorStop(1, isUnpublished ? '#666666' : '#ffd700');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13);
ctx.fill();
@@ -518,18 +575,59 @@ export default class ProfileScene extends BaseScene {
// 封面
const coverW = 70, coverH = h - 16;
const colors = this.getGradientColors(index);
const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10);
ctx.fill();
const coverX = x + 8, coverY = y + 8;
const coverUrl = item.coverUrl || item.cover_url;
const coverImg = coverUrl ? this.coverImages[coverUrl] : null;
// 尝试加载封面图片
if (coverUrl && this.coverImages[coverUrl] === undefined) {
this.loadCoverImage(coverUrl);
}
// 绘制封面
if (coverImg && coverImg !== false) {
// 有封面图片,裁剪绘制
ctx.save();
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.clip();
// 等比例填充
const imgRatio = coverImg.width / coverImg.height;
const areaRatio = coverW / coverH;
let drawW, drawH, drawX, drawY;
if (imgRatio > areaRatio) {
drawH = coverH;
drawW = drawH * imgRatio;
drawX = coverX - (drawW - coverW) / 2;
drawY = coverY;
} else {
drawW = coverW;
drawH = drawW / imgRatio;
drawX = coverX;
drawY = coverY - (drawH - coverH) / 2;
}
ctx.drawImage(coverImg, drawX, drawY, drawW, drawH);
ctx.restore();
} else {
// 无封面图片,使用渐变色
const colors = this.getGradientColors(index);
const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.fill();
}
ctx.fillStyle = 'rgba(255,255,255,0.8)';
// 类型标签
const typeText = item.draftType === 'continue' ? '续写' : (item.draftType === 'create' ? '创作' : '改写');
ctx.fillStyle = 'rgba(0,0,0,0.5)';
this.roundRect(ctx, coverX, coverY, 32, 18, 6);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
ctx.fillText(typeText, coverX + 16, coverY + 13);
const textX = x + 88;
const maxW = w - 100;
@@ -538,46 +636,55 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(this.truncateText(ctx, item.title || '未命名', maxW - 60), textX, y + 25);
const title = item.title || item.storyTitle || '未命名';
ctx.fillText(this.truncateText(ctx, title, maxW - 60), textX, y + 25);
// 审核状态标签
const statusMap = {
0: { text: '草稿', color: '#888888' },
1: { text: '审核中', color: '#f59e0b' },
2: { text: '已发布', color: '#22c55e' },
3: { text: '已下架', color: '#ef4444' },
4: { text: '被拒绝', color: '#ef4444' }
};
const status = statusMap[item.status] || statusMap[0];
const statusW = ctx.measureText(status.text).width + 12;
ctx.fillStyle = status.color + '33';
this.roundRect(ctx, textX + ctx.measureText(this.truncateText(ctx, item.title || '未命名', maxW - 60)).width + 8, y + 12, statusW, 18, 9);
// 已发布标签
const statusText = '已发布';
const statusW = ctx.measureText(statusText).width + 12;
ctx.fillStyle = 'rgba(34, 197, 94, 0.2)';
const titleWidth = ctx.measureText(this.truncateText(ctx, title, maxW - 60)).width;
this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9);
ctx.fill();
ctx.fillStyle = status.color;
ctx.fillStyle = '#22c55e';
ctx.font = 'bold 10px sans-serif';
ctx.fillText(status.text, textX + ctx.measureText(this.truncateText(ctx, item.title || '未命名', maxW - 60)).width + 8 + statusW / 2, y + 24);
ctx.textAlign = 'center';
ctx.fillText(statusText, textX + titleWidth + 8 + statusW / 2, y + 24);
// 数据统计
// 原故事标题
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(` ${this.formatNumber(item.play_count || 0)}`, textX, y + 50);
ctx.fillText(`${this.formatNumber(item.like_count || 0)}`, textX + 55, y + 50);
ctx.fillText(`💰 ${(item.earnings || 0).toFixed(1)}`, textX + 105, y + 50);
ctx.fillText(`原故事: ${item.storyTitle || ''}`, textX, y + 48);
// 操作按钮
const btnY = y + 65;
const btns = ['编辑', '数据'];
btns.forEach((btn, i) => {
const btnX = textX + i * 55;
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, btnX, btnY, 48, 24, 12);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(btn, btnX + 24, btnY + 16);
});
// 创建时间
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '10px sans-serif';
ctx.fillText(item.createdAt || '', textX, y + 68);
// 按钮区域
const btnY = y + 55;
// 取消发布按钮
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
this.roundRect(ctx, x + w - 125, btnY, 60, 26, 13);
ctx.fill();
ctx.fillStyle = '#ef4444';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('取消发布', x + w - 95, btnY + 17);
// 播放按钮
const btnGradient = ctx.createLinearGradient(x + w - 58, btnY, x + w - 10, btnY);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, x + w - 58, btnY, 48, 26, 13);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('播放', x + w - 34, btnY + 17);
}
renderDraftCard(ctx, item, x, y, w, h, index) {
@@ -586,22 +693,57 @@ export default class ProfileScene extends BaseScene {
ctx.fill();
const coverW = 70, coverH = h - 16;
const colors = this.getGradientColors(index);
const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10);
ctx.fill();
const coverX = x + 8, coverY = y + 8;
const coverUrl = item.coverUrl || item.cover_url;
const coverImg = coverUrl ? this.coverImages[coverUrl] : null;
// 尝试加载封面图片
if (coverUrl && this.coverImages[coverUrl] === undefined) {
this.loadCoverImage(coverUrl);
}
// 绘制封面
if (coverImg && coverImg !== false) {
// 有封面图片,裁剪绘制
ctx.save();
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.clip();
const imgRatio = coverImg.width / coverImg.height;
const areaRatio = coverW / coverH;
let drawW, drawH, drawX, drawY;
if (imgRatio > areaRatio) {
drawH = coverH;
drawW = drawH * imgRatio;
drawX = coverX - (drawW - coverW) / 2;
drawY = coverY;
} else {
drawW = coverW;
drawH = drawW / imgRatio;
drawX = coverX;
drawY = coverY - (drawH - coverH) / 2;
}
ctx.drawImage(coverImg, drawX, drawY, drawW, drawH);
ctx.restore();
} else {
// 无封面图片,使用渐变色
const colors = this.getGradientColors(index);
const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.fill();
}
// AI标签
ctx.fillStyle = '#a855f7';
this.roundRect(ctx, x + 8, y + 8, 28, 16, 8);
this.roundRect(ctx, coverX, coverY, 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.fillText('AI', coverX + 14, coverY + 11);
const textX = x + 88;
@@ -636,10 +778,10 @@ export default class ProfileScene extends BaseScene {
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);
ctx.fillText(item.createdAt || '', textX, y + 72);
// 未读标记
if (!item.isRead && item.status === 'completed') {
@@ -649,31 +791,50 @@ export default class ProfileScene extends BaseScene {
ctx.fill();
}
// 按钮
const btnY = y + 62;
// 删除按钮(所有状态都显示)
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);
// 按钮行(放在右下角)
const btnY = y + 60;
const btnStartX = x + w - 170; // 从右边开始排列按钮
// 播放按钮(仅已完成状态)
if (item.status === 'completed') {
const btnGradient = ctx.createLinearGradient(textX, btnY, textX + 65, btnY);
const btnGradient = ctx.createLinearGradient(btnStartX, btnY, btnStartX + 50, btnY);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, textX + 120, btnY, 60, 24, 12);
this.roundRect(ctx, btnStartX, btnY, 50, 26, 13);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('播放', textX + 150, btnY + 16);
ctx.fillText('播放', btnStartX + 25, btnY + 17);
// 发布按钮(仅已完成且未发布)
if (!item.publishedToCenter) {
ctx.fillStyle = 'rgba(34, 197, 94, 0.2)';
this.roundRect(ctx, btnStartX + 58, btnY, 50, 26, 13);
ctx.fill();
ctx.fillStyle = '#22c55e';
ctx.font = '11px sans-serif';
ctx.fillText('发布', btnStartX + 83, btnY + 17);
} else {
// 已发布标识
ctx.fillStyle = 'rgba(34, 197, 94, 0.15)';
this.roundRect(ctx, btnStartX + 58, btnY, 55, 26, 13);
ctx.fill();
ctx.fillStyle = '#22c55e';
ctx.font = '10px sans-serif';
ctx.fillText('已发布', btnStartX + 85, btnY + 17);
}
}
// 删除按钮(所有状态都显示,最右边)
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
this.roundRect(ctx, x + w - 55, btnY, 45, 26, 13);
ctx.fill();
ctx.fillStyle = '#ef4444';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('删除', x + w - 32, btnY + 17);
}
renderSimpleCard(ctx, item, x, y, w, h, index) {
@@ -682,25 +843,59 @@ export default class ProfileScene extends BaseScene {
ctx.fill();
const coverW = 60, coverH = h - 16;
const colors = this.getGradientColors(index);
const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 8);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
const coverX = x + 8, coverY = y + 8;
const coverUrl = item.coverUrl || item.cover_url;
const coverImg = coverUrl ? this.coverImages[coverUrl] : null;
// 尝试加载封面图片
if (coverUrl && this.coverImages[coverUrl] === undefined) {
this.loadCoverImage(coverUrl);
}
// 绘制封面
if (coverImg && coverImg !== false) {
ctx.save();
this.roundRect(ctx, coverX, coverY, coverW, coverH, 8);
ctx.clip();
const imgRatio = coverImg.width / coverImg.height;
const areaRatio = coverW / coverH;
let drawW, drawH, drawX, drawY;
if (imgRatio > areaRatio) {
drawH = coverH;
drawW = drawH * imgRatio;
drawX = coverX - (drawW - coverW) / 2;
drawY = coverY;
} else {
drawW = coverW;
drawH = drawW / imgRatio;
drawX = coverX;
drawY = coverY - (drawH - coverH) / 2;
}
ctx.drawImage(coverImg, drawX, drawY, drawW, drawH);
ctx.restore();
} else {
const colors = this.getGradientColors(index);
const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, coverX, coverY, coverW, coverH, 8);
ctx.fill();
// 无图片时显示分类
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 3);
}
const textX = x + 78;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
// 记录Tab使用 storyTitle收藏Tab使用 story_title
// 记录Tab使用 storyTitle收藏Tab使用 storyTitle
const title = item.storyTitle || item.story_title || item.title || '未知';
ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28);
@@ -709,11 +904,14 @@ export default class ProfileScene extends BaseScene {
if (this.currentTab === 3) {
// 记录Tab只显示记录数量
ctx.fillText(`${item.recordCount || 0} 条记录`, textX, y + 50);
} else if (this.currentTab === 2 && item.versionCount > 1) {
// 收藏Tab显示版本数量
ctx.fillText(`${item.versionCount} 个版本`, textX, y + 50);
} else {
ctx.fillText(item.category || '', textX, y + 50);
}
// 查看按钮记录Tab/ 继续按钮(收藏Tab
// 查看按钮记录Tab/收藏Tab/作品Tab
const btnGradient = ctx.createLinearGradient(x + w - 58, y + 28, x + w - 10, y + 28);
btnGradient.addColorStop(0, '#ff6b6b');
btnGradient.addColorStop(1, '#ffd700');
@@ -723,7 +921,7 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.currentTab === 3 ? '查看' : '继续', x + w - 34, y + 45);
ctx.fillText('查看', x + w - 34, y + 45);
}
getGradientColors(index) {
@@ -848,11 +1046,14 @@ export default class ProfileScene extends BaseScene {
}
}
// 创作按钮
// 前往草稿箱按钮
if (this.createBtnRect && this.currentTab === 0) {
const btn = this.createBtnRect;
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) {
this.main.sceneManager.switchScene('aiCreate');
// 切换到草稿箱 Tab
this.currentTab = 1;
this.scrollY = 0;
this.calculateMaxScroll();
return;
}
}
@@ -896,28 +1097,45 @@ export default class ProfileScene extends BaseScene {
// AI草稿 Tab 的按钮检测
if (this.currentTab === 1) {
const btnY = 62;
const btnH = 24;
const btnY = 60;
const btnH = 26;
const btnStartX = padding + cardW - 170;
// 检测删除按钮点击(右侧)
// 检测删除按钮点击(右侧)
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 });
if (x >= btnStartX && x <= btnStartX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
draftType: item.draftType || item.draft_type
});
return;
}
// 检测发布按钮点击(仅未发布状态)
if (!item.publishedToCenter) {
const publishBtnX = btnStartX + 58;
if (x >= publishBtnX && x <= publishBtnX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.confirmPublishDraft(item, index);
return;
}
}
}
// 点击卡片其他区域
if (item.status === 'completed') {
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
draftType: item.draftType || item.draft_type
});
} else if (item.status === 'failed') {
wx.showToast({ title: 'AI改写失败', icon: 'none' });
} else {
@@ -957,17 +1175,62 @@ export default class ProfileScene extends BaseScene {
}
if (this.currentTab === 2) {
// 收藏 - 跳转播放
this.main.sceneManager.switchScene('story', { storyId });
// 收藏 - 检查版本数量
if (item.versionCount > 1) {
// 多版本:弹出选择框
this.showVersionSelector(item);
} else if (item.versions && item.versions.length > 0) {
// 单版本:直接播放
const version = item.versions[0];
if (version.draftId) {
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: version.draftId });
} else {
this.main.sceneManager.switchScene('story', { storyId: item.storyId });
}
} else {
// 兼容旧数据
this.main.sceneManager.switchScene('story', { storyId });
}
return;
}
// 作品Tab - 点击按钮进入草稿播放或取消发布
if (this.currentTab === 0) {
const btnY = 55;
const btnH = 26;
// 检测取消发布按钮点击
const unpublishBtnX = padding + cardW - 125;
if (x >= unpublishBtnX && x <= unpublishBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.confirmUnpublishWork(item, index);
return;
}
// 检测播放按钮点击
const playBtnX = padding + cardW - 58;
if (x >= playBtnX && x <= playBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
draftType: item.draftType || item.draft_type
});
return;
}
// 点击卡片其他区域也进入播放
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
draftType: item.draftType || item.draft_type
});
}
// 作品Tab的按钮操作需要更精确判断暂略
}
}
// 显示故事的版本列表
async showStoryVersions(storyItem) {
const storyId = storyItem.story_id || storyItem.storyId || storyItem.id;
const storyTitle = storyItem.story_title || storyItem.title || '未知故事';
const storyTitle = storyItem.storyTitle || storyItem.story_title || storyItem.title || '未知故事';
try {
wx.showLoading({ title: '加载中...' });
@@ -994,11 +1257,13 @@ export default class ProfileScene extends BaseScene {
async startRecordReplay(recordItem) {
const recordId = recordItem.id;
const storyId = this.selectedStoryInfo.id;
const draftId = recordItem.draftId; // AI草稿ID原故事为null
// 进入故事场景,传入 playRecordId 参数
// 进入故事场景,传入 playRecordId 参数draftId用于回放草稿内容
this.main.sceneManager.switchScene('story', {
storyId,
playRecordId: recordId
playRecordId: recordId,
draftId // 回放草稿内容
});
}
@@ -1027,6 +1292,105 @@ export default class ProfileScene extends BaseScene {
});
}
// 确认发布草稿到创作中心
confirmPublishDraft(item, index) {
wx.showModal({
title: '发布到创作中心',
content: `确定要将「${item.title || 'AI改写'}」发布到创作中心吗?`,
confirmText: '发布',
confirmColor: '#22c55e',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
wx.showLoading({ title: '发布中...' });
const success = await this.main.userManager.publishDraft(item.id);
wx.hideLoading();
if (success) {
// 更新草稿箱状态
this.drafts[index].publishedToCenter = true;
// 同步添加到作品列表
this.myWorks.push({
id: item.id,
storyId: item.storyId,
storyTitle: item.storyTitle,
title: item.title,
draftType: item.draftType || 'rewrite',
createdAt: item.createdAt
});
this.stats.works = this.myWorks.length;
wx.showToast({ title: '发布成功', icon: 'success' });
} else {
wx.showToast({ title: '发布失败', icon: 'none' });
}
}
}
});
}
// 确认取消发布作品
confirmUnpublishWork(item, index) {
wx.showModal({
title: '取消发布',
content: `确定要将「${item.title || 'AI改写'}」从创作中心移除吗?草稿仍会保留在草稿箱中。`,
confirmText: '取消发布',
confirmColor: '#ef4444',
cancelText: '返回',
success: async (res) => {
if (res.confirm) {
wx.showLoading({ title: '处理中...' });
const success = await this.main.userManager.unpublishDraft(item.id);
wx.hideLoading();
if (success) {
// 从作品列表中移除
this.myWorks.splice(index, 1);
this.stats.works = this.myWorks.length;
this.calculateMaxScroll();
// 同步更新草稿箱状态
const draftIndex = this.drafts.findIndex(d => d.id === item.id);
if (draftIndex !== -1) {
this.drafts[draftIndex].publishedToCenter = false;
}
wx.showToast({ title: '已取消发布', icon: 'success' });
} else {
wx.showToast({ title: '操作失败', icon: 'none' });
}
}
}
});
}
// 显示版本选择弹窗收藏Tab用
showVersionSelector(item) {
const versions = item.versions || [];
const itemList = versions.map(v => {
if (v.type === 'original') {
return '原版故事';
} else if (v.type === 'rewrite') {
return `AI改写: ${v.title}`;
} else if (v.type === 'continue') {
return `AI续写: ${v.title}`;
}
return v.title;
});
wx.showActionSheet({
itemList,
success: (res) => {
const selectedVersion = versions[res.tapIndex];
if (selectedVersion) {
if (selectedVersion.draftId) {
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: selectedVersion.draftId
});
} else {
this.main.sceneManager.switchScene('story', { storyId: item.storyId });
}
}
}
});
}
// 确认删除游玩记录
confirmDeleteRecord(item, index) {
wx.showModal({

View File

@@ -2,12 +2,14 @@
* 故事播放场景 - 视觉小说风格
*/
import BaseScene from './BaseScene';
import { getNodeBackground, getNodeCharacter, getDraftNodeBackground, getStaticUrl } from '../utils/http';
export default class StoryScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.storyId = params.storyId;
this.draftId = params.draftId || null; // 草稿ID
this.draftType = params.draftType || null; // 草稿类型:'create' | 'rewrite' | 'continue'
this.playRecordId = params.playRecordId || null; // 游玩记录ID从记录回放
this.aiContent = params.aiContent || null; // AI改写内容
this.story = null;
@@ -31,8 +33,21 @@ export default class StoryScene extends BaseScene {
// 场景图相关
this.sceneImage = null;
this.sceneColors = this.generateSceneColors();
// 节点图片相关
this.nodeBackgroundImages = {}; // 缓存背景图 {nodeKey: Image}
this.nodeCharacterImages = {}; // 缓存立绘 {nodeKey: Image}
this.currentBackgroundImg = null;
this.currentCharacterImg = null;
// AI改写相关
this.isAIRewriting = false;
this.showRewritePanel = false; // 显示改写面板
this.rewritePrompt = ''; // 改写输入内容
this.selectedRewriteTag = -1; // 选中的快捷标签
this.rewriteTags = ['剧情反转', '主角逆袭', '意外相遇', '真相揭露', '危机来临']; // 快捷标签
this.rewriteTagRects = []; // 标签位置
this.rewriteInputRect = null; // 输入框位置
this.rewriteCancelBtn = null; // 取消按钮位置
this.rewriteConfirmBtn = null; // 确认按钮位置
// 剧情回顾模式
this.isRecapMode = false;
this.recapData = null;
@@ -124,7 +139,7 @@ export default class StoryScene extends BaseScene {
// 如果是从Draft加载先获取草稿详情进入回顾模式
if (this.draftId) {
this.main.showLoading('加载AI改写内容...');
this.main.showLoading('加载AI内容...');
const draft = await this.main.storyManager.getDraftDetail(this.draftId);
@@ -133,9 +148,44 @@ export default class StoryScene extends BaseScene {
hasAiNodes: !!draft?.aiNodes,
aiNodesKeys: draft?.aiNodes ? Object.keys(draft.aiNodes) : [],
entryNodeKey: draft?.entryNodeKey,
pathHistoryLength: draft?.pathHistory?.length
pathHistoryLength: draft?.pathHistory?.length,
draftType: this.draftType
}));
// AI创作类型ai_nodes 包含完整故事,不需要加载原故事
if (this.draftType === 'create' && draft && draft.aiNodes) {
// 构建虚拟故事对象
this.story = {
id: this.draftId,
title: draft.title || '未命名故事',
category: draft.aiNodes.category || '冒险',
nodes: draft.aiNodes.nodes || {},
characters: draft.aiNodes.characters || []
};
// 设置到 storyManager使选项选择能正常工作
this.main.storyManager.currentStory = this.story;
this.main.storyManager.pathHistory = [];
this.setThemeByCategory(this.story.category);
// 从起始节点开始播放
const startKey = draft.entryNodeKey || draft.aiNodes.startNodeKey || 'start';
this.main.storyManager.currentNodeKey = startKey;
this.currentNode = this.story.nodes[startKey];
if (this.currentNode) {
this.main.hideLoading();
this.startTypewriter(this.currentNode.content);
return;
}
this.main.hideLoading();
this.main.showError('故事内容加载失败');
this.main.sceneManager.switchScene('aiCreate');
return;
}
if (draft && draft.aiNodes && draft.storyId) {
// 先加载原故事
this.story = await this.main.storyManager.loadStoryDetail(draft.storyId);
@@ -638,6 +688,80 @@ export default class StoryScene extends BaseScene {
// 重置滚动
this.textScrollY = 0;
this.maxScrollY = 0;
// 加载当前节点的背景图和立绘
this.loadNodeImages();
}
// 加载当前节点的背景图和立绘
loadNodeImages() {
if (!this.currentNode || !this.storyId) return;
const nodeKey = this.currentNode.nodeKey || this.main.storyManager.currentNodeKey;
if (!nodeKey) return;
// 判断是否是草稿模式
const isDraftMode = !!this.draftId;
// 获取背景图 URL
let bgUrl;
if (isDraftMode) {
// 草稿模式:
// 1. AI生成的节点有 background_url使用它
// 2. 历史节点没有 background_url使用原故事的图片路径
if (this.currentNode.background_url) {
bgUrl = getStaticUrl(this.currentNode.background_url);
} else {
// 历史节点使用原故事的背景图
bgUrl = getNodeBackground(this.storyId, nodeKey);
}
} else {
// 普通模式:使用故事节点路径
bgUrl = getNodeBackground(this.storyId, nodeKey);
}
console.log('[loadNodeImages] nodeKey:', nodeKey, ', isDraftMode:', isDraftMode, ', bgUrl:', bgUrl);
// 加载背景图
if (!this.nodeBackgroundImages[nodeKey]) {
const bgImg = wx.createImage();
bgImg.onload = () => {
this.nodeBackgroundImages[nodeKey] = bgImg;
if (this.main.storyManager.currentNodeKey === nodeKey || isDraftMode) {
this.currentBackgroundImg = bgImg;
}
};
bgImg.onerror = () => {
// 图片加载失败,使用默认渐变
this.nodeBackgroundImages[nodeKey] = null;
};
bgImg.src = bgUrl;
} else {
this.currentBackgroundImg = this.nodeBackgroundImages[nodeKey];
}
// 加载角色立绘(只在后端返回了 character_image 时才加载)
if (!isDraftMode && this.currentNode.character_image) {
if (!this.nodeCharacterImages[nodeKey]) {
const charImg = wx.createImage();
charImg.onload = () => {
this.nodeCharacterImages[nodeKey] = charImg;
if (this.main.storyManager.currentNodeKey === nodeKey) {
this.currentCharacterImg = charImg;
}
};
charImg.onerror = () => {
// 图片加载失败,不显示立绘
this.nodeCharacterImages[nodeKey] = null;
};
// 优先使用后端返回的路径,否则用默认路径
charImg.src = this.currentNode.character_image.startsWith('http')
? this.currentNode.character_image
: getStaticUrl(this.currentNode.character_image);
} else {
this.currentCharacterImg = this.nodeCharacterImages[nodeKey];
}
}
}
update() {
@@ -692,18 +816,53 @@ export default class StoryScene extends BaseScene {
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
}
// 7. AI改写面板最顶层
if (this.showRewritePanel) {
this.renderRewritePanel(ctx);
}
}
renderSceneBackground(ctx) {
// 场景区域上方45%
// 场景区域上方42%
const sceneHeight = this.screenHeight * 0.42;
// 渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
gradient.addColorStop(0, this.sceneColors.bg1);
gradient.addColorStop(1, this.sceneColors.bg2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
// 优先显示背景
if (this.currentBackgroundImg) {
// 绘制背景图(等比例覆盖)
const img = this.currentBackgroundImg;
const imgRatio = img.width / img.height;
const areaRatio = this.screenWidth / sceneHeight;
let drawW, drawH, drawX, drawY;
if (imgRatio > areaRatio) {
// 图片更宽,按高度适配
drawH = sceneHeight;
drawW = drawH * imgRatio;
drawX = (this.screenWidth - drawW) / 2;
drawY = 0;
} else {
// 图片更高,按宽度适配
drawW = this.screenWidth;
drawH = drawW / imgRatio;
drawX = 0;
drawY = (sceneHeight - drawH) / 2;
}
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, this.screenWidth, sceneHeight);
ctx.clip();
ctx.drawImage(img, drawX, drawY, drawW, drawH);
ctx.restore();
} else {
// 无背景图,使用渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
gradient.addColorStop(0, this.sceneColors.bg1);
gradient.addColorStop(1, this.sceneColors.bg2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
}
// 底部渐变过渡到对话框
const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight);
@@ -721,21 +880,34 @@ export default class StoryScene extends BaseScene {
const sceneHeight = this.screenHeight * 0.42;
const centerX = this.screenWidth / 2;
// 场景氛围光效
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
glowGradient.addColorStop(1, 'transparent');
ctx.fillStyle = glowGradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
// 绘制角色立绘(如果有)
if (this.currentCharacterImg) {
const img = this.currentCharacterImg;
// 立绘高度占场景区域80%,保持比例
const charH = sceneHeight * 0.8;
const charW = charH * (img.width / img.height);
const charX = centerX - charW / 2;
const charY = sceneHeight - charH;
ctx.drawImage(img, charX, charY, charW, charH);
} else {
// 无立绘时显示装饰效果
// 场景氛围光效
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
glowGradient.addColorStop(1, 'transparent');
ctx.fillStyle = glowGradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
// 装饰粒子
ctx.fillStyle = this.sceneColors.accent + '40';
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
particles.forEach(([x, y]) => {
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
});
// 装饰粒子
ctx.fillStyle = this.sceneColors.accent + '40';
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
particles.forEach(([x, y]) => {
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
});
}
// 场景提示文字(中央)
ctx.fillStyle = 'rgba(255,255,255,0.15)';
@@ -1122,6 +1294,12 @@ export default class StoryScene extends BaseScene {
return;
}
// AI改写面板的点击处理最优先
if (this.showRewritePanel) {
this.handleRewritePanelTouch(x, y);
return;
}
// 回顾模式下的点击处理
if (this.isRecapMode) {
// 返回按钮
@@ -1231,6 +1409,25 @@ export default class StoryScene extends BaseScene {
return;
}
// AI创作模式下检查当前节点是否是结局即使没有 is_ending 标记)
if (!this.isReplayMode && this.draftType === 'create' && this.currentNode) {
// 没有选项或 is_ending=true 都视为结局
if (!this.currentNode.choices || this.currentNode.choices.length === 0 || this.currentNode.is_ending) {
console.log('[AI创作] 到达结局节点:', this.currentNode);
this.main.sceneManager.switchScene('ending', {
storyId: this.storyId,
draftId: this.draftId,
ending: {
name: this.currentNode.ending_name || '故事结局',
type: this.currentNode.ending_type || 'neutral',
content: this.currentNode.content,
score: this.currentNode.ending_score || 70
}
});
return;
}
}
// 回放模式下,如果回放路径已用完或到达原结局
if (this.isReplayMode) {
const currentNode = this.main.storyManager.getCurrentNode();
@@ -1370,19 +1567,12 @@ export default class StoryScene extends BaseScene {
}
/**
* 显示AI改写输入框
* 显示AI改写面板
*/
showAIRewriteInput() {
wx.showModal({
title: 'AI改写剧情',
editable: true,
placeholderText: '输入你的改写指令,如"让主角暴富"',
success: (res) => {
if (res.confirm && res.content) {
this.doAIRewriteAsync(res.content);
}
}
});
this.showRewritePanel = true;
this.rewritePrompt = '';
this.selectedRewriteTag = -1;
}
/**
@@ -1433,6 +1623,246 @@ export default class StoryScene extends BaseScene {
}
}
/**
* 渲染AI改写面板类似结局页的样式
*/
renderRewritePanel(ctx) {
const padding = 20;
const panelWidth = this.screenWidth - padding * 2;
const panelHeight = 400;
const panelX = padding;
const panelY = (this.screenHeight - panelHeight) / 2;
// 遮罩层
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 面板背景渐变
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
panelGradient.addColorStop(0, '#1a1a3e');
panelGradient.addColorStop(1, '#0d0d1a');
ctx.fillStyle = panelGradient;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.fill();
// 面板边框渐变
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
borderGradient.addColorStop(0, '#a855f7');
borderGradient.addColorStop(1, '#ec4899');
ctx.strokeStyle = borderGradient;
ctx.lineWidth = 2;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.stroke();
// 标题栏
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('✨ AI改写剧情', this.screenWidth / 2, panelY + 35);
// 副标题
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('输入你想要的剧情走向AI将为你重新创作', this.screenWidth / 2, panelY + 58);
// 分隔线
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75);
lineGradient.addColorStop(0, 'transparent');
lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)');
lineGradient.addColorStop(1, 'transparent');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(panelX + 20, panelY + 75);
ctx.lineTo(panelX + panelWidth - 20, panelY + 75);
ctx.stroke();
// 快捷标签标题
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('快捷选择:', panelX + 15, panelY + 100);
// 快捷标签
const tagStartX = panelX + 15;
const tagY = panelY + 115;
const tagHeight = 32;
const tagGap = 8;
let currentX = tagStartX;
let currentY = tagY;
this.rewriteTagRects = [];
this.rewriteTags.forEach((tag, index) => {
ctx.font = '12px sans-serif';
const tagWidth = ctx.measureText(tag).width + 24;
// 换行
if (currentX + tagWidth > panelX + panelWidth - 15) {
currentX = tagStartX;
currentY += tagHeight + tagGap;
}
// 标签背景
const isSelected = index === this.selectedRewriteTag;
if (isSelected) {
const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
tagGradient.addColorStop(0, '#a855f7');
tagGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = tagGradient;
} else {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
}
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
ctx.fill();
// 标签边框
ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
ctx.stroke();
// 标签文字
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21);
// 存储标签位置
this.rewriteTagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index });
currentX += tagWidth + tagGap;
});
// 自定义输入提示
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('或自定义输入:', panelX + 15, panelY + 200);
// 输入框背景
const inputY = panelY + 215;
const inputHeight = 45;
ctx.fillStyle = 'rgba(255,255,255,0.08)';
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
ctx.stroke();
// 存储输入框位置
this.rewriteInputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
// 输入框文字或占位符
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
if (this.rewritePrompt) {
ctx.fillStyle = '#ffffff';
const displayText = this.rewritePrompt.length > 20 ? this.rewritePrompt.substring(0, 20) + '...' : this.rewritePrompt;
ctx.fillText(displayText, panelX + 28, inputY + 28);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillText('点击输入你的改写想法...', panelX + 28, inputY + 28);
}
// 按钮
const btnY = panelY + panelHeight - 70;
const btnWidth = (panelWidth - 50) / 2;
const btnHeight = 45;
// 取消按钮
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '15px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28);
this.rewriteCancelBtn = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight };
// 确认按钮
const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY);
confirmGradient.addColorStop(0, '#a855f7');
confirmGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = confirmGradient;
this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 15px sans-serif';
ctx.fillText('✨ 开始改写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28);
this.rewriteConfirmBtn = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight };
}
/**
* 处理改写面板的触摸事件
*/
handleRewritePanelTouch(x, y) {
// 点击标签
for (const tag of this.rewriteTagRects) {
if (x >= tag.x && x <= tag.x + tag.width && y >= tag.y && y <= tag.y + tag.height) {
this.selectedRewriteTag = tag.index;
this.rewritePrompt = this.rewriteTags[tag.index];
return true;
}
}
// 点击输入框
if (this.rewriteInputRect) {
const r = this.rewriteInputRect;
if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) {
this.showCustomRewriteInput();
return true;
}
}
// 点击取消
if (this.rewriteCancelBtn) {
const r = this.rewriteCancelBtn;
if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) {
this.showRewritePanel = false;
return true;
}
}
// 点击确认
if (this.rewriteConfirmBtn) {
const r = this.rewriteConfirmBtn;
if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) {
if (this.rewritePrompt) {
this.showRewritePanel = false;
this.doAIRewriteAsync(this.rewritePrompt);
} else {
wx.showToast({ title: '请选择或输入改写内容', icon: 'none' });
}
return true;
}
}
return false;
}
/**
* 显示自定义输入弹窗
*/
showCustomRewriteInput() {
wx.showModal({
title: '输入改写想法',
editable: true,
placeholderText: '例如:让主角获得逆袭',
content: this.rewritePrompt,
success: (res) => {
if (res.confirm && res.content) {
this.rewritePrompt = res.content;
this.selectedRewriteTag = -1;
}
}
});
}
destroy() {
if (this.main.userManager.isLoggedIn && this.story) {
this.main.userManager.saveProgress(

View File

@@ -5,15 +5,17 @@
// ============================================
// 环境配置(切换这里即可)
// ============================================
const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管
const ENV = 'local'; // 'local' = 本地后端, 'cloud' = 微信云托管
const CONFIG = {
local: {
baseUrl: 'http://localhost:8000/api'
baseUrl: 'http://localhost:8000/api',
staticUrl: 'http://localhost:8000'
},
cloud: {
env: 'prod-6gjx1rd4c40f5884',
serviceName: 'express-fuvd'
serviceName: 'express-fuvd',
staticUrl: 'https://7072-prod-6gjx1rd4c40f5884-1409819450.tcb.qcloud.la'
}
};
@@ -46,7 +48,8 @@ function requestLocal(options) {
if (res.data && res.data.code === 0) {
resolve(res.data.data);
} else {
reject(new Error(res.data?.message || '请求失败'));
console.error('[HTTP-Local] 响应异常:', res.statusCode, res.data);
reject(new Error(res.data?.message || res.data?.detail || `请求失败(${res.statusCode})`));
}
},
fail(err) {
@@ -78,8 +81,10 @@ function requestCloud(options) {
if (res.data && res.data.code === 0) {
resolve(res.data.data);
} else if (res.data) {
reject(new Error(res.data.message || '请求失败'));
console.error('[HTTP-Cloud] 响应异常:', res.statusCode, res.data);
reject(new Error(res.data.message || res.data.detail || `请求失败(${res.statusCode})`));
} else {
console.error('[HTTP-Cloud] 响应数据异常:', res);
reject(new Error('响应数据异常'));
}
},
@@ -112,4 +117,68 @@ export function del(url, data) {
return request({ url, method: 'DELETE', data });
}
export default { request, get, post, del };
export function put(url, data) {
return request({ url, method: 'PUT', data });
}
/**
* 获取静态资源完整URL图片等
* @param {string} path - 相对路径,如 /uploads/stories/1/characters/1.jpg
* @returns {string} 完整URL
*/
export function getStaticUrl(path) {
if (!path) return '';
// 如果已经是完整URL直接返回
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const config = ENV === 'local' ? CONFIG.local : CONFIG.cloud;
return config.staticUrl + path;
}
/**
* 获取角色头像URL
* @param {number} storyId - 故事ID
* @param {number} characterId - 角色ID
*/
export function getCharacterAvatar(storyId, characterId) {
return getStaticUrl(`/uploads/stories/${storyId}/characters/${characterId}.jpg`);
}
/**
* 获取故事封面URL
* @param {number} storyId - 故事ID
*/
export function getStoryCover(storyId) {
return getStaticUrl(`/uploads/stories/${storyId}/cover/cover.jpg`);
}
/**
* 获取节点背景图URL
* @param {number} storyId - 故事ID
* @param {string} nodeKey - 节点key
*/
export function getNodeBackground(storyId, nodeKey) {
return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/background.jpg`);
}
/**
* 获取节点角色立绘URL
* @param {number} storyId - 故事ID
* @param {string} nodeKey - 节点key
*/
export function getNodeCharacter(storyId, nodeKey) {
return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/character.jpg`);
}
/**
* 获取草稿节点背景图URL
* @param {number} storyId - 故事ID
* @param {number} draftId - 草稿ID
* @param {string} nodeKey - 节点key
*/
export function getDraftNodeBackground(storyId, draftId, nodeKey) {
return getStaticUrl(`/uploads/stories/${storyId}/drafts/${draftId}/${nodeKey}/background.jpg`);
}
export default { request, get, post, put, del, getStaticUrl, getCharacterAvatar, getStoryCover, getNodeBackground, getNodeCharacter, getDraftNodeBackground };

View File

@@ -36,6 +36,12 @@ class Settings(BaseSettings):
wx_appid: str = ""
wx_secret: str = ""
# 微信云托管配置
wx_cloud_env: str = ""
# Gemini 图片生成配置
gemini_api_key: str = ""
# JWT 配置
jwt_secret_key: str = "your-super-secret-key-change-in-production"
jwt_expire_hours: int = 168 # 7天

View File

@@ -35,8 +35,9 @@ app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
app.include_router(upload.router, prefix="/api", tags=["上传"])
# 静态文件服务(用于访问上传的图片)
os.makedirs(settings.upload_path, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads")
upload_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', settings.upload_path))
os.makedirs(upload_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
@app.get("/")

View File

@@ -121,6 +121,9 @@ class StoryDraft(Base):
status = Column(Enum(DraftStatus), default=DraftStatus.pending)
error_message = Column(String(500), default="")
is_read = Column(Boolean, default=False) # 用户是否已查看
published_to_center = Column(Boolean, default=False) # 是否发布到创作中心
draft_type = Column(String(20), default="rewrite") # 草稿类型: rewrite/continue/create
is_collected = Column(Boolean, default=False) # 用户是否收藏
created_at = Column(TIMESTAMP, server_default=func.now())
completed_at = Column(TIMESTAMP, default=None)

View File

@@ -65,6 +65,7 @@ class PlayRecord(Base):
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)
draft_id = Column(Integer, default=None) # AI草稿ID原故事为空
ending_name = Column(String(100), nullable=False) # 结局名称
ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite)
path_history = Column(JSON, nullable=False) # 完整的选择路径

View File

@@ -8,9 +8,12 @@ from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import os
import base64
from app.database import get_db
from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter
from app.config import get_settings
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
@@ -36,6 +39,148 @@ async def get_story_characters(db: AsyncSession, story_id: int) -> List[dict]:
]
async def upload_to_cloud_storage(image_bytes: bytes, cloud_path: str) -> str:
"""
上传图片到微信云存储(云托管容器内调用)
cloud_path: 云存储路径,如 stories/1/drafts/10/branch_1/background.jpg
返回: 文件访问路径
"""
import httpx
env_id = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
if not env_id:
# 尝试从配置获取
settings = get_settings()
env_id = getattr(settings, 'wx_cloud_env', None)
if not env_id:
raise Exception("未检测到云环境ID")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
# 云托管内网调用云开发 API不需要 access_token
# 参考: https://developers.weixin.qq.com/miniprogram/dev/wxcloudrun/src/development/storage/service/upload.html
# 1. 获取上传链接
resp = await client.post(
"http://api.weixin.qq.com/tcb/uploadfile",
json={
"env": env_id,
"path": cloud_path
},
headers={"Content-Type": "application/json"}
)
if resp.status_code != 200:
raise Exception(f"获取上传链接失败: {resp.status_code} - {resp.text[:200]}")
data = resp.json()
if data.get("errcode", 0) != 0:
raise Exception(f"获取上传链接失败: {data.get('errmsg')}")
upload_url = data.get("url")
authorization = data.get("authorization")
token = data.get("token")
cos_file_id = data.get("cos_file_id")
file_id = data.get("file_id")
# 2. 上传文件到 COS
form_data = {
"key": cloud_path,
"Signature": authorization,
"x-cos-security-token": token,
"x-cos-meta-fileid": cos_file_id,
}
files = {"file": ("background.jpg", image_bytes, "image/jpeg")}
upload_resp = await client.post(upload_url, data=form_data, files=files)
if upload_resp.status_code not in [200, 204]:
raise Exception(f"上传文件失败: {upload_resp.status_code} - {upload_resp.text[:200]}")
print(f" [CloudStorage] 文件上传成功: {file_id}")
return file_id
except Exception as e:
print(f"[upload_to_cloud_storage] 上传失败: {e}")
raise
async def generate_draft_images(story_id: int, draft_id: int, ai_nodes: dict, story_category: str):
"""
为草稿的 AI 生成节点生成背景图
本地环境:保存到文件系统 /uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg
云端环境:上传到云存储
"""
from app.services.image_gen import ImageGenService
if not ai_nodes:
return
settings = get_settings()
# 检测是否是云端环境TCB_ENV 或 CBR_ENV_ID 是云托管容器自动注入的)
is_cloud = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
# 本地环境使用文件系统
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
draft_dir = os.path.join(base_dir, "stories", str(story_id), "drafts", str(draft_id))
service = ImageGenService()
for node_key, node_data in ai_nodes.items():
if not isinstance(node_data, dict):
continue
content = node_data.get('content', '')[:150]
if not content:
continue
try:
# 生成背景图 - 强调情绪表达
bg_prompt = f"Background scene for {story_category} story. Scene: {content}. Wide shot, atmospheric, no characters, anime style. Strong emotional expression, dramatic mood, vivid colors reflecting the scene's emotion."
result = await service.generate_image(bg_prompt, "background", "anime")
if result and result.get("success"):
image_bytes = base64.b64decode(result["image_data"])
# 路径格式和本地一致uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg
cloud_path = f"uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg"
# 云端环境:上传到云存储
if is_cloud:
try:
file_id = await upload_to_cloud_storage(image_bytes, cloud_path)
# 云存储返回的 file_id 格式: cloud://env-id.xxx/path
# 前端通过 CDN 地址访问: https://7072-prod-xxx.tcb.qcloud.la/uploads/...
node_data['background_url'] = f"/{cloud_path}"
print(f" ✓ 云端草稿节点 {node_key} 背景图上传成功")
except Exception as cloud_e:
print(f" ✗ 云端上传失败: {cloud_e}")
continue
# 本地环境:保存到文件系统
node_dir = os.path.join(draft_dir, node_key)
os.makedirs(node_dir, exist_ok=True)
bg_path = os.path.join(node_dir, "background.jpg")
with open(bg_path, "wb") as f:
f.write(image_bytes)
# 更新节点数据,添加图片路径
node_data['background_url'] = f"/uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg"
print(f" ✓ 草稿节点 {node_key} 背景图生成成功")
else:
print(f" ✗ 草稿节点 {node_key} 背景图生成失败: {result.get('error') if result else 'Unknown'}")
except Exception as e:
print(f" ✗ 草稿节点 {node_key} 图片生成异常: {e}")
# 避免请求过快
import asyncio
await asyncio.sleep(1)
# ============ 请求/响应模型 ============
class PathHistoryItem(BaseModel):
@@ -121,11 +266,14 @@ async def process_ai_rewrite(draft_id: int):
# 获取故事角色
characters = await get_story_characters(db, story.id)
print(f"[process_ai_rewrite] 获取到角色数: {len(characters)}")
# 转换路径历史格式
path_history = draft.path_history or []
print(f"[process_ai_rewrite] 路径历史长度: {len(path_history)}")
# 调用AI服务
print(f"[process_ai_rewrite] 开始调用 AI 服务...")
ai_result = await ai_service.rewrite_branch(
story_title=story.title,
story_category=story.category or "未知",
@@ -134,9 +282,21 @@ async def process_ai_rewrite(draft_id: int):
user_prompt=draft.user_prompt,
characters=characters
)
print(f"[process_ai_rewrite] AI 服务返回: {bool(ai_result)}")
if ai_result and ai_result.get("nodes"):
# 成功
# 成功 - 尝试生成配图(失败不影响改写结果)
try:
print(f"[process_ai_rewrite] AI生成成功开始生成配图...")
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft.id,
ai_nodes=ai_result["nodes"],
story_category=story.category or "都市言情"
)
except Exception as img_e:
print(f"[process_ai_rewrite] 配图生成失败(不影响改写结果): {img_e}")
draft.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1")
@@ -232,8 +392,7 @@ async def process_ai_rewrite_ending(draft_id: int):
pass
# 成功 - 存储为对象格式(与故事节点格式一致)
draft.status = DraftStatus.completed
draft.ai_nodes = {
ai_nodes = {
"ending_rewrite": {
"content": content,
"speaker": "旁白",
@@ -242,6 +401,21 @@ async def process_ai_rewrite_ending(draft_id: int):
"ending_type": "rewrite"
}
}
# 生成配图(失败不影响改写结果)
try:
print(f"[process_ai_rewrite_ending] AI生成成功开始生成配图...")
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft.id,
ai_nodes=ai_nodes,
story_category=story.category or "都市言情"
)
except Exception as img_e:
print(f"[process_ai_rewrite_ending] 配图生成失败(不影响改写结果): {img_e}")
draft.status = DraftStatus.completed
draft.ai_nodes = ai_nodes
draft.entry_node_key = "ending_rewrite"
draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-{new_ending_name}"
@@ -313,7 +487,18 @@ async def process_ai_continue_ending(draft_id: int):
)
if ai_result and ai_result.get("nodes"):
# 成功 - 存储多节点分支格式
# 成功 - 尝试生成配图(失败不影响续写结果)
try:
print(f"[process_ai_continue_ending] AI生成成功开始生成配图...")
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft.id,
ai_nodes=ai_result["nodes"],
story_category=story.category or "都市言情"
)
except Exception as img_e:
print(f"[process_ai_continue_ending] 配图生成失败(不影响续写结果): {img_e}")
draft.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1")
@@ -374,7 +559,8 @@ async def create_draft(
current_node_key=request.currentNodeKey,
current_content=request.currentContent,
user_prompt=request.prompt,
status=DraftStatus.pending
status=DraftStatus.pending,
draft_type='rewrite'
)
db.add(draft)
@@ -419,7 +605,8 @@ async def create_ending_draft(
current_node_key=request.endingName, # 保存结局名称
current_content=request.endingContent, # 保存结局内容
user_prompt=request.prompt,
status=DraftStatus.pending
status=DraftStatus.pending,
draft_type='rewrite'
)
db.add(draft)
@@ -464,7 +651,8 @@ async def create_continue_ending_draft(
current_node_key=request.endingName, # 保存结局名称
current_content=request.endingContent, # 保存结局内容
user_prompt=request.prompt,
status=DraftStatus.pending
status=DraftStatus.pending,
draft_type='continue'
)
db.add(draft)
@@ -490,7 +678,7 @@ async def get_drafts(
):
"""获取用户的草稿列表"""
result = await db.execute(
select(StoryDraft, Story.title.label("story_title"))
select(StoryDraft, Story.title.label("story_title"), Story.cover_url)
.join(Story, StoryDraft.story_id == Story.id)
.where(StoryDraft.user_id == userId)
.order_by(StoryDraft.created_at.desc())
@@ -500,6 +688,15 @@ async def get_drafts(
for row in result:
draft = row[0]
story_title = row[1]
story_cover_url = row[2]
# AI创作类型优先使用 ai_nodes 中的封面
cover_url = story_cover_url or ""
if draft.draft_type == "create" and draft.ai_nodes:
ai_cover = draft.ai_nodes.get("coverUrl") if isinstance(draft.ai_nodes, dict) else None
if ai_cover:
cover_url = ai_cover
drafts.append({
"id": draft.id,
"storyId": draft.story_id,
@@ -508,6 +705,9 @@ async def get_drafts(
"userPrompt": draft.user_prompt,
"status": draft.status.value if draft.status else "pending",
"isRead": draft.is_read,
"publishedToCenter": draft.published_to_center,
"draftType": draft.draft_type or "rewrite",
"coverUrl": cover_url,
"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
})
@@ -549,6 +749,66 @@ async def check_new_drafts(
}
@router.get("/published")
async def get_published_drafts(
userId: int,
draftType: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""获取草稿列表
- rewrite/continue 类型:返回已发布到创作中心的
- create 类型:返回所有已完成的(用户可选择发布)
"""
query = select(StoryDraft, Story.title.label('story_title'), Story.cover_url).join(
Story, StoryDraft.story_id == Story.id
).where(
StoryDraft.user_id == userId,
StoryDraft.status == DraftStatus.completed
)
# 按类型筛选
if draftType:
query = query.where(StoryDraft.draft_type == draftType)
# create 类型不需要 published_to_center 限制
if draftType != 'create':
query = query.where(StoryDraft.published_to_center == True)
else:
query = query.where(StoryDraft.published_to_center == True)
query = query.order_by(StoryDraft.created_at.desc())
result = await db.execute(query)
rows = result.all()
drafts = []
for draft, story_title, story_cover_url in rows:
# AI创作类型优先使用 ai_nodes 中的封面
cover_url = story_cover_url or ""
if draft.draft_type == "create" and draft.ai_nodes:
ai_cover = draft.ai_nodes.get("coverUrl") if isinstance(draft.ai_nodes, dict) else None
if ai_cover:
cover_url = ai_cover
drafts.append({
"id": draft.id,
"story_id": draft.story_id, # 添加 story_id 字段
"storyId": draft.story_id,
"storyTitle": story_title or "未知故事",
"title": draft.title or "",
"userPrompt": draft.user_prompt,
"draftType": draft.draft_type or "rewrite",
"status": draft.status.value if draft.status else "completed",
"published_to_center": draft.published_to_center,
"coverUrl": cover_url,
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else ""
})
return {
"code": 0,
"data": drafts
}
@router.get("/{draft_id}")
async def get_draft_detail(
draft_id: int,
@@ -601,7 +861,7 @@ async def delete_draft(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""删除草稿"""
"""删除草稿(同时清理图片文件)"""
result = await db.execute(
select(StoryDraft).where(
StoryDraft.id == draft_id,
@@ -613,6 +873,19 @@ async def delete_draft(
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
# 删除草稿对应的图片文件夹
try:
import shutil
settings = get_settings()
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
draft_dir = os.path.join(base_dir, "stories", str(draft.story_id), "drafts", str(draft_id))
if os.path.exists(draft_dir):
shutil.rmtree(draft_dir)
print(f"[delete_draft] 已清理图片目录: {draft_dir}")
except Exception as e:
print(f"[delete_draft] 清理图片失败: {e}")
# 图片清理失败不影响草稿删除
await db.delete(draft)
await db.commit()
@@ -652,3 +925,90 @@ async def mark_all_drafts_read(
await db.commit()
return {"code": 0, "message": "已全部标记为已读"}
@router.put("/{draft_id}/publish")
async def publish_draft_to_center(
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,
StoryDraft.status == DraftStatus.completed
)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在或未完成")
# 更新发布状态
draft.published_to_center = True
await db.commit()
return {"code": 0, "message": "已发布到创作中心"}
@router.put("/{draft_id}/unpublish")
async def unpublish_draft_from_center(
draft_id: int,
userId: int,
db: AsyncSession = Depends(get_db)
):
"""从创作中心取消发布"""
await db.execute(
update(StoryDraft)
.where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId
)
.values(published_to_center=False)
)
await db.commit()
return {"code": 0, "message": "已从创作中心移除"}
@router.put("/{draft_id}/collect")
async def collect_draft(
draft_id: int,
userId: int,
isCollected: bool = True,
db: AsyncSession = Depends(get_db)
):
"""收藏/取消收藏草稿"""
await db.execute(
update(StoryDraft)
.where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId
)
.values(is_collected=isCollected)
)
await db.commit()
return {"code": 0, "message": "收藏成功" if isCollected else "取消收藏成功"}
@router.get("/{draft_id}/collect-status")
async def get_draft_collect_status(
draft_id: int,
userId: int,
db: AsyncSession = Depends(get_db)
):
"""获取草稿收藏状态"""
result = await db.execute(
select(StoryDraft.is_collected)
.where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId
)
)
is_collected = result.scalar_one_or_none()
return {"code": 0, "data": {"isCollected": is_collected or False}}

View File

@@ -2,14 +2,15 @@
故事相关API路由
"""
import random
from fastapi import APIRouter, Depends, Query, HTTPException
import asyncio
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func, distinct
from typing import Optional, List
from pydantic import BaseModel
from app.database import get_db
from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter
from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter, StoryDraft, DraftStatus
router = APIRouter()
@@ -39,6 +40,41 @@ class RewriteBranchRequest(BaseModel):
prompt: str
class NodeImageUpdate(BaseModel):
nodeKey: str
backgroundImage: str = ""
characterImage: str = ""
class CharacterImageUpdate(BaseModel):
characterId: int
avatarUrl: str = ""
class ImageConfigRequest(BaseModel):
coverUrl: str = ""
nodes: List[NodeImageUpdate] = []
characters: List[CharacterImageUpdate] = []
class GenerateImageRequest(BaseModel):
prompt: str
style: str = "anime" # anime/realistic/illustration
category: str = "character" # character/background/cover
storyId: Optional[int] = None
targetField: Optional[str] = None # coverUrl/backgroundImage/characterImage/avatarUrl
targetKey: Optional[str] = None # nodeKey 或 characterId
class AICreateStoryRequest(BaseModel):
"""AI创作全新故事请求"""
userId: int
genre: str # 题材
keywords: str # 关键词
protagonist: Optional[str] = None # 主角设定
conflict: Optional[str] = None # 核心冲突
# ========== API接口 ==========
@router.get("")
@@ -110,6 +146,222 @@ async def get_categories(db: AsyncSession = Depends(get_db)):
return {"code": 0, "data": categories}
@router.get("/test-image-gen")
async def test_image_generation():
"""测试图片生成服务是否正常"""
from app.config import get_settings
from app.services.image_gen import get_image_gen_service
import httpx
settings = get_settings()
results = {
"api_key_configured": bool(settings.gemini_api_key),
"api_key_preview": settings.gemini_api_key[:8] + "..." if settings.gemini_api_key else "未配置",
"base_url": "https://work.poloapi.com/v1beta",
"network_test": None,
"generate_test": None
}
# 测试网络连接
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get("https://work.poloapi.com")
results["network_test"] = {
"success": response.status_code < 500,
"status_code": response.status_code,
"message": "网络连接正常" if response.status_code < 500 else "服务端错误"
}
except Exception as e:
results["network_test"] = {
"success": False,
"error": str(e),
"message": "网络连接失败"
}
# 测试实际生图(简单测试)
if settings.gemini_api_key:
try:
service = get_image_gen_service()
gen_result = await service.generate_image(
prompt="a simple red circle on white background",
image_type="avatar",
style="illustration"
)
results["generate_test"] = {
"success": gen_result.get("success", False),
"error": gen_result.get("error") if not gen_result.get("success") else None,
"has_image_data": bool(gen_result.get("image_data"))
}
except Exception as e:
results["generate_test"] = {
"success": False,
"error": str(e)
}
else:
results["generate_test"] = {
"success": False,
"error": "API Key 未配置"
}
return {
"code": 0,
"message": "测试完成",
"data": results
}
@router.get("/test-deepseek")
async def test_deepseek():
"""测试 DeepSeek AI 服务是否正常"""
from app.config import get_settings
import httpx
settings = get_settings()
results = {
"ai_service_enabled": settings.ai_service_enabled,
"provider": settings.ai_provider,
"api_key_configured": bool(settings.deepseek_api_key),
"api_key_preview": settings.deepseek_api_key[:8] + "..." + settings.deepseek_api_key[-4:] if settings.deepseek_api_key and len(settings.deepseek_api_key) > 12 else "未配置或太短",
"base_url": settings.deepseek_base_url,
"model": settings.deepseek_model,
"network_test": None,
"api_test": None
}
# 测试网络连接
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get("https://api.deepseek.com")
results["network_test"] = {
"success": response.status_code < 500,
"status_code": response.status_code,
"message": "网络连接正常"
}
except Exception as e:
results["network_test"] = {
"success": False,
"error": str(e),
"message": "网络连接失败"
}
# 测试 API 调用
if settings.deepseek_api_key:
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{settings.deepseek_base_url}/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.deepseek_api_key}"
},
json={
"model": settings.deepseek_model,
"messages": [{"role": "user", "content": "'测试成功'两个字"}],
"max_tokens": 10
}
)
if response.status_code == 200:
data = response.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
results["api_test"] = {
"success": True,
"status_code": 200,
"response": content[:50]
}
else:
results["api_test"] = {
"success": False,
"status_code": response.status_code,
"error": response.text[:200]
}
except Exception as e:
results["api_test"] = {
"success": False,
"error": str(e)
}
else:
results["api_test"] = {
"success": False,
"error": "API Key 未配置"
}
return {
"code": 0,
"message": "测试完成",
"data": results
}
@router.get("/test-cloud-upload")
async def test_cloud_upload():
"""测试云存储上传是否正常"""
import os
import httpx
tcb_env = os.environ.get("TCB_ENV") or os.environ.get("CBR_ENV_ID")
results = {
"env_id": tcb_env,
"is_cloud": bool(tcb_env)
}
if not tcb_env:
return {
"code": 1,
"message": "非云托管环境,无法测试云存储上传",
"data": results
}
try:
# 测试获取上传链接
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"http://api.weixin.qq.com/tcb/uploadfile",
json={
"env": tcb_env,
"path": "test/cloud_upload_test.txt"
},
headers={"Content-Type": "application/json"}
)
results["status_code"] = resp.status_code
results["response"] = resp.text[:500] if resp.text else ""
if resp.status_code == 200:
data = resp.json()
if data.get("errcode", 0) == 0:
results["upload_url"] = data.get("url", "")[:100]
results["file_id"] = data.get("file_id", "")
return {
"code": 0,
"message": "云存储上传链接获取成功",
"data": results
}
else:
results["errcode"] = data.get("errcode")
results["errmsg"] = data.get("errmsg")
return {
"code": 1,
"message": f"获取上传链接失败: {data.get('errmsg')}",
"data": results
}
else:
return {
"code": 1,
"message": f"请求失败: HTTP {resp.status_code}",
"data": results
}
except Exception as e:
results["error"] = str(e)
return {
"code": 1,
"message": f"测试异常: {str(e)}",
"data": results
}
@router.get("/{story_id}")
async def get_story_detail(story_id: int, db: AsyncSession = Depends(get_db)):
"""获取故事详情(含节点和选项)"""
@@ -371,4 +623,661 @@ async def ai_rewrite_branch(
"tokensUsed": 0,
"error": "AI服务暂时不可用"
}
}
}
@router.get("/{story_id}/images")
async def get_story_images(story_id: int, db: AsyncSession = Depends(get_db)):
"""获取故事的所有图片配置"""
# 获取故事封面
result = await db.execute(select(Story).where(Story.id == story_id))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 获取所有节点的图片
nodes_result = await db.execute(
select(StoryNode).where(StoryNode.story_id == story_id).order_by(StoryNode.sort_order)
)
nodes = nodes_result.scalars().all()
# 获取所有角色的头像
chars_result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
characters = chars_result.scalars().all()
return {
"code": 0,
"data": {
"storyId": story_id,
"title": story.title,
"coverUrl": story.cover_url or "",
"nodes": [
{
"nodeKey": n.node_key,
"content": n.content[:50] + "..." if len(n.content) > 50 else n.content,
"backgroundImage": n.background_image or "",
"characterImage": n.character_image or "",
"isEnding": n.is_ending,
"endingName": n.ending_name or ""
}
for n in nodes
],
"characters": [
{
"characterId": c.id,
"name": c.name,
"roleType": c.role_type,
"avatarUrl": c.avatar_url or "",
"avatarPrompt": c.avatar_prompt or ""
}
for c in characters
]
}
}
@router.put("/{story_id}/images")
async def update_story_images(
story_id: int,
request: ImageConfigRequest,
db: AsyncSession = Depends(get_db)
):
"""批量更新故事的图片配置"""
# 验证故事存在
result = await db.execute(select(Story).where(Story.id == story_id))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
updated = {"cover": False, "nodes": 0, "characters": 0}
# 更新封面
if request.coverUrl:
await db.execute(
update(Story).where(Story.id == story_id).values(cover_url=request.coverUrl)
)
updated["cover"] = True
# 更新节点图片
for node_img in request.nodes:
values = {}
if node_img.backgroundImage:
values["background_image"] = node_img.backgroundImage
if node_img.characterImage:
values["character_image"] = node_img.characterImage
if values:
await db.execute(
update(StoryNode)
.where(StoryNode.story_id == story_id, StoryNode.node_key == node_img.nodeKey)
.values(**values)
)
updated["nodes"] += 1
# 更新角色头像
for char_img in request.characters:
if char_img.avatarUrl:
await db.execute(
update(StoryCharacter)
.where(StoryCharacter.id == char_img.characterId, StoryCharacter.story_id == story_id)
.values(avatar_url=char_img.avatarUrl)
)
updated["characters"] += 1
await db.commit()
return {
"code": 0,
"message": "更新成功",
"data": updated
}
@router.post("/generate-image")
async def generate_story_image(
request: GenerateImageRequest,
db: AsyncSession = Depends(get_db)
):
"""使用AI生成图片并可选保存到故事"""
from app.services.image_gen import get_image_gen_service
# 生成图片
result = await get_image_gen_service().generate_and_save(
prompt=request.prompt,
category=request.category,
style=request.style
)
if not result.get("success"):
return {
"code": 1,
"message": result.get("error", "生成失败"),
"data": None
}
image_url = result["url"]
# 如果指定了故事和目标字段,自动更新
if request.storyId and request.targetField:
if request.targetField == "coverUrl":
await db.execute(
update(Story).where(Story.id == request.storyId).values(cover_url=image_url)
)
elif request.targetField == "backgroundImage" and request.targetKey:
await db.execute(
update(StoryNode)
.where(StoryNode.story_id == request.storyId, StoryNode.node_key == request.targetKey)
.values(background_image=image_url)
)
elif request.targetField == "characterImage" and request.targetKey:
await db.execute(
update(StoryNode)
.where(StoryNode.story_id == request.storyId, StoryNode.node_key == request.targetKey)
.values(character_image=image_url)
)
elif request.targetField == "avatarUrl" and request.targetKey:
await db.execute(
update(StoryCharacter)
.where(StoryCharacter.story_id == request.storyId, StoryCharacter.id == int(request.targetKey))
.values(avatar_url=image_url)
)
await db.commit()
return {
"code": 0,
"message": "生成成功",
"data": {
"url": image_url,
"filename": result.get("filename"),
"saved": bool(request.storyId and request.targetField)
}
}
@router.post("/{story_id}/generate-all-images")
async def generate_all_story_images(
story_id: int,
style: str = "anime",
db: AsyncSession = Depends(get_db)
):
"""为故事批量生成所有角色头像"""
from app.services.image_gen import get_image_gen_service
image_service = get_image_gen_service()
# 获取所有角色
result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
characters = result.scalars().all()
if not characters:
return {"code": 1, "message": "故事没有角色数据", "data": None}
generated = []
failed = []
for char in characters:
# 使用avatar_prompt或自动构建
prompt = char.avatar_prompt or f"{char.name}, {char.gender}, {char.appearance or ''}"
gen_result = await image_service.generate_and_save(
prompt=prompt,
category="character",
style=style
)
if gen_result.get("success"):
# 更新数据库
await db.execute(
update(StoryCharacter)
.where(StoryCharacter.id == char.id)
.values(avatar_url=gen_result["url"])
)
generated.append({"id": char.id, "name": char.name, "url": gen_result["url"]})
else:
failed.append({"id": char.id, "name": char.name, "error": gen_result.get("error")})
await db.commit()
return {
"code": 0,
"message": f"生成完成: {len(generated)}成功, {len(failed)}失败",
"data": {
"generated": generated,
"failed": failed
}
}
# ========== AI创作全新故事 ==========
@router.post("/ai-create")
async def ai_create_story(
request: AICreateStoryRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""AI创作全新故事异步处理- 只存储到 story_drafts不创建 Story"""
from app.models.user import User
# 验证用户
user_result = await db.execute(select(User).where(User.id == request.userId))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 获取或创建虚拟故事(用于满足 story_id 外键约束)
virtual_story = await get_or_create_virtual_story(db)
# 创建草稿记录(完整故事内容将存储在 ai_nodes 中)
draft = StoryDraft(
user_id=request.userId,
story_id=virtual_story.id, # 使用虚拟故事ID
title="AI创作中...",
user_prompt=f"题材:{request.genre}, 关键词:{request.keywords}, 主角:{request.protagonist or ''}, 冲突:{request.conflict or ''}",
draft_type="create",
status=DraftStatus.pending
)
db.add(draft)
await db.commit()
await db.refresh(draft)
# 添加后台任务
background_tasks.add_task(
process_ai_create_story,
draft.id,
request.userId,
request.genre,
request.keywords,
request.protagonist,
request.conflict
)
return {
"code": 0,
"data": {
"draftId": draft.id,
"message": "故事创作已开始,完成后将保存到草稿箱"
}
}
async def get_or_create_virtual_story(db: AsyncSession) -> Story:
"""获取或创建用于AI创作的虚拟故事满足外键约束"""
# 查找已存在的虚拟故事
result = await db.execute(
select(Story).where(Story.title == "[系统] AI创作占位故事")
)
virtual_story = result.scalar_one_or_none()
if not virtual_story:
# 创建虚拟故事
virtual_story = Story(
title="[系统] AI创作占位故事",
description="此故事仅用于AI创作功能的外键占位不可游玩",
category="系统",
status=-99, # 特殊状态,不会出现在任何列表中
cover_url="",
author_id=1 # 系统用户
)
db.add(virtual_story)
await db.commit()
await db.refresh(virtual_story)
return virtual_story
@router.get("/ai-create/{draft_id}/status")
async def get_ai_create_status(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取AI创作状态通过 draft_id 查询)"""
draft_result = await db.execute(
select(StoryDraft).where(StoryDraft.id == draft_id)
)
draft = draft_result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
is_completed = draft.status == DraftStatus.completed
is_failed = draft.status == DraftStatus.failed
return {
"code": 0,
"data": {
"draftId": draft.id,
"status": -1 if is_failed else (1 if is_completed else 0),
"title": draft.title,
"isCompleted": is_completed,
"isFailed": is_failed,
"errorMessage": draft.error_message if is_failed else None
}
}
@router.post("/ai-create/{draft_id}/publish")
async def publish_ai_created_story(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""发布AI创作的草稿到'我的作品'"""
draft_result = await db.execute(
select(StoryDraft).where(StoryDraft.id == draft_id)
)
draft = draft_result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
if draft.status != DraftStatus.completed:
raise HTTPException(status_code=400, detail="草稿尚未完成或已失败")
if draft.published_to_center:
raise HTTPException(status_code=400, detail="草稿已发布")
# 标记为已发布
draft.published_to_center = True
await db.commit()
return {
"code": 0,
"data": {
"draftId": draft.id,
"title": draft.title,
"message": "发布成功!可在'我的作品'中查看"
}
}
async def generate_draft_cover(
story_id: int,
draft_id: int,
title: str,
description: str,
category: str
) -> str:
"""
为AI创作的草稿生成封面图片
返回封面图片的URL路径
"""
from app.services.image_gen import ImageGenService
from app.config import get_settings
import os
import base64
settings = get_settings()
service = ImageGenService()
# 检测是否是云端环境
is_cloud = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
# 生成封面图
cover_prompt = f"Book cover for {category} story titled '{title}'. {description[:100] if description else ''}. Vertical cover image, anime style, vibrant colors, eye-catching design, high quality illustration."
print(f"[generate_draft_cover] 生成封面图: {title}")
result = await service.generate_image(cover_prompt, "cover", "anime")
if not result or not result.get("success"):
print(f"[generate_draft_cover] 封面图生成失败: {result.get('error') if result else 'Unknown'}")
return None
image_bytes = base64.b64decode(result["image_data"])
cover_path = f"uploads/stories/{story_id}/drafts/{draft_id}/cover.jpg"
if is_cloud:
# 云端环境:上传到云存储
try:
from app.routers.drafts import upload_to_cloud_storage
await upload_to_cloud_storage(image_bytes, cover_path)
print(f"[generate_draft_cover] ✓ 云端封面图上传成功")
return f"/{cover_path}"
except Exception as e:
print(f"[generate_draft_cover] 云端上传失败: {e}")
return None
else:
# 本地环境:保存到文件系统
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
full_path = os.path.join(base_dir, "stories", str(story_id), "drafts", str(draft_id), "cover.jpg")
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f:
f.write(image_bytes)
print(f"[generate_draft_cover] ✓ 本地封面图保存成功")
return f"/{cover_path}"
async def process_ai_create_story(
draft_id: int,
user_id: int,
genre: str,
keywords: str,
protagonist: str = None,
conflict: str = None
):
"""后台异步处理AI创作故事 - 将完整故事内容存入 ai_nodes"""
from app.database import async_session_factory
from app.services.ai import ai_service
print(f"\n[process_ai_create_story] ========== 开始创作 ==========")
print(f"[process_ai_create_story] draft_id={draft_id}, user_id={user_id}")
print(f"[process_ai_create_story] genre={genre}, keywords={keywords}")
async with async_session_factory() as db:
try:
# 获取草稿记录
draft_result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
draft = draft_result.scalar_one_or_none()
if not draft:
print(f"[process_ai_create_story] 草稿不存在")
return
# 调用AI服务创作故事
print(f"[process_ai_create_story] 开始调用AI服务...")
ai_result = await ai_service.create_story(
genre=genre,
keywords=keywords,
protagonist=protagonist,
conflict=conflict,
user_id=user_id
)
if not ai_result:
print(f"[process_ai_create_story] AI创作失败")
draft.status = DraftStatus.failed
draft.error_message = "AI创作失败"
await db.commit()
return
print(f"[process_ai_create_story] AI创作成功开始生成配图...")
# 获取故事节点并生成背景图(失败不影响创作结果)
story_nodes = ai_result.get("nodes", {})
story_category = ai_result.get("category", genre)
story_title = ai_result.get("title", "未命名故事")
story_description = ai_result.get("description", "")
# 生成封面图
try:
cover_url = await generate_draft_cover(
story_id=draft.story_id,
draft_id=draft_id,
title=story_title,
description=story_description,
category=story_category
)
if cover_url:
ai_result["coverUrl"] = cover_url
print(f"[process_ai_create_story] 封面图生成成功: {cover_url}")
except Exception as cover_e:
print(f"[process_ai_create_story] 封面图生成失败: {cover_e}")
# 生成节点背景图
if story_nodes:
try:
from app.routers.drafts import generate_draft_images
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft_id,
ai_nodes=story_nodes,
story_category=story_category
)
print(f"[process_ai_create_story] 配图生成完成")
except Exception as img_e:
print(f"[process_ai_create_story] 配图生成失败(不影响创作结果): {img_e}")
print(f"[process_ai_create_story] 保存到草稿...")
# 将完整故事内容存入 ai_nodes包含已生成的 background_url
draft.title = ai_result.get("title", "未命名故事")
draft.ai_nodes = ai_result # 存储完整的AI结果包含 title, description, characters, nodes, startNodeKey
draft.entry_node_key = ai_result.get("startNodeKey", "start")
draft.status = DraftStatus.completed
await db.commit()
print(f"[process_ai_create_story] ========== 创作完成(已保存到草稿箱) ==========")
print(f"[process_ai_create_story] 故事标题: {draft.title}")
print(f"[process_ai_create_story] 节点数量: {len(story_nodes)}")
except Exception as e:
print(f"[process_ai_create_story] 异常: {e}")
import traceback
traceback.print_exc()
try:
draft.status = DraftStatus.failed
draft.error_message = str(e)[:200]
await db.commit()
except:
pass
async def generate_story_images(story_id: int, ai_result: dict, genre: str):
"""为AI创作的故事生成图片"""
from app.database import async_session_factory
from app.services.image_gen import ImageGenService
from app.config import get_settings
import os
import base64
print(f"\n[generate_story_images] 开始为故事 {story_id} 生成图片")
settings = get_settings()
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
story_dir = os.path.join(base_dir, "stories", str(story_id))
service = ImageGenService()
async with async_session_factory() as db:
try:
# 1. 生成封面图
print(f"[generate_story_images] 生成封面图...")
title = ai_result.get("title", "")
description = ai_result.get("description", "")
cover_prompt = f"Book cover for {genre} story titled '{title}'. {description[:100]}. Vertical cover image, anime style, vibrant colors, eye-catching design."
cover_result = await service.generate_image(cover_prompt, "cover", "anime")
if cover_result and cover_result.get("success"):
cover_dir = os.path.join(story_dir, "cover")
os.makedirs(cover_dir, exist_ok=True)
cover_path = os.path.join(cover_dir, "cover.jpg")
with open(cover_path, "wb") as f:
f.write(base64.b64decode(cover_result["image_data"]))
# 更新数据库
await db.execute(
update(Story)
.where(Story.id == story_id)
.values(cover_url=f"/uploads/stories/{story_id}/cover/cover.jpg")
)
print(f" ✓ 封面图生成成功")
else:
print(f" ✗ 封面图生成失败")
await asyncio.sleep(1)
# 2. 生成角色头像
print(f"[generate_story_images] 生成角色头像...")
characters = ai_result.get("characters", [])
char_result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
db_characters = char_result.scalars().all()
char_dir = os.path.join(story_dir, "characters")
os.makedirs(char_dir, exist_ok=True)
for db_char in db_characters:
# 找到对应的AI生成数据
char_data = next((c for c in characters if c.get("name") == db_char.name), None)
appearance = db_char.appearance or ""
avatar_prompt = f"Character portrait: {db_char.name}, {db_char.gender}, {appearance}. Anime style avatar, head and shoulders, clear face, high quality."
avatar_result = await service.generate_image(avatar_prompt, "avatar", "anime")
if avatar_result and avatar_result.get("success"):
avatar_path = os.path.join(char_dir, f"{db_char.id}.jpg")
with open(avatar_path, "wb") as f:
f.write(base64.b64decode(avatar_result["image_data"]))
await db.execute(
update(StoryCharacter)
.where(StoryCharacter.id == db_char.id)
.values(avatar_url=f"/uploads/stories/{story_id}/characters/{db_char.id}.jpg")
)
print(f" ✓ 角色 {db_char.name} 头像生成成功")
else:
print(f" ✗ 角色 {db_char.name} 头像生成失败")
await asyncio.sleep(1)
# 3. 生成节点背景图
print(f"[generate_story_images] 生成节点背景图...")
nodes_data = ai_result.get("nodes", {})
nodes_dir = os.path.join(story_dir, "nodes")
for node_key, node_data in nodes_data.items():
content = node_data.get("content", "")[:150]
bg_prompt = f"Background scene for {genre} story. Scene: {content}. Wide shot, atmospheric, no characters, anime style, vivid colors."
bg_result = await service.generate_image(bg_prompt, "background", "anime")
if bg_result and bg_result.get("success"):
node_dir = os.path.join(nodes_dir, node_key)
os.makedirs(node_dir, exist_ok=True)
bg_path = os.path.join(node_dir, "background.jpg")
with open(bg_path, "wb") as f:
f.write(base64.b64decode(bg_result["image_data"]))
await db.execute(
update(StoryNode)
.where(StoryNode.story_id == story_id)
.where(StoryNode.node_key == node_key)
.values(background_image=f"/uploads/stories/{story_id}/nodes/{node_key}/background.jpg")
)
print(f" ✓ 节点 {node_key} 背景图生成成功")
else:
print(f" ✗ 节点 {node_key} 背景图生成失败")
await asyncio.sleep(1)
await db.commit()
print(f"[generate_story_images] 图片生成完成")
except Exception as e:
print(f"[generate_story_images] 异常: {e}")
import traceback
traceback.print_exc()

View File

@@ -52,6 +52,7 @@ class CollectRequest(BaseModel):
class PlayRecordRequest(BaseModel):
userId: int
storyId: int
draftId: Optional[int] = None # AI草稿ID原故事为空
endingName: str
endingType: str = ""
pathHistory: list
@@ -364,24 +365,79 @@ async def toggle_collect(request: CollectRequest, db: AsyncSession = Depends(get
@router.get("/collections")
async def get_collections(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
"""获取收藏列表"""
result = await db.execute(
select(Story)
"""获取收藏列表包含原故事和AI改写草稿"""
from app.models.story import StoryDraft, DraftStatus
# 查询收藏的原故事
original_result = await db.execute(
select(Story, UserProgress.updated_at)
.join(UserProgress, Story.id == UserProgress.story_id)
.where(UserProgress.user_id == user_id, UserProgress.is_collected == True)
.order_by(UserProgress.updated_at.desc())
)
stories = result.scalars().all()
original_stories = original_result.all()
data = [{
"id": s.id,
"title": s.title,
"cover_url": s.cover_url,
"description": s.description,
"category": s.category,
"play_count": s.play_count,
"like_count": s.like_count
} for s in stories]
# 查询收藏的草稿
draft_result = await db.execute(
select(StoryDraft, Story.title.label('story_title'), Story.category, Story.cover_url)
.join(Story, StoryDraft.story_id == Story.id)
.where(
StoryDraft.user_id == user_id,
StoryDraft.is_collected == True,
StoryDraft.status == DraftStatus.completed
)
.order_by(StoryDraft.created_at.desc())
)
drafts = draft_result.all()
# 按 story_id 分组
collections = {}
# 处理原故事
for story, updated_at in original_stories:
story_id = story.id
if story_id not in collections:
collections[story_id] = {
"storyId": story_id,
"storyTitle": story.title,
"category": story.category,
"coverUrl": story.cover_url,
"versions": []
}
collections[story_id]["versions"].append({
"type": "original",
"id": story_id,
"title": story.title,
"draftId": None
})
# 处理草稿
for row in drafts:
draft = row[0]
story_title = row[1]
category = row[2]
cover_url = row[3]
story_id = draft.story_id
if story_id not in collections:
collections[story_id] = {
"storyId": story_id,
"storyTitle": story_title,
"category": category,
"coverUrl": cover_url,
"versions": []
}
collections[story_id]["versions"].append({
"type": draft.draft_type or "rewrite",
"id": draft.id,
"title": draft.title or f"{story_title}-{draft.draft_type}",
"draftId": draft.id
})
# 转换为列表,添加版本数量
data = []
for item in collections.values():
item["versionCount"] = len(item["versions"])
data.append(item)
return {"code": 0, "data": data}
@@ -511,11 +567,17 @@ async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depend
"""保存游玩记录(相同路径只保留最新)"""
import json
# 查找该用户该故事的所有记录
result = await db.execute(
select(PlayRecord)
.where(PlayRecord.user_id == request.userId, PlayRecord.story_id == request.storyId)
# 查找该用户该故事的所有记录(区分原故事和草稿)
query = select(PlayRecord).where(
PlayRecord.user_id == request.userId,
PlayRecord.story_id == request.storyId
)
if request.draftId:
query = query.where(PlayRecord.draft_id == request.draftId)
else:
query = query.where(PlayRecord.draft_id == None)
result = await db.execute(query)
existing_records = result.scalars().all()
# 检查是否有相同路径的记录
@@ -530,6 +592,7 @@ async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depend
record = PlayRecord(
user_id=request.userId,
story_id=request.storyId,
draft_id=request.draftId,
ending_name=request.endingName,
ending_type=request.endingType,
path_history=request.pathHistory
@@ -554,8 +617,10 @@ async def get_play_records(
db: AsyncSession = Depends(get_db)
):
"""获取游玩记录列表"""
from app.models.story import StoryDraft
if story_id:
# 获取指定故事的记录
# 获取指定故事的记录(包含草稿发布状态)
result = await db.execute(
select(PlayRecord)
.where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id)
@@ -563,12 +628,33 @@ async def get_play_records(
)
records = result.scalars().all()
data = [{
"id": r.id,
"endingName": r.ending_name,
"endingType": r.ending_type,
"createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else ""
} for r in records]
# 获取相关草稿的发布状态
draft_ids = [r.draft_id for r in records if r.draft_id]
draft_status = {}
if draft_ids:
draft_result = await db.execute(
select(StoryDraft.id, StoryDraft.published_to_center, StoryDraft.title)
.where(StoryDraft.id.in_(draft_ids))
)
for d in draft_result.all():
draft_status[d.id] = {"published": d.published_to_center, "title": d.title}
data = []
for r in records:
item = {
"id": r.id,
"draftId": r.draft_id,
"endingName": r.ending_name,
"endingType": r.ending_type,
"createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else ""
}
# 如果是草稿记录,添加发布状态
if r.draft_id and r.draft_id in draft_status:
item["isPublished"] = draft_status[r.draft_id]["published"]
item["draftTitle"] = draft_status[r.draft_id]["title"]
else:
item["isPublished"] = True # 原故事视为"已发布"
data.append(item)
else:
# 获取所有玩过的故事(按故事分组,取最新一条)
result = await db.execute(

View File

@@ -453,6 +453,356 @@ class AIService:
traceback.print_exc()
return None
async def create_story(
self,
genre: str,
keywords: str,
protagonist: str = None,
conflict: str = None,
user_id: int = None
) -> Optional[Dict[str, Any]]:
"""
AI创作全新故事
:return: 包含完整故事结构的字典,或 None
"""
print(f"\n[create_story] ========== 开始创作 ==========")
print(f"[create_story] genre={genre}, keywords={keywords}")
print(f"[create_story] protagonist={protagonist}, conflict={conflict}")
print(f"[create_story] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
if not self.enabled or not self.api_key:
print(f"[create_story] 服务未启用或API Key为空返回None")
return None
# 构建系统提示词
system_prompt = """你是一个专业的互动故事创作专家。请根据用户提供的题材和关键词,创作一个完整的互动故事。
【故事结构要求】
1. 故事要有吸引人的标题10字以内和简介50-100字
2. 创建2-3个主要角色每个角色需要详细设定
3. 故事包含6-8个节点形成多分支结构
4. 必须有2-4个不同类型的结局good/bad/neutral/special
5. 每个非结局节点有2个选项选项要有明显的剧情差异
【角色设定要求】
每个角色需要:
- name: 角色名2-4字
- role_type: 角色类型protagonist/antagonist/supporting
- gender: 性别male/female
- age_range: 年龄段youth/adult/middle_aged/elderly
- appearance: 外貌描述50-100字包含发型、眼睛、身材、穿着等
- personality: 性格特点30-50字
【节点内容要求】
- 每个节点150-300字分2-3段\\n\\n分隔
- 包含场景描写、人物对话、心理活动
- 对话要自然生动,描写要有画面感
【结局要求】
- 结局内容200-400字有情感冲击力
- 结局名称4-8字体现剧情走向
- 结局需要评分ending_scoregood 80-100, bad 20-50, neutral 50-70, special 70-90
【输出格式】严格JSON不要有任何额外文字
{
"title": "故事标题",
"description": "故事简介50-100字",
"category": "题材分类",
"characters": [
{
"name": "角色名",
"role_type": "protagonist",
"gender": "male",
"age_range": "youth",
"appearance": "外貌描述...",
"personality": "性格特点..."
}
],
"nodes": {
"start": {
"content": "开篇内容...",
"speaker": "旁白",
"choices": [
{"text": "选项A", "nextNodeKey": "node_1a"},
{"text": "选项B", "nextNodeKey": "node_1b"}
]
},
"node_1a": {
"content": "...",
"speaker": "旁白",
"choices": [...]
},
"ending_good": {
"content": "好结局内容...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "good",
"ending_score": 90
}
},
"startNodeKey": "start"
}"""
# 构建用户提示词
protagonist_text = f"\n主角设定:{protagonist}" if protagonist else ""
conflict_text = f"\n核心冲突:{conflict}" if conflict else ""
user_prompt_text = f"""请创作一个互动故事:
【题材】{genre}
【关键词】{keywords}{protagonist_text}{conflict_text}
请创作完整的故事输出JSON格式"""
print(f"[create_story] 提示词构建完成开始调用AI...")
try:
result = None
if self.provider == "openai":
result = await self._call_openai_long(system_prompt, user_prompt_text)
elif self.provider == "claude":
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
elif self.provider == "qwen":
result = await self._call_qwen_long(system_prompt, user_prompt_text)
elif self.provider == "deepseek":
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
print(f"[create_story] AI调用完成result存在={result is not None}")
if result and result.get("content"):
print(f"[create_story] AI返回内容长度={len(result.get('content', ''))}")
# 解析JSON响应
parsed = self._parse_story_json(result["content"])
print(f"[create_story] JSON解析结果: parsed存在={parsed is not None}")
if parsed:
parsed["tokens_used"] = result.get("tokens_used", 0)
print(f"[create_story] 成功! title={parsed.get('title')}, nodes数量={len(parsed.get('nodes', {}))}")
return parsed
else:
print(f"[create_story] JSON解析失败!")
return None
except Exception as e:
print(f"[create_story] 异常: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
def _parse_story_json(self, content: str) -> Optional[Dict]:
"""解析AI返回的故事JSON"""
print(f"[_parse_story_json] 开始解析,内容长度={len(content)}")
# 移除 markdown 代码块标记
clean_content = content.strip()
if clean_content.startswith('```'):
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
clean_content = re.sub(r'\s*```$', '', clean_content)
result = None
# 方法1: 直接解析
try:
result = json.loads(clean_content)
if all(k in result for k in ['title', 'nodes', 'startNodeKey']):
print(f"[_parse_story_json] 直接解析成功!")
except json.JSONDecodeError as e:
print(f"[_parse_story_json] 直接解析失败: {e}")
result = None
# 方法2: 提取JSON块
if not result:
try:
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
if brace_match:
json_str = brace_match.group(0)
result = json.loads(json_str)
if all(k in result for k in ['title', 'nodes', 'startNodeKey']):
print(f"[_parse_story_json] 花括号块解析成功!")
else:
result = None
except json.JSONDecodeError as e:
print(f"[_parse_story_json] 花括号块解析失败: {e}")
# 尝试修复截断的JSON
try:
result = self._try_fix_story_json(json_str)
if result:
print(f"[_parse_story_json] JSON修复成功!")
except:
pass
except Exception as e:
print(f"[_parse_story_json] 提取解析失败: {e}")
if not result:
print(f"[_parse_story_json] 所有解析方法都失败了")
return None
# 验证并修复故事结构
result = self._validate_and_fix_story(result)
return result
def _validate_and_fix_story(self, story: Dict) -> Dict:
"""验证并修复故事结构,确保每个分支都有结局"""
nodes = story.get('nodes', {})
if not nodes:
return story
print(f"[_validate_and_fix_story] 开始验证,节点数={len(nodes)}")
# 1. 找出所有结局节点
ending_nodes = [k for k, v in nodes.items() if v.get('is_ending')]
print(f"[_validate_and_fix_story] 已有结局节点: {ending_nodes}")
# 2. 找出所有被引用的节点(作为 nextNodeKey
referenced_keys = set()
for node_key, node_data in nodes.items():
choices = node_data.get('choices', [])
if isinstance(choices, list):
for choice in choices:
if isinstance(choice, dict) and 'nextNodeKey' in choice:
referenced_keys.add(choice['nextNodeKey'])
# 3. 找出"叶子节点":没有 choices 或 choices 为空,且不是结局
leaf_nodes = []
broken_refs = [] # 引用了不存在节点的选项
for node_key, node_data in nodes.items():
choices = node_data.get('choices', [])
is_ending = node_data.get('is_ending', False)
# 检查 choices 中引用的节点是否存在
if isinstance(choices, list):
for choice in choices:
if isinstance(choice, dict):
next_key = choice.get('nextNodeKey')
if next_key and next_key not in nodes:
broken_refs.append((node_key, next_key))
# 没有有效选项且不是结局的节点
if not is_ending and (not choices or len(choices) == 0):
leaf_nodes.append(node_key)
print(f"[_validate_and_fix_story] 叶子节点(无选项非结局): {leaf_nodes}")
print(f"[_validate_and_fix_story] 断裂引用: {broken_refs}")
# 4. 修复:将叶子节点标记为结局
for node_key in leaf_nodes:
node = nodes[node_key]
print(f"[_validate_and_fix_story] 修复节点 {node_key} -> 标记为结局")
node['is_ending'] = True
if not node.get('ending_name'):
node['ending_name'] = '命运的转折'
if not node.get('ending_type'):
node['ending_type'] = 'neutral'
if not node.get('ending_score'):
node['ending_score'] = 60
# 5. 修复:处理断裂引用(选项指向不存在的节点)
for node_key, missing_key in broken_refs:
node = nodes[node_key]
choices = node.get('choices', [])
# 移除指向不存在节点的选项
valid_choices = [c for c in choices if c.get('nextNodeKey') in nodes]
if len(valid_choices) == 0:
# 没有有效选项了,标记为结局
print(f"[_validate_and_fix_story] 节点 {node_key} 所有选项失效 -> 标记为结局")
node['is_ending'] = True
node['choices'] = []
if not node.get('ending_name'):
node['ending_name'] = '未知结局'
if not node.get('ending_type'):
node['ending_type'] = 'neutral'
if not node.get('ending_score'):
node['ending_score'] = 50
else:
node['choices'] = valid_choices
# 6. 最终检查:确保至少有一个结局
ending_count = sum(1 for v in nodes.values() if v.get('is_ending'))
print(f"[_validate_and_fix_story] 修复后结局数: {ending_count}")
if ending_count == 0:
# 如果还是没有结局,找最后一个节点标记为结局
last_key = list(nodes.keys())[-1]
print(f"[_validate_and_fix_story] 强制将最后节点 {last_key} 标记为结局")
nodes[last_key]['is_ending'] = True
nodes[last_key]['ending_name'] = '故事的终点'
nodes[last_key]['ending_type'] = 'neutral'
nodes[last_key]['ending_score'] = 60
nodes[last_key]['choices'] = []
return story
def _try_fix_story_json(self, json_str: str) -> Optional[Dict]:
"""尝试修复不完整的故事JSON"""
try:
# 尝试找到最后一个完整的节点
# 查找 "nodes": { 的位置
nodes_match = re.search(r'"nodes"\s*:\s*\{', json_str)
if not nodes_match:
return None
# 找所有看起来完整的节点(有 "content" 字段的)
node_pattern = r'"(\w+)"\s*:\s*\{[^{}]*"content"[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
nodes = list(re.finditer(node_pattern, json_str[nodes_match.end():]))
if len(nodes) < 2:
return None
# 取到最后一个完整节点的位置
last_node_end = nodes_match.end() + nodes[-1].end()
# 尝试提取基本信息
title_match = re.search(r'"title"\s*:\s*"([^"]+)"', json_str)
desc_match = re.search(r'"description"\s*:\s*"([^"]+)"', json_str)
category_match = re.search(r'"category"\s*:\s*"([^"]+)"', json_str)
start_match = re.search(r'"startNodeKey"\s*:\s*"([^"]+)"', json_str)
title = title_match.group(1) if title_match else "AI创作故事"
description = desc_match.group(1) if desc_match else ""
category = category_match.group(1) if category_match else "都市言情"
startNodeKey = start_match.group(1) if start_match else "start"
# 提取角色
characters = []
char_match = re.search(r'"characters"\s*:\s*\[([\s\S]*?)\]', json_str)
if char_match:
try:
characters = json.loads('[' + char_match.group(1) + ']')
except:
pass
# 提取节点
nodes_content = json_str[nodes_match.start():last_node_end] + '}'
try:
nodes_obj = json.loads('{' + nodes_content + '}')
nodes_dict = nodes_obj.get('nodes', {})
except:
return None
if len(nodes_dict) < 2:
return None
result = {
"title": title,
"description": description,
"category": category,
"characters": characters,
"nodes": nodes_dict,
"startNodeKey": startNodeKey
}
print(f"[_try_fix_story_json] 修复成功! 节点数={len(nodes_dict)}")
return result
except Exception as e:
print(f"[_try_fix_story_json] 修复失败: {e}")
return None
def _parse_branch_json(self, content: str) -> Optional[Dict]:
"""解析AI返回的分支JSON"""
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
@@ -567,7 +917,7 @@ class AIService:
{"role": "user", "content": user_prompt}
],
"temperature": 0.85,
"max_tokens": 6000 # 增加输出长度确保JSON完整
"max_tokens": 8192 # DeepSeek 最大输出限制
}
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")

View File

@@ -0,0 +1,139 @@
"""
图片生成服务 - 使用 Gemini API 生图,存储到本地/云托管
"""
import httpx
import base64
import time
import hashlib
import os
from typing import Optional
from app.config import get_settings
# 图片尺寸规范(基于前端展示尺寸 × 3倍清晰度
IMAGE_SIZES = {
"cover": {"width": 240, "height": 330, "desc": "竖版封面图3:4比例"},
"avatar": {"width": 150, "height": 150, "desc": "正方形头像1:1比例"},
"background": {"width": 1120, "height": 840, "desc": "横版背景图4:3比例"},
"character": {"width": 512, "height": 768, "desc": "竖版角色立绘2:3比例透明背景"}
}
class ImageGenService:
def __init__(self):
settings = get_settings()
self.api_key = settings.gemini_api_key
self.base_url = "https://work.poloapi.com/v1beta"
# 计算绝对路径
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
self.upload_dir = os.path.join(base_dir, "images")
os.makedirs(self.upload_dir, exist_ok=True)
def get_size_prompt(self, image_type: str) -> str:
"""获取尺寸描述提示词"""
size = IMAGE_SIZES.get(image_type, IMAGE_SIZES["background"])
return f"Image size: {size['width']}x{size['height']} pixels, {size['desc']}."
async def generate_image(self, prompt: str, image_type: str = "background", style: str = "anime") -> Optional[dict]:
"""
调用 Gemini 生成图片
prompt: 图片描述
image_type: 图片类型cover/avatar/background/character
style: 风格anime/realistic/illustration
"""
style_prefix = {
"anime": "anime style, high quality illustration, vibrant colors, ",
"realistic": "photorealistic, high detail, cinematic lighting, ",
"illustration": "digital art illustration, beautiful artwork, "
}
# 组合完整提示词:风格 + 尺寸 + 内容
size_prompt = self.get_size_prompt(image_type)
full_prompt = f"{style_prefix.get(style, '')}{size_prompt} {prompt}"
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.base_url}/models/gemini-3-pro-image-preview:generateContent",
headers={
"Content-Type": "application/json",
"x-goog-api-key": self.api_key
},
json={
"contents": [{
"parts": [{
"text": f"Generate an image: {full_prompt}"
}]
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"]
}
}
)
if response.status_code == 200:
data = response.json()
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if "inlineData" in part:
return {
"success": True,
"image_data": part["inlineData"]["data"],
"mime_type": part["inlineData"].get("mimeType", "image/png")
}
return {"success": False, "error": "No image in response"}
else:
error_text = response.text[:200]
return {"success": False, "error": f"API error: {response.status_code} - {error_text}"}
except Exception as e:
return {"success": False, "error": str(e)}
async def save_image(self, image_data: str, filename: str) -> Optional[str]:
"""保存图片到本地返回访问URL"""
try:
image_bytes = base64.b64decode(image_data)
file_path = os.path.join(self.upload_dir, filename)
with open(file_path, "wb") as f:
f.write(image_bytes)
# 返回可访问的URL路径
return f"/uploads/images/{filename}"
except Exception as e:
print(f"Save error: {e}")
return None
async def generate_and_save(self, prompt: str, image_type: str = "background", style: str = "anime") -> dict:
"""生成图片并保存"""
result = await self.generate_image(prompt, image_type, style)
if not result or not result.get("success"):
return {
"success": False,
"error": result.get("error", "生成失败") if result else "生成失败"
}
# 生成文件名
timestamp = int(time.time() * 1000)
hash_str = hashlib.md5(prompt.encode()).hexdigest()[:8]
ext = "png" if "png" in result.get("mime_type", "") else "jpg"
filename = f"{image_type}_{timestamp}_{hash_str}.{ext}"
url = await self.save_image(result["image_data"], filename)
if url:
return {"success": True, "url": url, "filename": filename}
else:
return {"success": False, "error": "保存失败"}
# 延迟初始化单例
_service_instance = None
def get_image_gen_service():
global _service_instance
if _service_instance is None:
_service_instance = ImageGenService()
return _service_instance

View File

@@ -146,6 +146,9 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
`status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
`error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因',
`is_read` TINYINT(1) DEFAULT 0 COMMENT '用户是否已查看',
`published_to_center` TINYINT(1) DEFAULT 0 COMMENT '是否发布到创作中心',
`draft_type` VARCHAR(20) DEFAULT 'rewrite' COMMENT '草稿类型: rewrite/continue/create',
`is_collected` TINYINT(1) DEFAULT 0 COMMENT '用户是否收藏',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (`id`),
@@ -160,22 +163,7 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
-- ============================================
-- 8. 游玩记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `play_records` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`story_id` INT NOT NULL COMMENT '故事ID',
`ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称',
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型',
`path_history` JSON NOT NULL COMMENT '完整的选择路径',
`play_duration` INT DEFAULT 0 COMMENT '游玩时长(秒)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_story` (`user_id`, `story_id`),
KEY `idx_user` (`user_id`),
KEY `idx_story` (`story_id`),
CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表';
c
-- ============================================
-- 9. 故事角色表