7 Commits

17 changed files with 1894 additions and 260 deletions

BIN
.gitignore vendored

Binary file not shown.

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 { export default class UserManager {
constructor() { 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; if (!this.isLoggedIn) return null;
try { try {
return await post('/user/play-record', { return await post('/user/play-record', {
userId: this.userId, userId: this.userId,
storyId, storyId,
draftId, // AI草稿ID原故事为null
endingName, endingName,
endingType: endingType || '', endingType: endingType || '',
pathHistory: pathHistory || [] pathHistory: pathHistory || []

View File

@@ -6,8 +6,8 @@ import BaseScene from './BaseScene';
export default class AICreateScene extends BaseScene { export default class AICreateScene extends BaseScene {
constructor(main, params) { constructor(main, params) {
super(main, params); super(main, params);
this.currentTab = 0; // 0:改写 1:续写 2:创作 this.currentTab = 0; // 0:我的改写 1:我的续写 2:AI创作
this.tabs = ['AI改写', 'AI续写', 'AI创作']; this.tabs = ['我的改写', '我的续写', 'AI创作'];
// 滚动 // 滚动
this.scrollY = 0; this.scrollY = 0;
@@ -17,8 +17,8 @@ export default class AICreateScene extends BaseScene {
this.hasMoved = false; this.hasMoved = false;
// 用户数据 // 用户数据
this.recentStories = []; this.publishedRewrites = []; // 已发布的改写作品
this.aiHistory = []; this.publishedContinues = []; // 已发布的续写作品
this.quota = { daily: 3, used: 0, purchased: 0 }; this.quota = { daily: 3, used: 0, purchased: 0 };
// 创作表单 // 创作表单
@@ -29,12 +29,7 @@ export default class AICreateScene extends BaseScene {
conflict: '' conflict: ''
}; };
// 选中的故事(用于改写/续写)
this.selectedStory = null;
// 快捷标签 // 快捷标签
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘'];
this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除'];
this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战']; this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战'];
} }
@@ -44,10 +39,17 @@ export default class AICreateScene extends BaseScene {
async loadData() { async loadData() {
try { try {
// 加载最近游玩的故事 const userId = this.main.userManager.userId;
this.recentStories = await this.main.userManager.getRecentPlayed() || []; if (!userId) return;
// 加载AI创作历史
this.aiHistory = await this.main.userManager.getAIHistory() || []; // 加载已发布的改写作品
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(); const quotaData = await this.main.userManager.getAIQuota();
if (quotaData) this.quota = quotaData; if (quotaData) this.quota = quotaData;
@@ -59,8 +61,10 @@ export default class AICreateScene extends BaseScene {
calculateMaxScroll() { calculateMaxScroll() {
let contentHeight = 400; let contentHeight = 400;
if (this.currentTab === 0 || this.currentTab === 1) { if (this.currentTab === 0) {
contentHeight = 300 + this.recentStories.length * 80; contentHeight = 300 + this.publishedRewrites.length * 90;
} else if (this.currentTab === 1) {
contentHeight = 300 + this.publishedContinues.length * 90;
} else { } else {
contentHeight = 600; contentHeight = 600;
} }
@@ -210,23 +214,10 @@ export default class AICreateScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif'; ctx.font = '13px sans-serif';
ctx.textAlign = 'center'; 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)'; this.renderPublishedList(ctx, y + 50, this.publishedRewrites, 'rewrite');
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');
} }
renderContinueTab(ctx, startY) { renderContinueTab(ctx, startY) {
@@ -236,21 +227,10 @@ export default class AICreateScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif'; ctx.font = '13px sans-serif';
ctx.textAlign = 'center'; 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'; this.renderPublishedList(ctx, y + 50, this.publishedContinues, 'continue');
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');
} }
renderCreateTab(ctx, startY) { renderCreateTab(ctx, startY) {
@@ -273,16 +253,21 @@ export default class AICreateScene extends BaseScene {
// 关键词输入 // 关键词输入
let currentY = tagEndY + 25; let currentY = tagEndY + 25;
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.fillText('故事关键词:', padding, currentY); ctx.fillText('故事关键词:', padding, currentY);
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords'); this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords');
// 主角设定 // 主角设定
currentY += 80; currentY += 80;
ctx.textAlign = 'left';
ctx.fillText('主角设定:', padding, currentY); ctx.fillText('主角设定:', padding, currentY);
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist'); this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist');
// 核心冲突 // 核心冲突
currentY += 80; currentY += 80;
ctx.textAlign = 'left';
ctx.fillText('核心冲突:', padding, currentY); ctx.fillText('核心冲突:', padding, currentY);
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict'); this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict');
@@ -388,6 +373,84 @@ export default class AICreateScene extends BaseScene {
this.inputRects[field] = { x, y: y + this.scrollY, width, height, field }; 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) { renderStoryList(ctx, startY, type) {
const padding = 15; const padding = 15;
const cardHeight = 70; const cardHeight = 70;
@@ -550,7 +613,6 @@ export default class AICreateScene extends BaseScene {
if (this.currentTab !== tab.index) { if (this.currentTab !== tab.index) {
this.currentTab = tab.index; this.currentTab = tab.index;
this.scrollY = 0; this.scrollY = 0;
this.selectedStory = null;
this.calculateMaxScroll(); this.calculateMaxScroll();
} }
return; return;
@@ -561,14 +623,34 @@ export default class AICreateScene extends BaseScene {
// 调整y坐标考虑滚动 // 调整y坐标考虑滚动
const scrolledY = y + this.scrollY; const scrolledY = y + this.scrollY;
// 标签点击 // 前往草稿箱按钮
if (this.tagRects) { if (this.gotoDraftsBtnRect && this.isInRect(x, scrolledY, this.gotoDraftsBtnRect)) {
const tagType = this.currentTab === 0 ? 'rewrite' : this.currentTab === 1 ? 'continue' : 'genre'; this.main.sceneManager.switchScene('drafts');
const tags = this.tagRects[tagType]; 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) { if (tags) {
for (const tag of tags) { for (const tag of tags) {
if (this.isInRect(x, scrolledY, tag)) { if (this.isInRect(x, scrolledY, tag)) {
this.handleTagSelect(tagType, tag); this.handleTagSelect('genre', tag);
return; return;
} }
} }
@@ -586,26 +668,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)) { if (this.currentTab === 2 && this.createBtnRect && this.isInRect(x, scrolledY, this.createBtnRect)) {
this.handleCreate(); this.handleCreate();
@@ -613,6 +675,15 @@ export default class AICreateScene extends BaseScene {
} }
} }
handleReadPublished(item) {
// 跳转到故事场景播放AI改写/续写的内容
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
fromDrafts: true
});
}
isInRect(x, y, rect) { isInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
} }
@@ -620,10 +691,6 @@ export default class AICreateScene extends BaseScene {
handleTagSelect(type, tag) { handleTagSelect(type, tag) {
if (type === 'genre') { if (type === 'genre') {
this.createForm.genre = tag.value; this.createForm.genre = tag.value;
} else if (type === 'rewrite') {
this.selectedRewriteTag = tag.index;
} else if (type === 'continue') {
this.selectedContinueTag = tag.index;
} }
} }

View File

@@ -43,9 +43,48 @@ export default class EndingScene extends BaseScene {
this.showButtons = true; this.showButtons = true;
}, 1500); }, 1500);
// 保存游玩记录回放模式和AI草稿不保存 // 保存游玩记录
if (!this.isReplay && !this.draftId) { this.checkAndSavePlayRecord();
// 加载收藏状态
this.loadCollectStatus();
}
async checkAndSavePlayRecord() {
// 回放模式不保存
if (this.isReplay) return;
// 原故事:直接保存
if (!this.draftId) {
this.savePlayRecord(); 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 endingName = this.ending?.name || '未知结局';
const endingType = this.ending?.type || ''; const endingType = this.ending?.type || '';
// 调用保存接口 // 调用保存接口(传入 draftId 区分原故事和AI草稿
await this.main.userManager.savePlayRecord( await this.main.userManager.savePlayRecord(
this.storyId, this.storyId,
endingName, endingName,
endingType, endingType,
pathHistory pathHistory,
this.draftId // AI草稿ID原故事为null
); );
console.log('游玩记录保存成功'); console.log('游玩记录保存成功');
} catch (e) { } catch (e) {
@@ -1193,8 +1233,14 @@ export default class EndingScene extends BaseScene {
handleCollect() { handleCollect() {
this.isCollected = !this.isCollected; this.isCollected = !this.isCollected;
if (this.draftId) {
// AI草稿收藏草稿
this.main.userManager.collectDraft(this.draftId, this.isCollected);
} else {
// 原故事:收藏故事
this.main.userManager.collectStory(this.storyId, this.isCollected); this.main.userManager.collectStory(this.storyId, this.isCollected);
} }
}
// 启动草稿完成轮询每5秒检查一次持续2分钟 // 启动草稿完成轮询每5秒检查一次持续2分钟
startDraftPolling(draftId) { startDraftPolling(draftId) {

View File

@@ -2,6 +2,7 @@
* 首页场景 - 支持UGC * 首页场景 - 支持UGC
*/ */
import BaseScene from './BaseScene'; import BaseScene from './BaseScene';
import { getStaticUrl } from '../utils/http';
export default class HomeScene extends BaseScene { export default class HomeScene extends BaseScene {
constructor(main, params) { constructor(main, params) {
@@ -13,6 +14,9 @@ export default class HomeScene extends BaseScene {
this.lastTouchY = 0; this.lastTouchY = 0;
this.scrollVelocity = 0; this.scrollVelocity = 0;
// 封面图片缓存
this.coverImages = {};
// 底部Tab: 首页/发现/创作/我的 // 底部Tab: 首页/发现/创作/我的
this.bottomTab = 0; this.bottomTab = 0;
@@ -35,6 +39,23 @@ export default class HomeScene extends BaseScene {
async init() { async init() {
this.storyList = this.main.storyManager.storyList; this.storyList = this.main.storyManager.storyList;
this.calculateMaxScroll(); 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() { getFilteredStories() {
@@ -248,18 +269,32 @@ export default class HomeScene extends BaseScene {
// 封面 // 封面
const coverW = 80, coverH = height - 20; const coverW = 80, coverH = height - 20;
const coverGradient = ctx.createLinearGradient(x + 10, y + 10, x + 10 + coverW, y + 10 + coverH); 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); const colors = this.getCategoryGradient(story.category);
coverGradient.addColorStop(0, colors[0]); coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]); coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient; ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 10, y + 10, coverW, coverH, 10); this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
ctx.fill(); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 10px sans-serif'; ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(story.category || '故事', x + 10 + coverW / 2, y + 10 + coverH / 2 + 4); ctx.fillText(story.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 4);
}
const textX = x + 100; const textX = x + 100;
const maxW = width - 115; const maxW = width - 115;

View File

@@ -73,18 +73,21 @@ export default class ProfileScene extends BaseScene {
if (this.main.userManager.isLoggedIn) { if (this.main.userManager.isLoggedIn) {
try { try {
const userId = this.main.userManager.userId; 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') || [];
this.myWorks = [...publishedRewrites, ...publishedContinues];
// 加载 AI 改写草稿 // 加载 AI 改写草稿
this.drafts = await this.main.storyManager.getDrafts(userId) || []; this.drafts = await this.main.storyManager.getDrafts(userId) || [];
this.collections = await this.main.userManager.getCollections() || []; this.collections = await this.main.userManager.getCollections() || [];
// 加载游玩记录(故事列表) // 加载游玩记录(故事列表)
this.progress = await this.main.userManager.getPlayRecords() || []; this.progress = await this.main.userManager.getPlayRecords() || [];
// 计算统计 // 计算统计(作品数=已发布作品数)
this.stats.works = this.myWorks.length; this.stats.works = this.myWorks.length;
this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.play_count || 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.like_count || 0), 0); this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.likeCount || 0), 0);
this.stats.earnings = this.myWorks.reduce((sum, w) => sum + (w.earnings || 0), 0); this.stats.earnings = 0; // 暂无收益功能
} catch (e) { } catch (e) {
console.error('加载数据失败:', e); console.error('加载数据失败:', e);
} }
@@ -333,25 +336,25 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '13px sans-serif'; ctx.font = '13px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const emptyTexts = ['还没有发布作品,去创作吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录']; const emptyTexts = ['还没有发布作品,去草稿箱发布吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录'];
const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions') const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions')
? '该故事还没有游玩记录' ? '该故事还没有游玩记录'
: emptyTexts[this.currentTab]; : emptyTexts[this.currentTab];
ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50); ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50);
// 创作引导按钮 // 作品Tab引导按钮 - 跳转到草稿箱
if (this.currentTab === 0) { if (this.currentTab === 0) {
const btnY = listStartY + 80; const btnY = listStartY + 80;
const btnGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, btnY, this.screenWidth / 2 + 50, btnY); const btnGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, btnY, this.screenWidth / 2 + 50, btnY);
btnGradient.addColorStop(0, '#a855f7'); btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899'); btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient; 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.fill();
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px sans-serif'; ctx.font = 'bold 13px sans-serif';
ctx.fillText('✨ 开始创作', this.screenWidth / 2, btnY + 23); ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 23);
this.createBtnRect = { x: this.screenWidth / 2 - 50, y: btnY, width: 100, height: 36 }; this.createBtnRect = { x: this.screenWidth / 2 - 55, y: btnY, width: 110, height: 36 };
} }
ctx.restore(); ctx.restore();
@@ -431,7 +434,10 @@ export default class ProfileScene extends BaseScene {
// 渲染单条游玩记录版本卡片 // 渲染单条游玩记录版本卡片
renderRecordVersionCard(ctx, item, x, y, w, h, index) { 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); this.roundRect(ctx, x, y, w, h, 12);
ctx.fill(); ctx.fill();
@@ -441,34 +447,49 @@ export default class ProfileScene extends BaseScene {
const circleR = 18; const circleR = 18;
const colors = this.getGradientColors(index); const colors = this.getGradientColors(index);
const circleGradient = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR); const circleGradient = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR);
circleGradient.addColorStop(0, colors[0]); circleGradient.addColorStop(0, isUnpublished ? 'rgba(128,128,128,0.5)' : colors[0]);
circleGradient.addColorStop(1, colors[1]); circleGradient.addColorStop(1, isUnpublished ? 'rgba(96,96,96,0.5)' : colors[1]);
ctx.fillStyle = circleGradient; ctx.fillStyle = circleGradient;
ctx.beginPath(); ctx.beginPath();
ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2); ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// 序号 // 序号
ctx.fillStyle = '#ffffff'; ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff';
ctx.font = 'bold 14px sans-serif'; ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(`${index + 1}`, circleX, circleY + 5); ctx.fillText(`${index + 1}`, circleX, circleY + 5);
const textX = x + 65; 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.font = 'bold 14px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
const endingLabel = `结局:${item.endingName || '未知结局'}`; 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);
// 游玩时间 // 已下架标签(固定在结局名称右边)
ctx.fillStyle = 'rgba(255,255,255,0.45)'; 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 = isUnpublished ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.45)';
ctx.font = '11px sans-serif'; ctx.font = '11px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
const timeText = item.createdAt ? this.formatDateTime(item.createdAt) : ''; let subText = item.createdAt ? this.formatDateTime(item.createdAt) : '';
ctx.fillText(timeText, textX, y + 52); 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)'; ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
@@ -481,8 +502,8 @@ export default class ProfileScene extends BaseScene {
// 回放按钮 // 回放按钮
const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28); const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28);
btnGradient.addColorStop(0, '#ff6b6b'); btnGradient.addColorStop(0, isUnpublished ? '#888888' : '#ff6b6b');
btnGradient.addColorStop(1, '#ffd700'); btnGradient.addColorStop(1, isUnpublished ? '#666666' : '#ffd700');
ctx.fillStyle = btnGradient; ctx.fillStyle = btnGradient;
this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13); this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13);
ctx.fill(); ctx.fill();
@@ -526,10 +547,12 @@ export default class ProfileScene extends BaseScene {
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10); this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10);
ctx.fill(); ctx.fill();
// 类型标签
const typeText = item.draftType === 'continue' ? '续写' : '改写';
ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = 'bold 9px sans-serif'; ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3); ctx.fillText(typeText, x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
const textX = x + 88; const textX = x + 88;
const maxW = w - 100; const maxW = w - 100;
@@ -538,46 +561,55 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif'; ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(this.truncateText(ctx, item.title || '未命名', maxW - 60), textX, y + 25); const title = item.title || item.storyTitle || '未命名';
ctx.fillText(this.truncateText(ctx, title, maxW - 60), textX, y + 25);
// 审核状态标签 // 已发布标签
const statusMap = { const statusText = '已发布';
0: { text: '草稿', color: '#888888' }, const statusW = ctx.measureText(statusText).width + 12;
1: { text: '审核中', color: '#f59e0b' }, ctx.fillStyle = 'rgba(34, 197, 94, 0.2)';
2: { text: '已发布', color: '#22c55e' }, const titleWidth = ctx.measureText(this.truncateText(ctx, title, maxW - 60)).width;
3: { text: '已下架', color: '#ef4444' }, this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9);
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);
ctx.fill(); ctx.fill();
ctx.fillStyle = status.color; ctx.fillStyle = '#22c55e';
ctx.font = 'bold 10px sans-serif'; 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.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = '11px sans-serif'; ctx.font = '11px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(` ${this.formatNumber(item.play_count || 0)}`, textX, y + 50); ctx.fillText(`原故事: ${item.storyTitle || ''}`, textX, y + 48);
ctx.fillText(`${this.formatNumber(item.like_count || 0)}`, textX + 55, y + 50);
ctx.fillText(`💰 ${(item.earnings || 0).toFixed(1)}`, textX + 105, y + 50);
// 操作按钮 // 创建时间
const btnY = y + 65; ctx.fillStyle = 'rgba(255,255,255,0.35)';
const btns = ['编辑', '数据']; ctx.font = '10px sans-serif';
btns.forEach((btn, i) => { ctx.fillText(item.createdAt || '', textX, y + 68);
const btnX = textX + i * 55;
ctx.fillStyle = 'rgba(255,255,255,0.1)'; // 按钮区域
this.roundRect(ctx, btnX, btnY, 48, 24, 12); 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.fill();
ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillStyle = '#ef4444';
ctx.font = '11px sans-serif'; ctx.font = '11px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(btn, btnX + 24, btnY + 16); 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) { renderDraftCard(ctx, item, x, y, w, h, index) {
@@ -636,10 +668,10 @@ export default class ProfileScene extends BaseScene {
const promptText = item.userPrompt ? `"「${item.userPrompt}」"` : ''; const promptText = item.userPrompt ? `"「${item.userPrompt}」"` : '';
ctx.fillText(this.truncateText(ctx, promptText, w - 100), textX, y + 48); ctx.fillText(this.truncateText(ctx, promptText, w - 100), textX, y + 48);
// 时间 // 时间(放在左下角)
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '10px sans-serif'; 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') { if (!item.isRead && item.status === 'completed') {
@@ -649,33 +681,52 @@ export default class ProfileScene extends BaseScene {
ctx.fill(); ctx.fill();
} }
// 按钮 // 按钮行(放在右下角)
const btnY = y + 62; const btnY = y + 60;
const btnStartX = x + w - 170; // 从右边开始排列按钮
// 删除按钮(所有状态都显示)
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
this.roundRect(ctx, x + w - 55, btnY, 45, 24, 12);
ctx.fill();
ctx.fillStyle = '#ef4444';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('删除', x + w - 32, btnY + 16);
// 播放按钮(仅已完成状态) // 播放按钮(仅已完成状态)
if (item.status === 'completed') { 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(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899'); btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient; ctx.fillStyle = btnGradient;
this.roundRect(ctx, textX + 120, btnY, 60, 24, 12); this.roundRect(ctx, btnStartX, btnY, 50, 26, 13);
ctx.fill(); ctx.fill();
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif'; ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center'; 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) { renderSimpleCard(ctx, item, x, y, w, h, index) {
ctx.fillStyle = 'rgba(255,255,255,0.05)'; ctx.fillStyle = 'rgba(255,255,255,0.05)';
this.roundRect(ctx, x, y, w, h, 12); this.roundRect(ctx, x, y, w, h, 12);
@@ -700,7 +751,7 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif'; ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
// 记录Tab使用 storyTitle收藏Tab使用 story_title // 记录Tab使用 storyTitle收藏Tab使用 storyTitle
const title = item.storyTitle || item.story_title || item.title || '未知'; const title = item.storyTitle || item.story_title || item.title || '未知';
ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28); ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28);
@@ -709,11 +760,14 @@ export default class ProfileScene extends BaseScene {
if (this.currentTab === 3) { if (this.currentTab === 3) {
// 记录Tab只显示记录数量 // 记录Tab只显示记录数量
ctx.fillText(`${item.recordCount || 0} 条记录`, textX, y + 50); 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 { } else {
ctx.fillText(item.category || '', textX, y + 50); 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); const btnGradient = ctx.createLinearGradient(x + w - 58, y + 28, x + w - 10, y + 28);
btnGradient.addColorStop(0, '#ff6b6b'); btnGradient.addColorStop(0, '#ff6b6b');
btnGradient.addColorStop(1, '#ffd700'); btnGradient.addColorStop(1, '#ffd700');
@@ -723,7 +777,7 @@ export default class ProfileScene extends BaseScene {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif'; ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(this.currentTab === 3 ? '查看' : '继续', x + w - 34, y + 45); ctx.fillText('查看', x + w - 34, y + 45);
} }
getGradientColors(index) { getGradientColors(index) {
@@ -848,11 +902,14 @@ export default class ProfileScene extends BaseScene {
} }
} }
// 创作按钮 // 前往草稿箱按钮
if (this.createBtnRect && this.currentTab === 0) { if (this.createBtnRect && this.currentTab === 0) {
const btn = this.createBtnRect; const btn = this.createBtnRect;
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { 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; return;
} }
} }
@@ -896,23 +953,32 @@ export default class ProfileScene extends BaseScene {
// AI草稿 Tab 的按钮检测 // AI草稿 Tab 的按钮检测
if (this.currentTab === 1) { if (this.currentTab === 1) {
const btnY = 62; const btnY = 60;
const btnH = 24; const btnH = 26;
const btnStartX = padding + cardW - 170;
// 检测删除按钮点击(右侧) // 检测删除按钮点击(右侧)
const deleteBtnX = padding + cardW - 55; const deleteBtnX = padding + cardW - 55;
if (x >= deleteBtnX && x <= deleteBtnX + 45 && relativeY >= btnY && relativeY <= btnY + btnH) { if (x >= deleteBtnX && x <= deleteBtnX + 45 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.confirmDeleteDraft(item, index); this.confirmDeleteDraft(item, index);
return; return;
} }
// 检测播放按钮点击(左侧,仅已完成状态) // 检测播放按钮点击(仅已完成状态)
if (item.status === 'completed') { if (item.status === 'completed') {
const playBtnX = padding + 88 + 120; if (x >= btnStartX && x <= btnStartX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) {
if (x >= playBtnX && x <= playBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
return; return;
} }
// 检测发布按钮点击(仅未发布状态)
if (!item.publishedToCenter) {
const publishBtnX = btnStartX + 58;
if (x >= publishBtnX && x <= publishBtnX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) {
this.confirmPublishDraft(item, index);
return;
}
}
} }
// 点击卡片其他区域 // 点击卡片其他区域
@@ -957,17 +1023,54 @@ export default class ProfileScene extends BaseScene {
} }
if (this.currentTab === 2) { if (this.currentTab === 2) {
// 收藏 - 跳转播放 // 收藏 - 检查版本数量
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 }); this.main.sceneManager.switchScene('story', { storyId });
} }
// 作品Tab的按钮操作需要更精确判断暂略 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 });
return;
}
// 点击卡片其他区域也进入播放
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
}
} }
} }
// 显示故事的版本列表 // 显示故事的版本列表
async showStoryVersions(storyItem) { async showStoryVersions(storyItem) {
const storyId = storyItem.story_id || storyItem.storyId || storyItem.id; 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 { try {
wx.showLoading({ title: '加载中...' }); wx.showLoading({ title: '加载中...' });
@@ -994,11 +1097,13 @@ export default class ProfileScene extends BaseScene {
async startRecordReplay(recordItem) { async startRecordReplay(recordItem) {
const recordId = recordItem.id; const recordId = recordItem.id;
const storyId = this.selectedStoryInfo.id; const storyId = this.selectedStoryInfo.id;
const draftId = recordItem.draftId; // AI草稿ID原故事为null
// 进入故事场景,传入 playRecordId 参数 // 进入故事场景,传入 playRecordId 参数draftId用于回放草稿内容
this.main.sceneManager.switchScene('story', { this.main.sceneManager.switchScene('story', {
storyId, storyId,
playRecordId: recordId playRecordId: recordId,
draftId // 回放草稿内容
}); });
} }
@@ -1027,6 +1132,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) { confirmDeleteRecord(item, index) {
wx.showModal({ wx.showModal({

View File

@@ -2,6 +2,7 @@
* 故事播放场景 - 视觉小说风格 * 故事播放场景 - 视觉小说风格
*/ */
import BaseScene from './BaseScene'; import BaseScene from './BaseScene';
import { getNodeBackground, getNodeCharacter, getDraftNodeBackground, getStaticUrl } from '../utils/http';
export default class StoryScene extends BaseScene { export default class StoryScene extends BaseScene {
constructor(main, params) { constructor(main, params) {
@@ -31,6 +32,11 @@ export default class StoryScene extends BaseScene {
// 场景图相关 // 场景图相关
this.sceneImage = null; this.sceneImage = null;
this.sceneColors = this.generateSceneColors(); this.sceneColors = this.generateSceneColors();
// 节点图片相关
this.nodeBackgroundImages = {}; // 缓存背景图 {nodeKey: Image}
this.nodeCharacterImages = {}; // 缓存立绘 {nodeKey: Image}
this.currentBackgroundImg = null;
this.currentCharacterImg = null;
// AI改写相关 // AI改写相关
this.isAIRewriting = false; this.isAIRewriting = false;
// 剧情回顾模式 // 剧情回顾模式
@@ -638,6 +644,77 @@ export default class StoryScene extends BaseScene {
// 重置滚动 // 重置滚动
this.textScrollY = 0; this.textScrollY = 0;
this.maxScrollY = 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) {
// 草稿模式:优先使用节点中的 background_url需要转成完整URL否则用草稿路径
if (this.currentNode.background_url) {
bgUrl = getStaticUrl(this.currentNode.background_url);
} else {
bgUrl = getDraftNodeBackground(this.storyId, this.draftId, 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() { update() {
@@ -695,15 +772,45 @@ export default class StoryScene extends BaseScene {
} }
renderSceneBackground(ctx) { renderSceneBackground(ctx) {
// 场景区域上方45% // 场景区域上方42%
const sceneHeight = this.screenHeight * 0.42; const sceneHeight = this.screenHeight * 0.42;
// 渐变背景 // 优先显示背景
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); const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
gradient.addColorStop(0, this.sceneColors.bg1); gradient.addColorStop(0, this.sceneColors.bg1);
gradient.addColorStop(1, this.sceneColors.bg2); gradient.addColorStop(1, this.sceneColors.bg2);
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight); ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
}
// 底部渐变过渡到对话框 // 底部渐变过渡到对话框
const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight); const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight);
@@ -721,6 +828,18 @@ export default class StoryScene extends BaseScene {
const sceneHeight = this.screenHeight * 0.42; const sceneHeight = this.screenHeight * 0.42;
const centerX = this.screenWidth / 2; const centerX = this.screenWidth / 2;
// 绘制角色立绘(如果有)
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); const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
glowGradient.addColorStop(0, this.sceneColors.accent + '30'); glowGradient.addColorStop(0, this.sceneColors.accent + '30');
@@ -736,6 +855,7 @@ export default class StoryScene extends BaseScene {
ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); });
}
// 场景提示文字(中央) // 场景提示文字(中央)
ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fillStyle = 'rgba(255,255,255,0.15)';

View File

@@ -5,15 +5,17 @@
// ============================================ // ============================================
// 环境配置(切换这里即可) // 环境配置(切换这里即可)
// ============================================ // ============================================
const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管 const ENV = 'local'; // 'local' = 本地后端, 'cloud' = 微信云托管
const CONFIG = { const CONFIG = {
local: { local: {
baseUrl: 'http://localhost:8000/api' baseUrl: 'http://localhost:8000/api',
staticUrl: 'http://localhost:8000'
}, },
cloud: { cloud: {
env: 'prod-6gjx1rd4c40f5884', 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) { if (res.data && res.data.code === 0) {
resolve(res.data.data); resolve(res.data.data);
} else { } 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) { fail(err) {
@@ -78,8 +81,10 @@ function requestCloud(options) {
if (res.data && res.data.code === 0) { if (res.data && res.data.code === 0) {
resolve(res.data.data); resolve(res.data.data);
} else if (res.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 { } else {
console.error('[HTTP-Cloud] 响应数据异常:', res);
reject(new Error('响应数据异常')); reject(new Error('响应数据异常'));
} }
}, },
@@ -112,4 +117,68 @@ export function del(url, data) {
return request({ url, method: 'DELETE', 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_appid: str = ""
wx_secret: str = "" wx_secret: str = ""
# 微信云托管配置
wx_cloud_env: str = ""
# Gemini 图片生成配置
gemini_api_key: str = ""
# JWT 配置 # JWT 配置
jwt_secret_key: str = "your-super-secret-key-change-in-production" jwt_secret_key: str = "your-super-secret-key-change-in-production"
jwt_expire_hours: int = 168 # 7天 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=["上传"]) app.include_router(upload.router, prefix="/api", tags=["上传"])
# 静态文件服务(用于访问上传的图片) # 静态文件服务(用于访问上传的图片)
os.makedirs(settings.upload_path, exist_ok=True) upload_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', settings.upload_path))
app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads") os.makedirs(upload_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
@app.get("/") @app.get("/")

View File

@@ -121,6 +121,9 @@ class StoryDraft(Base):
status = Column(Enum(DraftStatus), default=DraftStatus.pending) status = Column(Enum(DraftStatus), default=DraftStatus.pending)
error_message = Column(String(500), default="") error_message = Column(String(500), default="")
is_read = Column(Boolean, default=False) # 用户是否已查看 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()) created_at = Column(TIMESTAMP, server_default=func.now())
completed_at = Column(TIMESTAMP, default=None) completed_at = Column(TIMESTAMP, default=None)

View File

@@ -65,6 +65,7 @@ class PlayRecord(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
story_id = Column(Integer, ForeignKey("stories.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_name = Column(String(100), nullable=False) # 结局名称
ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite) ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite)
path_history = Column(JSON, nullable=False) # 完整的选择路径 path_history = Column(JSON, nullable=False) # 完整的选择路径

View File

@@ -8,9 +8,12 @@ from sqlalchemy.sql import func
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
import os
import base64
from app.database import get_db from app.database import get_db
from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter
from app.config import get_settings
router = APIRouter(prefix="/drafts", tags=["草稿箱"]) 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): class PathHistoryItem(BaseModel):
@@ -121,11 +266,14 @@ async def process_ai_rewrite(draft_id: int):
# 获取故事角色 # 获取故事角色
characters = await get_story_characters(db, story.id) characters = await get_story_characters(db, story.id)
print(f"[process_ai_rewrite] 获取到角色数: {len(characters)}")
# 转换路径历史格式 # 转换路径历史格式
path_history = draft.path_history or [] path_history = draft.path_history or []
print(f"[process_ai_rewrite] 路径历史长度: {len(path_history)}")
# 调用AI服务 # 调用AI服务
print(f"[process_ai_rewrite] 开始调用 AI 服务...")
ai_result = await ai_service.rewrite_branch( ai_result = await ai_service.rewrite_branch(
story_title=story.title, story_title=story.title,
story_category=story.category or "未知", story_category=story.category or "未知",
@@ -134,9 +282,21 @@ async def process_ai_rewrite(draft_id: int):
user_prompt=draft.user_prompt, user_prompt=draft.user_prompt,
characters=characters characters=characters
) )
print(f"[process_ai_rewrite] AI 服务返回: {bool(ai_result)}")
if ai_result and ai_result.get("nodes"): 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.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"] draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1") draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1")
@@ -232,8 +392,7 @@ async def process_ai_rewrite_ending(draft_id: int):
pass pass
# 成功 - 存储为对象格式(与故事节点格式一致) # 成功 - 存储为对象格式(与故事节点格式一致)
draft.status = DraftStatus.completed ai_nodes = {
draft.ai_nodes = {
"ending_rewrite": { "ending_rewrite": {
"content": content, "content": content,
"speaker": "旁白", "speaker": "旁白",
@@ -242,6 +401,21 @@ async def process_ai_rewrite_ending(draft_id: int):
"ending_type": "rewrite" "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.entry_node_key = "ending_rewrite"
draft.tokens_used = ai_result.get("tokens_used", 0) draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-{new_ending_name}" 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"): 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.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"] draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1") draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1")
@@ -374,7 +559,8 @@ async def create_draft(
current_node_key=request.currentNodeKey, current_node_key=request.currentNodeKey,
current_content=request.currentContent, current_content=request.currentContent,
user_prompt=request.prompt, user_prompt=request.prompt,
status=DraftStatus.pending status=DraftStatus.pending,
draft_type='rewrite'
) )
db.add(draft) db.add(draft)
@@ -419,7 +605,8 @@ async def create_ending_draft(
current_node_key=request.endingName, # 保存结局名称 current_node_key=request.endingName, # 保存结局名称
current_content=request.endingContent, # 保存结局内容 current_content=request.endingContent, # 保存结局内容
user_prompt=request.prompt, user_prompt=request.prompt,
status=DraftStatus.pending status=DraftStatus.pending,
draft_type='rewrite'
) )
db.add(draft) db.add(draft)
@@ -464,7 +651,8 @@ async def create_continue_ending_draft(
current_node_key=request.endingName, # 保存结局名称 current_node_key=request.endingName, # 保存结局名称
current_content=request.endingContent, # 保存结局内容 current_content=request.endingContent, # 保存结局内容
user_prompt=request.prompt, user_prompt=request.prompt,
status=DraftStatus.pending status=DraftStatus.pending,
draft_type='continue'
) )
db.add(draft) db.add(draft)
@@ -508,6 +696,8 @@ async def get_drafts(
"userPrompt": draft.user_prompt, "userPrompt": draft.user_prompt,
"status": draft.status.value if draft.status else "pending", "status": draft.status.value if draft.status else "pending",
"isRead": draft.is_read, "isRead": draft.is_read,
"publishedToCenter": draft.published_to_center,
"draftType": draft.draft_type or "rewrite",
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "", "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 "completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
}) })
@@ -549,6 +739,48 @@ async def check_new_drafts(
} }
@router.get("/published")
async def get_published_drafts(
userId: int,
draftType: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""获取已发布到创作中心的草稿列表"""
query = select(StoryDraft, Story.title.label('story_title')).join(
Story, StoryDraft.story_id == Story.id
).where(
StoryDraft.user_id == userId,
StoryDraft.published_to_center == True,
StoryDraft.status == DraftStatus.completed
)
# 按类型筛选
if draftType:
query = query.where(StoryDraft.draft_type == draftType)
query = query.order_by(StoryDraft.created_at.desc())
result = await db.execute(query)
rows = result.all()
drafts = []
for draft, story_title in rows:
drafts.append({
"id": draft.id,
"storyId": draft.story_id,
"storyTitle": story_title or "未知故事",
"title": draft.title or "",
"userPrompt": draft.user_prompt,
"draftType": draft.draft_type or "rewrite",
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else ""
})
return {
"code": 0,
"data": drafts
}
@router.get("/{draft_id}") @router.get("/{draft_id}")
async def get_draft_detail( async def get_draft_detail(
draft_id: int, draft_id: int,
@@ -601,7 +833,7 @@ async def delete_draft(
userId: int, userId: int,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""删除草稿""" """删除草稿(同时清理图片文件)"""
result = await db.execute( result = await db.execute(
select(StoryDraft).where( select(StoryDraft).where(
StoryDraft.id == draft_id, StoryDraft.id == draft_id,
@@ -613,6 +845,19 @@ async def delete_draft(
if not draft: if not draft:
raise HTTPException(status_code=404, detail="草稿不存在") 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.delete(draft)
await db.commit() await db.commit()
@@ -652,3 +897,90 @@ async def mark_all_drafts_read(
await db.commit() await db.commit()
return {"code": 0, "message": "已全部标记为已读"} 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

@@ -39,6 +39,32 @@ class RewriteBranchRequest(BaseModel):
prompt: str 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
# ========== API接口 ========== # ========== API接口 ==========
@router.get("") @router.get("")
@@ -110,6 +136,222 @@ async def get_categories(db: AsyncSession = Depends(get_db)):
return {"code": 0, "data": categories} 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}") @router.get("/{story_id}")
async def get_story_detail(story_id: int, db: AsyncSession = Depends(get_db)): async def get_story_detail(story_id: int, db: AsyncSession = Depends(get_db)):
"""获取故事详情(含节点和选项)""" """获取故事详情(含节点和选项)"""
@@ -372,3 +614,227 @@ async def ai_rewrite_branch(
"error": "AI服务暂时不可用" "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
}
}

View File

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

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 '状态', `status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
`error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因', `error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因',
`is_read` TINYINT(1) DEFAULT 0 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, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间', `completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@@ -160,22 +163,7 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
-- ============================================ -- ============================================
-- 8. 游玩记录表 -- 8. 游玩记录表
-- ============================================ -- ============================================
CREATE TABLE IF NOT EXISTS `play_records` ( c
`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='游玩记录表';
-- ============================================ -- ============================================
-- 9. 故事角色表 -- 9. 故事角色表