Compare commits
11 Commits
d47ccd7039
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b941cc4e0 | ||
|
|
aa23db8a89 | ||
|
|
baf7dd1e2b | ||
| b973c8bdbb | |||
| 9948ccba8f | |||
| 5e931424ab | |||
| c960f9fa79 | |||
|
|
18db6a8cc6 | ||
|
|
bbdccfa843 | ||
| 66d4bd60c1 | |||
| 6416441539 |
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 故事数据管理器
|
||||
*/
|
||||
import { get, post } from '../utils/http';
|
||||
import { get, post, request } from '../utils/http';
|
||||
|
||||
export default class StoryManager {
|
||||
constructor() {
|
||||
@@ -9,6 +9,7 @@ export default class StoryManager {
|
||||
this.currentStory = null;
|
||||
this.currentNodeKey = 'start';
|
||||
this.categories = [];
|
||||
this.pathHistory = []; // 记录用户走过的路径
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,7 @@ export default class StoryManager {
|
||||
try {
|
||||
this.currentStory = await get(`/stories/${storyId}`);
|
||||
this.currentNodeKey = 'start';
|
||||
this.pathHistory = []; // 重置路径历史
|
||||
|
||||
// 记录游玩次数
|
||||
await post(`/stories/${storyId}/play`);
|
||||
@@ -85,6 +87,14 @@ export default class StoryManager {
|
||||
}
|
||||
|
||||
const choice = currentNode.choices[choiceIndex];
|
||||
|
||||
// 记录路径历史
|
||||
this.pathHistory.push({
|
||||
nodeKey: this.currentNodeKey,
|
||||
content: currentNode.content,
|
||||
choice: choice.text
|
||||
});
|
||||
|
||||
this.currentNodeKey = choice.nextNodeKey;
|
||||
|
||||
return this.getCurrentNode();
|
||||
@@ -118,6 +128,7 @@ export default class StoryManager {
|
||||
*/
|
||||
resetStory() {
|
||||
this.currentNodeKey = 'start';
|
||||
this.pathHistory = []; // 清空路径历史
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,6 +156,174 @@ export default class StoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI改写结局,异步提交到草稿箱
|
||||
*/
|
||||
async rewriteEndingAsync(storyId, ending, prompt, userId) {
|
||||
try {
|
||||
// 先标记之前的未读草稿为已读
|
||||
await this.markAllDraftsRead(userId);
|
||||
|
||||
console.log('[rewriteEndingAsync] pathHistory:', JSON.stringify(this.pathHistory));
|
||||
|
||||
const result = await post(`/drafts/ending`, {
|
||||
userId: userId,
|
||||
storyId: storyId,
|
||||
endingName: ending?.name || '未知结局',
|
||||
endingContent: ending?.content || '',
|
||||
prompt: prompt,
|
||||
pathHistory: this.pathHistory || [] // 传递游玩路径
|
||||
}, { timeout: 30000 });
|
||||
|
||||
if (result && result.draftId) {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('AI改写结局提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI续写结局,异步提交到草稿箱
|
||||
*/
|
||||
async continueEndingAsync(storyId, ending, prompt, userId) {
|
||||
try {
|
||||
// 先标记之前的未读草稿为已读
|
||||
await this.markAllDraftsRead(userId);
|
||||
|
||||
const result = await post(`/drafts/continue-ending`, {
|
||||
userId: userId,
|
||||
storyId: storyId,
|
||||
endingName: ending?.name || '未知结局',
|
||||
endingContent: ending?.content || '',
|
||||
prompt: prompt,
|
||||
pathHistory: this.pathHistory || [] // 传递游玩路径
|
||||
}, { timeout: 30000 });
|
||||
|
||||
if (result && result.draftId) {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('AI续写结局提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI改写中间章节,异步提交到草稿箱
|
||||
* @returns {Object|null} 成功返回草稿ID,失败返回 null
|
||||
*/
|
||||
async rewriteBranchAsync(storyId, prompt, userId) {
|
||||
try {
|
||||
// 先标记之前的未读草稿为已读,避免轮询弹出之前的通知
|
||||
await this.markAllDraftsRead(userId);
|
||||
|
||||
const currentNode = this.getCurrentNode();
|
||||
const result = await post(`/drafts`, {
|
||||
userId: userId,
|
||||
storyId: storyId,
|
||||
currentNodeKey: this.currentNodeKey,
|
||||
pathHistory: this.pathHistory,
|
||||
currentContent: currentNode?.content || '',
|
||||
prompt: prompt
|
||||
}, { timeout: 30000 });
|
||||
|
||||
if (result && result.draftId) {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('AI改写提交失败:', error?.errMsg || error?.message || JSON.stringify(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户草稿列表
|
||||
*/
|
||||
async getDrafts(userId) {
|
||||
try {
|
||||
const result = await get(`/drafts?userId=${userId}`);
|
||||
return result || [];
|
||||
} catch (error) {
|
||||
console.error('获取草稿列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有新完成的草稿
|
||||
*/
|
||||
async checkNewDrafts(userId) {
|
||||
try {
|
||||
const result = await get(`/drafts/check-new?userId=${userId}`);
|
||||
return result || { hasNew: false, count: 0, drafts: [] };
|
||||
} catch (error) {
|
||||
console.error('检查新草稿失败:', error);
|
||||
return { hasNew: false, count: 0, drafts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量标记所有未读草稿为已读
|
||||
*/
|
||||
async markAllDraftsRead(userId) {
|
||||
try {
|
||||
await request({ url: `/drafts/batch-read?userId=${userId}`, method: 'PUT' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('批量标记已读失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿详情
|
||||
*/
|
||||
async getDraftDetail(draftId) {
|
||||
try {
|
||||
const result = await get(`/drafts/${draftId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('获取草稿详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除草稿
|
||||
*/
|
||||
async deleteDraft(draftId, userId) {
|
||||
try {
|
||||
const result = await request({
|
||||
url: `/drafts/${draftId}?userId=${userId}`,
|
||||
method: 'DELETE'
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除草稿失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从草稿加载并播放 AI 生成的内容
|
||||
*/
|
||||
loadDraftContent(draft) {
|
||||
if (!draft || !draft.aiNodes) return null;
|
||||
|
||||
// 将 AI 生成的节点合并到当前故事
|
||||
if (this.currentStory) {
|
||||
Object.assign(this.currentStory.nodes, draft.aiNodes);
|
||||
this.currentNodeKey = draft.entryNodeKey || 'branch_1';
|
||||
return this.getCurrentNode();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI续写故事
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 用户数据管理器
|
||||
*/
|
||||
import { get, post } from '../utils/http';
|
||||
import { get, post, del } from '../utils/http';
|
||||
|
||||
export default class UserManager {
|
||||
constructor() {
|
||||
@@ -191,4 +191,66 @@ export default class UserManager {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 游玩记录相关 ==========
|
||||
|
||||
/**
|
||||
* 保存游玩记录
|
||||
*/
|
||||
async savePlayRecord(storyId, endingName, endingType, pathHistory) {
|
||||
if (!this.isLoggedIn) return null;
|
||||
try {
|
||||
return await post('/user/play-record', {
|
||||
userId: this.userId,
|
||||
storyId,
|
||||
endingName,
|
||||
endingType: endingType || '',
|
||||
pathHistory: pathHistory || []
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('保存游玩记录失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游玩记录列表
|
||||
* @param {number} storyId - 可选,指定故事ID获取该故事的所有记录
|
||||
*/
|
||||
async getPlayRecords(storyId = null) {
|
||||
if (!this.isLoggedIn) return [];
|
||||
try {
|
||||
const params = { userId: this.userId };
|
||||
if (storyId) params.storyId = storyId;
|
||||
return await get('/user/play-records', params);
|
||||
} catch (e) {
|
||||
console.error('获取游玩记录失败:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条记录详情
|
||||
*/
|
||||
async getPlayRecordDetail(recordId) {
|
||||
if (!this.isLoggedIn) return null;
|
||||
try {
|
||||
return await get(`/user/play-records/${recordId}`);
|
||||
} catch (e) {
|
||||
console.error('获取记录详情失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除游玩记录
|
||||
async deletePlayRecord(recordId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
await del(`/user/play-records/${recordId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('删除记录失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ export default class Main {
|
||||
|
||||
// 设置分享
|
||||
this.setupShare();
|
||||
|
||||
// 启动草稿检查(仅登录用户)
|
||||
if (this.userManager.isLoggedIn) {
|
||||
this.startDraftChecker();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Main] 初始化失败:', error);
|
||||
this.hideLoading();
|
||||
@@ -111,6 +116,67 @@ export default class Main {
|
||||
});
|
||||
}
|
||||
|
||||
// 启动草稿检查定时器
|
||||
startDraftChecker() {
|
||||
// 避免重复启动
|
||||
if (this.draftCheckTimer) return;
|
||||
|
||||
console.log('[Main] 启动草稿检查定时器');
|
||||
|
||||
// 每30秒检查一次
|
||||
this.draftCheckTimer = setInterval(async () => {
|
||||
try {
|
||||
if (!this.userManager.isLoggedIn) return;
|
||||
|
||||
// 如果结局页正在轮询,跳过全局检查
|
||||
const currentScene = this.sceneManager.currentScene;
|
||||
if (currentScene && currentScene.draftPollTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.storyManager.checkNewDrafts(this.userManager.userId);
|
||||
|
||||
if (result && result.hasNew && result.count > 0) {
|
||||
console.log('[Main] 检测到新草稿:', result.count);
|
||||
|
||||
// 先标记为已读,避免重复弹窗
|
||||
await this.storyManager.markAllDraftsRead(this.userManager.userId);
|
||||
|
||||
// 弹窗通知
|
||||
wx.showModal({
|
||||
title: 'AI改写完成',
|
||||
content: `您有 ${result.count} 个新的AI改写已完成,是否前往查看?`,
|
||||
confirmText: '查看',
|
||||
cancelText: '稍后',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 跳转到个人中心的草稿箱 tab
|
||||
this.sceneManager.switchScene('profile', { tab: 1 });
|
||||
} else {
|
||||
// 点击稍后,如果当前在个人中心页面则刷新草稿列表
|
||||
const currentScene = this.sceneManager.currentScene;
|
||||
if (currentScene && currentScene.refreshDrafts) {
|
||||
currentScene.refreshDrafts();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Main] 草稿检查失败:', e);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// 停止草稿检查定时器
|
||||
stopDraftChecker() {
|
||||
if (this.draftCheckTimer) {
|
||||
clearInterval(this.draftCheckTimer);
|
||||
this.draftCheckTimer = null;
|
||||
console.log('[Main] 停止草稿检查定时器');
|
||||
}
|
||||
}
|
||||
|
||||
// 设置分享
|
||||
setupShare() {
|
||||
wx.showShareMenu({
|
||||
|
||||
@@ -71,8 +71,10 @@ export default class ChapterScene extends BaseScene {
|
||||
const cardHeight = 85;
|
||||
const gap = 12;
|
||||
const headerHeight = 80;
|
||||
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight;
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
|
||||
const bottomPadding = 50; // 底部留出空间
|
||||
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight + bottomPadding;
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight);
|
||||
console.log('[ChapterScene] nodeList长度:', this.nodeList.length, 'contentHeight:', contentHeight, 'screenHeight:', this.screenHeight, 'maxScrollY:', this.maxScrollY);
|
||||
}
|
||||
|
||||
update() {}
|
||||
@@ -173,9 +175,17 @@ export default class ChapterScene extends BaseScene {
|
||||
if (this.maxScrollY > 0) {
|
||||
const scrollBarHeight = 50;
|
||||
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 5, scrollBarHeight, 2.5);
|
||||
ctx.fill();
|
||||
|
||||
// 如果还没滚动到底部,显示提示
|
||||
if (this.scrollY < this.maxScrollY - 10) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ export default class EndingScene extends BaseScene {
|
||||
super(main, params);
|
||||
this.storyId = params.storyId;
|
||||
this.ending = params.ending;
|
||||
console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending));
|
||||
this.draftId = params.draftId || null; // 保存草稿ID
|
||||
this.isReplay = params.isReplay || false; // 是否是回放模式
|
||||
console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending), ', isReplay:', this.isReplay);
|
||||
this.showButtons = false;
|
||||
this.fadeIn = 0;
|
||||
this.particles = [];
|
||||
@@ -19,6 +21,11 @@ export default class EndingScene extends BaseScene {
|
||||
this.rewritePrompt = '';
|
||||
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
|
||||
this.selectedTag = -1;
|
||||
// AI续写面板
|
||||
this.showContinuePanel = false;
|
||||
this.continuePrompt = '';
|
||||
this.continueTags = ['故事未完', '新的冒险', '多年以后', '意外转折', '番外篇'];
|
||||
this.selectedContinueTag = -1;
|
||||
// 改写历史
|
||||
this.rewriteHistory = [];
|
||||
this.currentHistoryIndex = -1;
|
||||
@@ -35,6 +42,31 @@ export default class EndingScene extends BaseScene {
|
||||
setTimeout(() => {
|
||||
this.showButtons = true;
|
||||
}, 1500);
|
||||
|
||||
// 保存游玩记录(回放模式和AI草稿不保存)
|
||||
if (!this.isReplay && !this.draftId) {
|
||||
this.savePlayRecord();
|
||||
}
|
||||
}
|
||||
|
||||
async savePlayRecord() {
|
||||
try {
|
||||
// 获取当前游玩路径
|
||||
const pathHistory = this.main.storyManager.pathHistory || [];
|
||||
const endingName = this.ending?.name || '未知结局';
|
||||
const endingType = this.ending?.type || '';
|
||||
|
||||
// 调用保存接口
|
||||
await this.main.userManager.savePlayRecord(
|
||||
this.storyId,
|
||||
endingName,
|
||||
endingType,
|
||||
pathHistory
|
||||
);
|
||||
console.log('游玩记录保存成功');
|
||||
} catch (e) {
|
||||
console.error('保存游玩记录失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadQuota() {
|
||||
@@ -84,6 +116,10 @@ export default class EndingScene extends BaseScene {
|
||||
if (this.showRewritePanel) {
|
||||
this.renderRewritePanel(ctx);
|
||||
}
|
||||
// AI续写面板
|
||||
if (this.showContinuePanel) {
|
||||
this.renderContinuePanel(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
renderBackground(ctx) {
|
||||
@@ -256,15 +292,17 @@ export default class EndingScene extends BaseScene {
|
||||
const buttonHeight = 38;
|
||||
const buttonMargin = 8;
|
||||
const startY = this.screenHeight - 220;
|
||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||
|
||||
// AI改写按钮(带配额提示)
|
||||
// AI改写按钮和AI续写按钮(第一行)
|
||||
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
||||
const aiBtnText = remaining > 0 ? '✨ AI改写结局' : '⚠️ 次数不足';
|
||||
this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, aiBtnText, ['#a855f7', '#ec4899']);
|
||||
const rewriteBtnText = remaining > 0 ? '✨ AI改写' : '⚠️ 次数不足';
|
||||
const continueBtnText = remaining > 0 ? '📖 AI续写' : '⚠️ 次数不足';
|
||||
this.renderGradientButton(ctx, padding, startY, buttonWidth, buttonHeight, rewriteBtnText, ['#a855f7', '#ec4899']);
|
||||
this.renderGradientButton(ctx, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight, continueBtnText, ['#10b981', '#059669']);
|
||||
|
||||
// 分享按钮
|
||||
const row2Y = startY + buttonHeight + buttonMargin;
|
||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||
this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']);
|
||||
|
||||
// 章节选择按钮
|
||||
@@ -502,6 +540,183 @@ export default class EndingScene extends BaseScene {
|
||||
this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
|
||||
}
|
||||
|
||||
renderContinuePanel(ctx) {
|
||||
const padding = 20;
|
||||
const panelWidth = this.screenWidth - padding * 2;
|
||||
const panelHeight = 450;
|
||||
const panelX = padding;
|
||||
const panelY = (this.screenHeight - panelHeight) / 2;
|
||||
|
||||
// 遮罩层
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 面板背景渐变
|
||||
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
|
||||
panelGradient.addColorStop(0, '#0d2818');
|
||||
panelGradient.addColorStop(1, '#0a1a10');
|
||||
ctx.fillStyle = panelGradient;
|
||||
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||
ctx.fill();
|
||||
|
||||
// 面板边框渐变
|
||||
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
|
||||
borderGradient.addColorStop(0, '#10b981');
|
||||
borderGradient.addColorStop(1, '#059669');
|
||||
ctx.strokeStyle = borderGradient;
|
||||
ctx.lineWidth = 2;
|
||||
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||
ctx.stroke();
|
||||
|
||||
// 标题栏
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('📖 AI续写结局', this.screenWidth / 2, panelY + 35);
|
||||
|
||||
// 配额提示
|
||||
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
||||
ctx.fillStyle = remaining > 0 ? 'rgba(255,255,255,0.6)' : 'rgba(255,100,100,0.8)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`剩余次数:${remaining}`, panelX + panelWidth - 15, panelY + 35);
|
||||
|
||||
// 副标题
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('从当前结局出发,AI将为你续写新的剧情分支', this.screenWidth / 2, panelY + 58);
|
||||
|
||||
// 分隔线
|
||||
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75);
|
||||
lineGradient.addColorStop(0, 'transparent');
|
||||
lineGradient.addColorStop(0.5, 'rgba(16,185,129,0.5)');
|
||||
lineGradient.addColorStop(1, 'transparent');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(panelX + 20, panelY + 75);
|
||||
ctx.lineTo(panelX + panelWidth - 20, panelY + 75);
|
||||
ctx.stroke();
|
||||
|
||||
// 快捷标签标题
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('快捷选择:', panelX + 15, panelY + 105);
|
||||
|
||||
// 快捷标签
|
||||
const tagStartX = panelX + 15;
|
||||
const tagY = panelY + 120;
|
||||
const tagHeight = 32;
|
||||
const tagGap = 8;
|
||||
let currentX = tagStartX;
|
||||
let currentY = tagY;
|
||||
|
||||
this.continueTagRects = [];
|
||||
this.continueTags.forEach((tag, index) => {
|
||||
ctx.font = '12px sans-serif';
|
||||
const tagWidth = ctx.measureText(tag).width + 24;
|
||||
|
||||
// 换行
|
||||
if (currentX + tagWidth > panelX + panelWidth - 15) {
|
||||
currentX = tagStartX;
|
||||
currentY += tagHeight + tagGap;
|
||||
}
|
||||
|
||||
// 标签背景
|
||||
const isSelected = index === this.selectedContinueTag;
|
||||
if (isSelected) {
|
||||
const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
|
||||
tagGradient.addColorStop(0, '#10b981');
|
||||
tagGradient.addColorStop(1, '#059669');
|
||||
ctx.fillStyle = tagGradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
}
|
||||
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
|
||||
ctx.fill();
|
||||
|
||||
// 标签边框
|
||||
ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
|
||||
ctx.stroke();
|
||||
|
||||
// 标签文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21);
|
||||
|
||||
// 存储标签位置
|
||||
this.continueTagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index });
|
||||
|
||||
currentX += tagWidth + tagGap;
|
||||
});
|
||||
|
||||
// 自定义输入提示
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('或自定义输入:', panelX + 15, panelY + 215);
|
||||
|
||||
// 输入框背景
|
||||
const inputY = panelY + 230;
|
||||
const inputHeight = 45;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
|
||||
ctx.stroke();
|
||||
|
||||
// 输入框文字或占位符
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
if (this.continuePrompt) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(this.continuePrompt, panelX + 28, inputY + 28);
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.fillText('点击输入你的续写想法...', panelX + 28, inputY + 28);
|
||||
}
|
||||
|
||||
// 按钮
|
||||
const btnY = panelY + panelHeight - 70;
|
||||
const btnWidth = (panelWidth - 50) / 2;
|
||||
const btnHeight = 44;
|
||||
|
||||
// 取消按钮
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28);
|
||||
|
||||
// 确认按钮
|
||||
const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY);
|
||||
confirmGradient.addColorStop(0, '#10b981');
|
||||
confirmGradient.addColorStop(1, '#059669');
|
||||
ctx.fillStyle = confirmGradient;
|
||||
this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('📖 开始续写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28);
|
||||
|
||||
// 存储按钮区域
|
||||
this.continueCancelBtnRect = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight };
|
||||
this.continueConfirmBtnRect = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight };
|
||||
this.continueInputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
|
||||
}
|
||||
|
||||
// 圆角矩形
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
@@ -609,6 +824,12 @@ export default class EndingScene extends BaseScene {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果续写面板打开,优先处理
|
||||
if (this.showContinuePanel) {
|
||||
this.handleContinuePanelTouch(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.showButtons) return;
|
||||
|
||||
const padding = 15;
|
||||
@@ -617,12 +838,18 @@ export default class EndingScene extends BaseScene {
|
||||
const startY = this.screenHeight - 220;
|
||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||
|
||||
// AI改写按钮
|
||||
if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) {
|
||||
// AI改写按钮(左)
|
||||
if (this.isInRect(x, y, padding, startY, buttonWidth, buttonHeight)) {
|
||||
this.handleAIRewrite();
|
||||
return;
|
||||
}
|
||||
|
||||
// AI续写按钮(右)
|
||||
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight)) {
|
||||
this.handleAIContinue();
|
||||
return;
|
||||
}
|
||||
|
||||
// 分享按钮
|
||||
const row2Y = startY + buttonHeight + buttonMargin;
|
||||
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
|
||||
@@ -645,6 +872,9 @@ export default class EndingScene extends BaseScene {
|
||||
|
||||
// 返回首页
|
||||
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) {
|
||||
// 清除当前故事状态
|
||||
this.main.storyManager.currentStory = null;
|
||||
this.main.storyManager.currentNodeKey = 'start';
|
||||
this.main.sceneManager.switchScene('home');
|
||||
return;
|
||||
}
|
||||
@@ -692,6 +922,20 @@ export default class EndingScene extends BaseScene {
|
||||
this.selectedTag = -1;
|
||||
}
|
||||
|
||||
handleAIContinue() {
|
||||
// 检查配额
|
||||
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
||||
if (remaining <= 0) {
|
||||
this.showQuotaModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示AI续写面板
|
||||
this.showContinuePanel = true;
|
||||
this.continuePrompt = '';
|
||||
this.selectedContinueTag = -1;
|
||||
}
|
||||
|
||||
handleRewritePanelTouch(x, y) {
|
||||
// 点击标签
|
||||
if (this.tagRects) {
|
||||
@@ -730,6 +974,44 @@ export default class EndingScene extends BaseScene {
|
||||
return false;
|
||||
}
|
||||
|
||||
handleContinuePanelTouch(x, y) {
|
||||
// 点击标签
|
||||
if (this.continueTagRects) {
|
||||
for (const tag of this.continueTagRects) {
|
||||
if (this.isInRect(x, y, tag.x, tag.y, tag.width, tag.height)) {
|
||||
this.selectedContinueTag = tag.index;
|
||||
this.continuePrompt = this.continueTags[tag.index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击输入框
|
||||
if (this.continueInputRect && this.isInRect(x, y, this.continueInputRect.x, this.continueInputRect.y, this.continueInputRect.width, this.continueInputRect.height)) {
|
||||
this.showContinueInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 点击取消
|
||||
if (this.continueCancelBtnRect && this.isInRect(x, y, this.continueCancelBtnRect.x, this.continueCancelBtnRect.y, this.continueCancelBtnRect.width, this.continueCancelBtnRect.height)) {
|
||||
this.showContinuePanel = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 点击确认
|
||||
if (this.continueConfirmBtnRect && this.isInRect(x, y, this.continueConfirmBtnRect.x, this.continueConfirmBtnRect.y, this.continueConfirmBtnRect.width, this.continueConfirmBtnRect.height)) {
|
||||
if (this.continuePrompt) {
|
||||
this.showContinuePanel = false;
|
||||
this.callAIContinue(this.continuePrompt);
|
||||
} else {
|
||||
wx.showToast({ title: '请选择或输入续写方向', icon: 'none' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
showCustomInput() {
|
||||
wx.showModal({
|
||||
title: '输入改写想法',
|
||||
@@ -745,6 +1027,21 @@ export default class EndingScene extends BaseScene {
|
||||
});
|
||||
}
|
||||
|
||||
showContinueInput() {
|
||||
wx.showModal({
|
||||
title: '输入续写想法',
|
||||
editable: true,
|
||||
placeholderText: '例如:主角开启新的冒险',
|
||||
content: this.continuePrompt,
|
||||
success: (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
this.continuePrompt = res.content;
|
||||
this.selectedContinueTag = -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callAIRewrite(prompt) {
|
||||
// 检查配额
|
||||
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
||||
@@ -754,68 +1051,100 @@ export default class EndingScene extends BaseScene {
|
||||
}
|
||||
|
||||
this.isRewriting = true;
|
||||
this.rewriteProgress = 0;
|
||||
|
||||
// 显示加载动画
|
||||
wx.showLoading({
|
||||
title: 'AI创作中...',
|
||||
title: '提交中...',
|
||||
mask: true
|
||||
});
|
||||
|
||||
// 模拟进度条效果
|
||||
const progressInterval = setInterval(() => {
|
||||
this.rewriteProgress += Math.random() * 20;
|
||||
if (this.rewriteProgress > 90) this.rewriteProgress = 90;
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const result = await this.main.storyManager.rewriteEnding(
|
||||
const userId = this.main.userManager.userId || 1;
|
||||
const result = await this.main.storyManager.rewriteEndingAsync(
|
||||
this.storyId,
|
||||
this.ending,
|
||||
prompt
|
||||
prompt,
|
||||
userId
|
||||
);
|
||||
|
||||
clearInterval(progressInterval);
|
||||
this.rewriteProgress = 100;
|
||||
wx.hideLoading();
|
||||
|
||||
if (result && result.content) {
|
||||
// 记录改写历史
|
||||
this.rewriteHistory.push({
|
||||
prompt: prompt,
|
||||
content: result.content,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
this.currentHistoryIndex = this.rewriteHistory.length - 1;
|
||||
|
||||
if (result && result.draftId) {
|
||||
// 扣除配额
|
||||
this.aiQuota.used += 1;
|
||||
|
||||
// 成功提示
|
||||
wx.showToast({
|
||||
title: '改写成功!',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
// 提交成功提示
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: 'AI正在后台生成新结局,完成后会通知您。\n您可以在草稿箱中查看。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
});
|
||||
|
||||
// 延迟跳转到故事场景播放新内容
|
||||
setTimeout(() => {
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId: this.storyId,
|
||||
aiContent: result
|
||||
});
|
||||
}, 1500);
|
||||
// 启动专门的草稿检查(每5秒检查一次,持续2分钟)
|
||||
this.startDraftPolling(result.draftId);
|
||||
} else {
|
||||
wx.showToast({ title: '改写失败,请重试', icon: 'none' });
|
||||
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
wx.hideLoading();
|
||||
console.error('改写失败:', error);
|
||||
wx.showToast({ title: error.message || '网络错误', icon: 'none' });
|
||||
} finally {
|
||||
this.isRewriting = false;
|
||||
this.rewriteProgress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async callAIContinue(prompt) {
|
||||
// 检查配额
|
||||
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
||||
if (remaining <= 0) {
|
||||
this.showQuotaModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRewriting = true;
|
||||
|
||||
// 显示加载动画
|
||||
wx.showLoading({
|
||||
title: '提交中...',
|
||||
mask: true
|
||||
});
|
||||
|
||||
try {
|
||||
const userId = this.main.userManager.userId || 1;
|
||||
const result = await this.main.storyManager.continueEndingAsync(
|
||||
this.storyId,
|
||||
this.ending,
|
||||
prompt,
|
||||
userId
|
||||
);
|
||||
|
||||
wx.hideLoading();
|
||||
|
||||
if (result && result.draftId) {
|
||||
// 扣除配额
|
||||
this.aiQuota.used += 1;
|
||||
|
||||
// 提交成功提示
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: 'AI正在后台生成续写剧情,完成后会通知您。\n您可以在草稿箱中查看。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
});
|
||||
|
||||
// 启动专门的草稿检查(每5秒检查一次,持续2分钟)
|
||||
this.startDraftPolling(result.draftId);
|
||||
} else {
|
||||
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
|
||||
}
|
||||
} catch (error) {
|
||||
wx.hideLoading();
|
||||
console.error('续写失败:', error);
|
||||
wx.showToast({ title: error.message || '网络错误', icon: 'none' });
|
||||
} finally {
|
||||
this.isRewriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,8 +1173,17 @@ export default class EndingScene extends BaseScene {
|
||||
|
||||
handleReplay() {
|
||||
this.main.storyManager.resetStory();
|
||||
|
||||
// 如果是从草稿进入的,重头游玩时保留草稿上下文
|
||||
if (this.draftId) {
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId: this.storyId,
|
||||
draftId: this.draftId
|
||||
});
|
||||
} else {
|
||||
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
|
||||
}
|
||||
}
|
||||
|
||||
handleLike() {
|
||||
this.isLiked = !this.isLiked;
|
||||
@@ -857,4 +1195,69 @@ export default class EndingScene extends BaseScene {
|
||||
this.isCollected = !this.isCollected;
|
||||
this.main.userManager.collectStory(this.storyId, this.isCollected);
|
||||
}
|
||||
|
||||
// 启动草稿完成轮询(每5秒检查一次,持续2分钟)
|
||||
startDraftPolling(draftId) {
|
||||
// 清除之前的轮询
|
||||
if (this.draftPollTimer) {
|
||||
clearInterval(this.draftPollTimer);
|
||||
}
|
||||
|
||||
let pollCount = 0;
|
||||
const maxPolls = 24; // 2分钟 / 5秒 = 24次
|
||||
|
||||
console.log('[EndingScene] 启动草稿轮询, draftId:', draftId);
|
||||
|
||||
this.draftPollTimer = setInterval(async () => {
|
||||
pollCount++;
|
||||
|
||||
if (pollCount > maxPolls) {
|
||||
console.log('[EndingScene] 轮询超时,停止检查');
|
||||
clearInterval(this.draftPollTimer);
|
||||
this.draftPollTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const result = await this.main.storyManager.checkNewDrafts(userId);
|
||||
|
||||
if (result && result.hasNew && result.count > 0) {
|
||||
console.log('[EndingScene] 检测到新草稿:', result.count);
|
||||
|
||||
// 停止轮询
|
||||
clearInterval(this.draftPollTimer);
|
||||
this.draftPollTimer = null;
|
||||
|
||||
// 标记为已读
|
||||
await this.main.storyManager.markAllDraftsRead(userId);
|
||||
|
||||
// 弹窗通知
|
||||
wx.showModal({
|
||||
title: 'AI改写完成',
|
||||
content: `您有 ${result.count} 个新的AI改写已完成,是否前往查看?`,
|
||||
confirmText: '查看',
|
||||
cancelText: '稍后',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.main.sceneManager.switchScene('profile', { tab: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EndingScene] 草稿检查失败:', e);
|
||||
}
|
||||
}, 5000); // 每5秒检查一次
|
||||
}
|
||||
|
||||
// 场景销毁时清理轮询
|
||||
destroy() {
|
||||
if (this.draftPollTimer) {
|
||||
clearInterval(this.draftPollTimer);
|
||||
this.draftPollTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import BaseScene from './BaseScene';
|
||||
export default class ProfileScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
// Tab: 0我的作品 1草稿箱 2收藏 3游玩记录
|
||||
this.currentTab = 0;
|
||||
this.tabs = ['作品', '草稿', '收藏', '记录'];
|
||||
// Tab: 0我的作品 1AI草稿 2收藏 3游玩记录
|
||||
this.currentTab = params.tab || 0; // 支持传入初始tab
|
||||
this.tabs = ['作品', 'AI草稿', '收藏', '记录'];
|
||||
|
||||
// 数据
|
||||
this.myWorks = [];
|
||||
@@ -16,6 +16,11 @@ export default class ProfileScene extends BaseScene {
|
||||
this.collections = [];
|
||||
this.progress = [];
|
||||
|
||||
// 记录版本列表相关状态
|
||||
this.recordViewMode = 'list'; // 'list' 故事列表 | 'versions' 版本列表
|
||||
this.selectedStoryRecords = []; // 选中故事的记录列表
|
||||
this.selectedStoryInfo = {}; // 选中故事的信息
|
||||
|
||||
// 统计
|
||||
this.stats = {
|
||||
works: 0,
|
||||
@@ -40,10 +45,13 @@ export default class ProfileScene extends BaseScene {
|
||||
async loadData() {
|
||||
if (this.main.userManager.isLoggedIn) {
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
this.myWorks = await this.main.userManager.getMyWorks?.() || [];
|
||||
this.drafts = await this.main.userManager.getDrafts?.() || [];
|
||||
// 加载 AI 改写草稿
|
||||
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
|
||||
this.collections = await this.main.userManager.getCollections() || [];
|
||||
this.progress = await this.main.userManager.getProgress() || [];
|
||||
// 加载游玩记录(故事列表)
|
||||
this.progress = await this.main.userManager.getPlayRecords() || [];
|
||||
|
||||
// 计算统计
|
||||
this.stats.works = this.myWorks.length;
|
||||
@@ -57,12 +65,27 @@ export default class ProfileScene extends BaseScene {
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
|
||||
// 刷新草稿列表
|
||||
async refreshDrafts() {
|
||||
if (this.main.userManager.isLoggedIn) {
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
|
||||
this.calculateMaxScroll();
|
||||
} catch (e) {
|
||||
console.error('刷新草稿失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentList() {
|
||||
switch (this.currentTab) {
|
||||
case 0: return this.myWorks;
|
||||
case 1: return this.drafts;
|
||||
case 2: return this.collections;
|
||||
case 3: return this.progress;
|
||||
case 3:
|
||||
// 记录 Tab:根据视图模式返回不同列表
|
||||
return this.recordViewMode === 'versions' ? this.selectedStoryRecords : this.progress;
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
@@ -239,16 +262,26 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.rect(0, startY - 5, this.screenWidth, this.screenHeight - startY + 5);
|
||||
ctx.clip();
|
||||
|
||||
// 记录 Tab 版本列表模式:显示返回按钮和标题
|
||||
if (this.currentTab === 3 && this.recordViewMode === 'versions') {
|
||||
this.renderVersionListHeader(ctx, startY);
|
||||
}
|
||||
|
||||
const listStartY = (this.currentTab === 3 && this.recordViewMode === 'versions') ? startY + 45 : startY;
|
||||
|
||||
if (list.length === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const emptyTexts = ['还没有发布作品,去创作吧', '草稿箱空空如也', '还没有收藏的故事', '还没有游玩记录'];
|
||||
ctx.fillText(emptyTexts[this.currentTab], this.screenWidth / 2, startY + 50);
|
||||
const emptyText = (this.currentTab === 3 && this.recordViewMode === 'versions')
|
||||
? '该故事还没有游玩记录'
|
||||
: emptyTexts[this.currentTab];
|
||||
ctx.fillText(emptyText, this.screenWidth / 2, listStartY + 50);
|
||||
|
||||
// 创作引导按钮
|
||||
if (this.currentTab === 0) {
|
||||
const btnY = startY + 80;
|
||||
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');
|
||||
@@ -266,12 +299,14 @@ export default class ProfileScene extends BaseScene {
|
||||
}
|
||||
|
||||
list.forEach((item, index) => {
|
||||
const y = startY + index * (cardH + gap) - this.scrollY;
|
||||
if (y > startY - cardH && y < this.screenHeight) {
|
||||
const y = listStartY + index * (cardH + gap) - this.scrollY;
|
||||
if (y > listStartY - cardH && y < this.screenHeight) {
|
||||
if (this.currentTab === 0) {
|
||||
this.renderWorkCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index);
|
||||
} else if (this.currentTab === 1) {
|
||||
this.renderDraftCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index);
|
||||
} else if (this.currentTab === 3 && this.recordViewMode === 'versions') {
|
||||
this.renderRecordVersionCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index);
|
||||
} else {
|
||||
this.renderSimpleCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardH, index);
|
||||
}
|
||||
@@ -281,6 +316,112 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 渲染版本列表头部(返回按钮+故事标题)
|
||||
renderVersionListHeader(ctx, startY) {
|
||||
const headerY = startY - 5;
|
||||
|
||||
// 返回按钮
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('‹ 返回', 15, headerY + 20);
|
||||
this.versionBackBtnRect = { x: 5, y: headerY, width: 70, height: 35 };
|
||||
|
||||
// 故事标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const title = this.selectedStoryInfo.title || '游玩记录';
|
||||
ctx.fillText(this.truncateText(ctx, title, this.screenWidth - 120), this.screenWidth / 2, headerY + 20);
|
||||
|
||||
// 记录数量
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${this.selectedStoryRecords.length} 条记录`, this.screenWidth - 15, headerY + 20);
|
||||
}
|
||||
|
||||
// 渲染单条游玩记录版本卡片
|
||||
renderRecordVersionCard(ctx, item, x, y, w, h, index) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
this.roundRect(ctx, x, y, w, h, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 左侧序号圆圈
|
||||
const circleX = x + 30;
|
||||
const circleY = y + h / 2;
|
||||
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]);
|
||||
ctx.fillStyle = circleGradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 序号
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${index + 1}`, circleX, circleY + 5);
|
||||
|
||||
const textX = x + 65;
|
||||
|
||||
// 结局名称
|
||||
ctx.fillStyle = '#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.fillStyle = '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);
|
||||
|
||||
// 删除按钮
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
this.roundRect(ctx, x + w - 125, y + 28, 48, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('删除', x + w - 101, y + 45);
|
||||
|
||||
// 回放按钮
|
||||
const btnGradient = ctx.createLinearGradient(x + w - 68, y + 28, x + w - 10, y + 28);
|
||||
btnGradient.addColorStop(0, '#ff6b6b');
|
||||
btnGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, x + w - 68, y + 28, 58, 26, 13);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('回放', x + w - 39, y + 45);
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
formatDateTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
// iOS 兼容:将 "2026-03-10 11:51" 转换为 "2026-03-10T11:51:00"
|
||||
const isoStr = dateStr.replace(' ', 'T');
|
||||
const date = new Date(isoStr);
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const hour = date.getHours().toString().padStart(2, '0');
|
||||
const minute = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${month}月${day}日 ${hour}:${minute}`;
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
renderWorkCard(ctx, item, x, y, w, h, index) {
|
||||
// 卡片背景
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
@@ -366,7 +507,6 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fill();
|
||||
|
||||
// AI标签
|
||||
if (item.source === 'ai') {
|
||||
ctx.fillStyle = '#a855f7';
|
||||
this.roundRect(ctx, x + 8, y + 8, 28, 16, 8);
|
||||
ctx.fill();
|
||||
@@ -374,42 +514,78 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('AI', x + 22, y + 19);
|
||||
}
|
||||
|
||||
const textX = x + 88;
|
||||
|
||||
// 标题(故事标题-改写)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(this.truncateText(ctx, item.title || '未命名草稿', w - 180), textX, y + 25);
|
||||
ctx.fillText(this.truncateText(ctx, item.title || item.storyTitle || 'AI改写', w - 180), textX, y + 25);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
// 状态标签
|
||||
const statusMap = {
|
||||
'pending': { text: '等待中', color: '#888888' },
|
||||
'processing': { text: '生成中', color: '#f59e0b' },
|
||||
'completed': { text: '已完成', color: '#22c55e' },
|
||||
'failed': { text: '失败', color: '#ef4444' }
|
||||
};
|
||||
const status = statusMap[item.status] || statusMap['pending'];
|
||||
const titleWidth = ctx.measureText(this.truncateText(ctx, item.title || item.storyTitle || 'AI改写', w - 180)).width;
|
||||
const statusW = ctx.measureText(status.text).width + 12;
|
||||
ctx.fillStyle = status.color + '33';
|
||||
this.roundRect(ctx, textX + titleWidth + 8, y + 12, statusW, 18, 9);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = status.color;
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(status.text, textX + titleWidth + 8 + statusW / 2, y + 24);
|
||||
|
||||
// 改写指令
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText(`创建于 ${item.created_at || '刚刚'}`, textX, y + 48);
|
||||
ctx.fillText(`${item.node_count || 0} 个节点`, textX + 100, y + 48);
|
||||
ctx.textAlign = 'left';
|
||||
const promptText = item.userPrompt ? `"「${item.userPrompt}」"` : '';
|
||||
ctx.fillText(this.truncateText(ctx, promptText, w - 100), textX, y + 48);
|
||||
|
||||
// 时间
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText(item.createdAt || '', textX, y + 68);
|
||||
|
||||
// 未读标记
|
||||
if (!item.isRead && item.status === 'completed') {
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + w - 20, y + 20, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 按钮
|
||||
const btnY = y + 62;
|
||||
const btns = [{ text: '继续编辑', primary: true }, { text: '删除', primary: false }];
|
||||
let btnX = textX;
|
||||
btns.forEach((btn) => {
|
||||
const btnW = btn.primary ? 65 : 45;
|
||||
if (btn.primary) {
|
||||
const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY);
|
||||
|
||||
// 删除按钮(所有状态都显示)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
|
||||
this.roundRect(ctx, x + w - 55, btnY, 45, 24, 12);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('删除', x + w - 32, btnY + 16);
|
||||
|
||||
// 播放按钮(仅已完成状态)
|
||||
if (item.status === 'completed') {
|
||||
const btnGradient = ctx.createLinearGradient(textX, btnY, textX + 65, btnY);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
}
|
||||
this.roundRect(ctx, btnX, btnY, btnW, 26, 13);
|
||||
this.roundRect(ctx, textX + 120, btnY, 60, 24, 12);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = btn.primary ? 'bold 11px sans-serif' : '11px sans-serif';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(btn.text, btnX + btnW / 2, btnY + 17);
|
||||
btnX += btnW + 8;
|
||||
});
|
||||
ctx.fillText('播放', textX + 150, btnY + 16);
|
||||
}
|
||||
}
|
||||
|
||||
renderSimpleCard(ctx, item, x, y, w, h, index) {
|
||||
@@ -436,20 +612,20 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(this.truncateText(ctx, item.story_title || item.title || '未知', w - 150), textX, y + 28);
|
||||
// 记录Tab使用 storyTitle,收藏Tab使用 story_title
|
||||
const title = item.storyTitle || item.story_title || item.title || '未知';
|
||||
ctx.fillText(this.truncateText(ctx, title, w - 150), textX, y + 28);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
ctx.font = '11px sans-serif';
|
||||
if (this.currentTab === 3 && item.is_completed) {
|
||||
ctx.fillStyle = '#4ade80';
|
||||
ctx.fillText('✓ 已完成', textX, y + 50);
|
||||
} else if (this.currentTab === 3) {
|
||||
ctx.fillText('进行中...', textX, y + 50);
|
||||
if (this.currentTab === 3) {
|
||||
// 记录Tab:只显示记录数量
|
||||
ctx.fillText(`${item.recordCount || 0} 条记录`, textX, y + 50);
|
||||
} else {
|
||||
ctx.fillText(item.category || '', textX, y + 50);
|
||||
}
|
||||
|
||||
// 继续按钮
|
||||
// 查看按钮(记录Tab)/ 继续按钮(收藏Tab)
|
||||
const btnGradient = ctx.createLinearGradient(x + w - 58, y + 28, x + w - 10, y + 28);
|
||||
btnGradient.addColorStop(0, '#ff6b6b');
|
||||
btnGradient.addColorStop(1, '#ffd700');
|
||||
@@ -459,7 +635,7 @@ export default class ProfileScene extends BaseScene {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('继续', x + w - 34, y + 45);
|
||||
ctx.fillText(this.currentTab === 3 ? '查看' : '继续', x + w - 34, y + 45);
|
||||
}
|
||||
|
||||
getGradientColors(index) {
|
||||
@@ -544,7 +720,13 @@ export default class ProfileScene extends BaseScene {
|
||||
if (this.currentTab !== rect.index) {
|
||||
this.currentTab = rect.index;
|
||||
this.scrollY = 0;
|
||||
this.recordViewMode = 'list'; // 切换 Tab 时重置记录视图模式
|
||||
this.calculateMaxScroll();
|
||||
|
||||
// 切换到 AI 草稿 tab 时刷新数据
|
||||
if (rect.index === 1) {
|
||||
this.refreshDrafts();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -569,22 +751,199 @@ export default class ProfileScene extends BaseScene {
|
||||
const startY = 250;
|
||||
const cardH = this.currentTab <= 1 ? 100 : 85;
|
||||
const gap = 10;
|
||||
const padding = 12;
|
||||
const cardW = this.screenWidth - padding * 2;
|
||||
|
||||
// 记录 Tab 版本列表模式下,检测返回按钮
|
||||
if (this.currentTab === 3 && this.recordViewMode === 'versions') {
|
||||
if (this.versionBackBtnRect) {
|
||||
const btn = this.versionBackBtnRect;
|
||||
if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) {
|
||||
this.recordViewMode = 'list';
|
||||
this.scrollY = 0;
|
||||
this.calculateMaxScroll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listStartY = (this.currentTab === 3 && this.recordViewMode === 'versions') ? startY + 45 : startY;
|
||||
const adjustedY = y + this.scrollY;
|
||||
const index = Math.floor((adjustedY - startY) / (cardH + gap));
|
||||
const index = Math.floor((adjustedY - listStartY) / (cardH + gap));
|
||||
|
||||
if (index >= 0 && index < list.length) {
|
||||
const item = list[index];
|
||||
const storyId = item.story_id || item.id;
|
||||
const storyId = item.story_id || item.storyId || item.id;
|
||||
|
||||
if (this.currentTab >= 2) {
|
||||
// 收藏/记录 - 跳转播放
|
||||
// 计算卡片内的相对位置
|
||||
const cardY = listStartY + index * (cardH + gap) - this.scrollY;
|
||||
const relativeY = y - cardY;
|
||||
|
||||
// AI草稿 Tab 的按钮检测
|
||||
if (this.currentTab === 1) {
|
||||
const btnY = 62;
|
||||
const btnH = 24;
|
||||
|
||||
// 检测删除按钮点击(右侧)
|
||||
const deleteBtnX = padding + cardW - 55;
|
||||
if (x >= deleteBtnX && x <= deleteBtnX + 45 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.confirmDeleteDraft(item, index);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测播放按钮点击(左侧,仅已完成状态)
|
||||
if (item.status === 'completed') {
|
||||
const playBtnX = padding + 88 + 120;
|
||||
if (x >= playBtnX && x <= playBtnX + 60 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 点击卡片其他区域
|
||||
if (item.status === 'completed') {
|
||||
this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id });
|
||||
} else if (item.status === 'failed') {
|
||||
wx.showToast({ title: 'AI改写失败', icon: 'none' });
|
||||
} else {
|
||||
wx.showToast({ title: '正在生成中,请稍后', icon: 'none' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录 Tab 处理
|
||||
if (this.currentTab === 3) {
|
||||
if (this.recordViewMode === 'list') {
|
||||
// 故事列表模式:点击进入版本列表
|
||||
this.showStoryVersions(item);
|
||||
} else {
|
||||
// 版本列表模式
|
||||
const btnY = 28;
|
||||
const btnH = 26;
|
||||
|
||||
// 检测删除按钮点击
|
||||
const deleteBtnX = padding + cardW - 125;
|
||||
if (x >= deleteBtnX && x <= deleteBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.confirmDeleteRecord(item, index);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测回放按钮点击
|
||||
const replayBtnX = padding + cardW - 68;
|
||||
if (x >= replayBtnX && x <= replayBtnX + 58 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
||||
this.startRecordReplay(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击卡片其他区域也进入回放
|
||||
this.startRecordReplay(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentTab === 2) {
|
||||
// 收藏 - 跳转播放
|
||||
this.main.sceneManager.switchScene('story', { storyId });
|
||||
} else if (this.currentTab === 1) {
|
||||
// 草稿 - 跳转编辑(暂用AI创作)
|
||||
this.main.sceneManager.switchScene('aiCreate', { draftId: item.id });
|
||||
}
|
||||
// 作品Tab的按钮操作需要更精确判断,暂略
|
||||
}
|
||||
}
|
||||
|
||||
// 显示故事的版本列表
|
||||
async showStoryVersions(storyItem) {
|
||||
const storyId = storyItem.story_id || storyItem.storyId || storyItem.id;
|
||||
const storyTitle = storyItem.story_title || storyItem.title || '未知故事';
|
||||
|
||||
try {
|
||||
wx.showLoading({ title: '加载中...' });
|
||||
const records = await this.main.userManager.getPlayRecords(storyId);
|
||||
wx.hideLoading();
|
||||
|
||||
if (records && records.length > 0) {
|
||||
this.selectedStoryInfo = { id: storyId, title: storyTitle };
|
||||
this.selectedStoryRecords = records;
|
||||
this.recordViewMode = 'versions';
|
||||
this.scrollY = 0;
|
||||
this.calculateMaxScroll();
|
||||
} else {
|
||||
wx.showToast({ title: '暂无游玩记录', icon: 'none' });
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading();
|
||||
console.error('加载版本列表失败:', e);
|
||||
wx.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
// 开始回放记录
|
||||
async startRecordReplay(recordItem) {
|
||||
const recordId = recordItem.id;
|
||||
const storyId = this.selectedStoryInfo.id;
|
||||
|
||||
// 进入故事场景,传入 playRecordId 参数
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId,
|
||||
playRecordId: recordId
|
||||
});
|
||||
}
|
||||
|
||||
// 确认删除草稿
|
||||
confirmDeleteDraft(item, index) {
|
||||
wx.showModal({
|
||||
title: '删除草稿',
|
||||
content: `确定要删除「${item.title || 'AI改写'}」吗?`,
|
||||
confirmText: '删除',
|
||||
confirmColor: '#ef4444',
|
||||
cancelText: '取消',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const userId = this.main.userManager.userId;
|
||||
const success = await this.main.storyManager.deleteDraft(item.id, userId);
|
||||
if (success) {
|
||||
// 从列表中移除
|
||||
this.drafts.splice(index, 1);
|
||||
this.calculateMaxScroll();
|
||||
wx.showToast({ title: '删除成功', icon: 'success' });
|
||||
} else {
|
||||
wx.showToast({ title: '删除失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 确认删除游玩记录
|
||||
confirmDeleteRecord(item, index) {
|
||||
wx.showModal({
|
||||
title: '删除记录',
|
||||
content: `确定要删除这条「${item.endingName || '未知结局'}」的记录吗?`,
|
||||
confirmText: '删除',
|
||||
confirmColor: '#ef4444',
|
||||
cancelText: '取消',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await this.main.userManager.deletePlayRecord(item.id);
|
||||
if (success) {
|
||||
// 从版本列表中移除
|
||||
this.selectedStoryRecords.splice(index, 1);
|
||||
this.calculateMaxScroll();
|
||||
wx.showToast({ title: '删除成功', icon: 'success' });
|
||||
|
||||
// 如果删光了,返回故事列表
|
||||
if (this.selectedStoryRecords.length === 0) {
|
||||
this.recordViewMode = 'list';
|
||||
// 从 progress 列表中也移除该故事
|
||||
const storyId = this.selectedStoryInfo.id;
|
||||
const idx = this.progress.findIndex(p => (p.story_id || p.storyId) === storyId);
|
||||
if (idx >= 0) {
|
||||
this.progress.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wx.showToast({ title: '删除失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// API基础地址(开发环境)
|
||||
const BASE_URL = 'http://localhost:3000/api';
|
||||
const BASE_URL = 'https://express-fuvd-231535-4-1409819450.sh.run.tcloudbase.com/api';
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
@@ -49,4 +49,11 @@ export function post(url, data, options = {}) {
|
||||
return request({ url, method: 'POST', data, ...options });
|
||||
}
|
||||
|
||||
export default { request, get, post };
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
export function del(url, data) {
|
||||
return request({ url, method: 'DELETE', data });
|
||||
}
|
||||
|
||||
export default { request, get, post, del };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appid": "wx27be06bc3365e84b",
|
||||
"compileType": "game",
|
||||
"appid": "wxed1cd84761d927fd",
|
||||
"compileType": "miniprogram",
|
||||
"projectname": "stardom-story",
|
||||
"setting": {
|
||||
"es6": true,
|
||||
@@ -42,5 +42,7 @@
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
"editorSetting": {},
|
||||
"libVersion": "3.14.3",
|
||||
"isGameTourist": false
|
||||
}
|
||||
19
server/.dockerignore
Normal file
19
server/.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# 忽略本地环境配置
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 忽略缓存
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# 忽略git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# 忽略测试文件
|
||||
test_*.py
|
||||
|
||||
# 忽略IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
19
server/Dockerfile
Normal file
19
server/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
# 复制代码
|
||||
COPY . .
|
||||
|
||||
# 删除本地环境配置,使用容器环境变量
|
||||
RUN rm -f .env
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,9 @@ AsyncSessionLocal = sessionmaker(
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# 后台任务使用的会话工厂
|
||||
async_session_factory = AsyncSessionLocal
|
||||
|
||||
# 基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.routers import story, user
|
||||
from app.routers import story, user, drafts
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -29,6 +29,7 @@ app.add_middleware(
|
||||
# 注册路由
|
||||
app.include_router(story.router, prefix="/api/stories", tags=["故事"])
|
||||
app.include_router(user.router, prefix="/api/user", tags=["用户"])
|
||||
app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
Binary file not shown.
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
故事相关ORM模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey, Enum, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class Story(Base):
|
||||
@@ -64,3 +65,43 @@ class StoryChoice(Base):
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
node = relationship("StoryNode", back_populates="choices")
|
||||
|
||||
|
||||
class DraftStatus(enum.Enum):
|
||||
"""草稿状态枚举"""
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class StoryDraft(Base):
|
||||
"""AI改写草稿表"""
|
||||
__tablename__ = "story_drafts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(100), default="")
|
||||
|
||||
# 用户输入
|
||||
path_history = Column(JSON, default=None) # 用户之前的选择路径
|
||||
current_node_key = Column(String(50), default="")
|
||||
current_content = Column(Text, default="")
|
||||
user_prompt = Column(String(500), nullable=False)
|
||||
|
||||
# AI生成结果
|
||||
ai_nodes = Column(JSON, default=None) # AI生成的新节点
|
||||
entry_node_key = Column(String(50), default="")
|
||||
tokens_used = Column(Integer, default=0)
|
||||
|
||||
# 状态
|
||||
status = Column(Enum(DraftStatus), default=DraftStatus.pending)
|
||||
error_message = Column(String(500), default="")
|
||||
is_read = Column(Boolean, default=False) # 用户是否已查看
|
||||
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
completed_at = Column(TIMESTAMP, default=None)
|
||||
|
||||
# 关联
|
||||
story = relationship("Story")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
用户相关ORM模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint, JSON, Index
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
@@ -56,3 +56,21 @@ class UserEnding(Base):
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'story_id', 'ending_name', name='uk_user_ending'),
|
||||
)
|
||||
|
||||
|
||||
class PlayRecord(Base):
|
||||
"""游玩记录表 - 保存每次游玩的完整路径"""
|
||||
__tablename__ = "play_records"
|
||||
|
||||
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)
|
||||
ending_name = Column(String(100), nullable=False) # 结局名称
|
||||
ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite)
|
||||
path_history = Column(JSON, nullable=False) # 完整的选择路径
|
||||
play_duration = Column(Integer, default=0) # 游玩时长(秒)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_story', 'user_id', 'story_id'),
|
||||
)
|
||||
|
||||
BIN
server/app/routers/__pycache__/drafts.cpython-310.pyc
Normal file
BIN
server/app/routers/__pycache__/drafts.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
621
server/app/routers/drafts.py
Normal file
621
server/app/routers/drafts.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""
|
||||
草稿箱路由 - AI异步改写功能
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story, StoryDraft, DraftStatus
|
||||
|
||||
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
|
||||
|
||||
|
||||
# ============ 请求/响应模型 ============
|
||||
|
||||
class PathHistoryItem(BaseModel):
|
||||
nodeKey: str
|
||||
content: str
|
||||
choice: str
|
||||
|
||||
|
||||
class CreateDraftRequest(BaseModel):
|
||||
userId: int
|
||||
storyId: int
|
||||
currentNodeKey: str
|
||||
pathHistory: List[PathHistoryItem]
|
||||
currentContent: str
|
||||
prompt: str
|
||||
|
||||
|
||||
class CreateEndingDraftRequest(BaseModel):
|
||||
"""结局改写请求"""
|
||||
userId: int
|
||||
storyId: int
|
||||
endingName: str
|
||||
endingContent: str
|
||||
prompt: str
|
||||
pathHistory: list = [] # 游玩路径历史(可选)
|
||||
|
||||
|
||||
class ContinueEndingDraftRequest(BaseModel):
|
||||
"""结局续写请求"""
|
||||
userId: int
|
||||
storyId: int
|
||||
endingName: str
|
||||
endingContent: str
|
||||
prompt: str
|
||||
pathHistory: list = [] # 游玩路径历史(可选)
|
||||
|
||||
|
||||
class DraftResponse(BaseModel):
|
||||
id: int
|
||||
storyId: int
|
||||
storyTitle: str
|
||||
title: str
|
||||
userPrompt: str
|
||||
status: str
|
||||
isRead: bool
|
||||
createdAt: str
|
||||
completedAt: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 后台任务 ============
|
||||
|
||||
async def process_ai_rewrite(draft_id: int):
|
||||
"""后台异步处理AI改写"""
|
||||
from app.database import async_session_factory
|
||||
from app.services.ai import ai_service
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
# 获取草稿
|
||||
result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
|
||||
draft = result.scalar_one_or_none()
|
||||
|
||||
if not draft:
|
||||
return
|
||||
|
||||
# 更新状态为处理中
|
||||
draft.status = DraftStatus.processing
|
||||
await db.commit()
|
||||
|
||||
# 获取故事信息
|
||||
story_result = await db.execute(select(Story).where(Story.id == draft.story_id))
|
||||
story = story_result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = "故事不存在"
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 转换路径历史格式
|
||||
path_history = draft.path_history or []
|
||||
|
||||
# 调用AI服务
|
||||
ai_result = await ai_service.rewrite_branch(
|
||||
story_title=story.title,
|
||||
story_category=story.category or "未知",
|
||||
path_history=path_history,
|
||||
current_content=draft.current_content or "",
|
||||
user_prompt=draft.user_prompt
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
# 成功
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = ai_result["nodes"]
|
||||
draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1")
|
||||
draft.tokens_used = ai_result.get("tokens_used", 0)
|
||||
draft.title = f"{story.title}-改写"
|
||||
else:
|
||||
# 失败
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = "AI服务暂时不可用"
|
||||
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[process_ai_rewrite] 异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 更新失败状态
|
||||
try:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = str(e)[:500]
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def process_ai_rewrite_ending(draft_id: int):
|
||||
"""后台异步处理AI改写结局"""
|
||||
from app.database import async_session_factory
|
||||
from app.services.ai import ai_service
|
||||
import json
|
||||
import re
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
# 获取草稿
|
||||
result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
|
||||
draft = result.scalar_one_or_none()
|
||||
|
||||
if not draft:
|
||||
return
|
||||
|
||||
# 更新状态为处理中
|
||||
draft.status = DraftStatus.processing
|
||||
await db.commit()
|
||||
|
||||
# 获取故事信息
|
||||
story_result = await db.execute(select(Story).where(Story.id == draft.story_id))
|
||||
story = story_result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = "故事不存在"
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 从草稿字段获取结局信息
|
||||
ending_name = draft.current_node_key or "未知结局"
|
||||
ending_content = draft.current_content or ""
|
||||
|
||||
# 调用AI服务改写结局
|
||||
ai_result = await ai_service.rewrite_ending(
|
||||
story_title=story.title,
|
||||
story_category=story.category or "未知",
|
||||
ending_name=ending_name,
|
||||
ending_content=ending_content,
|
||||
user_prompt=draft.user_prompt
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("content"):
|
||||
content = ai_result["content"]
|
||||
new_ending_name = f"{ending_name}(AI改写)"
|
||||
|
||||
# 尝试解析 JSON 格式的返回
|
||||
try:
|
||||
json_match = re.search(r'\{[^{}]*"ending_name"[^{}]*"content"[^{}]*\}', content, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group())
|
||||
new_ending_name = parsed.get("ending_name", new_ending_name)
|
||||
content = parsed.get("content", content)
|
||||
else:
|
||||
parsed = json.loads(content)
|
||||
new_ending_name = parsed.get("ending_name", new_ending_name)
|
||||
content = parsed.get("content", content)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
# 成功 - 存储为对象格式(与故事节点格式一致)
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = {
|
||||
"ending_rewrite": {
|
||||
"content": content,
|
||||
"speaker": "旁白",
|
||||
"is_ending": True,
|
||||
"ending_name": new_ending_name,
|
||||
"ending_type": "rewrite"
|
||||
}
|
||||
}
|
||||
draft.entry_node_key = "ending_rewrite"
|
||||
draft.tokens_used = ai_result.get("tokens_used", 0)
|
||||
draft.title = f"{story.title}-{new_ending_name}"
|
||||
else:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = "AI服务暂时不可用"
|
||||
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[process_ai_rewrite_ending] 异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
try:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = str(e)[:500]
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def process_ai_continue_ending(draft_id: int):
|
||||
"""后台异步处理AI续写结局"""
|
||||
from app.database import async_session_factory
|
||||
from app.services.ai import ai_service
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
# 获取草稿
|
||||
result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
|
||||
draft = result.scalar_one_or_none()
|
||||
|
||||
if not draft:
|
||||
return
|
||||
|
||||
# 更新状态为处理中
|
||||
draft.status = DraftStatus.processing
|
||||
await db.commit()
|
||||
|
||||
# 获取故事信息
|
||||
story_result = await db.execute(select(Story).where(Story.id == draft.story_id))
|
||||
story = story_result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = "故事不存在"
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 从草稿字段获取结局信息
|
||||
ending_name = draft.current_node_key or "未知结局"
|
||||
ending_content = draft.current_content or ""
|
||||
|
||||
# 调用AI服务续写结局
|
||||
ai_result = await ai_service.continue_ending(
|
||||
story_title=story.title,
|
||||
story_category=story.category or "未知",
|
||||
ending_name=ending_name,
|
||||
ending_content=ending_content,
|
||||
user_prompt=draft.user_prompt
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
# 成功 - 存储多节点分支格式
|
||||
draft.status = DraftStatus.completed
|
||||
draft.ai_nodes = ai_result["nodes"]
|
||||
draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1")
|
||||
draft.tokens_used = ai_result.get("tokens_used", 0)
|
||||
draft.title = f"{story.title}-{ending_name}续写"
|
||||
else:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = "AI服务暂时不可用"
|
||||
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[process_ai_continue_ending] 异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
try:
|
||||
draft.status = DraftStatus.failed
|
||||
draft.error_message = str(e)[:500]
|
||||
draft.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# ============ API 路由 ============
|
||||
|
||||
@router.post("")
|
||||
async def create_draft(
|
||||
request: CreateDraftRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""提交AI改写任务(异步处理)"""
|
||||
if not request.prompt:
|
||||
raise HTTPException(status_code=400, detail="请输入改写指令")
|
||||
|
||||
# 获取故事信息
|
||||
result = await db.execute(select(Story).where(Story.id == request.storyId))
|
||||
story = result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 转换路径历史
|
||||
path_history = [
|
||||
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
|
||||
for item in request.pathHistory
|
||||
]
|
||||
|
||||
# 创建草稿记录
|
||||
draft = StoryDraft(
|
||||
user_id=request.userId,
|
||||
story_id=request.storyId,
|
||||
title=f"{story.title}-改写",
|
||||
path_history=path_history,
|
||||
current_node_key=request.currentNodeKey,
|
||||
current_content=request.currentContent,
|
||||
user_prompt=request.prompt,
|
||||
status=DraftStatus.pending
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
await db.commit()
|
||||
await db.refresh(draft)
|
||||
|
||||
# 添加后台任务
|
||||
background_tasks.add_task(process_ai_rewrite, draft.id)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"draftId": draft.id,
|
||||
"message": "已提交,AI正在生成中..."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ending")
|
||||
async def create_ending_draft(
|
||||
request: CreateEndingDraftRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""提交AI改写结局任务(异步处理)"""
|
||||
if not request.prompt:
|
||||
raise HTTPException(status_code=400, detail="请输入改写指令")
|
||||
|
||||
# 获取故事信息
|
||||
result = await db.execute(select(Story).where(Story.id == request.storyId))
|
||||
story = result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 创建草稿记录,保存游玩路径和结局信息
|
||||
draft = StoryDraft(
|
||||
user_id=request.userId,
|
||||
story_id=request.storyId,
|
||||
title=f"{story.title}-结局改写",
|
||||
path_history=request.pathHistory, # 保存游玩路径
|
||||
current_node_key=request.endingName, # 保存结局名称
|
||||
current_content=request.endingContent, # 保存结局内容
|
||||
user_prompt=request.prompt,
|
||||
status=DraftStatus.pending
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
await db.commit()
|
||||
await db.refresh(draft)
|
||||
|
||||
# 添加后台任务
|
||||
background_tasks.add_task(process_ai_rewrite_ending, draft.id)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"draftId": draft.id,
|
||||
"message": "已提交,AI正在生成新结局..."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/continue-ending")
|
||||
async def create_continue_ending_draft(
|
||||
request: ContinueEndingDraftRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""提交AI续写结局任务(异步处理)"""
|
||||
if not request.prompt:
|
||||
raise HTTPException(status_code=400, detail="请输入续写指令")
|
||||
|
||||
# 获取故事信息
|
||||
result = await db.execute(select(Story).where(Story.id == request.storyId))
|
||||
story = result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 创建草稿记录,保存游玩路径和结局信息
|
||||
draft = StoryDraft(
|
||||
user_id=request.userId,
|
||||
story_id=request.storyId,
|
||||
title=f"{story.title}-结局续写",
|
||||
path_history=request.pathHistory, # 保存游玩路径
|
||||
current_node_key=request.endingName, # 保存结局名称
|
||||
current_content=request.endingContent, # 保存结局内容
|
||||
user_prompt=request.prompt,
|
||||
status=DraftStatus.pending
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
await db.commit()
|
||||
await db.refresh(draft)
|
||||
|
||||
# 添加后台任务
|
||||
background_tasks.add_task(process_ai_continue_ending, draft.id)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"draftId": draft.id,
|
||||
"message": "已提交,AI正在续写故事..."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_drafts(
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取用户的草稿列表"""
|
||||
result = await db.execute(
|
||||
select(StoryDraft, Story.title.label("story_title"))
|
||||
.join(Story, StoryDraft.story_id == Story.id)
|
||||
.where(StoryDraft.user_id == userId)
|
||||
.order_by(StoryDraft.created_at.desc())
|
||||
)
|
||||
|
||||
drafts = []
|
||||
for row in result:
|
||||
draft = row[0]
|
||||
story_title = row[1]
|
||||
drafts.append({
|
||||
"id": draft.id,
|
||||
"storyId": draft.story_id,
|
||||
"storyTitle": story_title,
|
||||
"title": draft.title,
|
||||
"userPrompt": draft.user_prompt,
|
||||
"status": draft.status.value if draft.status else "pending",
|
||||
"isRead": draft.is_read,
|
||||
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
|
||||
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
|
||||
})
|
||||
|
||||
return {"code": 0, "data": drafts}
|
||||
|
||||
|
||||
@router.get("/check-new")
|
||||
async def check_new_drafts(
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""检查是否有新完成的草稿(用于弹窗通知)"""
|
||||
result = await db.execute(
|
||||
select(StoryDraft)
|
||||
.where(
|
||||
StoryDraft.user_id == userId,
|
||||
StoryDraft.status == DraftStatus.completed,
|
||||
StoryDraft.is_read == False
|
||||
)
|
||||
)
|
||||
|
||||
unread_drafts = result.scalars().all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"hasNew": len(unread_drafts) > 0,
|
||||
"count": len(unread_drafts),
|
||||
"drafts": [
|
||||
{
|
||||
"id": d.id,
|
||||
"title": d.title,
|
||||
"userPrompt": d.user_prompt
|
||||
}
|
||||
for d in unread_drafts[:3] # 最多返回3个
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{draft_id}")
|
||||
async def get_draft_detail(
|
||||
draft_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取草稿详情"""
|
||||
result = await db.execute(
|
||||
select(StoryDraft, Story)
|
||||
.join(Story, StoryDraft.story_id == Story.id)
|
||||
.where(StoryDraft.id == draft_id)
|
||||
)
|
||||
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="草稿不存在")
|
||||
|
||||
draft, story = row
|
||||
|
||||
# 标记为已读
|
||||
if not draft.is_read:
|
||||
draft.is_read = True
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": draft.id,
|
||||
"storyId": draft.story_id,
|
||||
"storyTitle": story.title,
|
||||
"storyCategory": story.category,
|
||||
"title": draft.title,
|
||||
"pathHistory": draft.path_history,
|
||||
"currentNodeKey": draft.current_node_key,
|
||||
"currentContent": draft.current_content,
|
||||
"userPrompt": draft.user_prompt,
|
||||
"aiNodes": draft.ai_nodes,
|
||||
"entryNodeKey": draft.entry_node_key,
|
||||
"tokensUsed": draft.tokens_used,
|
||||
"status": draft.status.value if draft.status else "pending",
|
||||
"errorMessage": draft.error_message,
|
||||
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
|
||||
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{draft_id}")
|
||||
async def delete_draft(
|
||||
draft_id: int,
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除草稿"""
|
||||
result = await db.execute(
|
||||
select(StoryDraft).where(
|
||||
StoryDraft.id == draft_id,
|
||||
StoryDraft.user_id == userId
|
||||
)
|
||||
)
|
||||
|
||||
draft = result.scalar_one_or_none()
|
||||
if not draft:
|
||||
raise HTTPException(status_code=404, detail="草稿不存在")
|
||||
|
||||
await db.delete(draft)
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "删除成功"}
|
||||
|
||||
|
||||
@router.put("/{draft_id}/read")
|
||||
async def mark_draft_read(
|
||||
draft_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""标记草稿为已读"""
|
||||
await db.execute(
|
||||
update(StoryDraft)
|
||||
.where(StoryDraft.id == draft_id)
|
||||
.values(is_read=True)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "已标记为已读"}
|
||||
|
||||
|
||||
@router.put("/batch-read")
|
||||
async def mark_all_drafts_read(
|
||||
userId: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""批量标记所有未读草稿为已读"""
|
||||
await db.execute(
|
||||
update(StoryDraft)
|
||||
.where(
|
||||
StoryDraft.user_id == userId,
|
||||
StoryDraft.is_read == False
|
||||
)
|
||||
.values(is_read=True)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "已全部标记为已读"}
|
||||
@@ -5,7 +5,7 @@ import random
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func, distinct
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
@@ -25,6 +25,20 @@ class RewriteRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class PathHistoryItem(BaseModel):
|
||||
nodeKey: str
|
||||
content: str = ""
|
||||
choice: str = ""
|
||||
|
||||
|
||||
class RewriteBranchRequest(BaseModel):
|
||||
userId: int
|
||||
currentNodeKey: str
|
||||
pathHistory: List[PathHistoryItem]
|
||||
currentContent: str
|
||||
prompt: str
|
||||
|
||||
|
||||
# ========== API接口 ==========
|
||||
|
||||
@router.get("")
|
||||
@@ -268,3 +282,59 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
|
||||
"ending_type": "rewrite"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{story_id}/rewrite-branch")
|
||||
async def ai_rewrite_branch(
|
||||
story_id: int,
|
||||
request: RewriteBranchRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""AI改写中间章节,生成新的剧情分支"""
|
||||
if not request.prompt:
|
||||
raise HTTPException(status_code=400, detail="请输入改写指令")
|
||||
|
||||
# 获取故事信息
|
||||
result = await db.execute(select(Story).where(Story.id == story_id))
|
||||
story = result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 将 Pydantic 模型转换为字典列表
|
||||
path_history = [
|
||||
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
|
||||
for item in request.pathHistory
|
||||
]
|
||||
|
||||
# 调用 AI 服务
|
||||
from app.services.ai import ai_service
|
||||
|
||||
ai_result = await ai_service.rewrite_branch(
|
||||
story_title=story.title,
|
||||
story_category=story.category or "未知",
|
||||
path_history=path_history,
|
||||
current_content=request.currentContent,
|
||||
user_prompt=request.prompt
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": ai_result["nodes"],
|
||||
"entryNodeKey": ai_result.get("entryNodeKey", "branch_1"),
|
||||
"tokensUsed": ai_result.get("tokens_used", 0)
|
||||
}
|
||||
}
|
||||
|
||||
# AI 服务不可用时,返回空结果(不使用兜底模板)
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": None,
|
||||
"entryNodeKey": None,
|
||||
"tokensUsed": 0,
|
||||
"error": "AI服务暂时不可用"
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func, text
|
||||
from sqlalchemy import select, update, func, text, delete
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserProgress, UserEnding
|
||||
from app.models.user import User, UserProgress, UserEnding, PlayRecord
|
||||
from app.models.story import Story
|
||||
|
||||
router = APIRouter()
|
||||
@@ -46,6 +46,14 @@ class CollectRequest(BaseModel):
|
||||
isCollected: bool
|
||||
|
||||
|
||||
class PlayRecordRequest(BaseModel):
|
||||
userId: int
|
||||
storyId: int
|
||||
endingName: str
|
||||
endingType: str = ""
|
||||
pathHistory: list
|
||||
|
||||
|
||||
# ========== API接口 ==========
|
||||
|
||||
@router.post("/login")
|
||||
@@ -419,3 +427,147 @@ async def get_ai_quota(user_id: int = Query(..., alias="userId"), db: AsyncSessi
|
||||
"gift": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ========== 游玩记录 API ==========
|
||||
|
||||
@router.post("/play-record")
|
||||
async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""保存游玩记录(相同路径只保留最新)"""
|
||||
import json
|
||||
|
||||
# 查找该用户该故事的所有记录
|
||||
result = await db.execute(
|
||||
select(PlayRecord)
|
||||
.where(PlayRecord.user_id == request.userId, PlayRecord.story_id == request.storyId)
|
||||
)
|
||||
existing_records = result.scalars().all()
|
||||
|
||||
# 检查是否有相同路径的记录
|
||||
new_path_str = json.dumps(request.pathHistory, sort_keys=True, ensure_ascii=False)
|
||||
for old_record in existing_records:
|
||||
old_path_str = json.dumps(old_record.path_history, sort_keys=True, ensure_ascii=False)
|
||||
if old_path_str == new_path_str:
|
||||
# 相同路径,删除旧记录
|
||||
await db.delete(old_record)
|
||||
|
||||
# 创建新记录
|
||||
record = PlayRecord(
|
||||
user_id=request.userId,
|
||||
story_id=request.storyId,
|
||||
ending_name=request.endingName,
|
||||
ending_type=request.endingType,
|
||||
path_history=request.pathHistory
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"recordId": record.id,
|
||||
"message": "记录保存成功"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/play-records")
|
||||
async def get_play_records(
|
||||
user_id: int = Query(..., alias="userId"),
|
||||
story_id: Optional[int] = Query(None, alias="storyId"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取游玩记录列表"""
|
||||
if story_id:
|
||||
# 获取指定故事的记录
|
||||
result = await db.execute(
|
||||
select(PlayRecord)
|
||||
.where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id)
|
||||
.order_by(PlayRecord.created_at.desc())
|
||||
)
|
||||
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]
|
||||
else:
|
||||
# 获取所有玩过的故事(按故事分组,取最新一条)
|
||||
result = await db.execute(
|
||||
select(PlayRecord, Story.title, Story.cover_url)
|
||||
.join(Story, PlayRecord.story_id == Story.id)
|
||||
.where(PlayRecord.user_id == user_id)
|
||||
.order_by(PlayRecord.created_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# 按 story_id 分组,取每个故事的最新记录和记录数
|
||||
story_map = {}
|
||||
for row in rows:
|
||||
sid = row.PlayRecord.story_id
|
||||
if sid not in story_map:
|
||||
story_map[sid] = {
|
||||
"storyId": sid,
|
||||
"storyTitle": row.title,
|
||||
"coverUrl": row.cover_url,
|
||||
"latestEnding": row.PlayRecord.ending_name,
|
||||
"latestTime": row.PlayRecord.created_at.strftime("%Y-%m-%d %H:%M") if row.PlayRecord.created_at else "",
|
||||
"recordCount": 0
|
||||
}
|
||||
story_map[sid]["recordCount"] += 1
|
||||
|
||||
data = list(story_map.values())
|
||||
|
||||
return {"code": 0, "data": data}
|
||||
|
||||
|
||||
@router.get("/play-records/{record_id}")
|
||||
async def get_play_record_detail(
|
||||
record_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取单条记录详情"""
|
||||
result = await db.execute(
|
||||
select(PlayRecord, Story.title)
|
||||
.join(Story, PlayRecord.story_id == Story.id)
|
||||
.where(PlayRecord.id == record_id)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if not row:
|
||||
return {"code": 404, "message": "记录不存在"}
|
||||
|
||||
record = row.PlayRecord
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": record.id,
|
||||
"storyId": record.story_id,
|
||||
"storyTitle": row.title,
|
||||
"endingName": record.ending_name,
|
||||
"endingType": record.ending_type,
|
||||
"pathHistory": record.path_history,
|
||||
"createdAt": record.created_at.strftime("%Y-%m-%d %H:%M") if record.created_at else ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/play-records/{record_id}")
|
||||
async def delete_play_record(
|
||||
record_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除游玩记录"""
|
||||
result = await db.execute(select(PlayRecord).where(PlayRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
return {"code": 404, "message": "记录不存在"}
|
||||
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
|
||||
return {"code": 0, "message": "删除成功"}
|
||||
|
||||
BIN
server/app/services/__pycache__/ai.cpython-310.pyc
Normal file
BIN
server/app/services/__pycache__/ai.cpython-310.pyc
Normal file
Binary file not shown.
@@ -2,8 +2,10 @@
|
||||
AI服务封装模块
|
||||
支持多种AI提供商:DeepSeek, OpenAI, Claude, 通义千问
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
import httpx
|
||||
import json
|
||||
import re
|
||||
|
||||
class AIService:
|
||||
def __init__(self):
|
||||
@@ -13,11 +15,14 @@ class AIService:
|
||||
self.enabled = settings.ai_service_enabled
|
||||
self.provider = settings.ai_provider
|
||||
|
||||
print(f"[AI服务初始化] enabled={self.enabled}, provider={self.provider}")
|
||||
|
||||
# 根据提供商初始化配置
|
||||
if self.provider == "deepseek":
|
||||
self.api_key = settings.deepseek_api_key
|
||||
self.base_url = settings.deepseek_base_url
|
||||
self.model = settings.deepseek_model
|
||||
print(f"[AI服务初始化] DeepSeek配置: api_key={self.api_key[:20] + '...' if self.api_key else 'None'}, base_url={self.base_url}, model={self.model}")
|
||||
elif self.provider == "openai":
|
||||
self.api_key = settings.openai_api_key
|
||||
self.base_url = settings.openai_base_url
|
||||
@@ -86,6 +91,532 @@ class AIService:
|
||||
|
||||
return None
|
||||
|
||||
async def rewrite_branch(
|
||||
self,
|
||||
story_title: str,
|
||||
story_category: str,
|
||||
path_history: List[Dict[str, str]],
|
||||
current_content: str,
|
||||
user_prompt: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI改写中间章节,生成新的剧情分支
|
||||
"""
|
||||
print(f"\n[rewrite_branch] ========== 开始调用 ==========")
|
||||
print(f"[rewrite_branch] story_title={story_title}, category={story_category}")
|
||||
print(f"[rewrite_branch] user_prompt={user_prompt}")
|
||||
print(f"[rewrite_branch] path_history长度={len(path_history)}")
|
||||
print(f"[rewrite_branch] current_content长度={len(current_content)}")
|
||||
print(f"[rewrite_branch] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
|
||||
|
||||
if not self.enabled or not self.api_key:
|
||||
print(f"[rewrite_branch] 服务未启用或API Key为空,返回None")
|
||||
return None
|
||||
|
||||
# 构建路径历史文本
|
||||
path_text = ""
|
||||
for i, item in enumerate(path_history, 1):
|
||||
path_text += f"第{i}段:{item.get('content', '')}\n"
|
||||
if item.get('choice'):
|
||||
path_text += f" → 用户选择:{item['choice']}\n"
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt = """你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
|
||||
|
||||
【任务】
|
||||
请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。
|
||||
|
||||
【写作要求】
|
||||
1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 严格符合用户的改写意图,围绕用户指令展开剧情
|
||||
6. 保持原故事的人物性格、语言风格和世界观
|
||||
7. 对话要自然生动,描写要有画面感
|
||||
|
||||
【关于结局 - 极其重要!】
|
||||
★★★ 每一条分支路径的尽头必须是结局节点 ★★★
|
||||
- 结局节点必须设置 "is_ending": true
|
||||
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
|
||||
- 结局名称 4-8 字,体现剧情走向
|
||||
- 如果有2个选项分支,最终必须有2个不同的结局
|
||||
- 不允许出现没有结局的"死胡同"节点
|
||||
- 每个结局必须有 "ending_score" 评分(0-100):
|
||||
- good 好结局:80-100分
|
||||
- bad 坏结局:20-50分
|
||||
- neutral 中立结局:50-70分
|
||||
- special 特殊结局:70-90分
|
||||
|
||||
【内容分段示例】
|
||||
"content": "他的声音在耳边响起,像是一阵温柔的风。\n\n\"我喜欢你。\"他说,目光坚定地看着你。\n\n你的心跳漏了一拍,一时间不知该如何回应。"
|
||||
|
||||
【输出格式】(严格JSON,不要有任何额外文字)
|
||||
{
|
||||
"nodes": {
|
||||
"branch_1": {
|
||||
"content": "新剧情第一段(150-300字)...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项A(5-15字)", "nextNodeKey": "branch_2a"},
|
||||
{"text": "选项B(5-15字)", "nextNodeKey": "branch_2b"}
|
||||
]
|
||||
},
|
||||
"branch_2a": {
|
||||
"content": "...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项C", "nextNodeKey": "branch_ending_good"},
|
||||
{"text": "选项D", "nextNodeKey": "branch_ending_bad"}
|
||||
]
|
||||
},
|
||||
"branch_2b": {
|
||||
"content": "...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项E", "nextNodeKey": "branch_ending_neutral"},
|
||||
{"text": "选项F", "nextNodeKey": "branch_ending_special"}
|
||||
]
|
||||
},
|
||||
"branch_ending_good": {
|
||||
"content": "好结局内容(200-400字)...\n\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
"ending_type": "good",
|
||||
"ending_score": 90
|
||||
},
|
||||
"branch_ending_bad": {
|
||||
"content": "坏结局内容...\n\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
"ending_type": "bad",
|
||||
"ending_score": 40
|
||||
},
|
||||
"branch_ending_neutral": {
|
||||
"content": "中立结局...\n\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
"ending_type": "neutral",
|
||||
"ending_score": 60
|
||||
},
|
||||
"branch_ending_special": {
|
||||
"content": "特殊结局...\n\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
"ending_type": "special",
|
||||
"ending_score": 80
|
||||
}
|
||||
},
|
||||
"entryNodeKey": "branch_1"
|
||||
}"""
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt_text = f"""【原故事信息】
|
||||
故事标题:{story_title}
|
||||
故事分类:{story_category}
|
||||
|
||||
【用户已走过的剧情】
|
||||
{path_text}
|
||||
|
||||
【当前节点】
|
||||
{current_content}
|
||||
|
||||
【用户改写指令】
|
||||
{user_prompt}
|
||||
|
||||
请创作新的剧情分支(输出JSON格式):"""
|
||||
|
||||
print(f"[rewrite_branch] 提示词构建完成,开始调用AI...")
|
||||
print(f"[rewrite_branch] provider={self.provider}")
|
||||
|
||||
try:
|
||||
result = None
|
||||
if self.provider == "openai":
|
||||
print(f"[rewrite_branch] 调用 OpenAI...")
|
||||
result = await self._call_openai_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "claude":
|
||||
print(f"[rewrite_branch] 调用 Claude...")
|
||||
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
|
||||
elif self.provider == "qwen":
|
||||
print(f"[rewrite_branch] 调用 Qwen...")
|
||||
result = await self._call_qwen_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "deepseek":
|
||||
print(f"[rewrite_branch] 调用 DeepSeek...")
|
||||
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
|
||||
|
||||
print(f"[rewrite_branch] AI调用完成,result存在={result is not None}")
|
||||
|
||||
if result and result.get("content"):
|
||||
print(f"[rewrite_branch] AI返回内容长度={len(result.get('content', ''))}")
|
||||
print(f"[rewrite_branch] AI返回内容前500字: {result.get('content', '')[:500]}")
|
||||
|
||||
# 解析JSON响应
|
||||
parsed = self._parse_branch_json(result["content"])
|
||||
print(f"[rewrite_branch] JSON解析结果: parsed存在={parsed is not None}")
|
||||
|
||||
if parsed:
|
||||
parsed["tokens_used"] = result.get("tokens_used", 0)
|
||||
print(f"[rewrite_branch] 成功! nodes数量={len(parsed.get('nodes', {}))}, tokens={parsed.get('tokens_used')}")
|
||||
return parsed
|
||||
else:
|
||||
print(f"[rewrite_branch] JSON解析失败!")
|
||||
else:
|
||||
print(f"[rewrite_branch] AI返回为空或无content")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[rewrite_branch] 异常: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
async def continue_ending(
|
||||
self,
|
||||
story_title: str,
|
||||
story_category: str,
|
||||
ending_name: str,
|
||||
ending_content: str,
|
||||
user_prompt: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI续写结局,从结局开始续写新的剧情分支
|
||||
"""
|
||||
print(f"\n[continue_ending] ========== 开始调用 ==========")
|
||||
print(f"[continue_ending] story_title={story_title}, category={story_category}")
|
||||
print(f"[continue_ending] ending_name={ending_name}")
|
||||
print(f"[continue_ending] user_prompt={user_prompt}")
|
||||
print(f"[continue_ending] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
|
||||
|
||||
if not self.enabled or not self.api_key:
|
||||
print(f"[continue_ending] 服务未启用或API Key为空,返回None")
|
||||
return None
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt = """你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。
|
||||
|
||||
【任务】
|
||||
请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。
|
||||
|
||||
【写作要求】
|
||||
1. 第一个节点必须紧密衔接原结局,像是结局之后自然发生的事
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 严格符合用户的续写意图,围绕用户指令展开剧情
|
||||
6. 保持原故事的人物性格、语言风格和世界观
|
||||
7. 对话要自然生动,描写要有画面感
|
||||
|
||||
【关于新结局 - 极其重要!】
|
||||
★★★ 每一条分支路径的尽头必须是新结局节点 ★★★
|
||||
- 结局节点必须设置 "is_ending": true
|
||||
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
|
||||
- 结局名称 4-8 字,体现剧情走向
|
||||
- 如果有2个选项分支,最终必须有2个不同的结局
|
||||
- 每个结局必须有 "ending_score" 评分(0-100)
|
||||
|
||||
【输出格式】(严格JSON,不要有任何额外文字)
|
||||
{
|
||||
"nodes": {
|
||||
"continue_1": {
|
||||
"content": "续写剧情第一段(150-300字)...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项A(5-15字)", "nextNodeKey": "continue_2a"},
|
||||
{"text": "选项B(5-15字)", "nextNodeKey": "continue_2b"}
|
||||
]
|
||||
},
|
||||
"continue_2a": {
|
||||
"content": "...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项C", "nextNodeKey": "continue_ending_good"},
|
||||
{"text": "选项D", "nextNodeKey": "continue_ending_bad"}
|
||||
]
|
||||
},
|
||||
"continue_ending_good": {
|
||||
"content": "新好结局内容(200-400字)...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "新结局名称",
|
||||
"ending_type": "good",
|
||||
"ending_score": 90
|
||||
},
|
||||
"continue_ending_bad": {
|
||||
"content": "新坏结局内容...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "新结局名称",
|
||||
"ending_type": "bad",
|
||||
"ending_score": 40
|
||||
}
|
||||
},
|
||||
"entryNodeKey": "continue_1"
|
||||
}"""
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt_text = f"""【原故事信息】
|
||||
故事标题:{story_title}
|
||||
故事分类:{story_category}
|
||||
|
||||
【已达成的结局】
|
||||
结局名称:{ending_name}
|
||||
结局内容:{ending_content[:800]}
|
||||
|
||||
【用户续写指令】
|
||||
{user_prompt}
|
||||
|
||||
请从这个结局开始续写新的剧情分支(输出JSON格式):"""
|
||||
|
||||
print(f"[continue_ending] 提示词构建完成,开始调用AI...")
|
||||
|
||||
try:
|
||||
result = None
|
||||
if self.provider == "openai":
|
||||
result = await self._call_openai_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "claude":
|
||||
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
|
||||
elif self.provider == "qwen":
|
||||
result = await self._call_qwen_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "deepseek":
|
||||
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
|
||||
|
||||
print(f"[continue_ending] AI调用完成,result存在={result is not None}")
|
||||
|
||||
if result and result.get("content"):
|
||||
print(f"[continue_ending] AI返回内容长度={len(result.get('content', ''))}")
|
||||
|
||||
# 解析JSON响应(复用 rewrite_branch 的解析方法)
|
||||
parsed = self._parse_branch_json(result["content"])
|
||||
print(f"[continue_ending] JSON解析结果: parsed存在={parsed is not None}")
|
||||
|
||||
if parsed:
|
||||
parsed["tokens_used"] = result.get("tokens_used", 0)
|
||||
print(f"[continue_ending] 成功! nodes数量={len(parsed.get('nodes', {}))}")
|
||||
return parsed
|
||||
else:
|
||||
print(f"[continue_ending] JSON解析失败!")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[continue_ending] 异常: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _parse_branch_json(self, content: str) -> Optional[Dict]:
|
||||
"""解析AI返回的分支JSON"""
|
||||
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
||||
|
||||
# 移除 markdown 代码块标记
|
||||
clean_content = content.strip()
|
||||
if clean_content.startswith('```'):
|
||||
# 移除开头的 ```json 或 ```
|
||||
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
|
||||
# 移除结尾的 ```
|
||||
clean_content = re.sub(r'\s*```$', '', clean_content)
|
||||
|
||||
try:
|
||||
# 尝试直接解析
|
||||
result = json.loads(clean_content)
|
||||
print(f"[_parse_branch_json] 直接解析成功!")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[_parse_branch_json] 直接解析失败: {e}")
|
||||
|
||||
# 尝试提取JSON块
|
||||
try:
|
||||
# 匹配 { ... } 结构
|
||||
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
|
||||
if brace_match:
|
||||
json_str = brace_match.group(0)
|
||||
print(f"[_parse_branch_json] 找到花括号块,尝试解析...")
|
||||
|
||||
try:
|
||||
result = json.loads(json_str)
|
||||
print(f"[_parse_branch_json] 花括号块解析成功!")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[_parse_branch_json] 花括号块解析失败: {e}")
|
||||
# 打印错误位置附近的内容
|
||||
error_pos = e.pos if hasattr(e, 'pos') else 0
|
||||
start = max(0, error_pos - 100)
|
||||
end = min(len(json_str), error_pos + 100)
|
||||
print(f"[_parse_branch_json] 错误位置附近内容: ...{json_str[start:end]}...")
|
||||
|
||||
# 尝试修复不完整的 JSON
|
||||
print(f"[_parse_branch_json] 尝试修复不完整的JSON...")
|
||||
fixed_json = self._try_fix_incomplete_json(json_str)
|
||||
if fixed_json:
|
||||
print(f"[_parse_branch_json] JSON修复成功!")
|
||||
return fixed_json
|
||||
|
||||
except Exception as e:
|
||||
print(f"[_parse_branch_json] 提取解析异常: {e}")
|
||||
|
||||
print(f"[_parse_branch_json] 所有解析方法都失败了")
|
||||
return None
|
||||
|
||||
def _try_fix_incomplete_json(self, json_str: str) -> Optional[Dict]:
|
||||
"""尝试修复不完整的JSON(被截断的情况)"""
|
||||
try:
|
||||
# 找到已完成的节点,截断不完整的部分
|
||||
# 查找最后一个完整的节点(以 } 结尾,后面跟着逗号或闭括号)
|
||||
|
||||
# 先找到 "nodes": { 的位置
|
||||
nodes_match = re.search(r'"nodes"\s*:\s*\{', json_str)
|
||||
if not nodes_match:
|
||||
return None
|
||||
|
||||
nodes_start = nodes_match.end()
|
||||
|
||||
# 找所有完整的 branch 节点
|
||||
branch_pattern = r'"branch_\w+"\s*:\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
|
||||
branches = list(re.finditer(branch_pattern, json_str[nodes_start:]))
|
||||
|
||||
if not branches:
|
||||
return None
|
||||
|
||||
# 取最后一个完整的节点的结束位置
|
||||
last_complete_end = nodes_start + branches[-1].end()
|
||||
|
||||
# 构建修复后的 JSON
|
||||
# 截取到最后一个完整节点,然后补全结构
|
||||
truncated = json_str[:last_complete_end]
|
||||
|
||||
# 补全 JSON 结构
|
||||
fixed = truncated + '\n },\n "entryNodeKey": "branch_1"\n}'
|
||||
|
||||
print(f"[_try_fix_incomplete_json] 修复后的JSON长度: {len(fixed)}")
|
||||
result = json.loads(fixed)
|
||||
|
||||
# 验证结果结构
|
||||
if "nodes" in result and len(result["nodes"]) > 0:
|
||||
print(f"[_try_fix_incomplete_json] 修复后节点数: {len(result['nodes'])}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[_try_fix_incomplete_json] 修复失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _call_deepseek_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用 DeepSeek API (长文本版本)"""
|
||||
print(f"[_call_deepseek_long] 开始调用...")
|
||||
print(f"[_call_deepseek_long] base_url={self.base_url}")
|
||||
print(f"[_call_deepseek_long] model={self.model}")
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
"temperature": 0.85,
|
||||
"max_tokens": 6000 # 增加输出长度,确保JSON完整
|
||||
}
|
||||
|
||||
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")
|
||||
print(f"[_call_deepseek_long] user_prompt长度={len(user_prompt)}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
try:
|
||||
print(f"[_call_deepseek_long] 发送请求到 {url}...")
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
print(f"[_call_deepseek_long] 响应状态码: {response.status_code}")
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
print(f"[_call_deepseek_long] 响应JSON keys: {result.keys()}")
|
||||
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
tokens = result.get("usage", {}).get("total_tokens", 0)
|
||||
print(f"[_call_deepseek_long] 成功! content长度={len(content)}, tokens={tokens}")
|
||||
return {"content": content.strip(), "tokens_used": tokens}
|
||||
else:
|
||||
print(f"[_call_deepseek_long] 响应异常,无choices: {result}")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"[_call_deepseek_long] HTTP错误: {e.response.status_code} - {e.response.text}")
|
||||
return None
|
||||
except httpx.TimeoutException as e:
|
||||
print(f"[_call_deepseek_long] 请求超时: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[_call_deepseek_long] 其他错误: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
async def _call_openai_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用OpenAI API (长文本版本)"""
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
tokens = result["usage"]["total_tokens"]
|
||||
|
||||
return {"content": content.strip(), "tokens_used": tokens}
|
||||
|
||||
async def _call_qwen_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用通义千问API (长文本版本)"""
|
||||
url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"input": {
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"result_format": "message",
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
content = result["output"]["choices"][0]["message"]["content"]
|
||||
tokens = result.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
return {"content": content.strip(), "tokens_used": tokens}
|
||||
|
||||
async def _call_openai(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用OpenAI API"""
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
|
||||
26
server/container.config.json
Normal file
26
server/container.config.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"containerPort": 3000,
|
||||
"dockerfilePath": "Dockerfile",
|
||||
"buildDir": "",
|
||||
"minNum": 0,
|
||||
"maxNum": 10,
|
||||
"cpu": 0.5,
|
||||
"mem": 1,
|
||||
"policyType": "cpu",
|
||||
"policyThreshold": 60,
|
||||
"envParams": {
|
||||
"SERVER_HOST": "0.0.0.0",
|
||||
"SERVER_PORT": "3000",
|
||||
"DEBUG": "False",
|
||||
"DB_HOST": "10.45.108.178",
|
||||
"DB_PORT": "3306",
|
||||
"DB_USER": "root",
|
||||
"DB_PASSWORD": "!Lgd20020523",
|
||||
"DB_NAME": "stardom_story",
|
||||
"AI_SERVICE_ENABLED": "True",
|
||||
"AI_PROVIDER": "deepseek",
|
||||
"DEEPSEEK_API_KEY": "sk-a685e8a0e97e41e4b3cb70fa6fcc3af1",
|
||||
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"DEEPSEEK_MODEL": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
httpx==0.27.0
|
||||
|
||||
29
server/sql/add_test_user.py
Normal file
29
server/sql/add_test_user.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""添加测试用户"""
|
||||
import os
|
||||
import pymysql
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_DIR = Path(__file__).parent.parent
|
||||
env_file = SERVER_DIR / '.env'
|
||||
if env_file.exists():
|
||||
with open(env_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
port=int(os.getenv('DB_PORT', 3306)),
|
||||
user=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', ''),
|
||||
database='stardom_story',
|
||||
charset='utf8mb4'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT IGNORE INTO users (id, openid, nickname) VALUES (1, 'test_user', '测试用户')")
|
||||
conn.commit()
|
||||
print('测试用户创建成功')
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -9,12 +9,25 @@ from pathlib import Path
|
||||
# 获取当前脚本所在目录
|
||||
SQL_DIR = Path(__file__).parent
|
||||
|
||||
# 数据库配置(从环境变量或默认值)
|
||||
# 从.env文件读取配置
|
||||
def load_env():
|
||||
env_file = SQL_DIR.parent / '.env'
|
||||
config = {}
|
||||
if env_file.exists():
|
||||
for line in env_file.read_text(encoding='utf-8').splitlines():
|
||||
if '=' in line and not line.startswith('#'):
|
||||
k, v = line.split('=', 1)
|
||||
config[k.strip()] = v.strip()
|
||||
return config
|
||||
|
||||
env_config = load_env()
|
||||
|
||||
# 数据库配置(优先从.env读取)
|
||||
DB_CONFIG = {
|
||||
'host': os.getenv('DB_HOST', 'localhost'),
|
||||
'port': int(os.getenv('DB_PORT', 3306)),
|
||||
'user': os.getenv('DB_USER', 'root'),
|
||||
'password': os.getenv('DB_PASSWORD', '123456'),
|
||||
'host': env_config.get('DB_HOST', os.getenv('DB_HOST', 'localhost')),
|
||||
'port': int(env_config.get('DB_PORT', os.getenv('DB_PORT', 3306))),
|
||||
'user': env_config.get('DB_USER', os.getenv('DB_USER', 'root')),
|
||||
'password': env_config.get('DB_PASSWORD', os.getenv('DB_PASSWORD', '')),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
|
||||
74
server/sql/rebuild_db.py
Normal file
74
server/sql/rebuild_db.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""删库重建脚本"""
|
||||
import os
|
||||
import sys
|
||||
import pymysql
|
||||
from pathlib import Path
|
||||
|
||||
SQL_DIR = Path(__file__).parent
|
||||
SERVER_DIR = SQL_DIR.parent
|
||||
|
||||
# 加载 .env 文件
|
||||
env_file = SERVER_DIR / '.env'
|
||||
if env_file.exists():
|
||||
with open(env_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
DB_CONFIG = {
|
||||
'host': os.getenv('DB_HOST', 'localhost'),
|
||||
'port': int(os.getenv('DB_PORT', 3306)),
|
||||
'user': os.getenv('DB_USER', 'root'),
|
||||
'password': os.getenv('DB_PASSWORD', '123456'),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
def read_sql_file(filename):
|
||||
with open(SQL_DIR / filename, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
def execute_sql(cursor, sql, desc):
|
||||
print(f'{desc}...')
|
||||
for stmt in [s.strip() for s in sql.split(';') if s.strip()]:
|
||||
try:
|
||||
cursor.execute(stmt)
|
||||
except pymysql.Error as e:
|
||||
if e.args[0] not in [1007, 1050]:
|
||||
print(f' 警告: {e.args[1]}')
|
||||
print(f' {desc}完成!')
|
||||
|
||||
def rebuild():
|
||||
print('=' * 50)
|
||||
print('星域故事汇 - 删库重建')
|
||||
print('=' * 50)
|
||||
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 删库
|
||||
print('删除旧数据库...')
|
||||
cur.execute('DROP DATABASE IF EXISTS stardom_story')
|
||||
conn.commit()
|
||||
print(' 删除完成!')
|
||||
|
||||
# 重建
|
||||
schema_sql = read_sql_file('schema.sql')
|
||||
execute_sql(cur, schema_sql, '创建数据库表结构')
|
||||
conn.commit()
|
||||
|
||||
seed1 = read_sql_file('seed_stories_part1.sql')
|
||||
execute_sql(cur, seed1, '导入种子数据(第1部分)')
|
||||
conn.commit()
|
||||
|
||||
seed2 = read_sql_file('seed_stories_part2.sql')
|
||||
execute_sql(cur, seed2, '导入种子数据(第2部分)')
|
||||
conn.commit()
|
||||
|
||||
print('\n数据库重建完成!')
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
rebuild()
|
||||
@@ -1,107 +1,178 @@
|
||||
-- 星域故事汇数据库初始化脚本
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- ============================================
|
||||
-- 星域故事汇数据库结构
|
||||
-- 基于实际数据库导出,共7张表
|
||||
-- ============================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE stardom_story;
|
||||
|
||||
-- 故事主表
|
||||
CREATE TABLE IF NOT EXISTS stories (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
title VARCHAR(100) NOT NULL COMMENT '故事标题',
|
||||
cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
|
||||
description TEXT COMMENT '故事简介',
|
||||
author_id INT DEFAULT 0 COMMENT '作者ID,0表示官方',
|
||||
category VARCHAR(50) NOT NULL COMMENT '故事分类',
|
||||
play_count INT DEFAULT 0 COMMENT '游玩次数',
|
||||
like_count INT DEFAULT 0 COMMENT '点赞数',
|
||||
is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_category (category),
|
||||
INDEX idx_featured (is_featured),
|
||||
INDEX idx_play_count (play_count)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表';
|
||||
|
||||
-- 故事节点表
|
||||
CREATE TABLE IF NOT EXISTS story_nodes (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
story_id INT NOT NULL COMMENT '故事ID',
|
||||
node_key VARCHAR(50) NOT NULL COMMENT '节点唯一标识',
|
||||
content TEXT NOT NULL COMMENT '节点内容文本',
|
||||
speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色名',
|
||||
background_image VARCHAR(255) DEFAULT '' COMMENT '背景图URL',
|
||||
character_image VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL',
|
||||
bgm VARCHAR(255) DEFAULT '' COMMENT '背景音乐',
|
||||
is_ending BOOLEAN DEFAULT FALSE COMMENT '是否为结局节点',
|
||||
ending_name VARCHAR(100) DEFAULT '' COMMENT '结局名称',
|
||||
ending_score INT DEFAULT 0 COMMENT '结局评分',
|
||||
ending_type VARCHAR(20) DEFAULT '' COMMENT '结局类型:good/bad/normal/hidden',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_story_id (story_id),
|
||||
INDEX idx_node_key (story_id, node_key),
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表';
|
||||
|
||||
-- 故事选项表
|
||||
CREATE TABLE IF NOT EXISTS story_choices (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
node_id INT NOT NULL COMMENT '所属节点ID',
|
||||
story_id INT NOT NULL COMMENT '故事ID(冗余,便于查询)',
|
||||
text VARCHAR(200) NOT NULL COMMENT '选项文本',
|
||||
next_node_key VARCHAR(50) NOT NULL COMMENT '下一个节点key',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||
is_locked BOOLEAN DEFAULT FALSE COMMENT '是否锁定(需广告解锁)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_node_id (node_id),
|
||||
INDEX idx_story_id (story_id),
|
||||
FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表';
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid',
|
||||
nickname VARCHAR(100) DEFAULT '' COMMENT '昵称',
|
||||
avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像URL',
|
||||
gender TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
|
||||
total_play_count INT DEFAULT 0 COMMENT '总游玩次数',
|
||||
total_endings INT DEFAULT 0 COMMENT '解锁结局数',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_openid (openid)
|
||||
-- ============================================
|
||||
-- 1. 用户表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`openid` VARCHAR(100) NOT NULL COMMENT '微信openid',
|
||||
`nickname` VARCHAR(100) DEFAULT '' COMMENT '昵称',
|
||||
`avatar_url` VARCHAR(255) DEFAULT '' COMMENT '头像URL',
|
||||
`gender` TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
|
||||
`total_play_count` INT DEFAULT 0 COMMENT '总游玩次数',
|
||||
`total_endings` INT DEFAULT 0 COMMENT '解锁结局数',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `openid` (`openid`),
|
||||
KEY `idx_openid` (`openid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 用户进度表
|
||||
CREATE TABLE IF NOT EXISTS user_progress (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
story_id INT NOT NULL COMMENT '故事ID',
|
||||
current_node_key VARCHAR(50) DEFAULT 'start' COMMENT '当前节点',
|
||||
is_completed BOOLEAN DEFAULT FALSE COMMENT '是否完成',
|
||||
ending_reached VARCHAR(100) DEFAULT '' COMMENT '达成的结局',
|
||||
is_liked BOOLEAN DEFAULT FALSE COMMENT '是否点赞',
|
||||
is_collected BOOLEAN DEFAULT FALSE COMMENT '是否收藏',
|
||||
play_count INT DEFAULT 1 COMMENT '游玩次数',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_story (user_id, story_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_story_id (story_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
-- ============================================
|
||||
-- 2. 故事主表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `stories` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(100) NOT NULL COMMENT '故事标题',
|
||||
`cover_url` VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
|
||||
`description` TEXT COMMENT '故事简介',
|
||||
`author_id` INT DEFAULT 0 COMMENT '作者ID,0表示官方',
|
||||
`category` VARCHAR(50) NOT NULL COMMENT '故事分类',
|
||||
`play_count` INT DEFAULT 0 COMMENT '游玩次数',
|
||||
`like_count` INT DEFAULT 0 COMMENT '点赞数',
|
||||
`is_featured` TINYINT(1) DEFAULT 0 COMMENT '是否精选',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_category` (`category`),
|
||||
KEY `idx_featured` (`is_featured`),
|
||||
KEY `idx_play_count` (`play_count`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表';
|
||||
|
||||
-- ============================================
|
||||
-- 3. 故事节点表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `story_nodes` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`story_id` INT NOT NULL COMMENT '故事ID',
|
||||
`node_key` VARCHAR(50) NOT NULL COMMENT '节点唯一标识',
|
||||
`content` TEXT NOT NULL COMMENT '节点内容文本',
|
||||
`speaker` VARCHAR(50) DEFAULT '' COMMENT '说话角色名',
|
||||
`background_image` VARCHAR(255) DEFAULT '' COMMENT '背景图URL',
|
||||
`character_image` VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL',
|
||||
`bgm` VARCHAR(255) DEFAULT '' COMMENT '背景音乐',
|
||||
`is_ending` TINYINT(1) DEFAULT 0 COMMENT '是否为结局节点',
|
||||
`ending_name` VARCHAR(100) DEFAULT '' COMMENT '结局名称',
|
||||
`ending_score` INT DEFAULT 0 COMMENT '结局评分',
|
||||
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型:good/bad/normal/hidden',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_story_id` (`story_id`),
|
||||
KEY `idx_node_key` (`story_id`, `node_key`),
|
||||
CONSTRAINT `story_nodes_ibfk_1` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表';
|
||||
|
||||
-- ============================================
|
||||
-- 4. 故事选项表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `story_choices` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`node_id` INT NOT NULL COMMENT '所属节点ID',
|
||||
`story_id` INT NOT NULL COMMENT '故事ID(冗余,便于查询)',
|
||||
`text` VARCHAR(200) NOT NULL COMMENT '选项文本',
|
||||
`next_node_key` VARCHAR(50) NOT NULL COMMENT '下一个节点key',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序',
|
||||
`is_locked` TINYINT(1) DEFAULT 0 COMMENT '是否锁定(需广告解锁)',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_node_id` (`node_id`),
|
||||
KEY `idx_story_id` (`story_id`),
|
||||
CONSTRAINT `story_choices_ibfk_1` FOREIGN KEY (`node_id`) REFERENCES `story_nodes` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表';
|
||||
|
||||
-- ============================================
|
||||
-- 5. 用户进度表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `user_progress` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`story_id` INT NOT NULL COMMENT '故事ID',
|
||||
`current_node_key` VARCHAR(50) DEFAULT 'start' COMMENT '当前节点',
|
||||
`is_completed` TINYINT(1) DEFAULT 0 COMMENT '是否完成',
|
||||
`ending_reached` VARCHAR(100) DEFAULT '' COMMENT '达成的结局',
|
||||
`is_liked` TINYINT(1) DEFAULT 0 COMMENT '是否点赞',
|
||||
`is_collected` TINYINT(1) DEFAULT 0 COMMENT '是否收藏',
|
||||
`play_count` INT DEFAULT 1 COMMENT '游玩次数',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_story` (`user_id`, `story_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_story_id` (`story_id`),
|
||||
CONSTRAINT `user_progress_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `user_progress_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表';
|
||||
|
||||
-- 用户结局收集表
|
||||
CREATE TABLE IF NOT EXISTS user_endings (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
story_id INT NOT NULL,
|
||||
ending_name VARCHAR(100) NOT NULL,
|
||||
ending_score INT DEFAULT 0,
|
||||
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_ending (user_id, story_id, ending_name),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
-- ============================================
|
||||
-- 6. 用户结局收集表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `user_endings` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`story_id` INT NOT NULL,
|
||||
`ending_name` VARCHAR(100) NOT NULL,
|
||||
`ending_score` INT DEFAULT 0,
|
||||
`unlocked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_ending` (`user_id`, `story_id`, `ending_name`),
|
||||
KEY `story_id` (`story_id`),
|
||||
CONSTRAINT `user_endings_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `user_endings_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户结局收集表';
|
||||
|
||||
-- ============================================
|
||||
-- 7. AI改写草稿表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `story_drafts` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`story_id` INT NOT NULL COMMENT '原故事ID',
|
||||
`title` VARCHAR(100) DEFAULT '' COMMENT '草稿标题',
|
||||
`path_history` JSON DEFAULT NULL COMMENT '用户之前的选择路径',
|
||||
`current_node_key` VARCHAR(50) DEFAULT '' COMMENT '改写起始节点',
|
||||
`current_content` TEXT COMMENT '当前节点内容',
|
||||
`user_prompt` VARCHAR(500) NOT NULL COMMENT '用户改写指令',
|
||||
`ai_nodes` JSON DEFAULT NULL COMMENT 'AI生成的新节点',
|
||||
`entry_node_key` VARCHAR(50) DEFAULT '' COMMENT '入口节点',
|
||||
`tokens_used` INT DEFAULT 0 COMMENT '消耗token数',
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
|
||||
`error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因',
|
||||
`is_read` TINYINT(1) DEFAULT 0 COMMENT '用户是否已查看',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_story` (`story_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_user_unread` (`user_id`, `is_read`),
|
||||
CONSTRAINT `story_drafts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `story_drafts_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI改写草稿表';
|
||||
|
||||
-- ============================================
|
||||
-- 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='游玩记录表';
|
||||
|
||||
@@ -564,3 +564,39 @@ CREATE TABLE sensitive_words (
|
||||
UNIQUE KEY uk_word (word),
|
||||
INDEX idx_category (category)
|
||||
) ENGINE=InnoDB COMMENT='敏感词表';
|
||||
|
||||
-- ============================================
|
||||
-- 九、AI改写草稿箱
|
||||
-- ============================================
|
||||
|
||||
-- AI改写草稿表
|
||||
CREATE TABLE story_drafts (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
story_id BIGINT NOT NULL COMMENT '原故事ID',
|
||||
title VARCHAR(100) DEFAULT '' COMMENT '草稿标题',
|
||||
|
||||
-- 用户输入
|
||||
path_history JSON COMMENT '用户之前的选择路径',
|
||||
current_node_key VARCHAR(50) DEFAULT '' COMMENT '改写起始节点',
|
||||
current_content TEXT COMMENT '当前节点内容',
|
||||
user_prompt VARCHAR(500) NOT NULL COMMENT '用户改写指令',
|
||||
|
||||
-- AI生成结果
|
||||
ai_nodes JSON COMMENT 'AI生成的新节点',
|
||||
entry_node_key VARCHAR(50) DEFAULT '' COMMENT '入口节点',
|
||||
tokens_used INT DEFAULT 0 COMMENT '消耗token数',
|
||||
|
||||
-- 状态
|
||||
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态',
|
||||
error_message VARCHAR(500) DEFAULT '' COMMENT '失败原因',
|
||||
is_read BOOLEAN DEFAULT FALSE COMMENT '用户是否已查看',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_story (story_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_user_unread (user_id, is_read)
|
||||
) ENGINE=InnoDB COMMENT='AI改写草稿表';
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
-- 10个种子故事数据
|
||||
-- 种子数据:测试用户 + 10个故事
|
||||
USE stardom_story;
|
||||
|
||||
-- ============================================
|
||||
-- 测试用户
|
||||
-- ============================================
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(1, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
|
||||
-- ============================================
|
||||
-- 1. 都市言情:《总裁的替身新娘》
|
||||
-- ============================================
|
||||
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||
(1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user