Compare commits
7 Commits
4ac47c8474
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 253bc4aed2 | |||
| c850623a48 | |||
| 5f94129236 | |||
| 0da6f210a6 | |||
|
|
4a69bf2711 | ||
|
|
411110ce0c | ||
|
|
e101e8721b |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 用户数据管理器
|
||||
*/
|
||||
import { get, post, del } from '../utils/http';
|
||||
import { get, post, put, del } from '../utils/http';
|
||||
|
||||
export default class UserManager {
|
||||
constructor() {
|
||||
@@ -203,6 +203,29 @@ export default class UserManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏草稿
|
||||
*/
|
||||
async collectDraft(draftId, isCollected) {
|
||||
if (!this.isLoggedIn) return;
|
||||
await put(`/drafts/${draftId}/collect`, null, {
|
||||
params: { userId: this.userId, isCollected }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿收藏状态
|
||||
*/
|
||||
async getDraftCollectStatus(draftId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
const res = await get(`/drafts/${draftId}/collect-status`, { userId: this.userId });
|
||||
return res?.isCollected || false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
*/
|
||||
@@ -271,17 +294,65 @@ export default class UserManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已发布到创作中心的草稿
|
||||
* @param {string} draftType - 草稿类型: rewrite/continue
|
||||
*/
|
||||
async getPublishedDrafts(draftType) {
|
||||
if (!this.isLoggedIn) return [];
|
||||
try {
|
||||
console.log('[UserManager] 获取已发布草稿, userId:', this.userId, 'draftType:', draftType);
|
||||
const res = await get('/drafts/published', { userId: this.userId, draftType });
|
||||
console.log('[UserManager] 已发布草稿响应:', res);
|
||||
return res || [];
|
||||
} catch (e) {
|
||||
console.error('获取已发布草稿失败:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布草稿到创作中心
|
||||
* @param {number} draftId - 草稿ID
|
||||
*/
|
||||
async publishDraft(draftId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
await put(`/drafts/${draftId}/publish?userId=${this.userId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('发布草稿失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从创作中心取消发布
|
||||
* @param {number} draftId - 草稿ID
|
||||
*/
|
||||
async unpublishDraft(draftId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
await put(`/drafts/${draftId}/unpublish?userId=${this.userId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('取消发布失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 游玩记录相关 ==========
|
||||
|
||||
/**
|
||||
* 保存游玩记录
|
||||
*/
|
||||
async savePlayRecord(storyId, endingName, endingType, pathHistory) {
|
||||
async savePlayRecord(storyId, endingName, endingType, pathHistory, draftId = null) {
|
||||
if (!this.isLoggedIn) return null;
|
||||
try {
|
||||
return await post('/user/play-record', {
|
||||
userId: this.userId,
|
||||
storyId,
|
||||
draftId, // AI草稿ID,原故事为null
|
||||
endingName,
|
||||
endingType: endingType || '',
|
||||
pathHistory: pathHistory || []
|
||||
|
||||
@@ -6,8 +6,8 @@ import BaseScene from './BaseScene';
|
||||
export default class AICreateScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.currentTab = 0; // 0:改写 1:续写 2:创作
|
||||
this.tabs = ['AI改写', 'AI续写', 'AI创作'];
|
||||
this.currentTab = 0; // 0:我的改写 1:我的续写 2:AI创作
|
||||
this.tabs = ['我的改写', '我的续写', 'AI创作'];
|
||||
|
||||
// 滚动
|
||||
this.scrollY = 0;
|
||||
@@ -17,8 +17,8 @@ export default class AICreateScene extends BaseScene {
|
||||
this.hasMoved = false;
|
||||
|
||||
// 用户数据
|
||||
this.recentStories = [];
|
||||
this.aiHistory = [];
|
||||
this.publishedRewrites = []; // 已发布的改写作品
|
||||
this.publishedContinues = []; // 已发布的续写作品
|
||||
this.quota = { daily: 3, used: 0, purchased: 0 };
|
||||
|
||||
// 创作表单
|
||||
@@ -29,12 +29,7 @@ export default class AICreateScene extends BaseScene {
|
||||
conflict: ''
|
||||
};
|
||||
|
||||
// 选中的故事(用于改写/续写)
|
||||
this.selectedStory = null;
|
||||
|
||||
// 快捷标签
|
||||
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘'];
|
||||
this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除'];
|
||||
this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战'];
|
||||
}
|
||||
|
||||
@@ -44,10 +39,17 @@ export default class AICreateScene extends BaseScene {
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
// 加载最近游玩的故事
|
||||
this.recentStories = await this.main.userManager.getRecentPlayed() || [];
|
||||
// 加载AI创作历史
|
||||
this.aiHistory = await this.main.userManager.getAIHistory() || [];
|
||||
const userId = this.main.userManager.userId;
|
||||
if (!userId) return;
|
||||
|
||||
// 加载已发布的改写作品
|
||||
const rewriteRes = await this.main.userManager.getPublishedDrafts('rewrite');
|
||||
this.publishedRewrites = rewriteRes || [];
|
||||
|
||||
// 加载已发布的续写作品
|
||||
const continueRes = await this.main.userManager.getPublishedDrafts('continue');
|
||||
this.publishedContinues = continueRes || [];
|
||||
|
||||
// 加载配额
|
||||
const quotaData = await this.main.userManager.getAIQuota();
|
||||
if (quotaData) this.quota = quotaData;
|
||||
@@ -59,8 +61,10 @@ export default class AICreateScene extends BaseScene {
|
||||
|
||||
calculateMaxScroll() {
|
||||
let contentHeight = 400;
|
||||
if (this.currentTab === 0 || this.currentTab === 1) {
|
||||
contentHeight = 300 + this.recentStories.length * 80;
|
||||
if (this.currentTab === 0) {
|
||||
contentHeight = 300 + this.publishedRewrites.length * 90;
|
||||
} else if (this.currentTab === 1) {
|
||||
contentHeight = 300 + this.publishedContinues.length * 90;
|
||||
} else {
|
||||
contentHeight = 600;
|
||||
}
|
||||
@@ -210,23 +214,10 @@ export default class AICreateScene extends BaseScene {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('选择一个已玩过的故事,AI帮你改写结局', this.screenWidth / 2, y + 25);
|
||||
ctx.fillText('展示你从草稿箱发布的改写作品', this.screenWidth / 2, y + 25);
|
||||
|
||||
// 快捷标签
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('热门改写方向:', padding, y + 55);
|
||||
|
||||
const tagEndY = this.renderTags(ctx, this.rewriteTags, padding, y + 70, 'rewrite');
|
||||
|
||||
// 选择故事 - 位置根据标签高度动态调整
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('选择要改写的故事:', padding, tagEndY + 25);
|
||||
|
||||
this.renderStoryList(ctx, tagEndY + 40, 'rewrite');
|
||||
// 作品列表
|
||||
this.renderPublishedList(ctx, y + 50, this.publishedRewrites, 'rewrite');
|
||||
}
|
||||
|
||||
renderContinueTab(ctx, startY) {
|
||||
@@ -236,21 +227,10 @@ export default class AICreateScene extends BaseScene {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('选择一个进行中的故事,AI帮你续写剧情', this.screenWidth / 2, y + 25);
|
||||
ctx.fillText('展示你从草稿箱发布的续写作品', this.screenWidth / 2, y + 25);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('续写方向:', padding, y + 55);
|
||||
|
||||
const tagEndY = this.renderTags(ctx, this.continueTags, padding, y + 70, 'continue');
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('选择要续写的故事:', padding, tagEndY + 25);
|
||||
|
||||
this.renderStoryList(ctx, tagEndY + 40, 'continue');
|
||||
// 作品列表
|
||||
this.renderPublishedList(ctx, y + 50, this.publishedContinues, 'continue');
|
||||
}
|
||||
|
||||
renderCreateTab(ctx, startY) {
|
||||
@@ -273,16 +253,21 @@ export default class AICreateScene extends BaseScene {
|
||||
|
||||
// 关键词输入
|
||||
let currentY = tagEndY + 25;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillText('故事关键词:', padding, currentY);
|
||||
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords');
|
||||
|
||||
// 主角设定
|
||||
currentY += 80;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('主角设定:', padding, currentY);
|
||||
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist');
|
||||
|
||||
// 核心冲突
|
||||
currentY += 80;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('核心冲突:', padding, currentY);
|
||||
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict');
|
||||
|
||||
@@ -388,6 +373,84 @@ export default class AICreateScene extends BaseScene {
|
||||
this.inputRects[field] = { x, y: y + this.scrollY, width, height, field };
|
||||
}
|
||||
|
||||
renderPublishedList(ctx, startY, items, type) {
|
||||
const padding = 15;
|
||||
const cardHeight = 80;
|
||||
const cardGap = 12;
|
||||
|
||||
if (!this.publishedRects) this.publishedRects = {};
|
||||
this.publishedRects[type] = [];
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const tipText = type === 'rewrite'
|
||||
? '暂无改写作品,去草稿箱发布吧'
|
||||
: '暂无续写作品,去草稿箱发布吧';
|
||||
ctx.fillText(tipText, this.screenWidth / 2, startY + 40);
|
||||
|
||||
// 跳转草稿箱按钮
|
||||
const btnY = startY + 70;
|
||||
const btnWidth = 120;
|
||||
const btnX = (this.screenWidth - btnWidth) / 2;
|
||||
ctx.fillStyle = 'rgba(168, 85, 247, 0.3)';
|
||||
this.roundRect(ctx, btnX, btnY, btnWidth, 36, 18);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#a855f7';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 24);
|
||||
this.gotoDraftsBtnRect = { x: btnX, y: btnY + this.scrollY, width: btnWidth, height: 36 };
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const y = startY + index * (cardHeight + cardGap);
|
||||
|
||||
// 卡片背景
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const title = item.title?.length > 15 ? item.title.substring(0, 15) + '...' : (item.title || '未命名作品');
|
||||
ctx.fillText(title, padding + 15, y + 25);
|
||||
|
||||
// 原故事
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText(`原故事:${item.storyTitle || '未知'}`, padding + 15, y + 45);
|
||||
|
||||
// 创作时间
|
||||
ctx.fillText(item.createdAt || '', padding + 15, y + 65);
|
||||
|
||||
// 阅读按钮
|
||||
const btnX = this.screenWidth - padding - 70;
|
||||
const btnGradient = ctx.createLinearGradient(btnX, y + 25, btnX + 60, y + 25);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, btnX, y + 25, 60, 30, 15);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('阅读', btnX + 30, y + 45);
|
||||
|
||||
this.publishedRects[type].push({
|
||||
x: padding,
|
||||
y: y + this.scrollY,
|
||||
width: this.screenWidth - padding * 2,
|
||||
height: cardHeight,
|
||||
item,
|
||||
btnRect: { x: btnX, y: y + 25 + this.scrollY, width: 60, height: 30 }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderStoryList(ctx, startY, type) {
|
||||
const padding = 15;
|
||||
const cardHeight = 70;
|
||||
@@ -550,7 +613,6 @@ export default class AICreateScene extends BaseScene {
|
||||
if (this.currentTab !== tab.index) {
|
||||
this.currentTab = tab.index;
|
||||
this.scrollY = 0;
|
||||
this.selectedStory = null;
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
return;
|
||||
@@ -561,14 +623,34 @@ export default class AICreateScene extends BaseScene {
|
||||
// 调整y坐标(考虑滚动)
|
||||
const scrolledY = y + this.scrollY;
|
||||
|
||||
// 标签点击
|
||||
if (this.tagRects) {
|
||||
const tagType = this.currentTab === 0 ? 'rewrite' : this.currentTab === 1 ? 'continue' : 'genre';
|
||||
const tags = this.tagRects[tagType];
|
||||
// 前往草稿箱按钮
|
||||
if (this.gotoDraftsBtnRect && this.isInRect(x, scrolledY, this.gotoDraftsBtnRect)) {
|
||||
this.main.sceneManager.switchScene('drafts');
|
||||
return;
|
||||
}
|
||||
|
||||
// 已发布作品点击(改写/续写Tab)
|
||||
if (this.currentTab < 2 && this.publishedRects) {
|
||||
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
|
||||
const items = this.publishedRects[type];
|
||||
if (items) {
|
||||
for (const rect of items) {
|
||||
// 阅读按钮点击
|
||||
if (this.isInRect(x, scrolledY, rect.btnRect)) {
|
||||
this.handleReadPublished(rect.item);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签点击(只有创作Tab有标签)
|
||||
if (this.currentTab === 2 && this.tagRects) {
|
||||
const tags = this.tagRects['genre'];
|
||||
if (tags) {
|
||||
for (const tag of tags) {
|
||||
if (this.isInRect(x, scrolledY, tag)) {
|
||||
this.handleTagSelect(tagType, tag);
|
||||
this.handleTagSelect('genre', tag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -586,26 +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)) {
|
||||
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) {
|
||||
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) {
|
||||
if (type === 'genre') {
|
||||
this.createForm.genre = tag.value;
|
||||
} else if (type === 'rewrite') {
|
||||
this.selectedRewriteTag = tag.index;
|
||||
} else if (type === 'continue') {
|
||||
this.selectedContinueTag = tag.index;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,48 @@ export default class EndingScene extends BaseScene {
|
||||
this.showButtons = true;
|
||||
}, 1500);
|
||||
|
||||
// 保存游玩记录(回放模式和AI草稿不保存)
|
||||
if (!this.isReplay && !this.draftId) {
|
||||
// 保存游玩记录
|
||||
this.checkAndSavePlayRecord();
|
||||
|
||||
// 加载收藏状态
|
||||
this.loadCollectStatus();
|
||||
}
|
||||
|
||||
async checkAndSavePlayRecord() {
|
||||
// 回放模式不保存
|
||||
if (this.isReplay) return;
|
||||
|
||||
// 原故事:直接保存
|
||||
if (!this.draftId) {
|
||||
this.savePlayRecord();
|
||||
return;
|
||||
}
|
||||
|
||||
// AI草稿:检查是否已发布,已发布才保存
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
const drafts = await this.main.storyManager.getDrafts(userId) || [];
|
||||
const draft = drafts.find(d => d.id === this.draftId);
|
||||
if (draft?.publishedToCenter) {
|
||||
this.savePlayRecord();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查草稿发布状态失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCollectStatus() {
|
||||
try {
|
||||
if (this.draftId) {
|
||||
// AI草稿:获取草稿收藏状态
|
||||
this.isCollected = await this.main.userManager.getDraftCollectStatus(this.draftId);
|
||||
} else {
|
||||
// 原故事:获取故事收藏状态
|
||||
const progress = await this.main.userManager.getProgress(this.storyId);
|
||||
this.isCollected = progress?.isCollected || false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载收藏状态失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +95,13 @@ export default class EndingScene extends BaseScene {
|
||||
const endingName = this.ending?.name || '未知结局';
|
||||
const endingType = this.ending?.type || '';
|
||||
|
||||
// 调用保存接口
|
||||
// 调用保存接口(传入 draftId 区分原故事和AI草稿)
|
||||
await this.main.userManager.savePlayRecord(
|
||||
this.storyId,
|
||||
endingName,
|
||||
endingType,
|
||||
pathHistory
|
||||
pathHistory,
|
||||
this.draftId // AI草稿ID,原故事为null
|
||||
);
|
||||
console.log('游玩记录保存成功');
|
||||
} catch (e) {
|
||||
@@ -1193,7 +1233,13 @@ export default class EndingScene extends BaseScene {
|
||||
|
||||
handleCollect() {
|
||||
this.isCollected = !this.isCollected;
|
||||
this.main.userManager.collectStory(this.storyId, this.isCollected);
|
||||
if (this.draftId) {
|
||||
// AI草稿:收藏草稿
|
||||
this.main.userManager.collectDraft(this.draftId, this.isCollected);
|
||||
} else {
|
||||
// 原故事:收藏故事
|
||||
this.main.userManager.collectStory(this.storyId, this.isCollected);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动草稿完成轮询(每5秒检查一次,持续2分钟)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 首页场景 - 支持UGC
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
import { getStaticUrl } from '../utils/http';
|
||||
|
||||
export default class HomeScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
@@ -13,6 +14,9 @@ export default class HomeScene extends BaseScene {
|
||||
this.lastTouchY = 0;
|
||||
this.scrollVelocity = 0;
|
||||
|
||||
// 封面图片缓存
|
||||
this.coverImages = {};
|
||||
|
||||
// 底部Tab: 首页/发现/创作/我的
|
||||
this.bottomTab = 0;
|
||||
|
||||
@@ -35,6 +39,23 @@ export default class HomeScene extends BaseScene {
|
||||
async init() {
|
||||
this.storyList = this.main.storyManager.storyList;
|
||||
this.calculateMaxScroll();
|
||||
// 预加载封面图片
|
||||
this.preloadCoverImages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载故事封面图片
|
||||
*/
|
||||
preloadCoverImages() {
|
||||
this.storyList.forEach(story => {
|
||||
if (story.cover_url && !this.coverImages[story.id]) {
|
||||
const img = wx.createImage();
|
||||
img.onload = () => {
|
||||
this.coverImages[story.id] = img;
|
||||
};
|
||||
img.src = getStaticUrl(story.cover_url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFilteredStories() {
|
||||
@@ -248,18 +269,32 @@ export default class HomeScene extends BaseScene {
|
||||
|
||||
// 封面
|
||||
const coverW = 80, coverH = height - 20;
|
||||
const coverGradient = ctx.createLinearGradient(x + 10, y + 10, x + 10 + coverW, y + 10 + coverH);
|
||||
const colors = this.getCategoryGradient(story.category);
|
||||
coverGradient.addColorStop(0, colors[0]);
|
||||
coverGradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = coverGradient;
|
||||
this.roundRect(ctx, x + 10, y + 10, coverW, coverH, 10);
|
||||
ctx.fill();
|
||||
const coverX = x + 10, coverY = y + 10;
|
||||
|
||||
// 尝试显示封面图片
|
||||
const coverImg = this.coverImages[story.id];
|
||||
if (coverImg) {
|
||||
// 有图片,绘制图片
|
||||
ctx.save();
|
||||
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
|
||||
ctx.clip();
|
||||
ctx.drawImage(coverImg, coverX, coverY, coverW, coverH);
|
||||
ctx.restore();
|
||||
} else {
|
||||
// 无图片,显示渐变占位
|
||||
const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH);
|
||||
const colors = this.getCategoryGradient(story.category);
|
||||
coverGradient.addColorStop(0, colors[0]);
|
||||
coverGradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = coverGradient;
|
||||
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(story.category || '故事', x + 10 + coverW / 2, y + 10 + coverH / 2 + 4);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(story.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 4);
|
||||
}
|
||||
|
||||
const textX = x + 100;
|
||||
const maxW = width - 115;
|
||||
|
||||
@@ -73,18 +73,21 @@ export default class ProfileScene extends BaseScene {
|
||||
if (this.main.userManager.isLoggedIn) {
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
this.myWorks = await this.main.userManager.getMyWorks?.() || [];
|
||||
// 加载已发布到创作中心的作品(改写+续写)
|
||||
const publishedRewrites = await this.main.userManager.getPublishedDrafts('rewrite') || [];
|
||||
const publishedContinues = await this.main.userManager.getPublishedDrafts('continue') || [];
|
||||
this.myWorks = [...publishedRewrites, ...publishedContinues];
|
||||
// 加载 AI 改写草稿
|
||||
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
|
||||
this.collections = await this.main.userManager.getCollections() || [];
|
||||
// 加载游玩记录(故事列表)
|
||||
this.progress = await this.main.userManager.getPlayRecords() || [];
|
||||
|
||||
// 计算统计
|
||||
// 计算统计(作品数=已发布作品数)
|
||||
this.stats.works = this.myWorks.length;
|
||||
this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.play_count || 0), 0);
|
||||
this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.like_count || 0), 0);
|
||||
this.stats.earnings = this.myWorks.reduce((sum, w) => sum + (w.earnings || 0), 0);
|
||||
this.stats.totalPlays = this.myWorks.reduce((sum, w) => sum + (w.playCount || 0), 0);
|
||||
this.stats.totalLikes = this.myWorks.reduce((sum, w) => sum + (w.likeCount || 0), 0);
|
||||
this.stats.earnings = 0; // 暂无收益功能
|
||||
} catch (e) {
|
||||
console.error('加载数据失败:', e);
|
||||
}
|
||||
@@ -333,25 +336,25 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const emptyTexts = ['还没有发布作品,去创作吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录'];
|
||||
const emptyTexts = ['还没有发布作品,去草稿箱发布吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录'];
|
||||
const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions')
|
||||
? '该故事还没有游玩记录'
|
||||
: emptyTexts[this.currentTab];
|
||||
ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50);
|
||||
|
||||
// 创作引导按钮
|
||||
// 作品Tab引导按钮 - 跳转到草稿箱
|
||||
if (this.currentTab === 0) {
|
||||
const btnY = listStartY + 80;
|
||||
const btnGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, btnY, this.screenWidth / 2 + 50, btnY);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, this.screenWidth / 2 - 50, btnY, 100, 36, 18);
|
||||
this.roundRect(ctx, this.screenWidth / 2 - 55, btnY, 110, 36, 18);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 13px sans-serif';
|
||||
ctx.fillText('✨ 开始创作', this.screenWidth / 2, btnY + 23);
|
||||
this.createBtnRect = { x: this.screenWidth / 2 - 50, y: btnY, width: 100, height: 36 };
|
||||
ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 23);
|
||||
this.createBtnRect = { x: this.screenWidth / 2 - 55, y: btnY, width: 110, height: 36 };
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
@@ -431,7 +434,10 @@ export default class ProfileScene extends BaseScene {
|
||||
|
||||
// 渲染单条游玩记录版本卡片
|
||||
renderRecordVersionCard(ctx, item, x, y, w, h, index) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
const isUnpublished = item.draftId && item.isPublished === false;
|
||||
|
||||
// 已下架的卡片背景更暗
|
||||
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)';
|
||||
this.roundRect(ctx, x, y, w, h, 12);
|
||||
ctx.fill();
|
||||
|
||||
@@ -441,34 +447,49 @@ export default class ProfileScene extends BaseScene {
|
||||
const circleR = 18;
|
||||
const colors = this.getGradientColors(index);
|
||||
const circleGradient = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR);
|
||||
circleGradient.addColorStop(0, colors[0]);
|
||||
circleGradient.addColorStop(1, colors[1]);
|
||||
circleGradient.addColorStop(0, isUnpublished ? 'rgba(128,128,128,0.5)' : colors[0]);
|
||||
circleGradient.addColorStop(1, isUnpublished ? 'rgba(96,96,96,0.5)' : colors[1]);
|
||||
ctx.fillStyle = circleGradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 序号
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${index + 1}`, circleX, circleY + 5);
|
||||
|
||||
const textX = x + 65;
|
||||
const maxTextWidth = w - 200; // 留出按钮空间
|
||||
|
||||
// 结局名称
|
||||
ctx.fillStyle = '#ffffff';
|
||||
// 结局名称(只显示结局,不显示草稿标题)
|
||||
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.5)' : '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const endingLabel = `结局:${item.endingName || '未知结局'}`;
|
||||
ctx.fillText(this.truncateText(ctx, endingLabel, w - 150), textX, y + 28);
|
||||
ctx.fillText(this.truncateText(ctx, endingLabel, maxTextWidth - 60), textX, y + 28);
|
||||
|
||||
// 已下架标签(固定在结局名称右边)
|
||||
if (isUnpublished) {
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.3)';
|
||||
this.roundRect(ctx, x + w - 185, y + 15, 44, 18, 9);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('已下架', x + w - 163, y + 27);
|
||||
}
|
||||
|
||||
// 游玩时间
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
// 游玩时间(如果是草稿,显示草稿标题)
|
||||
ctx.fillStyle = isUnpublished ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.45)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const timeText = item.createdAt ? this.formatDateTime(item.createdAt) : '';
|
||||
ctx.fillText(timeText, textX, y + 52);
|
||||
let subText = item.createdAt ? this.formatDateTime(item.createdAt) : '';
|
||||
if (item.draftTitle) {
|
||||
subText = this.truncateText(ctx, item.draftTitle, 100) + ' · ' + subText;
|
||||
}
|
||||
ctx.fillText(subText, textX, y + 52);
|
||||
|
||||
// 删除按钮
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
@@ -481,8 +502,8 @@ export default class ProfileScene extends BaseScene {
|
||||
|
||||
// 回放按钮
|
||||
const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28);
|
||||
btnGradient.addColorStop(0, '#ff6b6b');
|
||||
btnGradient.addColorStop(1, '#ffd700');
|
||||
btnGradient.addColorStop(0, isUnpublished ? '#888888' : '#ff6b6b');
|
||||
btnGradient.addColorStop(1, isUnpublished ? '#666666' : '#ffd700');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13);
|
||||
ctx.fill();
|
||||
@@ -526,10 +547,12 @@ export default class ProfileScene extends BaseScene {
|
||||
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10);
|
||||
ctx.fill();
|
||||
|
||||
// 类型标签
|
||||
const typeText = item.draftType === 'continue' ? '续写' : '改写';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
|
||||
ctx.fillText(typeText, x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
|
||||
|
||||
const textX = x + 88;
|
||||
const maxW = w - 100;
|
||||
@@ -538,46 +561,55 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(this.truncateText(ctx, item.title || '未命名', maxW - 60), textX, y + 25);
|
||||
const title = item.title || item.storyTitle || '未命名';
|
||||
ctx.fillText(this.truncateText(ctx, title, maxW - 60), textX, y + 25);
|
||||
|
||||
// 审核状态标签
|
||||
const statusMap = {
|
||||
0: { text: '草稿', color: '#888888' },
|
||||
1: { text: '审核中', color: '#f59e0b' },
|
||||
2: { text: '已发布', color: '#22c55e' },
|
||||
3: { text: '已下架', color: '#ef4444' },
|
||||
4: { text: '被拒绝', color: '#ef4444' }
|
||||
};
|
||||
const status = statusMap[item.status] || statusMap[0];
|
||||
const statusW = ctx.measureText(status.text).width + 12;
|
||||
ctx.fillStyle = status.color + '33';
|
||||
this.roundRect(ctx, textX + ctx.measureText(this.truncateText(ctx, item.title || '未命名', maxW - 60)).width + 8, y + 12, statusW, 18, 9);
|
||||
// 已发布标签
|
||||
const statusText = '已发布';
|
||||
const statusW = ctx.measureText(statusText).width + 12;
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.2)';
|
||||
const titleWidth = ctx.measureText(this.truncateText(ctx, title, maxW - 60)).width;
|
||||
this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = status.color;
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.fillText(status.text, textX + ctx.measureText(this.truncateText(ctx, item.title || '未命名', maxW - 60)).width + 8 + statusW / 2, y + 24);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(statusText, textX + titleWidth + 8 + statusW / 2, y + 24);
|
||||
|
||||
// 数据统计
|
||||
// 原故事标题
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(`▶ ${this.formatNumber(item.play_count || 0)}`, textX, y + 50);
|
||||
ctx.fillText(`♥ ${this.formatNumber(item.like_count || 0)}`, textX + 55, y + 50);
|
||||
ctx.fillText(`💰 ${(item.earnings || 0).toFixed(1)}`, textX + 105, y + 50);
|
||||
ctx.fillText(`原故事: ${item.storyTitle || ''}`, textX, y + 48);
|
||||
|
||||
// 操作按钮
|
||||
const btnY = y + 65;
|
||||
const btns = ['编辑', '数据'];
|
||||
btns.forEach((btn, i) => {
|
||||
const btnX = textX + i * 55;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
this.roundRect(ctx, btnX, btnY, 48, 24, 12);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(btn, btnX + 24, btnY + 16);
|
||||
});
|
||||
// 创建时间
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText(item.createdAt || '', textX, y + 68);
|
||||
|
||||
// 按钮区域
|
||||
const btnY = y + 55;
|
||||
|
||||
// 取消发布按钮
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
this.roundRect(ctx, x + w - 125, btnY, 60, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('取消发布', x + w - 95, btnY + 17);
|
||||
|
||||
// 播放按钮
|
||||
const btnGradient = ctx.createLinearGradient(x + w - 58, btnY, x + w - 10, btnY);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, x + w - 58, btnY, 48, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('播放', x + w - 34, btnY + 17);
|
||||
}
|
||||
|
||||
renderDraftCard(ctx, item, x, y, w, h, index) {
|
||||
@@ -636,10 +668,10 @@ export default class ProfileScene extends BaseScene {
|
||||
const promptText = item.userPrompt ? `"「${item.userPrompt}」"` : '';
|
||||
ctx.fillText(this.truncateText(ctx, promptText, w - 100), textX, y + 48);
|
||||
|
||||
// 时间
|
||||
// 时间(放在左下角)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText(item.createdAt || '', textX, y + 68);
|
||||
ctx.fillText(item.createdAt || '', textX, y + 72);
|
||||
|
||||
// 未读标记
|
||||
if (!item.isRead && item.status === 'completed') {
|
||||
@@ -649,31 +681,50 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 按钮
|
||||
const btnY = y + 62;
|
||||
|
||||
// 删除按钮(所有状态都显示)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
this.roundRect(ctx, x + w - 55, btnY, 45, 24, 12);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('删除', x + w - 32, btnY + 16);
|
||||
// 按钮行(放在右下角)
|
||||
const btnY = y + 60;
|
||||
const btnStartX = x + w - 170; // 从右边开始排列按钮
|
||||
|
||||
// 播放按钮(仅已完成状态)
|
||||
if (item.status === 'completed') {
|
||||
const btnGradient = ctx.createLinearGradient(textX, btnY, textX + 65, btnY);
|
||||
const btnGradient = ctx.createLinearGradient(btnStartX, btnY, btnStartX + 50, btnY);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, textX + 120, btnY, 60, 24, 12);
|
||||
this.roundRect(ctx, btnStartX, btnY, 50, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('播放', textX + 150, btnY + 16);
|
||||
ctx.fillText('播放', btnStartX + 25, btnY + 17);
|
||||
|
||||
// 发布按钮(仅已完成且未发布)
|
||||
if (!item.publishedToCenter) {
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.2)';
|
||||
this.roundRect(ctx, btnStartX + 58, btnY, 50, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText('发布', btnStartX + 83, btnY + 17);
|
||||
} else {
|
||||
// 已发布标识
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.15)';
|
||||
this.roundRect(ctx, btnStartX + 58, btnY, 55, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText('已发布', btnStartX + 85, btnY + 17);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除按钮(所有状态都显示,最右边)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
this.roundRect(ctx, x + w - 55, btnY, 45, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('删除', x + w - 32, btnY + 17);
|
||||
}
|
||||
|
||||
renderSimpleCard(ctx, item, x, y, w, h, index) {
|
||||
@@ -700,7 +751,7 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
// 记录Tab使用 storyTitle,收藏Tab使用 story_title
|
||||
// 记录Tab使用 storyTitle,收藏Tab使用 storyTitle
|
||||
const title = item.storyTitle || item.story_title || item.title || '未知';
|
||||
ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28);
|
||||
|
||||
@@ -709,11 +760,14 @@ export default class ProfileScene extends BaseScene {
|
||||
if (this.currentTab === 3) {
|
||||
// 记录Tab:只显示记录数量
|
||||
ctx.fillText(`${item.recordCount || 0} 条记录`, textX, y + 50);
|
||||
} else if (this.currentTab === 2 && item.versionCount > 1) {
|
||||
// 收藏Tab:显示版本数量
|
||||
ctx.fillText(`${item.versionCount} 个版本`, textX, y + 50);
|
||||
} else {
|
||||
ctx.fillText(item.category || '', textX, y + 50);
|
||||
}
|
||||
|
||||
// 查看按钮(记录Tab)/ 继续按钮(收藏Tab)
|
||||
// 查看按钮(记录Tab/收藏Tab/作品Tab)
|
||||
const btnGradient = ctx.createLinearGradient(x + w - 58, y + 28, x + w - 10, y + 28);
|
||||
btnGradient.addColorStop(0, '#ff6b6b');
|
||||
btnGradient.addColorStop(1, '#ffd700');
|
||||
@@ -723,7 +777,7 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.currentTab === 3 ? '查看' : '继续', x + w - 34, y + 45);
|
||||
ctx.fillText('查看', x + w - 34, y + 45);
|
||||
}
|
||||
|
||||
getGradientColors(index) {
|
||||
@@ -848,11 +902,14 @@ export default class ProfileScene extends BaseScene {
|
||||
}
|
||||
}
|
||||
|
||||
// 创作按钮
|
||||
// 前往草稿箱按钮
|
||||
if (this.createBtnRect && this.currentTab === 0) {
|
||||
const btn = this.createBtnRect;
|
||||
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) {
|
||||
this.main.sceneManager.switchScene('aiCreate');
|
||||
// 切换到草稿箱 Tab
|
||||
this.currentTab = 1;
|
||||
this.scrollY = 0;
|
||||
this.calculateMaxScroll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -896,23 +953,32 @@ export default class ProfileScene extends BaseScene {
|
||||
|
||||
// AI草稿 Tab 的按钮检测
|
||||
if (this.currentTab === 1) {
|
||||
const btnY = 62;
|
||||
const btnH = 24;
|
||||
const btnY = 60;
|
||||
const btnH = 26;
|
||||
const btnStartX = padding + cardW - 170;
|
||||
|
||||
// 检测删除按钮点击(右侧)
|
||||
// 检测删除按钮点击(最右侧)
|
||||
const deleteBtnX = padding + cardW - 55;
|
||||
if (x >= deleteBtnX && x <= deleteBtnX + 45 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.confirmDeleteDraft(item, index);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测播放按钮点击(左侧,仅已完成状态)
|
||||
// 检测播放按钮点击(仅已完成状态)
|
||||
if (item.status === 'completed') {
|
||||
const playBtnX = padding + 88 + 120;
|
||||
if (x >= playBtnX && x <= playBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
if (x >= btnStartX && x <= btnStartX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
|
||||
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) {
|
||||
// 收藏 - 跳转播放
|
||||
this.main.sceneManager.switchScene('story', { storyId });
|
||||
// 收藏 - 检查版本数量
|
||||
if (item.versionCount > 1) {
|
||||
// 多版本:弹出选择框
|
||||
this.showVersionSelector(item);
|
||||
} else if (item.versions && item.versions.length > 0) {
|
||||
// 单版本:直接播放
|
||||
const version = item.versions[0];
|
||||
if (version.draftId) {
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: version.draftId });
|
||||
} else {
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId });
|
||||
}
|
||||
} else {
|
||||
// 兼容旧数据
|
||||
this.main.sceneManager.switchScene('story', { storyId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 作品Tab - 点击按钮进入草稿播放或取消发布
|
||||
if (this.currentTab === 0) {
|
||||
const btnY = 55;
|
||||
const btnH = 26;
|
||||
|
||||
// 检测取消发布按钮点击
|
||||
const unpublishBtnX = padding + cardW - 125;
|
||||
if (x >= unpublishBtnX && x <= unpublishBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.confirmUnpublishWork(item, index);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测播放按钮点击
|
||||
const playBtnX = padding + cardW - 58;
|
||||
if (x >= playBtnX && x <= playBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击卡片其他区域也进入播放
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
|
||||
}
|
||||
// 作品Tab的按钮操作需要更精确判断,暂略
|
||||
}
|
||||
}
|
||||
|
||||
// 显示故事的版本列表
|
||||
async showStoryVersions(storyItem) {
|
||||
const storyId = storyItem.story_id || storyItem.storyId || storyItem.id;
|
||||
const storyTitle = storyItem.story_title || storyItem.title || '未知故事';
|
||||
const storyTitle = storyItem.storyTitle || storyItem.story_title || storyItem.title || '未知故事';
|
||||
|
||||
try {
|
||||
wx.showLoading({ title: '加载中...' });
|
||||
@@ -994,11 +1097,13 @@ export default class ProfileScene extends BaseScene {
|
||||
async startRecordReplay(recordItem) {
|
||||
const recordId = recordItem.id;
|
||||
const storyId = this.selectedStoryInfo.id;
|
||||
const draftId = recordItem.draftId; // AI草稿ID,原故事为null
|
||||
|
||||
// 进入故事场景,传入 playRecordId 参数
|
||||
// 进入故事场景,传入 playRecordId 参数(draftId用于回放草稿内容)
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId,
|
||||
playRecordId: recordId
|
||||
playRecordId: recordId,
|
||||
draftId // 回放草稿内容
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1027,6 +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) {
|
||||
wx.showModal({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 故事播放场景 - 视觉小说风格
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
import { getNodeBackground, getNodeCharacter, getDraftNodeBackground, getStaticUrl } from '../utils/http';
|
||||
|
||||
export default class StoryScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
@@ -31,6 +32,11 @@ export default class StoryScene extends BaseScene {
|
||||
// 场景图相关
|
||||
this.sceneImage = null;
|
||||
this.sceneColors = this.generateSceneColors();
|
||||
// 节点图片相关
|
||||
this.nodeBackgroundImages = {}; // 缓存背景图 {nodeKey: Image}
|
||||
this.nodeCharacterImages = {}; // 缓存立绘 {nodeKey: Image}
|
||||
this.currentBackgroundImg = null;
|
||||
this.currentCharacterImg = null;
|
||||
// AI改写相关
|
||||
this.isAIRewriting = false;
|
||||
// 剧情回顾模式
|
||||
@@ -638,6 +644,77 @@ export default class StoryScene extends BaseScene {
|
||||
// 重置滚动
|
||||
this.textScrollY = 0;
|
||||
this.maxScrollY = 0;
|
||||
|
||||
// 加载当前节点的背景图和立绘
|
||||
this.loadNodeImages();
|
||||
}
|
||||
|
||||
// 加载当前节点的背景图和立绘
|
||||
loadNodeImages() {
|
||||
if (!this.currentNode || !this.storyId) return;
|
||||
|
||||
const nodeKey = this.currentNode.nodeKey || this.main.storyManager.currentNodeKey;
|
||||
if (!nodeKey) return;
|
||||
|
||||
// 判断是否是草稿模式
|
||||
const isDraftMode = !!this.draftId;
|
||||
|
||||
// 获取背景图 URL
|
||||
let bgUrl;
|
||||
if (isDraftMode) {
|
||||
// 草稿模式:优先使用节点中的 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() {
|
||||
@@ -695,15 +772,45 @@ export default class StoryScene extends BaseScene {
|
||||
}
|
||||
|
||||
renderSceneBackground(ctx) {
|
||||
// 场景区域(上方45%)
|
||||
// 场景区域(上方42%)
|
||||
const sceneHeight = this.screenHeight * 0.42;
|
||||
|
||||
// 渐变背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
|
||||
gradient.addColorStop(0, this.sceneColors.bg1);
|
||||
gradient.addColorStop(1, this.sceneColors.bg2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||
// 优先显示背景图
|
||||
if (this.currentBackgroundImg) {
|
||||
// 绘制背景图(等比例覆盖)
|
||||
const img = this.currentBackgroundImg;
|
||||
const imgRatio = img.width / img.height;
|
||||
const areaRatio = this.screenWidth / sceneHeight;
|
||||
|
||||
let drawW, drawH, drawX, drawY;
|
||||
if (imgRatio > areaRatio) {
|
||||
// 图片更宽,按高度适配
|
||||
drawH = sceneHeight;
|
||||
drawW = drawH * imgRatio;
|
||||
drawX = (this.screenWidth - drawW) / 2;
|
||||
drawY = 0;
|
||||
} else {
|
||||
// 图片更高,按宽度适配
|
||||
drawW = this.screenWidth;
|
||||
drawH = drawW / imgRatio;
|
||||
drawX = 0;
|
||||
drawY = (sceneHeight - drawH) / 2;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, this.screenWidth, sceneHeight);
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
||||
ctx.restore();
|
||||
} else {
|
||||
// 无背景图,使用渐变背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
|
||||
gradient.addColorStop(0, this.sceneColors.bg1);
|
||||
gradient.addColorStop(1, this.sceneColors.bg2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||
}
|
||||
|
||||
// 底部渐变过渡到对话框
|
||||
const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight);
|
||||
@@ -721,21 +828,34 @@ export default class StoryScene extends BaseScene {
|
||||
const sceneHeight = this.screenHeight * 0.42;
|
||||
const centerX = this.screenWidth / 2;
|
||||
|
||||
// 场景氛围光效
|
||||
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
|
||||
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
|
||||
glowGradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = glowGradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||
// 绘制角色立绘(如果有)
|
||||
if (this.currentCharacterImg) {
|
||||
const img = this.currentCharacterImg;
|
||||
// 立绘高度占场景区域80%,保持比例
|
||||
const charH = sceneHeight * 0.8;
|
||||
const charW = charH * (img.width / img.height);
|
||||
const charX = centerX - charW / 2;
|
||||
const charY = sceneHeight - charH;
|
||||
|
||||
ctx.drawImage(img, charX, charY, charW, charH);
|
||||
} else {
|
||||
// 无立绘时显示装饰效果
|
||||
// 场景氛围光效
|
||||
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
|
||||
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
|
||||
glowGradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = glowGradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||
|
||||
// 装饰粒子
|
||||
ctx.fillStyle = this.sceneColors.accent + '40';
|
||||
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
|
||||
particles.forEach(([x, y]) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
// 装饰粒子
|
||||
ctx.fillStyle = this.sceneColors.accent + '40';
|
||||
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
|
||||
particles.forEach(([x, y]) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
// 场景提示文字(中央)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
// ============================================
|
||||
// 环境配置(切换这里即可)
|
||||
// ============================================
|
||||
const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管
|
||||
const ENV = 'local'; // 'local' = 本地后端, 'cloud' = 微信云托管
|
||||
|
||||
const CONFIG = {
|
||||
local: {
|
||||
baseUrl: 'http://localhost:8000/api'
|
||||
baseUrl: 'http://localhost:8000/api',
|
||||
staticUrl: 'http://localhost:8000'
|
||||
},
|
||||
cloud: {
|
||||
env: 'prod-6gjx1rd4c40f5884',
|
||||
serviceName: 'express-fuvd'
|
||||
serviceName: 'express-fuvd',
|
||||
staticUrl: 'https://7072-prod-6gjx1rd4c40f5884-1409819450.tcb.qcloud.la'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,7 +48,8 @@ function requestLocal(options) {
|
||||
if (res.data && res.data.code === 0) {
|
||||
resolve(res.data.data);
|
||||
} else {
|
||||
reject(new Error(res.data?.message || '请求失败'));
|
||||
console.error('[HTTP-Local] 响应异常:', res.statusCode, res.data);
|
||||
reject(new Error(res.data?.message || res.data?.detail || `请求失败(${res.statusCode})`));
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
@@ -78,8 +81,10 @@ function requestCloud(options) {
|
||||
if (res.data && res.data.code === 0) {
|
||||
resolve(res.data.data);
|
||||
} else if (res.data) {
|
||||
reject(new Error(res.data.message || '请求失败'));
|
||||
console.error('[HTTP-Cloud] 响应异常:', res.statusCode, res.data);
|
||||
reject(new Error(res.data.message || res.data.detail || `请求失败(${res.statusCode})`));
|
||||
} else {
|
||||
console.error('[HTTP-Cloud] 响应数据异常:', res);
|
||||
reject(new Error('响应数据异常'));
|
||||
}
|
||||
},
|
||||
@@ -112,4 +117,68 @@ export function del(url, data) {
|
||||
return request({ url, method: 'DELETE', data });
|
||||
}
|
||||
|
||||
export default { request, get, post, del };
|
||||
export function put(url, data) {
|
||||
return request({ url, method: 'PUT', data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取静态资源完整URL(图片等)
|
||||
* @param {string} path - 相对路径,如 /uploads/stories/1/characters/1.jpg
|
||||
* @returns {string} 完整URL
|
||||
*/
|
||||
export function getStaticUrl(path) {
|
||||
if (!path) return '';
|
||||
// 如果已经是完整URL,直接返回
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
const config = ENV === 'local' ? CONFIG.local : CONFIG.cloud;
|
||||
return config.staticUrl + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色头像URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {number} characterId - 角色ID
|
||||
*/
|
||||
export function getCharacterAvatar(storyId, characterId) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/characters/${characterId}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取故事封面URL
|
||||
* @param {number} storyId - 故事ID
|
||||
*/
|
||||
export function getStoryCover(storyId) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/cover/cover.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点背景图URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {string} nodeKey - 节点key
|
||||
*/
|
||||
export function getNodeBackground(storyId, nodeKey) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/background.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点角色立绘URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {string} nodeKey - 节点key
|
||||
*/
|
||||
export function getNodeCharacter(storyId, nodeKey) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/character.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿节点背景图URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {number} draftId - 草稿ID
|
||||
* @param {string} nodeKey - 节点key
|
||||
*/
|
||||
export function getDraftNodeBackground(storyId, draftId, nodeKey) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/drafts/${draftId}/${nodeKey}/background.jpg`);
|
||||
}
|
||||
|
||||
export default { request, get, post, put, del, getStaticUrl, getCharacterAvatar, getStoryCover, getNodeBackground, getNodeCharacter, getDraftNodeBackground };
|
||||
|
||||
@@ -36,6 +36,12 @@ class Settings(BaseSettings):
|
||||
wx_appid: str = ""
|
||||
wx_secret: str = ""
|
||||
|
||||
# 微信云托管配置
|
||||
wx_cloud_env: str = ""
|
||||
|
||||
# Gemini 图片生成配置
|
||||
gemini_api_key: str = ""
|
||||
|
||||
# JWT 配置
|
||||
jwt_secret_key: str = "your-super-secret-key-change-in-production"
|
||||
jwt_expire_hours: int = 168 # 7天
|
||||
|
||||
@@ -35,8 +35,9 @@ app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
|
||||
app.include_router(upload.router, prefix="/api", tags=["上传"])
|
||||
|
||||
# 静态文件服务(用于访问上传的图片)
|
||||
os.makedirs(settings.upload_path, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads")
|
||||
upload_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', settings.upload_path))
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -121,6 +121,9 @@ class StoryDraft(Base):
|
||||
status = Column(Enum(DraftStatus), default=DraftStatus.pending)
|
||||
error_message = Column(String(500), default="")
|
||||
is_read = Column(Boolean, default=False) # 用户是否已查看
|
||||
published_to_center = Column(Boolean, default=False) # 是否发布到创作中心
|
||||
draft_type = Column(String(20), default="rewrite") # 草稿类型: rewrite/continue/create
|
||||
is_collected = Column(Boolean, default=False) # 用户是否收藏
|
||||
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
completed_at = Column(TIMESTAMP, default=None)
|
||||
|
||||
@@ -65,6 +65,7 @@ class PlayRecord(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
|
||||
draft_id = Column(Integer, default=None) # AI草稿ID,原故事为空
|
||||
ending_name = Column(String(100), nullable=False) # 结局名称
|
||||
ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite)
|
||||
path_history = Column(JSON, nullable=False) # 完整的选择路径
|
||||
|
||||
@@ -8,9 +8,12 @@ from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
import base64
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
|
||||
|
||||
@@ -36,6 +39,148 @@ async def get_story_characters(db: AsyncSession, story_id: int) -> List[dict]:
|
||||
]
|
||||
|
||||
|
||||
async def upload_to_cloud_storage(image_bytes: bytes, cloud_path: str) -> str:
|
||||
"""
|
||||
上传图片到微信云存储(云托管容器内调用)
|
||||
cloud_path: 云存储路径,如 stories/1/drafts/10/branch_1/background.jpg
|
||||
返回: 文件访问路径
|
||||
"""
|
||||
import httpx
|
||||
|
||||
env_id = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
|
||||
if not env_id:
|
||||
# 尝试从配置获取
|
||||
settings = get_settings()
|
||||
env_id = getattr(settings, 'wx_cloud_env', None)
|
||||
|
||||
if not env_id:
|
||||
raise Exception("未检测到云环境ID")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
# 云托管内网调用云开发 API(不需要 access_token)
|
||||
# 参考: https://developers.weixin.qq.com/miniprogram/dev/wxcloudrun/src/development/storage/service/upload.html
|
||||
|
||||
# 1. 获取上传链接
|
||||
resp = await client.post(
|
||||
"http://api.weixin.qq.com/tcb/uploadfile",
|
||||
json={
|
||||
"env": env_id,
|
||||
"path": cloud_path
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"获取上传链接失败: {resp.status_code} - {resp.text[:200]}")
|
||||
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) != 0:
|
||||
raise Exception(f"获取上传链接失败: {data.get('errmsg')}")
|
||||
|
||||
upload_url = data.get("url")
|
||||
authorization = data.get("authorization")
|
||||
token = data.get("token")
|
||||
cos_file_id = data.get("cos_file_id")
|
||||
file_id = data.get("file_id")
|
||||
|
||||
# 2. 上传文件到 COS
|
||||
form_data = {
|
||||
"key": cloud_path,
|
||||
"Signature": authorization,
|
||||
"x-cos-security-token": token,
|
||||
"x-cos-meta-fileid": cos_file_id,
|
||||
}
|
||||
|
||||
files = {"file": ("background.jpg", image_bytes, "image/jpeg")}
|
||||
|
||||
upload_resp = await client.post(upload_url, data=form_data, files=files)
|
||||
|
||||
if upload_resp.status_code not in [200, 204]:
|
||||
raise Exception(f"上传文件失败: {upload_resp.status_code} - {upload_resp.text[:200]}")
|
||||
|
||||
print(f" [CloudStorage] 文件上传成功: {file_id}")
|
||||
return file_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"[upload_to_cloud_storage] 上传失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def generate_draft_images(story_id: int, draft_id: int, ai_nodes: dict, story_category: str):
|
||||
"""
|
||||
为草稿的 AI 生成节点生成背景图
|
||||
本地环境:保存到文件系统 /uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg
|
||||
云端环境:上传到云存储
|
||||
"""
|
||||
from app.services.image_gen import ImageGenService
|
||||
|
||||
if not ai_nodes:
|
||||
return
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 检测是否是云端环境(TCB_ENV 或 CBR_ENV_ID 是云托管容器自动注入的)
|
||||
is_cloud = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
|
||||
|
||||
# 本地环境使用文件系统
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
|
||||
draft_dir = os.path.join(base_dir, "stories", str(story_id), "drafts", str(draft_id))
|
||||
|
||||
service = ImageGenService()
|
||||
|
||||
for node_key, node_data in ai_nodes.items():
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
|
||||
content = node_data.get('content', '')[:150]
|
||||
if not content:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 生成背景图 - 强调情绪表达
|
||||
bg_prompt = f"Background scene for {story_category} story. Scene: {content}. Wide shot, atmospheric, no characters, anime style. Strong emotional expression, dramatic mood, vivid colors reflecting the scene's emotion."
|
||||
result = await service.generate_image(bg_prompt, "background", "anime")
|
||||
|
||||
if result and result.get("success"):
|
||||
image_bytes = base64.b64decode(result["image_data"])
|
||||
# 路径格式和本地一致:uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg
|
||||
cloud_path = f"uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg"
|
||||
|
||||
# 云端环境:上传到云存储
|
||||
if is_cloud:
|
||||
try:
|
||||
file_id = await upload_to_cloud_storage(image_bytes, cloud_path)
|
||||
# 云存储返回的 file_id 格式: cloud://env-id.xxx/path
|
||||
# 前端通过 CDN 地址访问: https://7072-prod-xxx.tcb.qcloud.la/uploads/...
|
||||
node_data['background_url'] = f"/{cloud_path}"
|
||||
print(f" ✓ 云端草稿节点 {node_key} 背景图上传成功")
|
||||
except Exception as cloud_e:
|
||||
print(f" ✗ 云端上传失败: {cloud_e}")
|
||||
continue
|
||||
|
||||
# 本地环境:保存到文件系统
|
||||
node_dir = os.path.join(draft_dir, node_key)
|
||||
os.makedirs(node_dir, exist_ok=True)
|
||||
bg_path = os.path.join(node_dir, "background.jpg")
|
||||
|
||||
with open(bg_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# 更新节点数据,添加图片路径
|
||||
node_data['background_url'] = f"/uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg"
|
||||
print(f" ✓ 草稿节点 {node_key} 背景图生成成功")
|
||||
else:
|
||||
print(f" ✗ 草稿节点 {node_key} 背景图生成失败: {result.get('error') if result else 'Unknown'}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ 草稿节点 {node_key} 图片生成异常: {e}")
|
||||
|
||||
# 避免请求过快
|
||||
import asyncio
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
# ============ 请求/响应模型 ============
|
||||
|
||||
class PathHistoryItem(BaseModel):
|
||||
@@ -121,11 +266,14 @@ async def process_ai_rewrite(draft_id: int):
|
||||
|
||||
# 获取故事角色
|
||||
characters = await get_story_characters(db, story.id)
|
||||
print(f"[process_ai_rewrite] 获取到角色数: {len(characters)}")
|
||||
|
||||
# 转换路径历史格式
|
||||
path_history = draft.path_history or []
|
||||
print(f"[process_ai_rewrite] 路径历史长度: {len(path_history)}")
|
||||
|
||||
# 调用AI服务
|
||||
print(f"[process_ai_rewrite] 开始调用 AI 服务...")
|
||||
ai_result = await ai_service.rewrite_branch(
|
||||
story_title=story.title,
|
||||
story_category=story.category or "未知",
|
||||
@@ -134,9 +282,21 @@ async def process_ai_rewrite(draft_id: int):
|
||||
user_prompt=draft.user_prompt,
|
||||
characters=characters
|
||||
)
|
||||
print(f"[process_ai_rewrite] AI 服务返回: {bool(ai_result)}")
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
# 成功
|
||||
# 成功 - 尝试生成配图(失败不影响改写结果)
|
||||
try:
|
||||
print(f"[process_ai_rewrite] AI生成成功,开始生成配图...")
|
||||
await generate_draft_images(
|
||||
story_id=draft.story_id,
|
||||
draft_id=draft.id,
|
||||
ai_nodes=ai_result["nodes"],
|
||||
story_category=story.category or "都市言情"
|
||||
)
|
||||
except Exception as img_e:
|
||||
print(f"[process_ai_rewrite] 配图生成失败(不影响改写结果): {img_e}")
|
||||
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = ai_result["nodes"]
|
||||
draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1")
|
||||
@@ -232,8 +392,7 @@ async def process_ai_rewrite_ending(draft_id: int):
|
||||
pass
|
||||
|
||||
# 成功 - 存储为对象格式(与故事节点格式一致)
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = {
|
||||
ai_nodes = {
|
||||
"ending_rewrite": {
|
||||
"content": content,
|
||||
"speaker": "旁白",
|
||||
@@ -242,6 +401,21 @@ async def process_ai_rewrite_ending(draft_id: int):
|
||||
"ending_type": "rewrite"
|
||||
}
|
||||
}
|
||||
|
||||
# 生成配图(失败不影响改写结果)
|
||||
try:
|
||||
print(f"[process_ai_rewrite_ending] AI生成成功,开始生成配图...")
|
||||
await generate_draft_images(
|
||||
story_id=draft.story_id,
|
||||
draft_id=draft.id,
|
||||
ai_nodes=ai_nodes,
|
||||
story_category=story.category or "都市言情"
|
||||
)
|
||||
except Exception as img_e:
|
||||
print(f"[process_ai_rewrite_ending] 配图生成失败(不影响改写结果): {img_e}")
|
||||
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = ai_nodes
|
||||
draft.entry_node_key = "ending_rewrite"
|
||||
draft.tokens_used = ai_result.get("tokens_used", 0)
|
||||
draft.title = f"{story.title}-{new_ending_name}"
|
||||
@@ -313,7 +487,18 @@ async def process_ai_continue_ending(draft_id: int):
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
# 成功 - 存储多节点分支格式
|
||||
# 成功 - 尝试生成配图(失败不影响续写结果)
|
||||
try:
|
||||
print(f"[process_ai_continue_ending] AI生成成功,开始生成配图...")
|
||||
await generate_draft_images(
|
||||
story_id=draft.story_id,
|
||||
draft_id=draft.id,
|
||||
ai_nodes=ai_result["nodes"],
|
||||
story_category=story.category or "都市言情"
|
||||
)
|
||||
except Exception as img_e:
|
||||
print(f"[process_ai_continue_ending] 配图生成失败(不影响续写结果): {img_e}")
|
||||
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = ai_result["nodes"]
|
||||
draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1")
|
||||
@@ -374,7 +559,8 @@ async def create_draft(
|
||||
current_node_key=request.currentNodeKey,
|
||||
current_content=request.currentContent,
|
||||
user_prompt=request.prompt,
|
||||
status=DraftStatus.pending
|
||||
status=DraftStatus.pending,
|
||||
draft_type='rewrite'
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
@@ -419,7 +605,8 @@ async def create_ending_draft(
|
||||
current_node_key=request.endingName, # 保存结局名称
|
||||
current_content=request.endingContent, # 保存结局内容
|
||||
user_prompt=request.prompt,
|
||||
status=DraftStatus.pending
|
||||
status=DraftStatus.pending,
|
||||
draft_type='rewrite'
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
@@ -464,7 +651,8 @@ async def create_continue_ending_draft(
|
||||
current_node_key=request.endingName, # 保存结局名称
|
||||
current_content=request.endingContent, # 保存结局内容
|
||||
user_prompt=request.prompt,
|
||||
status=DraftStatus.pending
|
||||
status=DraftStatus.pending,
|
||||
draft_type='continue'
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
@@ -508,6 +696,8 @@ async def get_drafts(
|
||||
"userPrompt": draft.user_prompt,
|
||||
"status": draft.status.value if draft.status else "pending",
|
||||
"isRead": draft.is_read,
|
||||
"publishedToCenter": draft.published_to_center,
|
||||
"draftType": draft.draft_type or "rewrite",
|
||||
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
|
||||
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
|
||||
})
|
||||
@@ -549,6 +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}")
|
||||
async def get_draft_detail(
|
||||
draft_id: int,
|
||||
@@ -601,7 +833,7 @@ async def delete_draft(
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除草稿"""
|
||||
"""删除草稿(同时清理图片文件)"""
|
||||
result = await db.execute(
|
||||
select(StoryDraft).where(
|
||||
StoryDraft.id == draft_id,
|
||||
@@ -613,6 +845,19 @@ async def delete_draft(
|
||||
if not draft:
|
||||
raise HTTPException(status_code=404, detail="草稿不存在")
|
||||
|
||||
# 删除草稿对应的图片文件夹
|
||||
try:
|
||||
import shutil
|
||||
settings = get_settings()
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
|
||||
draft_dir = os.path.join(base_dir, "stories", str(draft.story_id), "drafts", str(draft_id))
|
||||
if os.path.exists(draft_dir):
|
||||
shutil.rmtree(draft_dir)
|
||||
print(f"[delete_draft] 已清理图片目录: {draft_dir}")
|
||||
except Exception as e:
|
||||
print(f"[delete_draft] 清理图片失败: {e}")
|
||||
# 图片清理失败不影响草稿删除
|
||||
|
||||
await db.delete(draft)
|
||||
await db.commit()
|
||||
|
||||
@@ -652,3 +897,90 @@ async def mark_all_drafts_read(
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "已全部标记为已读"}
|
||||
|
||||
|
||||
@router.put("/{draft_id}/publish")
|
||||
async def publish_draft_to_center(
|
||||
draft_id: int,
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""发布草稿到创作中心"""
|
||||
# 验证草稿存在且属于该用户
|
||||
result = await db.execute(
|
||||
select(StoryDraft).where(
|
||||
StoryDraft.id == draft_id,
|
||||
StoryDraft.user_id == userId,
|
||||
StoryDraft.status == DraftStatus.completed
|
||||
)
|
||||
)
|
||||
draft = result.scalar_one_or_none()
|
||||
|
||||
if not draft:
|
||||
raise HTTPException(status_code=404, detail="草稿不存在或未完成")
|
||||
|
||||
# 更新发布状态
|
||||
draft.published_to_center = True
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "已发布到创作中心"}
|
||||
|
||||
|
||||
@router.put("/{draft_id}/unpublish")
|
||||
async def unpublish_draft_from_center(
|
||||
draft_id: int,
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""从创作中心取消发布"""
|
||||
await db.execute(
|
||||
update(StoryDraft)
|
||||
.where(
|
||||
StoryDraft.id == draft_id,
|
||||
StoryDraft.user_id == userId
|
||||
)
|
||||
.values(published_to_center=False)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "已从创作中心移除"}
|
||||
|
||||
|
||||
@router.put("/{draft_id}/collect")
|
||||
async def collect_draft(
|
||||
draft_id: int,
|
||||
userId: int,
|
||||
isCollected: bool = True,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""收藏/取消收藏草稿"""
|
||||
await db.execute(
|
||||
update(StoryDraft)
|
||||
.where(
|
||||
StoryDraft.id == draft_id,
|
||||
StoryDraft.user_id == userId
|
||||
)
|
||||
.values(is_collected=isCollected)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "收藏成功" if isCollected else "取消收藏成功"}
|
||||
|
||||
|
||||
@router.get("/{draft_id}/collect-status")
|
||||
async def get_draft_collect_status(
|
||||
draft_id: int,
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取草稿收藏状态"""
|
||||
result = await db.execute(
|
||||
select(StoryDraft.is_collected)
|
||||
.where(
|
||||
StoryDraft.id == draft_id,
|
||||
StoryDraft.user_id == userId
|
||||
)
|
||||
)
|
||||
is_collected = result.scalar_one_or_none()
|
||||
|
||||
return {"code": 0, "data": {"isCollected": is_collected or False}}
|
||||
|
||||
@@ -39,6 +39,32 @@ class RewriteBranchRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class NodeImageUpdate(BaseModel):
|
||||
nodeKey: str
|
||||
backgroundImage: str = ""
|
||||
characterImage: str = ""
|
||||
|
||||
|
||||
class CharacterImageUpdate(BaseModel):
|
||||
characterId: int
|
||||
avatarUrl: str = ""
|
||||
|
||||
|
||||
class ImageConfigRequest(BaseModel):
|
||||
coverUrl: str = ""
|
||||
nodes: List[NodeImageUpdate] = []
|
||||
characters: List[CharacterImageUpdate] = []
|
||||
|
||||
|
||||
class GenerateImageRequest(BaseModel):
|
||||
prompt: str
|
||||
style: str = "anime" # anime/realistic/illustration
|
||||
category: str = "character" # character/background/cover
|
||||
storyId: Optional[int] = None
|
||||
targetField: Optional[str] = None # coverUrl/backgroundImage/characterImage/avatarUrl
|
||||
targetKey: Optional[str] = None # nodeKey 或 characterId
|
||||
|
||||
|
||||
# ========== API接口 ==========
|
||||
|
||||
@router.get("")
|
||||
@@ -110,6 +136,222 @@ async def get_categories(db: AsyncSession = Depends(get_db)):
|
||||
return {"code": 0, "data": categories}
|
||||
|
||||
|
||||
@router.get("/test-image-gen")
|
||||
async def test_image_generation():
|
||||
"""测试图片生成服务是否正常"""
|
||||
from app.config import get_settings
|
||||
from app.services.image_gen import get_image_gen_service
|
||||
import httpx
|
||||
|
||||
settings = get_settings()
|
||||
results = {
|
||||
"api_key_configured": bool(settings.gemini_api_key),
|
||||
"api_key_preview": settings.gemini_api_key[:8] + "..." if settings.gemini_api_key else "未配置",
|
||||
"base_url": "https://work.poloapi.com/v1beta",
|
||||
"network_test": None,
|
||||
"generate_test": None
|
||||
}
|
||||
|
||||
# 测试网络连接
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get("https://work.poloapi.com")
|
||||
results["network_test"] = {
|
||||
"success": response.status_code < 500,
|
||||
"status_code": response.status_code,
|
||||
"message": "网络连接正常" if response.status_code < 500 else "服务端错误"
|
||||
}
|
||||
except Exception as e:
|
||||
results["network_test"] = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "网络连接失败"
|
||||
}
|
||||
|
||||
# 测试实际生图(简单测试)
|
||||
if settings.gemini_api_key:
|
||||
try:
|
||||
service = get_image_gen_service()
|
||||
gen_result = await service.generate_image(
|
||||
prompt="a simple red circle on white background",
|
||||
image_type="avatar",
|
||||
style="illustration"
|
||||
)
|
||||
results["generate_test"] = {
|
||||
"success": gen_result.get("success", False),
|
||||
"error": gen_result.get("error") if not gen_result.get("success") else None,
|
||||
"has_image_data": bool(gen_result.get("image_data"))
|
||||
}
|
||||
except Exception as e:
|
||||
results["generate_test"] = {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
else:
|
||||
results["generate_test"] = {
|
||||
"success": False,
|
||||
"error": "API Key 未配置"
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "测试完成",
|
||||
"data": results
|
||||
}
|
||||
|
||||
|
||||
@router.get("/test-deepseek")
|
||||
async def test_deepseek():
|
||||
"""测试 DeepSeek AI 服务是否正常"""
|
||||
from app.config import get_settings
|
||||
import httpx
|
||||
|
||||
settings = get_settings()
|
||||
results = {
|
||||
"ai_service_enabled": settings.ai_service_enabled,
|
||||
"provider": settings.ai_provider,
|
||||
"api_key_configured": bool(settings.deepseek_api_key),
|
||||
"api_key_preview": settings.deepseek_api_key[:8] + "..." + settings.deepseek_api_key[-4:] if settings.deepseek_api_key and len(settings.deepseek_api_key) > 12 else "未配置或太短",
|
||||
"base_url": settings.deepseek_base_url,
|
||||
"model": settings.deepseek_model,
|
||||
"network_test": None,
|
||||
"api_test": None
|
||||
}
|
||||
|
||||
# 测试网络连接
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get("https://api.deepseek.com")
|
||||
results["network_test"] = {
|
||||
"success": response.status_code < 500,
|
||||
"status_code": response.status_code,
|
||||
"message": "网络连接正常"
|
||||
}
|
||||
except Exception as e:
|
||||
results["network_test"] = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "网络连接失败"
|
||||
}
|
||||
|
||||
# 测试 API 调用
|
||||
if settings.deepseek_api_key:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.deepseek_base_url}/chat/completions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.deepseek_api_key}"
|
||||
},
|
||||
json={
|
||||
"model": settings.deepseek_model,
|
||||
"messages": [{"role": "user", "content": "说'测试成功'两个字"}],
|
||||
"max_tokens": 10
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
results["api_test"] = {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"response": content[:50]
|
||||
}
|
||||
else:
|
||||
results["api_test"] = {
|
||||
"success": False,
|
||||
"status_code": response.status_code,
|
||||
"error": response.text[:200]
|
||||
}
|
||||
except Exception as e:
|
||||
results["api_test"] = {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
else:
|
||||
results["api_test"] = {
|
||||
"success": False,
|
||||
"error": "API Key 未配置"
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "测试完成",
|
||||
"data": results
|
||||
}
|
||||
|
||||
|
||||
@router.get("/test-cloud-upload")
|
||||
async def test_cloud_upload():
|
||||
"""测试云存储上传是否正常"""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
tcb_env = os.environ.get("TCB_ENV") or os.environ.get("CBR_ENV_ID")
|
||||
|
||||
results = {
|
||||
"env_id": tcb_env,
|
||||
"is_cloud": bool(tcb_env)
|
||||
}
|
||||
|
||||
if not tcb_env:
|
||||
return {
|
||||
"code": 1,
|
||||
"message": "非云托管环境,无法测试云存储上传",
|
||||
"data": results
|
||||
}
|
||||
|
||||
try:
|
||||
# 测试获取上传链接
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
"http://api.weixin.qq.com/tcb/uploadfile",
|
||||
json={
|
||||
"env": tcb_env,
|
||||
"path": "test/cloud_upload_test.txt"
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
results["status_code"] = resp.status_code
|
||||
results["response"] = resp.text[:500] if resp.text else ""
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) == 0:
|
||||
results["upload_url"] = data.get("url", "")[:100]
|
||||
results["file_id"] = data.get("file_id", "")
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "云存储上传链接获取成功",
|
||||
"data": results
|
||||
}
|
||||
else:
|
||||
results["errcode"] = data.get("errcode")
|
||||
results["errmsg"] = data.get("errmsg")
|
||||
return {
|
||||
"code": 1,
|
||||
"message": f"获取上传链接失败: {data.get('errmsg')}",
|
||||
"data": results
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"code": 1,
|
||||
"message": f"请求失败: HTTP {resp.status_code}",
|
||||
"data": results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
results["error"] = str(e)
|
||||
return {
|
||||
"code": 1,
|
||||
"message": f"测试异常: {str(e)}",
|
||||
"data": results
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{story_id}")
|
||||
async def get_story_detail(story_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取故事详情(含节点和选项)"""
|
||||
@@ -371,4 +613,228 @@ async def ai_rewrite_branch(
|
||||
"tokensUsed": 0,
|
||||
"error": "AI服务暂时不可用"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{story_id}/images")
|
||||
async def get_story_images(story_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取故事的所有图片配置"""
|
||||
# 获取故事封面
|
||||
result = await db.execute(select(Story).where(Story.id == story_id))
|
||||
story = result.scalar_one_or_none()
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 获取所有节点的图片
|
||||
nodes_result = await db.execute(
|
||||
select(StoryNode).where(StoryNode.story_id == story_id).order_by(StoryNode.sort_order)
|
||||
)
|
||||
nodes = nodes_result.scalars().all()
|
||||
|
||||
# 获取所有角色的头像
|
||||
chars_result = await db.execute(
|
||||
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
|
||||
)
|
||||
characters = chars_result.scalars().all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"storyId": story_id,
|
||||
"title": story.title,
|
||||
"coverUrl": story.cover_url or "",
|
||||
"nodes": [
|
||||
{
|
||||
"nodeKey": n.node_key,
|
||||
"content": n.content[:50] + "..." if len(n.content) > 50 else n.content,
|
||||
"backgroundImage": n.background_image or "",
|
||||
"characterImage": n.character_image or "",
|
||||
"isEnding": n.is_ending,
|
||||
"endingName": n.ending_name or ""
|
||||
}
|
||||
for n in nodes
|
||||
],
|
||||
"characters": [
|
||||
{
|
||||
"characterId": c.id,
|
||||
"name": c.name,
|
||||
"roleType": c.role_type,
|
||||
"avatarUrl": c.avatar_url or "",
|
||||
"avatarPrompt": c.avatar_prompt or ""
|
||||
}
|
||||
for c in characters
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{story_id}/images")
|
||||
async def update_story_images(
|
||||
story_id: int,
|
||||
request: ImageConfigRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""批量更新故事的图片配置"""
|
||||
# 验证故事存在
|
||||
result = await db.execute(select(Story).where(Story.id == story_id))
|
||||
story = result.scalar_one_or_none()
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
updated = {"cover": False, "nodes": 0, "characters": 0}
|
||||
|
||||
# 更新封面
|
||||
if request.coverUrl:
|
||||
await db.execute(
|
||||
update(Story).where(Story.id == story_id).values(cover_url=request.coverUrl)
|
||||
)
|
||||
updated["cover"] = True
|
||||
|
||||
# 更新节点图片
|
||||
for node_img in request.nodes:
|
||||
values = {}
|
||||
if node_img.backgroundImage:
|
||||
values["background_image"] = node_img.backgroundImage
|
||||
if node_img.characterImage:
|
||||
values["character_image"] = node_img.characterImage
|
||||
if values:
|
||||
await db.execute(
|
||||
update(StoryNode)
|
||||
.where(StoryNode.story_id == story_id, StoryNode.node_key == node_img.nodeKey)
|
||||
.values(**values)
|
||||
)
|
||||
updated["nodes"] += 1
|
||||
|
||||
# 更新角色头像
|
||||
for char_img in request.characters:
|
||||
if char_img.avatarUrl:
|
||||
await db.execute(
|
||||
update(StoryCharacter)
|
||||
.where(StoryCharacter.id == char_img.characterId, StoryCharacter.story_id == story_id)
|
||||
.values(avatar_url=char_img.avatarUrl)
|
||||
)
|
||||
updated["characters"] += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "更新成功",
|
||||
"data": updated
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-image")
|
||||
async def generate_story_image(
|
||||
request: GenerateImageRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""使用AI生成图片并可选保存到故事"""
|
||||
from app.services.image_gen import get_image_gen_service
|
||||
|
||||
# 生成图片
|
||||
result = await get_image_gen_service().generate_and_save(
|
||||
prompt=request.prompt,
|
||||
category=request.category,
|
||||
style=request.style
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return {
|
||||
"code": 1,
|
||||
"message": result.get("error", "生成失败"),
|
||||
"data": None
|
||||
}
|
||||
|
||||
image_url = result["url"]
|
||||
|
||||
# 如果指定了故事和目标字段,自动更新
|
||||
if request.storyId and request.targetField:
|
||||
if request.targetField == "coverUrl":
|
||||
await db.execute(
|
||||
update(Story).where(Story.id == request.storyId).values(cover_url=image_url)
|
||||
)
|
||||
elif request.targetField == "backgroundImage" and request.targetKey:
|
||||
await db.execute(
|
||||
update(StoryNode)
|
||||
.where(StoryNode.story_id == request.storyId, StoryNode.node_key == request.targetKey)
|
||||
.values(background_image=image_url)
|
||||
)
|
||||
elif request.targetField == "characterImage" and request.targetKey:
|
||||
await db.execute(
|
||||
update(StoryNode)
|
||||
.where(StoryNode.story_id == request.storyId, StoryNode.node_key == request.targetKey)
|
||||
.values(character_image=image_url)
|
||||
)
|
||||
elif request.targetField == "avatarUrl" and request.targetKey:
|
||||
await db.execute(
|
||||
update(StoryCharacter)
|
||||
.where(StoryCharacter.story_id == request.storyId, StoryCharacter.id == int(request.targetKey))
|
||||
.values(avatar_url=image_url)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "生成成功",
|
||||
"data": {
|
||||
"url": image_url,
|
||||
"filename": result.get("filename"),
|
||||
"saved": bool(request.storyId and request.targetField)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{story_id}/generate-all-images")
|
||||
async def generate_all_story_images(
|
||||
story_id: int,
|
||||
style: str = "anime",
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""为故事批量生成所有角色头像"""
|
||||
from app.services.image_gen import get_image_gen_service
|
||||
image_service = get_image_gen_service()
|
||||
|
||||
# 获取所有角色
|
||||
result = await db.execute(
|
||||
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
|
||||
)
|
||||
characters = result.scalars().all()
|
||||
|
||||
if not characters:
|
||||
return {"code": 1, "message": "故事没有角色数据", "data": None}
|
||||
|
||||
generated = []
|
||||
failed = []
|
||||
|
||||
for char in characters:
|
||||
# 使用avatar_prompt或自动构建
|
||||
prompt = char.avatar_prompt or f"{char.name}, {char.gender}, {char.appearance or ''}"
|
||||
|
||||
gen_result = await image_service.generate_and_save(
|
||||
prompt=prompt,
|
||||
category="character",
|
||||
style=style
|
||||
)
|
||||
|
||||
if gen_result.get("success"):
|
||||
# 更新数据库
|
||||
await db.execute(
|
||||
update(StoryCharacter)
|
||||
.where(StoryCharacter.id == char.id)
|
||||
.values(avatar_url=gen_result["url"])
|
||||
)
|
||||
generated.append({"id": char.id, "name": char.name, "url": gen_result["url"]})
|
||||
else:
|
||||
failed.append({"id": char.id, "name": char.name, "error": gen_result.get("error")})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": f"生成完成: {len(generated)}成功, {len(failed)}失败",
|
||||
"data": {
|
||||
"generated": generated,
|
||||
"failed": failed
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class CollectRequest(BaseModel):
|
||||
class PlayRecordRequest(BaseModel):
|
||||
userId: int
|
||||
storyId: int
|
||||
draftId: Optional[int] = None # AI草稿ID,原故事为空
|
||||
endingName: str
|
||||
endingType: str = ""
|
||||
pathHistory: list
|
||||
@@ -364,24 +365,79 @@ async def toggle_collect(request: CollectRequest, db: AsyncSession = Depends(get
|
||||
|
||||
@router.get("/collections")
|
||||
async def get_collections(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||
"""获取收藏列表"""
|
||||
result = await db.execute(
|
||||
select(Story)
|
||||
"""获取收藏列表(包含原故事和AI改写草稿)"""
|
||||
from app.models.story import StoryDraft, DraftStatus
|
||||
|
||||
# 查询收藏的原故事
|
||||
original_result = await db.execute(
|
||||
select(Story, UserProgress.updated_at)
|
||||
.join(UserProgress, Story.id == UserProgress.story_id)
|
||||
.where(UserProgress.user_id == user_id, UserProgress.is_collected == True)
|
||||
.order_by(UserProgress.updated_at.desc())
|
||||
)
|
||||
stories = result.scalars().all()
|
||||
original_stories = original_result.all()
|
||||
|
||||
data = [{
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"cover_url": s.cover_url,
|
||||
"description": s.description,
|
||||
"category": s.category,
|
||||
"play_count": s.play_count,
|
||||
"like_count": s.like_count
|
||||
} for s in stories]
|
||||
# 查询收藏的草稿
|
||||
draft_result = await db.execute(
|
||||
select(StoryDraft, Story.title.label('story_title'), Story.category, Story.cover_url)
|
||||
.join(Story, StoryDraft.story_id == Story.id)
|
||||
.where(
|
||||
StoryDraft.user_id == user_id,
|
||||
StoryDraft.is_collected == True,
|
||||
StoryDraft.status == DraftStatus.completed
|
||||
)
|
||||
.order_by(StoryDraft.created_at.desc())
|
||||
)
|
||||
drafts = draft_result.all()
|
||||
|
||||
# 按 story_id 分组
|
||||
collections = {}
|
||||
|
||||
# 处理原故事
|
||||
for story, updated_at in original_stories:
|
||||
story_id = story.id
|
||||
if story_id not in collections:
|
||||
collections[story_id] = {
|
||||
"storyId": story_id,
|
||||
"storyTitle": story.title,
|
||||
"category": story.category,
|
||||
"coverUrl": story.cover_url,
|
||||
"versions": []
|
||||
}
|
||||
collections[story_id]["versions"].append({
|
||||
"type": "original",
|
||||
"id": story_id,
|
||||
"title": story.title,
|
||||
"draftId": None
|
||||
})
|
||||
|
||||
# 处理草稿
|
||||
for row in drafts:
|
||||
draft = row[0]
|
||||
story_title = row[1]
|
||||
category = row[2]
|
||||
cover_url = row[3]
|
||||
story_id = draft.story_id
|
||||
|
||||
if story_id not in collections:
|
||||
collections[story_id] = {
|
||||
"storyId": story_id,
|
||||
"storyTitle": story_title,
|
||||
"category": category,
|
||||
"coverUrl": cover_url,
|
||||
"versions": []
|
||||
}
|
||||
collections[story_id]["versions"].append({
|
||||
"type": draft.draft_type or "rewrite",
|
||||
"id": draft.id,
|
||||
"title": draft.title or f"{story_title}-{draft.draft_type}",
|
||||
"draftId": draft.id
|
||||
})
|
||||
|
||||
# 转换为列表,添加版本数量
|
||||
data = []
|
||||
for item in collections.values():
|
||||
item["versionCount"] = len(item["versions"])
|
||||
data.append(item)
|
||||
|
||||
return {"code": 0, "data": data}
|
||||
|
||||
@@ -511,11 +567,17 @@ async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depend
|
||||
"""保存游玩记录(相同路径只保留最新)"""
|
||||
import json
|
||||
|
||||
# 查找该用户该故事的所有记录
|
||||
result = await db.execute(
|
||||
select(PlayRecord)
|
||||
.where(PlayRecord.user_id == request.userId, PlayRecord.story_id == request.storyId)
|
||||
# 查找该用户该故事的所有记录(区分原故事和草稿)
|
||||
query = select(PlayRecord).where(
|
||||
PlayRecord.user_id == request.userId,
|
||||
PlayRecord.story_id == request.storyId
|
||||
)
|
||||
if request.draftId:
|
||||
query = query.where(PlayRecord.draft_id == request.draftId)
|
||||
else:
|
||||
query = query.where(PlayRecord.draft_id == None)
|
||||
|
||||
result = await db.execute(query)
|
||||
existing_records = result.scalars().all()
|
||||
|
||||
# 检查是否有相同路径的记录
|
||||
@@ -530,6 +592,7 @@ async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depend
|
||||
record = PlayRecord(
|
||||
user_id=request.userId,
|
||||
story_id=request.storyId,
|
||||
draft_id=request.draftId,
|
||||
ending_name=request.endingName,
|
||||
ending_type=request.endingType,
|
||||
path_history=request.pathHistory
|
||||
@@ -554,8 +617,10 @@ async def get_play_records(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取游玩记录列表"""
|
||||
from app.models.story import StoryDraft
|
||||
|
||||
if story_id:
|
||||
# 获取指定故事的记录
|
||||
# 获取指定故事的记录(包含草稿发布状态)
|
||||
result = await db.execute(
|
||||
select(PlayRecord)
|
||||
.where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id)
|
||||
@@ -563,12 +628,33 @@ async def get_play_records(
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
data = [{
|
||||
"id": r.id,
|
||||
"endingName": r.ending_name,
|
||||
"endingType": r.ending_type,
|
||||
"createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else ""
|
||||
} for r in records]
|
||||
# 获取相关草稿的发布状态
|
||||
draft_ids = [r.draft_id for r in records if r.draft_id]
|
||||
draft_status = {}
|
||||
if draft_ids:
|
||||
draft_result = await db.execute(
|
||||
select(StoryDraft.id, StoryDraft.published_to_center, StoryDraft.title)
|
||||
.where(StoryDraft.id.in_(draft_ids))
|
||||
)
|
||||
for d in draft_result.all():
|
||||
draft_status[d.id] = {"published": d.published_to_center, "title": d.title}
|
||||
|
||||
data = []
|
||||
for r in records:
|
||||
item = {
|
||||
"id": r.id,
|
||||
"draftId": r.draft_id,
|
||||
"endingName": r.ending_name,
|
||||
"endingType": r.ending_type,
|
||||
"createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else ""
|
||||
}
|
||||
# 如果是草稿记录,添加发布状态
|
||||
if r.draft_id and r.draft_id in draft_status:
|
||||
item["isPublished"] = draft_status[r.draft_id]["published"]
|
||||
item["draftTitle"] = draft_status[r.draft_id]["title"]
|
||||
else:
|
||||
item["isPublished"] = True # 原故事视为"已发布"
|
||||
data.append(item)
|
||||
else:
|
||||
# 获取所有玩过的故事(按故事分组,取最新一条)
|
||||
result = await db.execute(
|
||||
|
||||
139
server/app/services/image_gen.py
Normal file
139
server/app/services/image_gen.py
Normal 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
|
||||
@@ -146,6 +146,9 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
|
||||
`error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因',
|
||||
`is_read` TINYINT(1) DEFAULT 0 COMMENT '用户是否已查看',
|
||||
`published_to_center` TINYINT(1) DEFAULT 0 COMMENT '是否发布到创作中心',
|
||||
`draft_type` VARCHAR(20) DEFAULT 'rewrite' COMMENT '草稿类型: rewrite/continue/create',
|
||||
`is_collected` TINYINT(1) DEFAULT 0 COMMENT '用户是否收藏',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -160,22 +163,7 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
|
||||
-- ============================================
|
||||
-- 8. 游玩记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `play_records` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`story_id` INT NOT NULL COMMENT '故事ID',
|
||||
`ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称',
|
||||
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型',
|
||||
`path_history` JSON NOT NULL COMMENT '完整的选择路径',
|
||||
`play_duration` INT DEFAULT 0 COMMENT '游玩时长(秒)',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_story` (`user_id`, `story_id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_story` (`story_id`),
|
||||
CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表';
|
||||
c
|
||||
|
||||
-- ============================================
|
||||
-- 9. 故事角色表
|
||||
|
||||
Reference in New Issue
Block a user