feat: 新增AI创作中心场景,支持改写/续写/创作
This commit is contained in:
@@ -144,4 +144,38 @@ export default class StoryManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI续写故事
|
||||||
|
*/
|
||||||
|
async continueStory(storyId, prompt) {
|
||||||
|
try {
|
||||||
|
const result = await post(`/stories/${storyId}/continue`, {
|
||||||
|
current_node_key: this.currentNodeKey,
|
||||||
|
prompt: prompt
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI续写失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI创作新故事
|
||||||
|
*/
|
||||||
|
async createStory(params) {
|
||||||
|
try {
|
||||||
|
const result = await post('/stories/ai-create', {
|
||||||
|
genre: params.genre,
|
||||||
|
keywords: params.keywords,
|
||||||
|
protagonist: params.protagonist,
|
||||||
|
conflict: params.conflict
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI创作失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,4 +131,40 @@ export default class UserManager {
|
|||||||
if (!this.isLoggedIn) return [];
|
if (!this.isLoggedIn) return [];
|
||||||
return await get('/user/collections', { userId: this.userId });
|
return await get('/user/collections', { userId: this.userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近游玩的故事
|
||||||
|
*/
|
||||||
|
async getRecentPlayed() {
|
||||||
|
if (!this.isLoggedIn) return [];
|
||||||
|
try {
|
||||||
|
return await get('/user/recent-played', { userId: this.userId, limit: 10 });
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取AI创作历史
|
||||||
|
*/
|
||||||
|
async getAIHistory() {
|
||||||
|
if (!this.isLoggedIn) return [];
|
||||||
|
try {
|
||||||
|
return await get('/user/ai-history', { userId: this.userId, limit: 20 });
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取AI配额
|
||||||
|
*/
|
||||||
|
async getAIQuota() {
|
||||||
|
if (!this.isLoggedIn) return { daily: 3, used: 0, purchased: 0 };
|
||||||
|
try {
|
||||||
|
return await get('/user/ai-quota', { userId: this.userId });
|
||||||
|
} catch (e) {
|
||||||
|
return { daily: 3, used: 0, purchased: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
794
client/js/scenes/AICreateScene.js
Normal file
794
client/js/scenes/AICreateScene.js
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
/**
|
||||||
|
* AI创作中心场景
|
||||||
|
*/
|
||||||
|
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.scrollY = 0;
|
||||||
|
this.maxScrollY = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastTouchY = 0;
|
||||||
|
this.hasMoved = false;
|
||||||
|
|
||||||
|
// 用户数据
|
||||||
|
this.recentStories = [];
|
||||||
|
this.aiHistory = [];
|
||||||
|
this.quota = { daily: 3, used: 0, purchased: 0 };
|
||||||
|
|
||||||
|
// 创作表单
|
||||||
|
this.createForm = {
|
||||||
|
genre: '',
|
||||||
|
keywords: '',
|
||||||
|
protagonist: '',
|
||||||
|
conflict: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选中的故事(用于改写/续写)
|
||||||
|
this.selectedStory = null;
|
||||||
|
|
||||||
|
// 快捷标签
|
||||||
|
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘'];
|
||||||
|
this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除'];
|
||||||
|
this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战'];
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
// 加载最近游玩的故事
|
||||||
|
this.recentStories = await this.main.userManager.getRecentPlayed() || [];
|
||||||
|
// 加载AI创作历史
|
||||||
|
this.aiHistory = await this.main.userManager.getAIHistory() || [];
|
||||||
|
// 加载配额
|
||||||
|
const quotaData = await this.main.userManager.getAIQuota();
|
||||||
|
if (quotaData) this.quota = quotaData;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载数据失败:', e);
|
||||||
|
}
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateMaxScroll() {
|
||||||
|
let contentHeight = 400;
|
||||||
|
if (this.currentTab === 0 || this.currentTab === 1) {
|
||||||
|
contentHeight = 300 + this.recentStories.length * 80;
|
||||||
|
} else {
|
||||||
|
contentHeight = 600;
|
||||||
|
}
|
||||||
|
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
this.renderBackground(ctx);
|
||||||
|
this.renderHeader(ctx);
|
||||||
|
this.renderQuotaBar(ctx);
|
||||||
|
this.renderTabs(ctx);
|
||||||
|
this.renderContent(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBackground(ctx) {
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||||
|
gradient.addColorStop(0, '#0f0c29');
|
||||||
|
gradient.addColorStop(0.5, '#302b63');
|
||||||
|
gradient.addColorStop(1, '#24243e');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 装饰光效
|
||||||
|
const glow = ctx.createRadialGradient(this.screenWidth / 2, 150, 0, this.screenWidth / 2, 150, 200);
|
||||||
|
glow.addColorStop(0, 'rgba(168, 85, 247, 0.15)');
|
||||||
|
glow.addColorStop(1, 'transparent');
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(ctx) {
|
||||||
|
// 顶部遮罩
|
||||||
|
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
|
||||||
|
headerGradient.addColorStop(0, 'rgba(15,12,41,1)');
|
||||||
|
headerGradient.addColorStop(1, 'rgba(15,12,41,0)');
|
||||||
|
ctx.fillStyle = headerGradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, 80);
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '16px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('‹ 返回', 15, 40);
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = 'bold 18px sans-serif';
|
||||||
|
const titleGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, 0, this.screenWidth / 2 + 50, 0);
|
||||||
|
titleGradient.addColorStop(0, '#a855f7');
|
||||||
|
titleGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = titleGradient;
|
||||||
|
ctx.fillText('✨ AI创作中心', this.screenWidth / 2, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderQuotaBar(ctx) {
|
||||||
|
const barY = 60;
|
||||||
|
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
|
||||||
|
|
||||||
|
// 配额背景
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
this.roundRect(ctx, 15, barY, this.screenWidth - 30, 36, 18);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 配额文字
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`今日剩余: ${remaining}次`, 30, barY + 23);
|
||||||
|
|
||||||
|
// 充值按钮
|
||||||
|
const btnWidth = 70;
|
||||||
|
const btnX = this.screenWidth - 30 - btnWidth;
|
||||||
|
const btnGradient = ctx.createLinearGradient(btnX, barY + 5, btnX + btnWidth, barY + 5);
|
||||||
|
btnGradient.addColorStop(0, '#a855f7');
|
||||||
|
btnGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = btnGradient;
|
||||||
|
this.roundRect(ctx, btnX, barY + 5, btnWidth, 26, 13);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 11px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('获取更多', btnX + btnWidth / 2, barY + 22);
|
||||||
|
|
||||||
|
this.quotaBtnRect = { x: btnX, y: barY + 5, width: btnWidth, height: 26 };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabs(ctx) {
|
||||||
|
const tabY = 110;
|
||||||
|
const tabWidth = (this.screenWidth - 40) / 3;
|
||||||
|
const padding = 15;
|
||||||
|
|
||||||
|
this.tabRects = [];
|
||||||
|
this.tabs.forEach((tab, index) => {
|
||||||
|
const x = padding + index * (tabWidth + 5);
|
||||||
|
const isActive = index === this.currentTab;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
const gradient = ctx.createLinearGradient(x, tabY, x + tabWidth, tabY);
|
||||||
|
gradient.addColorStop(0, '#a855f7');
|
||||||
|
gradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, x, tabY, tabWidth, 36, 18);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.font = isActive ? 'bold 13px sans-serif' : '13px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(tab, x + tabWidth / 2, tabY + 23);
|
||||||
|
|
||||||
|
this.tabRects.push({ x, y: tabY, width: tabWidth, height: 36, index });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContent(ctx) {
|
||||||
|
const contentY = 160;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, contentY, this.screenWidth, this.screenHeight - contentY);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
switch (this.currentTab) {
|
||||||
|
case 0:
|
||||||
|
this.renderRewriteTab(ctx, contentY);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
this.renderContinueTab(ctx, contentY);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
this.renderCreateTab(ctx, contentY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRewriteTab(ctx, startY) {
|
||||||
|
const y = startY - this.scrollY;
|
||||||
|
const padding = 15;
|
||||||
|
|
||||||
|
// 说明文字
|
||||||
|
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.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('热门改写方向:', padding, y + 60);
|
||||||
|
|
||||||
|
this.renderTags(ctx, this.rewriteTags, padding, y + 75, 'rewrite');
|
||||||
|
|
||||||
|
// 选择故事
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.fillText('选择要改写的故事:', padding, y + 145);
|
||||||
|
|
||||||
|
this.renderStoryList(ctx, y + 160, 'rewrite');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContinueTab(ctx, startY) {
|
||||||
|
const y = startY - this.scrollY;
|
||||||
|
const padding = 15;
|
||||||
|
|
||||||
|
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.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('续写方向:', padding, y + 60);
|
||||||
|
|
||||||
|
this.renderTags(ctx, this.continueTags, padding, y + 75, 'continue');
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.fillText('选择要续写的故事:', padding, y + 145);
|
||||||
|
|
||||||
|
this.renderStoryList(ctx, y + 160, 'continue');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCreateTab(ctx, startY) {
|
||||||
|
const y = startY - this.scrollY;
|
||||||
|
const padding = 15;
|
||||||
|
const inputWidth = this.screenWidth - padding * 2;
|
||||||
|
|
||||||
|
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.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('选择题材:', padding, y + 60);
|
||||||
|
|
||||||
|
this.renderTags(ctx, this.genreTags, padding, y + 75, 'genre');
|
||||||
|
|
||||||
|
// 关键词输入
|
||||||
|
ctx.fillText('故事关键词:', padding, y + 145);
|
||||||
|
this.renderInputBox(ctx, padding, y + 160, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords');
|
||||||
|
|
||||||
|
// 主角设定
|
||||||
|
ctx.fillText('主角设定:', padding, y + 225);
|
||||||
|
this.renderInputBox(ctx, padding, y + 240, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist');
|
||||||
|
|
||||||
|
// 核心冲突
|
||||||
|
ctx.fillText('核心冲突:', padding, y + 305);
|
||||||
|
this.renderInputBox(ctx, padding, y + 320, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict');
|
||||||
|
|
||||||
|
// 开始创作按钮
|
||||||
|
const btnY = y + 400;
|
||||||
|
const btnGradient = ctx.createLinearGradient(padding, btnY, this.screenWidth - padding, btnY);
|
||||||
|
btnGradient.addColorStop(0, '#a855f7');
|
||||||
|
btnGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = btnGradient;
|
||||||
|
this.roundRect(ctx, padding, btnY, inputWidth, 50, 25);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 16px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('✨ 开始AI创作', this.screenWidth / 2, btnY + 32);
|
||||||
|
|
||||||
|
this.createBtnRect = { x: padding, y: btnY + this.scrollY, width: inputWidth, height: 50 };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTags(ctx, tags, startX, startY, type) {
|
||||||
|
const tagHeight = 30;
|
||||||
|
const tagGap = 8;
|
||||||
|
let currentX = startX;
|
||||||
|
let currentY = startY;
|
||||||
|
|
||||||
|
if (!this.tagRects) this.tagRects = {};
|
||||||
|
this.tagRects[type] = [];
|
||||||
|
|
||||||
|
tags.forEach((tag, index) => {
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
const tagWidth = ctx.measureText(tag).width + 20;
|
||||||
|
|
||||||
|
if (currentX + tagWidth > this.screenWidth - 15) {
|
||||||
|
currentX = startX;
|
||||||
|
currentY += tagHeight + tagGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = (type === 'genre' && this.createForm.genre === tag) ||
|
||||||
|
(type === 'rewrite' && this.selectedRewriteTag === index) ||
|
||||||
|
(type === 'continue' && this.selectedContinueTag === index);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
const gradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
|
||||||
|
gradient.addColorStop(0, '#a855f7');
|
||||||
|
gradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 15);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
if (!isSelected) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 15);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 20);
|
||||||
|
|
||||||
|
this.tagRects[type].push({
|
||||||
|
x: currentX,
|
||||||
|
y: currentY + this.scrollY,
|
||||||
|
width: tagWidth,
|
||||||
|
height: tagHeight,
|
||||||
|
index,
|
||||||
|
value: tag
|
||||||
|
});
|
||||||
|
|
||||||
|
currentX += tagWidth + tagGap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInputBox(ctx, x, y, width, height, placeholder, field) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
this.roundRect(ctx, x, y, width, height, 12);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, x, y, width, height, 12);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
if (this.createForm[field]) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText(this.createForm[field], x + 15, y + height / 2 + 5);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.fillText(placeholder, x + 15, y + height / 2 + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.inputRects) this.inputRects = {};
|
||||||
|
this.inputRects[field] = { x, y: y + this.scrollY, width, height, field };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStoryList(ctx, startY, type) {
|
||||||
|
const padding = 15;
|
||||||
|
const cardHeight = 70;
|
||||||
|
const cardGap = 10;
|
||||||
|
|
||||||
|
if (!this.storyRects) this.storyRects = {};
|
||||||
|
this.storyRects[type] = [];
|
||||||
|
|
||||||
|
if (this.recentStories.length === 0) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('暂无游玩记录,去首页体验故事吧', this.screenWidth / 2, startY + 40);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentStories.forEach((story, index) => {
|
||||||
|
const y = startY + index * (cardHeight + cardGap);
|
||||||
|
const isSelected = this.selectedStory?.id === story.id;
|
||||||
|
|
||||||
|
// 卡片背景
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.fillStyle = 'rgba(168, 85, 247, 0.2)';
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.strokeStyle = '#a855f7';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面占位
|
||||||
|
const coverSize = 50;
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
this.roundRect(ctx, padding + 10, y + 10, coverSize, coverSize, 8);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 故事标题
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
const title = story.title?.length > 12 ? story.title.substring(0, 12) + '...' : story.title;
|
||||||
|
ctx.fillText(title || '未知故事', padding + 70, y + 28);
|
||||||
|
|
||||||
|
// 分类和进度
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.fillText(`${story.category || '未分类'} · ${story.progress || '进行中'}`, padding + 70, y + 50);
|
||||||
|
|
||||||
|
// 选择按钮
|
||||||
|
const btnX = this.screenWidth - padding - 60;
|
||||||
|
ctx.fillStyle = isSelected ? '#a855f7' : 'rgba(255,255,255,0.2)';
|
||||||
|
this.roundRect(ctx, btnX, y + 20, 50, 30, 15);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(isSelected ? '已选' : '选择', btnX + 25, y + 40);
|
||||||
|
|
||||||
|
this.storyRects[type].push({
|
||||||
|
x: padding,
|
||||||
|
y: y + this.scrollY,
|
||||||
|
width: this.screenWidth - padding * 2,
|
||||||
|
height: cardHeight,
|
||||||
|
story
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始按钮
|
||||||
|
if (this.selectedStory) {
|
||||||
|
const btnY = startY + this.recentStories.length * (cardHeight + cardGap) + 20;
|
||||||
|
const btnGradient = ctx.createLinearGradient(padding, btnY, this.screenWidth - padding, btnY);
|
||||||
|
btnGradient.addColorStop(0, '#a855f7');
|
||||||
|
btnGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = btnGradient;
|
||||||
|
this.roundRect(ctx, padding, btnY, this.screenWidth - padding * 2, 48, 24);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 15px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const btnText = type === 'rewrite' ? '✨ 开始AI改写' : '✨ 开始AI续写';
|
||||||
|
ctx.fillText(btnText, this.screenWidth / 2, btnY + 30);
|
||||||
|
|
||||||
|
this.actionBtnRect = {
|
||||||
|
x: padding,
|
||||||
|
y: btnY + this.scrollY,
|
||||||
|
width: this.screenWidth - padding * 2,
|
||||||
|
height: 48,
|
||||||
|
type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
this.touchStartY = touch.clientY;
|
||||||
|
this.hasMoved = false;
|
||||||
|
if (touch.clientY > 160) {
|
||||||
|
this.isDragging = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaY = this.lastTouchY - touch.clientY;
|
||||||
|
if (Math.abs(deltaY) > 3) {
|
||||||
|
this.hasMoved = true;
|
||||||
|
}
|
||||||
|
this.scrollY += deltaY;
|
||||||
|
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
this.isDragging = false;
|
||||||
|
if (this.hasMoved) return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
if (y < 60 && x < 80) {
|
||||||
|
this.main.sceneManager.switchScene('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配额按钮
|
||||||
|
if (this.quotaBtnRect && this.isInRect(x, y, this.quotaBtnRect)) {
|
||||||
|
this.showQuotaModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab切换
|
||||||
|
if (this.tabRects) {
|
||||||
|
for (const tab of this.tabRects) {
|
||||||
|
if (this.isInRect(x, y, tab)) {
|
||||||
|
if (this.currentTab !== tab.index) {
|
||||||
|
this.currentTab = tab.index;
|
||||||
|
this.scrollY = 0;
|
||||||
|
this.selectedStory = null;
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整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 (tags) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (this.isInRect(x, scrolledY, tag)) {
|
||||||
|
this.handleTagSelect(tagType, tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框点击(创作Tab)
|
||||||
|
if (this.currentTab === 2 && this.inputRects) {
|
||||||
|
for (const key in this.inputRects) {
|
||||||
|
const rect = this.inputRects[key];
|
||||||
|
if (this.isInRect(x, scrolledY, rect)) {
|
||||||
|
this.showInputModal(rect.field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 故事列表点击
|
||||||
|
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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInRect(x, y, rect) {
|
||||||
|
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showInputModal(field) {
|
||||||
|
const titles = {
|
||||||
|
keywords: '输入故事关键词',
|
||||||
|
protagonist: '输入主角设定',
|
||||||
|
conflict: '输入核心冲突'
|
||||||
|
};
|
||||||
|
wx.showModal({
|
||||||
|
title: titles[field],
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入...',
|
||||||
|
content: this.createForm[field] || '',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm && res.content) {
|
||||||
|
this.createForm[field] = res.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showQuotaModal() {
|
||||||
|
wx.showModal({
|
||||||
|
title: 'AI创作次数',
|
||||||
|
content: `今日剩余${this.quota.daily - this.quota.used}次\n购买次数${this.quota.purchased}次\n\n观看广告可获得1次`,
|
||||||
|
confirmText: '看广告',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
this.watchAdForQuota();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watchAdForQuota() {
|
||||||
|
wx.showToast({ title: '获得1次AI次数', icon: 'success' });
|
||||||
|
this.quota.purchased += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAction(type) {
|
||||||
|
if (!this.selectedStory) {
|
||||||
|
wx.showToast({ title: '请先选择故事', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
this.showQuotaModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'rewrite') {
|
||||||
|
this.startRewrite();
|
||||||
|
} else {
|
||||||
|
this.startContinue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startRewrite() {
|
||||||
|
const tag = this.selectedRewriteTag !== undefined ? this.rewriteTags[this.selectedRewriteTag] : '';
|
||||||
|
|
||||||
|
wx.showModal({
|
||||||
|
title: 'AI改写',
|
||||||
|
content: '确定要改写这个故事的结局吗?',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: tag || '输入改写方向(可选)',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.showLoading({ title: 'AI创作中...' });
|
||||||
|
try {
|
||||||
|
const result = await this.main.storyManager.rewriteEnding(
|
||||||
|
this.selectedStory.id,
|
||||||
|
{ name: '当前结局', content: '' },
|
||||||
|
res.content || tag || '改写结局'
|
||||||
|
);
|
||||||
|
wx.hideLoading();
|
||||||
|
if (result) {
|
||||||
|
this.quota.used += 1;
|
||||||
|
this.main.sceneManager.switchScene('story', {
|
||||||
|
storyId: this.selectedStory.id,
|
||||||
|
aiContent: result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showToast({ title: '创作失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startContinue() {
|
||||||
|
const tag = this.selectedContinueTag !== undefined ? this.continueTags[this.selectedContinueTag] : '';
|
||||||
|
|
||||||
|
wx.showModal({
|
||||||
|
title: 'AI续写',
|
||||||
|
content: '确定要让AI续写这个故事吗?',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: tag || '输入续写方向(可选)',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.showLoading({ title: 'AI创作中...' });
|
||||||
|
try {
|
||||||
|
// TODO: 实现续写API
|
||||||
|
const result = await this.main.storyManager.continueStory(
|
||||||
|
this.selectedStory.id,
|
||||||
|
res.content || tag || '续写剧情'
|
||||||
|
);
|
||||||
|
wx.hideLoading();
|
||||||
|
if (result) {
|
||||||
|
this.quota.used += 1;
|
||||||
|
this.main.sceneManager.switchScene('story', {
|
||||||
|
storyId: this.selectedStory.id,
|
||||||
|
aiContent: result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showToast({ title: '创作失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCreate() {
|
||||||
|
if (!this.createForm.genre) {
|
||||||
|
wx.showToast({ title: '请选择题材', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.createForm.keywords) {
|
||||||
|
wx.showToast({ title: '请输入关键词', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
|
||||||
|
if (remaining < 5) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '次数不足',
|
||||||
|
content: 'AI创作需要5次配额,当前剩余' + remaining + '次',
|
||||||
|
confirmText: '获取更多',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) this.showQuotaModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.showModal({
|
||||||
|
title: '确认创作',
|
||||||
|
content: `题材:${this.createForm.genre}\n关键词:${this.createForm.keywords}\n\n将消耗5次AI次数`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.showLoading({ title: 'AI创作中...', mask: true });
|
||||||
|
try {
|
||||||
|
// TODO: 实现完整创作API
|
||||||
|
const result = await this.main.storyManager.createStory(this.createForm);
|
||||||
|
wx.hideLoading();
|
||||||
|
if (result) {
|
||||||
|
this.quota.used += 5;
|
||||||
|
wx.showToast({ title: '创作成功!', icon: 'success' });
|
||||||
|
// 跳转到新故事
|
||||||
|
setTimeout(() => {
|
||||||
|
this.main.sceneManager.switchScene('story', { storyId: result.storyId });
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showToast({ title: '创作失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,24 @@ export default class HomeScene extends BaseScene {
|
|||||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
ctx.font = '13px sans-serif';
|
ctx.font = '13px sans-serif';
|
||||||
ctx.fillText('每个选择,都是一个新世界', 20, 75);
|
ctx.fillText('每个选择,都是一个新世界', 20, 75);
|
||||||
|
|
||||||
|
// AI创作入口按钮
|
||||||
|
const btnWidth = 80;
|
||||||
|
const btnHeight = 32;
|
||||||
|
const btnX = this.screenWidth - btnWidth - 15;
|
||||||
|
const btnY = 35;
|
||||||
|
const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
|
||||||
|
btnGradient.addColorStop(0, '#a855f7');
|
||||||
|
btnGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = btnGradient;
|
||||||
|
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 16);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('✨ AI创作', btnX + btnWidth / 2, btnY + 21);
|
||||||
|
|
||||||
|
this.aiCreateBtnRect = { x: btnX, y: btnY, width: btnWidth, height: btnHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCategories(ctx) {
|
renderCategories(ctx) {
|
||||||
@@ -470,6 +488,15 @@ export default class HomeScene extends BaseScene {
|
|||||||
const x = touch.clientX;
|
const x = touch.clientX;
|
||||||
const y = touch.clientY;
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
// 检测AI创作按钮点击
|
||||||
|
if (this.aiCreateBtnRect) {
|
||||||
|
const btn = this.aiCreateBtnRect;
|
||||||
|
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) {
|
||||||
|
this.main.sceneManager.switchScene('aiCreate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检测Tab栏点击
|
// 检测Tab栏点击
|
||||||
if (y > this.screenHeight - 65) {
|
if (y > this.screenHeight - 65) {
|
||||||
const tabWidth = this.screenWidth / 3;
|
const tabWidth = this.screenWidth / 3;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import StoryScene from './StoryScene';
|
|||||||
import EndingScene from './EndingScene';
|
import EndingScene from './EndingScene';
|
||||||
import ProfileScene from './ProfileScene';
|
import ProfileScene from './ProfileScene';
|
||||||
import ChapterScene from './ChapterScene';
|
import ChapterScene from './ChapterScene';
|
||||||
|
import AICreateScene from './AICreateScene';
|
||||||
|
|
||||||
export default class SceneManager {
|
export default class SceneManager {
|
||||||
constructor(main) {
|
constructor(main) {
|
||||||
@@ -16,7 +17,8 @@ export default class SceneManager {
|
|||||||
story: StoryScene,
|
story: StoryScene,
|
||||||
ending: EndingScene,
|
ending: EndingScene,
|
||||||
profile: ProfileScene,
|
profile: ProfileScene,
|
||||||
chapter: ChapterScene
|
chapter: ChapterScene,
|
||||||
|
aiCreate: AICreateScene
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
396
docs/AI创作系统设计.md
Normal file
396
docs/AI创作系统设计.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
# AI创作系统设计文档
|
||||||
|
|
||||||
|
## 一、功能矩阵
|
||||||
|
|
||||||
|
| 功能 | 入口 | 输入 | 输出 | 配额消耗 |
|
||||||
|
|-----|------|------|------|---------|
|
||||||
|
| AI改写结局 | 结局页 | 原结局+用户指令 | 新结局文本 | 1次 |
|
||||||
|
| AI改写节点 | 章节选择页 | 原节点+用户指令 | 新节点+选项 | 1次 |
|
||||||
|
| AI续写 | 故事播放页 | 当前节点+用户指令 | 后续2-3个节点 | 2次 |
|
||||||
|
| AI创作大纲 | 创作中心 | 题材/关键词 | 标题+简介+大纲 | 1次 |
|
||||||
|
| AI完整创作 | 创作中心 | 大纲确认 | 完整故事节点树 | 5次 |
|
||||||
|
| AI润色 | 编辑器 | 原文本 | 优化后文本 | 1次 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AI创作完整流程 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
用户触发 ──► 配额检查 ──► 构建Prompt ──► 调用AI ──► 解析响应 ──► 存储记录
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ ▼
|
||||||
|
│ 配额不足? [ai_generations] 解析失败?
|
||||||
|
│ │ 记录调用 │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ 引导充值/看广告 重试/降级
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
展示结果 ◄── 用户操作 ──► 采纳? ──► 写入故事表 ──► 进入审核流程
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
放弃/编辑
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Prompt模板设计
|
||||||
|
|
||||||
|
### 3.1 改写结局
|
||||||
|
```
|
||||||
|
[系统提示]
|
||||||
|
你是一个互动故事创作专家。根据用户的改写指令,重新创作故事结局。
|
||||||
|
要求:
|
||||||
|
- 保持原故事的世界观和人物性格
|
||||||
|
- 结局要有张力和情感冲击
|
||||||
|
- 字数控制在200-400字
|
||||||
|
- 输出格式:纯文本
|
||||||
|
|
||||||
|
[用户提示]
|
||||||
|
故事标题:{title}
|
||||||
|
故事分类:{category}
|
||||||
|
原结局名称:{ending_name}
|
||||||
|
原结局内容:{ending_content}
|
||||||
|
---
|
||||||
|
用户改写指令:{user_prompt}
|
||||||
|
---
|
||||||
|
请创作新的结局:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 续写剧情
|
||||||
|
```
|
||||||
|
[系统提示]
|
||||||
|
你是一个互动故事创作专家。根据当前剧情,续写后续发展。
|
||||||
|
要求:
|
||||||
|
- 提供2-3个剧情走向选项
|
||||||
|
- 每个选项后续写1个节点内容
|
||||||
|
- 保持悬念和代入感
|
||||||
|
- 输出JSON格式
|
||||||
|
|
||||||
|
[用户提示]
|
||||||
|
故事标题:{title}
|
||||||
|
当前剧情:{current_content}
|
||||||
|
已做选择:{choices_history}
|
||||||
|
---
|
||||||
|
用户期望:{user_prompt}
|
||||||
|
---
|
||||||
|
请续写剧情,输出格式:
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"text": "选项1文本", "content": "选择后的剧情内容", "speaker": "角色名"},
|
||||||
|
{"text": "选项2文本", "content": "选择后的剧情内容", "speaker": "角色名"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 完整创作
|
||||||
|
```
|
||||||
|
[系统提示]
|
||||||
|
你是一个互动故事创作专家。根据用户提供的关键词,创作一个完整的互动故事。
|
||||||
|
要求:
|
||||||
|
- 故事有3-5个关键分支点
|
||||||
|
- 至少2个不同结局(好结局/坏结局)
|
||||||
|
- 每个节点100-200字
|
||||||
|
- 输出完整的节点树JSON
|
||||||
|
|
||||||
|
[用户提示]
|
||||||
|
题材:{genre}
|
||||||
|
关键词:{keywords}
|
||||||
|
主角设定:{protagonist}
|
||||||
|
核心冲突:{conflict}
|
||||||
|
---
|
||||||
|
请创作完整故事,输出格式:
|
||||||
|
{
|
||||||
|
"title": "故事标题",
|
||||||
|
"description": "故事简介",
|
||||||
|
"nodes": {
|
||||||
|
"start": {"content": "开头内容", "speaker": "", "choices": [...]},
|
||||||
|
"node_1": {...},
|
||||||
|
"ending_good": {"content": "好结局", "is_ending": true, "ending_type": "good"},
|
||||||
|
"ending_bad": {"content": "坏结局", "is_ending": true, "ending_type": "bad"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、API设计
|
||||||
|
|
||||||
|
### 4.1 AI改写结局
|
||||||
|
```
|
||||||
|
POST /api/ai/rewrite-ending
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"story_id": 123,
|
||||||
|
"ending_name": "双向奔赴",
|
||||||
|
"ending_content": "原结局内容...",
|
||||||
|
"prompt": "让主角逆袭"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"generation_id": 456,
|
||||||
|
"content": "新结局内容...",
|
||||||
|
"ending_name": "双向奔赴(改写版)",
|
||||||
|
"tokens_used": 580,
|
||||||
|
"quota_remaining": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 AI续写
|
||||||
|
```
|
||||||
|
POST /api/ai/continue
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"story_id": 123,
|
||||||
|
"current_node_key": "node_5",
|
||||||
|
"choices_history": ["选项A", "选项B"],
|
||||||
|
"prompt": "希望有反转"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"generation_id": 457,
|
||||||
|
"choices": [
|
||||||
|
{"text": "追上去", "content": "你快步追上...", "next_key": "ai_node_1"},
|
||||||
|
{"text": "放手离开", "content": "你转身离去...", "next_key": "ai_node_2"}
|
||||||
|
],
|
||||||
|
"nodes": {
|
||||||
|
"ai_node_1": {"content": "...", "choices": [...]},
|
||||||
|
"ai_node_2": {"content": "...", "is_ending": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 AI完整创作
|
||||||
|
```
|
||||||
|
POST /api/ai/create
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"genre": "都市言情",
|
||||||
|
"keywords": "霸总,契约婚姻,追妻火葬场",
|
||||||
|
"protagonist": "独立女性设计师",
|
||||||
|
"conflict": "假结婚变真爱"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"generation_id": 458,
|
||||||
|
"draft_story_id": 789,
|
||||||
|
"title": "契约总裁的心动法则",
|
||||||
|
"description": "...",
|
||||||
|
"node_count": 12,
|
||||||
|
"ending_count": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 配额查询
|
||||||
|
```
|
||||||
|
GET /api/user/ai-quota
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"daily_free_remaining": 3,
|
||||||
|
"purchased_remaining": 10,
|
||||||
|
"vip_bonus": 5,
|
||||||
|
"total_available": 18,
|
||||||
|
"reset_time": "2026-03-04 00:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、配额与计费
|
||||||
|
|
||||||
|
### 5.1 配额规则
|
||||||
|
| 用户类型 | 每日免费 | 购买包 | 说明 |
|
||||||
|
|---------|---------|-------|------|
|
||||||
|
| 普通用户 | 3次 | 10次/6元 | 看广告+1次 |
|
||||||
|
| 月卡VIP | 10次 | 同上 | 月费18元 |
|
||||||
|
| 年卡VIP | 20次 | 同上 | 年费168元 |
|
||||||
|
|
||||||
|
### 5.2 消耗逻辑
|
||||||
|
```javascript
|
||||||
|
// 扣费优先级:每日免费 > 赠送 > 购买 > VIP额外
|
||||||
|
async function consumeQuota(userId, amount = 1) {
|
||||||
|
const quota = await UserAIQuota.findByPk(userId);
|
||||||
|
|
||||||
|
// 检查是否需要重置每日配额
|
||||||
|
if (quota.daily_reset_date !== today) {
|
||||||
|
quota.daily_free_used = 0;
|
||||||
|
quota.daily_reset_date = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算可用配额
|
||||||
|
const dailyFreeRemain = quota.daily_free_total - quota.daily_free_used;
|
||||||
|
const available = dailyFreeRemain + quota.gift_quota + quota.purchased_quota + quota.vip_daily_bonus;
|
||||||
|
|
||||||
|
if (available < amount) {
|
||||||
|
throw new Error('QUOTA_INSUFFICIENT');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级扣除
|
||||||
|
let toConsume = amount;
|
||||||
|
if (dailyFreeRemain > 0) {
|
||||||
|
const use = Math.min(toConsume, dailyFreeRemain);
|
||||||
|
quota.daily_free_used += use;
|
||||||
|
toConsume -= use;
|
||||||
|
}
|
||||||
|
// ... 依次扣除其他配额
|
||||||
|
|
||||||
|
await quota.save();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、审核流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户发布 ──► 机器审核 ──► 通过?──► 直接上架
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
疑似违规 人工审核 ──► 通过/拒绝
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
进入人工队列
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 机器审核维度
|
||||||
|
- 敏感词检测(sensitive_words表)
|
||||||
|
- 内容安全API(腾讯云/阿里云)
|
||||||
|
- AI生成内容标记检测
|
||||||
|
|
||||||
|
### 6.2 审核状态流转
|
||||||
|
```
|
||||||
|
草稿(0) ──► 提交审核 ──► 审核中(1) ──► 已发布(2)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
已拒绝(4) ──► 修改后重新提交
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、数据统计
|
||||||
|
|
||||||
|
### 7.1 核心指标
|
||||||
|
- AI调用成功率
|
||||||
|
- 平均响应时间
|
||||||
|
- 用户采纳率
|
||||||
|
- 生成内容发布率
|
||||||
|
- Token消耗/成本
|
||||||
|
|
||||||
|
### 7.2 日报表聚合
|
||||||
|
```sql
|
||||||
|
-- 每日定时任务聚合到 ai_daily_stats
|
||||||
|
INSERT INTO ai_daily_stats (stat_date, gen_type, model_name, call_count, ...)
|
||||||
|
SELECT
|
||||||
|
DATE(created_at),
|
||||||
|
gen_type,
|
||||||
|
model_name,
|
||||||
|
COUNT(*),
|
||||||
|
SUM(CASE WHEN status=1 THEN 1 ELSE 0 END),
|
||||||
|
SUM(input_tokens),
|
||||||
|
SUM(output_tokens),
|
||||||
|
AVG(latency_ms)
|
||||||
|
FROM ai_generations
|
||||||
|
WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY
|
||||||
|
GROUP BY DATE(created_at), gen_type, model_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、技术实现要点
|
||||||
|
|
||||||
|
### 8.1 服务端架构
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ API Gateway │
|
||||||
|
└──────────────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ 故事服务 │ │ AI服务 │ │ 用户服务 │
|
||||||
|
└─────────┘ └────┬────┘ └─────────┘
|
||||||
|
│
|
||||||
|
┌────────────┼────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ OpenAI │ │ Claude │ │ 本地模型 │
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 AI服务封装
|
||||||
|
```javascript
|
||||||
|
// services/ai.js
|
||||||
|
class AIService {
|
||||||
|
constructor() {
|
||||||
|
this.providers = {
|
||||||
|
openai: new OpenAIProvider(),
|
||||||
|
claude: new ClaudeProvider(),
|
||||||
|
local: new LocalProvider()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(options) {
|
||||||
|
const { type, provider = 'openai', ...params } = options;
|
||||||
|
const template = await this.getPromptTemplate(type);
|
||||||
|
const prompt = this.buildPrompt(template, params);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await this.providers[provider].chat(prompt);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: result.content,
|
||||||
|
tokens: result.usage,
|
||||||
|
latency: Date.now() - startTime
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 前端交互优化
|
||||||
|
- 流式输出:使用SSE实时展示生成过程
|
||||||
|
- 骨架屏:生成中显示打字动画
|
||||||
|
- 失败重试:自动重试2次,超时30秒
|
||||||
|
- 结果缓存:相同输入5分钟内复用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、后续迭代
|
||||||
|
|
||||||
|
### Phase 1(当前)
|
||||||
|
- [x] AI改写结局
|
||||||
|
- [ ] 配额系统接入
|
||||||
|
- [ ] 基础审核流程
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
- [ ] AI续写功能
|
||||||
|
- [ ] AI创作大纲
|
||||||
|
- [ ] 创作中心入口
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
- [ ] AI完整创作
|
||||||
|
- [ ] UGC发布流程
|
||||||
|
- [ ] 创作者认证
|
||||||
|
- [ ] 收益分成
|
||||||
Reference in New Issue
Block a user