Compare commits
23 Commits
66d4bd60c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b6348029 | ||
|
|
d111f1a2cf | ||
| 253bc4aed2 | |||
| c850623a48 | |||
| 5f94129236 | |||
| 0da6f210a6 | |||
|
|
4a69bf2711 | ||
|
|
411110ce0c | ||
|
|
e101e8721b | ||
| 4ac47c8474 | |||
| 2470cea7e4 | |||
| c82c2ec8df | |||
|
|
eac6b2fd1f | ||
| 906b5649f7 | |||
|
|
2b941cc4e0 | ||
|
|
aa23db8a89 | ||
|
|
baf7dd1e2b | ||
| b973c8bdbb | |||
| 9948ccba8f | |||
| 5e931424ab | |||
| c960f9fa79 | |||
|
|
18db6a8cc6 | ||
|
|
bbdccfa843 |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
4
.idea/misc.xml
generated
4
.idea/misc.xml
generated
@@ -1,9 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MavenRunner">
|
||||
<option name="jreName" value="1.8" />
|
||||
<option name="vmOptions" value="-DarchetypeCatalog=internal" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
|
||||
@@ -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续写故事
|
||||
*/
|
||||
@@ -166,7 +345,12 @@ export default class StoryManager {
|
||||
*/
|
||||
async createStory(params) {
|
||||
try {
|
||||
if (!params.userId) {
|
||||
console.error('AI创作失败: 缺少userId');
|
||||
return null;
|
||||
}
|
||||
const result = await post('/stories/ai-create', {
|
||||
userId: params.userId,
|
||||
genre: params.genre,
|
||||
keywords: params.keywords,
|
||||
protagonist: params.protagonist,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 用户数据管理器
|
||||
*/
|
||||
import { get, post } from '../utils/http';
|
||||
import { get, post, put, del } from '../utils/http';
|
||||
|
||||
export default class UserManager {
|
||||
constructor() {
|
||||
@@ -9,50 +9,125 @@ export default class UserManager {
|
||||
this.openid = null;
|
||||
this.nickname = '';
|
||||
this.avatarUrl = '';
|
||||
this.token = '';
|
||||
this.isLoggedIn = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户
|
||||
* 检查是否已登录(只检查本地缓存)
|
||||
* @returns {boolean} 是否已登录
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 尝试从本地存储恢复用户信息
|
||||
checkLogin() {
|
||||
const cached = wx.getStorageSync('userInfo');
|
||||
if (cached) {
|
||||
if (cached && cached.userId && cached.token) {
|
||||
this.userId = cached.userId;
|
||||
this.openid = cached.openid;
|
||||
this.nickname = cached.nickname;
|
||||
this.avatarUrl = cached.avatarUrl;
|
||||
this.nickname = cached.nickname || '游客';
|
||||
this.avatarUrl = cached.avatarUrl || '';
|
||||
this.token = cached.token;
|
||||
this.isLoggedIn = true;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户(只恢复缓存,不自动登录)
|
||||
*/
|
||||
async init() {
|
||||
// 只检查本地缓存,不自动登录
|
||||
this.checkLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行登录(供登录按钮调用)
|
||||
* @param {Object} userInfo - 微信用户信息(头像、昵称等),可选
|
||||
*/
|
||||
async doLogin(userInfo = null) {
|
||||
try {
|
||||
// 获取登录code
|
||||
const { code } = await this.wxLogin();
|
||||
|
||||
// 调用后端登录接口
|
||||
const result = await post('/user/login', { code });
|
||||
// 调用后端登录接口,传入用户信息
|
||||
const result = await post('/user/login', {
|
||||
code,
|
||||
userInfo: userInfo ? {
|
||||
nickname: userInfo.nickName,
|
||||
avatarUrl: userInfo.avatarUrl,
|
||||
gender: userInfo.gender || 0
|
||||
} : null
|
||||
});
|
||||
|
||||
this.userId = result.userId;
|
||||
this.openid = result.openid;
|
||||
this.nickname = result.nickname || '游客';
|
||||
this.avatarUrl = result.avatarUrl || '';
|
||||
// 优先使用后端返回的,其次用授权获取的
|
||||
this.nickname = result.nickname || (userInfo?.nickName) || '游客';
|
||||
this.avatarUrl = result.avatarUrl || (userInfo?.avatarUrl) || '';
|
||||
this.token = result.token || '';
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// 缓存用户信息
|
||||
// 缓存用户信息(包含 token)
|
||||
wx.setStorageSync('userInfo', {
|
||||
userId: this.userId,
|
||||
openid: this.openid,
|
||||
nickname: this.nickname,
|
||||
avatarUrl: this.avatarUrl
|
||||
avatarUrl: this.avatarUrl,
|
||||
token: this.token
|
||||
});
|
||||
|
||||
return {
|
||||
userId: this.userId,
|
||||
nickname: this.nickname,
|
||||
avatarUrl: this.avatarUrl
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('用户初始化失败:', error);
|
||||
// 使用临时身份
|
||||
this.userId = 0;
|
||||
this.nickname = '游客';
|
||||
console.error('登录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
logout() {
|
||||
this.userId = null;
|
||||
this.openid = null;
|
||||
this.nickname = '';
|
||||
this.avatarUrl = '';
|
||||
this.token = '';
|
||||
this.isLoggedIn = false;
|
||||
wx.removeStorageSync('userInfo');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
*/
|
||||
async updateProfile(nickname, avatarUrl) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
await post('/user/profile', {
|
||||
nickname,
|
||||
avatarUrl,
|
||||
gender: 0
|
||||
}, { params: { userId: this.userId } });
|
||||
|
||||
// 更新本地数据
|
||||
this.nickname = nickname;
|
||||
this.avatarUrl = avatarUrl;
|
||||
|
||||
// 更新缓存(保留 token)
|
||||
wx.setStorageSync('userInfo', {
|
||||
userId: this.userId,
|
||||
openid: this.openid,
|
||||
nickname: this.nickname,
|
||||
avatarUrl: this.avatarUrl,
|
||||
token: this.token
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('更新资料失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +138,16 @@ export default class UserManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('登录超时'));
|
||||
}, 3000);
|
||||
}, 5000);
|
||||
|
||||
wx.login({
|
||||
success: (res) => {
|
||||
clearTimeout(timeout);
|
||||
if (res.code) {
|
||||
resolve(res);
|
||||
} else {
|
||||
reject(new Error('获取code失败'));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
clearTimeout(timeout);
|
||||
@@ -124,6 +203,29 @@ export default class UserManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏草稿
|
||||
*/
|
||||
async collectDraft(draftId, isCollected) {
|
||||
if (!this.isLoggedIn) return;
|
||||
await put(`/drafts/${draftId}/collect`, null, {
|
||||
params: { userId: this.userId, isCollected }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿收藏状态
|
||||
*/
|
||||
async getDraftCollectStatus(draftId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
const res = await get(`/drafts/${draftId}/collect-status`, { userId: this.userId });
|
||||
return res?.isCollected || false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
*/
|
||||
@@ -191,4 +293,114 @@ export default class UserManager {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已发布到创作中心的草稿
|
||||
* @param {string} draftType - 草稿类型: rewrite/continue
|
||||
*/
|
||||
async getPublishedDrafts(draftType) {
|
||||
if (!this.isLoggedIn) return [];
|
||||
try {
|
||||
console.log('[UserManager] 获取已发布草稿, userId:', this.userId, 'draftType:', draftType);
|
||||
const res = await get('/drafts/published', { userId: this.userId, draftType });
|
||||
console.log('[UserManager] 已发布草稿响应:', res);
|
||||
return res || [];
|
||||
} catch (e) {
|
||||
console.error('获取已发布草稿失败:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布草稿到创作中心
|
||||
* @param {number} draftId - 草稿ID
|
||||
*/
|
||||
async publishDraft(draftId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
await put(`/drafts/${draftId}/publish?userId=${this.userId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('发布草稿失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从创作中心取消发布
|
||||
* @param {number} draftId - 草稿ID
|
||||
*/
|
||||
async unpublishDraft(draftId) {
|
||||
if (!this.isLoggedIn) return false;
|
||||
try {
|
||||
await put(`/drafts/${draftId}/unpublish?userId=${this.userId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('取消发布失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 游玩记录相关 ==========
|
||||
|
||||
/**
|
||||
* 保存游玩记录
|
||||
*/
|
||||
async savePlayRecord(storyId, endingName, endingType, pathHistory, draftId = null) {
|
||||
if (!this.isLoggedIn) return null;
|
||||
try {
|
||||
return await post('/user/play-record', {
|
||||
userId: this.userId,
|
||||
storyId,
|
||||
draftId, // AI草稿ID,原故事为null
|
||||
endingName,
|
||||
endingType: endingType || '',
|
||||
pathHistory: pathHistory || []
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,45 @@ export default class Main {
|
||||
this.showLoading('正在加载...');
|
||||
console.log('[Main] 开始初始化...');
|
||||
|
||||
// 用户初始化(失败不阻塞)
|
||||
console.log('[Main] 初始化用户...');
|
||||
await this.userManager.init().catch(e => {
|
||||
console.warn('[Main] 用户初始化失败,使用游客模式:', e);
|
||||
// 初始化云环境
|
||||
console.log('[Main] 初始化云环境...');
|
||||
wx.cloud.init({
|
||||
env: 'prod-4gc9i2da1c70fc52', // 云环境ID,需替换为实际值
|
||||
traceUser: true
|
||||
});
|
||||
console.log('[Main] 用户初始化完成');
|
||||
console.log('[Main] 云环境初始化完成');
|
||||
|
||||
// 检查用户是否已登录(只检查缓存,不自动登录)
|
||||
console.log('[Main] 检查登录状态...');
|
||||
const isLoggedIn = this.userManager.checkLogin();
|
||||
console.log('[Main] 登录状态:', isLoggedIn ? '已登录' : '未登录');
|
||||
|
||||
// 隐藏加载界面
|
||||
this.hideLoading();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// 未登录,显示登录场景
|
||||
console.log('[Main] 未登录,显示登录页面');
|
||||
this.sceneManager.switchScene('login');
|
||||
} else {
|
||||
// 已登录,加载数据并进入首页
|
||||
await this.loadAndEnterHome();
|
||||
}
|
||||
|
||||
// 设置分享
|
||||
this.setupShare();
|
||||
} catch (error) {
|
||||
console.error('[Main] 初始化失败:', error);
|
||||
this.hideLoading();
|
||||
this.showError('初始化失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据并进入首页
|
||||
async loadAndEnterHome() {
|
||||
this.showLoading('正在加载...');
|
||||
|
||||
try {
|
||||
// 加载故事列表
|
||||
console.log('[Main] 加载故事列表...');
|
||||
await this.storyManager.loadStoryList();
|
||||
@@ -57,12 +89,15 @@ export default class Main {
|
||||
this.sceneManager.switchScene('home');
|
||||
console.log('[Main] 初始化完成,进入首页');
|
||||
|
||||
// 设置分享
|
||||
this.setupShare();
|
||||
// 启动草稿检查(仅登录用户)
|
||||
if (this.userManager.isLoggedIn) {
|
||||
this.startDraftChecker();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Main] 初始化失败:', error);
|
||||
this.hideLoading();
|
||||
this.showError('初始化失败,请重试');
|
||||
console.error('[Main] 加载失败:', error);
|
||||
// 加载失败也进入首页,让用户可以重试
|
||||
this.sceneManager.switchScene('home');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +146,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({
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* AI创作中心场景
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
import { get, post } from '../utils/http';
|
||||
|
||||
export default class AICreateScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.currentTab = 0; // 0:改写 1:续写 2:创作
|
||||
this.tabs = ['AI改写', 'AI续写', 'AI创作'];
|
||||
this.currentTab = 0; // 0:我的改写 1:我的续写 2:AI创作
|
||||
this.tabs = ['我的改写', '我的续写', 'AI创作'];
|
||||
|
||||
// 滚动
|
||||
this.scrollY = 0;
|
||||
@@ -17,8 +18,8 @@ export default class AICreateScene extends BaseScene {
|
||||
this.hasMoved = false;
|
||||
|
||||
// 用户数据
|
||||
this.recentStories = [];
|
||||
this.aiHistory = [];
|
||||
this.publishedRewrites = []; // 已发布的改写作品
|
||||
this.publishedContinues = []; // 已发布的续写作品
|
||||
this.quota = { daily: 3, used: 0, purchased: 0 };
|
||||
|
||||
// 创作表单
|
||||
@@ -29,13 +30,12 @@ export default class AICreateScene extends BaseScene {
|
||||
conflict: ''
|
||||
};
|
||||
|
||||
// 选中的故事(用于改写/续写)
|
||||
this.selectedStory = null;
|
||||
|
||||
// 快捷标签
|
||||
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘'];
|
||||
this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除'];
|
||||
this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战'];
|
||||
|
||||
// 创作确认面板
|
||||
this.showCreatePanel = false;
|
||||
this.createPanelBtns = {};
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -44,10 +44,17 @@ export default class AICreateScene extends BaseScene {
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
// 加载最近游玩的故事
|
||||
this.recentStories = await this.main.userManager.getRecentPlayed() || [];
|
||||
// 加载AI创作历史
|
||||
this.aiHistory = await this.main.userManager.getAIHistory() || [];
|
||||
const userId = this.main.userManager.userId;
|
||||
if (!userId) return;
|
||||
|
||||
// 加载已发布的改写作品
|
||||
const rewriteRes = await this.main.userManager.getPublishedDrafts('rewrite');
|
||||
this.publishedRewrites = rewriteRes || [];
|
||||
|
||||
// 加载已发布的续写作品
|
||||
const continueRes = await this.main.userManager.getPublishedDrafts('continue');
|
||||
this.publishedContinues = continueRes || [];
|
||||
|
||||
// 加载配额
|
||||
const quotaData = await this.main.userManager.getAIQuota();
|
||||
if (quotaData) this.quota = quotaData;
|
||||
@@ -59,10 +66,15 @@ export default class AICreateScene extends BaseScene {
|
||||
|
||||
calculateMaxScroll() {
|
||||
let contentHeight = 400;
|
||||
if (this.currentTab === 0 || this.currentTab === 1) {
|
||||
contentHeight = 300 + this.recentStories.length * 80;
|
||||
if (this.currentTab === 0) {
|
||||
contentHeight = 300 + this.publishedRewrites.length * 90;
|
||||
} else if (this.currentTab === 1) {
|
||||
contentHeight = 300 + this.publishedContinues.length * 90;
|
||||
} else {
|
||||
contentHeight = 600;
|
||||
// AI创作Tab:表单高度 + 已创作列表高度
|
||||
const formHeight = 500;
|
||||
const listHeight = this.createdStories ? this.createdStories.length * 90 : 0;
|
||||
contentHeight = formHeight + listHeight + 100;
|
||||
}
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 200);
|
||||
}
|
||||
@@ -75,6 +87,11 @@ export default class AICreateScene extends BaseScene {
|
||||
this.renderQuotaBar(ctx);
|
||||
this.renderTabs(ctx);
|
||||
this.renderContent(ctx);
|
||||
|
||||
// 创作确认面板(最上层)
|
||||
if (this.showCreatePanel) {
|
||||
this.renderCreatePanel(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
renderBackground(ctx) {
|
||||
@@ -210,23 +227,10 @@ export default class AICreateScene extends BaseScene {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('选择一个已玩过的故事,AI帮你改写结局', this.screenWidth / 2, y + 25);
|
||||
ctx.fillText('展示你从草稿箱发布的改写作品', this.screenWidth / 2, y + 25);
|
||||
|
||||
// 快捷标签
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('热门改写方向:', padding, y + 55);
|
||||
|
||||
const tagEndY = this.renderTags(ctx, this.rewriteTags, padding, y + 70, 'rewrite');
|
||||
|
||||
// 选择故事 - 位置根据标签高度动态调整
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('选择要改写的故事:', padding, tagEndY + 25);
|
||||
|
||||
this.renderStoryList(ctx, tagEndY + 40, 'rewrite');
|
||||
// 作品列表
|
||||
this.renderPublishedList(ctx, y + 50, this.publishedRewrites, 'rewrite');
|
||||
}
|
||||
|
||||
renderContinueTab(ctx, startY) {
|
||||
@@ -236,21 +240,10 @@ export default class AICreateScene extends BaseScene {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('选择一个进行中的故事,AI帮你续写剧情', this.screenWidth / 2, y + 25);
|
||||
ctx.fillText('展示你从草稿箱发布的续写作品', this.screenWidth / 2, y + 25);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('续写方向:', padding, y + 55);
|
||||
|
||||
const tagEndY = this.renderTags(ctx, this.continueTags, padding, y + 70, 'continue');
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('选择要续写的故事:', padding, tagEndY + 25);
|
||||
|
||||
this.renderStoryList(ctx, tagEndY + 40, 'continue');
|
||||
// 作品列表
|
||||
this.renderPublishedList(ctx, y + 50, this.publishedContinues, 'continue');
|
||||
}
|
||||
|
||||
renderCreateTab(ctx, startY) {
|
||||
@@ -273,16 +266,21 @@ export default class AICreateScene extends BaseScene {
|
||||
|
||||
// 关键词输入
|
||||
let currentY = tagEndY + 25;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillText('故事关键词:', padding, currentY);
|
||||
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords');
|
||||
|
||||
// 主角设定
|
||||
currentY += 80;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('主角设定:', padding, currentY);
|
||||
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist');
|
||||
|
||||
// 核心冲突
|
||||
currentY += 80;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('核心冲突:', padding, currentY);
|
||||
this.renderInputBox(ctx, padding, currentY + 15, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict');
|
||||
|
||||
@@ -301,6 +299,120 @@ export default class AICreateScene extends BaseScene {
|
||||
ctx.fillText('✨ 开始AI创作', this.screenWidth / 2, btnY + 32);
|
||||
|
||||
this.createBtnRect = { x: padding, y: btnY + this.scrollY, width: inputWidth, height: 50 };
|
||||
|
||||
// 提示文字
|
||||
const tipY = btnY + 75;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('创作完成后可在「个人中心 > 草稿箱」查看', this.screenWidth / 2, tipY);
|
||||
}
|
||||
|
||||
renderCreatedList(ctx, startY, list) {
|
||||
const padding = 15;
|
||||
const cardWidth = this.screenWidth - padding * 2;
|
||||
const cardHeight = 80;
|
||||
const cardGap = 10;
|
||||
|
||||
this.createdItemRects = [];
|
||||
|
||||
list.forEach((item, index) => {
|
||||
const y = startY + index * (cardHeight + cardGap);
|
||||
|
||||
// 卡片背景
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
this.roundRect(ctx, padding, y, cardWidth, cardHeight, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const title = item.title || '未命名故事';
|
||||
ctx.fillText(title.length > 15 ? title.substring(0, 15) + '...' : title, padding + 15, y + 25);
|
||||
|
||||
// 状态标签
|
||||
const isPending = item.status === 'pending';
|
||||
const isFailed = item.status === 'failed';
|
||||
const isCompleted = item.status === 'completed';
|
||||
const isPublished = item.published_to_center;
|
||||
|
||||
let statusText = '草稿';
|
||||
let statusColor = '#fbbf24';
|
||||
if (isPublished) {
|
||||
statusText = '已发布';
|
||||
statusColor = '#10b981';
|
||||
} else if (isCompleted) {
|
||||
statusText = '已完成';
|
||||
statusColor = '#60a5fa';
|
||||
} else if (isPending) {
|
||||
statusText = '创作中...';
|
||||
statusColor = '#a855f7';
|
||||
} else if (isFailed) {
|
||||
statusText = '失败';
|
||||
statusColor = '#ef4444';
|
||||
}
|
||||
|
||||
ctx.fillStyle = statusColor;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText(statusText, padding + 15, y + 50);
|
||||
|
||||
// 按钮(只有完成状态才能操作)
|
||||
if (isCompleted) {
|
||||
const btnWidth = 50;
|
||||
const btnHeight = 28;
|
||||
const btnGap = 8;
|
||||
let btnX = this.screenWidth - padding - btnWidth - 10;
|
||||
const btnY = y + (cardHeight - btnHeight) / 2;
|
||||
|
||||
// 阅读按钮
|
||||
const readGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
|
||||
readGradient.addColorStop(0, '#a855f7');
|
||||
readGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = readGradient;
|
||||
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 14);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('阅读', btnX + btnWidth / 2, btnY + 18);
|
||||
|
||||
this.createdItemRects.push({
|
||||
x: btnX,
|
||||
y: btnY + this.scrollY,
|
||||
width: btnWidth,
|
||||
height: btnHeight,
|
||||
action: 'preview',
|
||||
item: item
|
||||
});
|
||||
|
||||
// 发布按钮(未发布时显示)
|
||||
if (!isPublished) {
|
||||
btnX = btnX - btnWidth - btnGap;
|
||||
const pubGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
|
||||
pubGradient.addColorStop(0, '#10b981');
|
||||
pubGradient.addColorStop(1, '#059669');
|
||||
ctx.fillStyle = pubGradient;
|
||||
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 14);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('发布', btnX + btnWidth / 2, btnY + 18);
|
||||
|
||||
this.createdItemRects.push({
|
||||
x: btnX,
|
||||
y: btnY + this.scrollY,
|
||||
width: btnWidth,
|
||||
height: btnHeight,
|
||||
action: 'publish',
|
||||
item: item
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderTags(ctx, tags, startX, startY, type) {
|
||||
@@ -388,6 +500,84 @@ export default class AICreateScene extends BaseScene {
|
||||
this.inputRects[field] = { x, y: y + this.scrollY, width, height, field };
|
||||
}
|
||||
|
||||
renderPublishedList(ctx, startY, items, type) {
|
||||
const padding = 15;
|
||||
const cardHeight = 80;
|
||||
const cardGap = 12;
|
||||
|
||||
if (!this.publishedRects) this.publishedRects = {};
|
||||
this.publishedRects[type] = [];
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const tipText = type === 'rewrite'
|
||||
? '暂无改写作品,去草稿箱发布吧'
|
||||
: '暂无续写作品,去草稿箱发布吧';
|
||||
ctx.fillText(tipText, this.screenWidth / 2, startY + 40);
|
||||
|
||||
// 跳转草稿箱按钮
|
||||
const btnY = startY + 70;
|
||||
const btnWidth = 120;
|
||||
const btnX = (this.screenWidth - btnWidth) / 2;
|
||||
ctx.fillStyle = 'rgba(168, 85, 247, 0.3)';
|
||||
this.roundRect(ctx, btnX, btnY, btnWidth, 36, 18);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#a855f7';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 24);
|
||||
this.gotoDraftsBtnRect = { x: btnX, y: btnY + this.scrollY, width: btnWidth, height: 36 };
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const y = startY + index * (cardHeight + cardGap);
|
||||
|
||||
// 卡片背景
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const title = item.title?.length > 15 ? item.title.substring(0, 15) + '...' : (item.title || '未命名作品');
|
||||
ctx.fillText(title, padding + 15, y + 25);
|
||||
|
||||
// 原故事
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText(`原故事:${item.storyTitle || '未知'}`, padding + 15, y + 45);
|
||||
|
||||
// 创作时间
|
||||
ctx.fillText(item.createdAt || '', padding + 15, y + 65);
|
||||
|
||||
// 阅读按钮
|
||||
const btnX = this.screenWidth - padding - 70;
|
||||
const btnGradient = ctx.createLinearGradient(btnX, y + 25, btnX + 60, y + 25);
|
||||
btnGradient.addColorStop(0, '#a855f7');
|
||||
btnGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, btnX, y + 25, 60, 30, 15);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('阅读', btnX + 30, y + 45);
|
||||
|
||||
this.publishedRects[type].push({
|
||||
x: padding,
|
||||
y: y + this.scrollY,
|
||||
width: this.screenWidth - padding * 2,
|
||||
height: cardHeight,
|
||||
item,
|
||||
btnRect: { x: btnX, y: y + 25 + this.scrollY, width: 60, height: 30 }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderStoryList(ctx, startY, type) {
|
||||
const padding = 15;
|
||||
const cardHeight = 70;
|
||||
@@ -487,6 +677,134 @@ export default class AICreateScene extends BaseScene {
|
||||
}
|
||||
}
|
||||
|
||||
renderCreatePanel(ctx) {
|
||||
const padding = 20;
|
||||
const panelWidth = this.screenWidth - padding * 2;
|
||||
const panelHeight = 380;
|
||||
const panelX = padding;
|
||||
const panelY = (this.screenHeight - panelHeight) / 2;
|
||||
|
||||
// 遮罩层
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 面板背景渐变
|
||||
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
|
||||
panelGradient.addColorStop(0, '#1a1a3e');
|
||||
panelGradient.addColorStop(1, '#0d0d1a');
|
||||
ctx.fillStyle = panelGradient;
|
||||
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||
ctx.fill();
|
||||
|
||||
// 面板边框渐变
|
||||
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
|
||||
borderGradient.addColorStop(0, '#a855f7');
|
||||
borderGradient.addColorStop(1, '#ec4899');
|
||||
ctx.strokeStyle = borderGradient;
|
||||
ctx.lineWidth = 2;
|
||||
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||
ctx.stroke();
|
||||
|
||||
// 标题栏
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('✨ 确认创作', this.screenWidth / 2, panelY + 35);
|
||||
|
||||
// 配额提示
|
||||
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
|
||||
ctx.fillStyle = remaining > 0 ? 'rgba(255,255,255,0.6)' : 'rgba(255,100,100,0.8)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`剩余次数:${remaining}`, panelX + panelWidth - 15, panelY + 35);
|
||||
|
||||
// 分隔线
|
||||
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 55, panelX + panelWidth - 20, panelY + 55);
|
||||
lineGradient.addColorStop(0, 'transparent');
|
||||
lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)');
|
||||
lineGradient.addColorStop(1, 'transparent');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(panelX + 20, panelY + 55);
|
||||
ctx.lineTo(panelX + panelWidth - 20, panelY + 55);
|
||||
ctx.stroke();
|
||||
|
||||
// 创作信息展示
|
||||
let infoY = panelY + 85;
|
||||
const lineHeight = 45;
|
||||
|
||||
const items = [
|
||||
{ label: '题材', value: this.createForm.genre || '未选择' },
|
||||
{ label: '关键词', value: this.createForm.keywords || '未填写' },
|
||||
{ label: '主角设定', value: this.createForm.protagonist || '未填写' },
|
||||
{ label: '核心冲突', value: this.createForm.conflict || '未填写' }
|
||||
];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const y = infoY + index * lineHeight;
|
||||
|
||||
// 标签
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(item.label + ':', panelX + 20, y);
|
||||
|
||||
// 值
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px sans-serif';
|
||||
let displayValue = item.value;
|
||||
if (displayValue.length > 18) {
|
||||
displayValue = displayValue.substring(0, 18) + '...';
|
||||
}
|
||||
ctx.fillText(displayValue, panelX + 85, y);
|
||||
});
|
||||
|
||||
// 消耗提示
|
||||
ctx.fillStyle = 'rgba(255,200,100,0.8)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('将消耗 1 次 AI 次数', this.screenWidth / 2, panelY + panelHeight - 85);
|
||||
|
||||
// 按钮区域
|
||||
const btnWidth = (panelWidth - 50) / 2;
|
||||
const btnHeight = 42;
|
||||
const btnY = panelY + panelHeight - 60;
|
||||
|
||||
// 取消按钮
|
||||
const cancelX = panelX + 15;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
this.roundRect(ctx, cancelX, btnY, btnWidth, btnHeight, 21);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, cancelX, btnY, btnWidth, btnHeight, 21);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('取消', cancelX + btnWidth / 2, btnY + 27);
|
||||
|
||||
this.createPanelBtns.cancel = { x: cancelX, y: btnY, width: btnWidth, height: btnHeight };
|
||||
|
||||
// 确认按钮
|
||||
const confirmX = panelX + panelWidth - btnWidth - 15;
|
||||
const confirmGradient = ctx.createLinearGradient(confirmX, btnY, confirmX + btnWidth, btnY);
|
||||
confirmGradient.addColorStop(0, '#a855f7');
|
||||
confirmGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = confirmGradient;
|
||||
this.roundRect(ctx, confirmX, btnY, btnWidth, btnHeight, 21);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('开始创作', confirmX + btnWidth / 2, btnY + 27);
|
||||
|
||||
this.createPanelBtns.confirm = { x: confirmX, y: btnY, width: btnWidth, height: btnHeight };
|
||||
}
|
||||
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
@@ -531,6 +849,12 @@ export default class AICreateScene extends BaseScene {
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
// 创作确认面板优先处理
|
||||
if (this.showCreatePanel) {
|
||||
this.handleCreatePanelTouch(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回按钮
|
||||
if (y < 60 && x < 80) {
|
||||
this.main.sceneManager.switchScene('home');
|
||||
@@ -550,7 +874,6 @@ export default class AICreateScene extends BaseScene {
|
||||
if (this.currentTab !== tab.index) {
|
||||
this.currentTab = tab.index;
|
||||
this.scrollY = 0;
|
||||
this.selectedStory = null;
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
return;
|
||||
@@ -561,14 +884,34 @@ export default class AICreateScene extends BaseScene {
|
||||
// 调整y坐标(考虑滚动)
|
||||
const scrolledY = y + this.scrollY;
|
||||
|
||||
// 标签点击
|
||||
if (this.tagRects) {
|
||||
const tagType = this.currentTab === 0 ? 'rewrite' : this.currentTab === 1 ? 'continue' : 'genre';
|
||||
const tags = this.tagRects[tagType];
|
||||
// 前往草稿箱按钮
|
||||
if (this.gotoDraftsBtnRect && this.isInRect(x, scrolledY, this.gotoDraftsBtnRect)) {
|
||||
this.main.sceneManager.switchScene('drafts');
|
||||
return;
|
||||
}
|
||||
|
||||
// 已发布作品点击(改写/续写Tab)
|
||||
if (this.currentTab < 2 && this.publishedRects) {
|
||||
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
|
||||
const items = this.publishedRects[type];
|
||||
if (items) {
|
||||
for (const rect of items) {
|
||||
// 阅读按钮点击
|
||||
if (this.isInRect(x, scrolledY, rect.btnRect)) {
|
||||
this.handleReadPublished(rect.item);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签点击(只有创作Tab有标签)
|
||||
if (this.currentTab === 2 && this.tagRects) {
|
||||
const tags = this.tagRects['genre'];
|
||||
if (tags) {
|
||||
for (const tag of tags) {
|
||||
if (this.isInRect(x, scrolledY, tag)) {
|
||||
this.handleTagSelect(tagType, tag);
|
||||
this.handleTagSelect('genre', tag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -586,26 +929,6 @@ export default class AICreateScene extends BaseScene {
|
||||
}
|
||||
}
|
||||
|
||||
// 故事列表点击
|
||||
if (this.currentTab < 2 && this.storyRects) {
|
||||
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
|
||||
const stories = this.storyRects[type];
|
||||
if (stories) {
|
||||
for (const rect of stories) {
|
||||
if (this.isInRect(x, scrolledY, rect)) {
|
||||
this.selectedStory = rect.story;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
if (this.actionBtnRect && this.isInRect(x, scrolledY, this.actionBtnRect)) {
|
||||
this.handleAction(this.actionBtnRect.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创作按钮
|
||||
if (this.currentTab === 2 && this.createBtnRect && this.isInRect(x, scrolledY, this.createBtnRect)) {
|
||||
this.handleCreate();
|
||||
@@ -613,6 +936,52 @@ export default class AICreateScene extends BaseScene {
|
||||
}
|
||||
}
|
||||
|
||||
handleReadPublished(item) {
|
||||
// 跳转到故事场景,播放AI改写/续写的内容
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId: item.storyId,
|
||||
draftId: item.id,
|
||||
fromDrafts: true
|
||||
});
|
||||
}
|
||||
|
||||
handlePreviewCreated(item) {
|
||||
// 跳转到故事场景,播放AI创作的故事(使用 draftId)
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId: item.story_id,
|
||||
draftId: item.id,
|
||||
fromDrafts: true,
|
||||
draftType: 'create' // 标记为AI创作类型
|
||||
});
|
||||
}
|
||||
|
||||
async handlePublishCreated(item) {
|
||||
wx.showModal({
|
||||
title: '确认发布',
|
||||
content: `确定要发布《${item.title || '未命名故事'}》吗?\n发布后可在"我的作品"中查看`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
wx.showLoading({ title: '发布中...', mask: true });
|
||||
try {
|
||||
const result = await post(`/stories/ai-create/${item.id}/publish`);
|
||||
wx.hideLoading();
|
||||
if (result && result.code === 0) {
|
||||
wx.showToast({ title: '发布成功!', icon: 'success' });
|
||||
// 刷新列表
|
||||
this.loadData();
|
||||
this.render();
|
||||
} else {
|
||||
wx.showToast({ title: result?.data?.message || '发布失败', icon: 'none' });
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '发布失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isInRect(x, y, rect) {
|
||||
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
||||
}
|
||||
@@ -620,10 +989,6 @@ export default class AICreateScene extends BaseScene {
|
||||
handleTagSelect(type, tag) {
|
||||
if (type === 'genre') {
|
||||
this.createForm.genre = tag.value;
|
||||
} else if (type === 'rewrite') {
|
||||
this.selectedRewriteTag = tag.index;
|
||||
} else if (type === 'continue') {
|
||||
this.selectedContinueTag = tag.index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,10 +1127,10 @@ export default class AICreateScene extends BaseScene {
|
||||
}
|
||||
|
||||
const remaining = this.quota.daily - this.quota.used + this.quota.purchased;
|
||||
if (remaining < 5) {
|
||||
if (remaining < 1) {
|
||||
wx.showModal({
|
||||
title: '次数不足',
|
||||
content: 'AI创作需要5次配额,当前剩余' + remaining + '次',
|
||||
content: 'AI创作需要1次配额,当前剩余' + remaining + '次',
|
||||
confirmText: '获取更多',
|
||||
success: (res) => {
|
||||
if (res.confirm) this.showQuotaModal();
|
||||
@@ -774,30 +1139,122 @@ export default class AICreateScene extends BaseScene {
|
||||
return;
|
||||
}
|
||||
|
||||
wx.showModal({
|
||||
title: '确认创作',
|
||||
content: `题材:${this.createForm.genre}\n关键词:${this.createForm.keywords}\n\n将消耗5次AI次数`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
wx.showLoading({ title: 'AI创作中...', mask: true });
|
||||
// 显示创作确认面板
|
||||
this.showCreatePanel = true;
|
||||
}
|
||||
|
||||
handleCreatePanelTouch(x, y) {
|
||||
// 点击取消
|
||||
if (this.createPanelBtns.cancel && this.isInRect(x, y, this.createPanelBtns.cancel)) {
|
||||
this.showCreatePanel = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击确认
|
||||
if (this.createPanelBtns.confirm && this.isInRect(x, y, this.createPanelBtns.confirm)) {
|
||||
this.showCreatePanel = false;
|
||||
this.confirmCreate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmCreate() {
|
||||
wx.showLoading({ title: '提交中...', mask: true });
|
||||
try {
|
||||
// TODO: 实现完整创作API
|
||||
const result = await this.main.storyManager.createStory(this.createForm);
|
||||
const userId = this.main?.userManager?.userId;
|
||||
if (!userId) {
|
||||
wx.hideLoading();
|
||||
if (result) {
|
||||
this.quota.used += 5;
|
||||
wx.showToast({ title: '创作成功!', icon: 'success' });
|
||||
// 跳转到新故事
|
||||
setTimeout(() => {
|
||||
this.main.sceneManager.switchScene('story', { storyId: result.storyId });
|
||||
}, 1500);
|
||||
wx.showToast({ title: '请先登录', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const result = await this.main.storyManager.createStory({
|
||||
...this.createForm,
|
||||
userId: userId
|
||||
});
|
||||
|
||||
wx.hideLoading();
|
||||
|
||||
const draftId = result?.data?.draftId || result?.draftId;
|
||||
if (draftId) {
|
||||
this.quota.used += 1;
|
||||
// 显示提示框,用户可以选择等待或返回
|
||||
wx.showModal({
|
||||
title: '创作已提交',
|
||||
content: 'AI正在创作故事,预计需要1-2分钟,完成后可在草稿箱查看',
|
||||
confirmText: '等待结果',
|
||||
cancelText: '返回',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
// 用户选择等待,显示loading并轮询
|
||||
wx.showLoading({ title: 'AI创作中...', mask: true });
|
||||
this.pollCreateStatus(draftId);
|
||||
}
|
||||
// 用户选择返回,后台继续创作,稍后可在草稿箱查看
|
||||
}
|
||||
});
|
||||
} else {
|
||||
wx.showToast({ title: '创作失败', icon: 'none' });
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '创作失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询AI创作状态
|
||||
*/
|
||||
async pollCreateStatus(draftId, retries = 0) {
|
||||
const maxRetries = 60; // 最多等待5分钟(每5秒检查一次)
|
||||
|
||||
if (retries >= maxRetries) {
|
||||
wx.hideLoading();
|
||||
wx.showModal({
|
||||
title: '创作超时',
|
||||
content: '故事创作时间较长,请稍后在"AI创作"中查看',
|
||||
showCancel: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await get(`/stories/ai-create/${draftId}/status`);
|
||||
const status = res?.data || res; // 兼容两种格式
|
||||
|
||||
if (status && status.isCompleted) {
|
||||
wx.hideLoading();
|
||||
wx.showModal({
|
||||
title: '创作成功!',
|
||||
content: `故事《${status.title}》已保存到草稿箱`,
|
||||
confirmText: '去查看',
|
||||
cancelText: '继续创作',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 刷新当前页面数据
|
||||
this.loadData();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (status && (status.status === -1 || status.isFailed)) {
|
||||
wx.hideLoading();
|
||||
wx.showModal({
|
||||
title: '创作失败',
|
||||
content: status.errorMessage || '故事创作失败,请检查输入后重试',
|
||||
showCancel: false
|
||||
});
|
||||
} else {
|
||||
// 继续轮询
|
||||
setTimeout(() => {
|
||||
this.pollCreateStatus(draftId, retries + 1);
|
||||
}, 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('轮询状态失败:', e);
|
||||
// 请求失败,继续重试
|
||||
setTimeout(() => {
|
||||
this.pollCreateStatus(draftId, retries + 1);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,71 @@ export default class EndingScene extends BaseScene {
|
||||
setTimeout(() => {
|
||||
this.showButtons = true;
|
||||
}, 1500);
|
||||
|
||||
// 保存游玩记录
|
||||
this.checkAndSavePlayRecord();
|
||||
|
||||
// 加载收藏状态
|
||||
this.loadCollectStatus();
|
||||
}
|
||||
|
||||
async checkAndSavePlayRecord() {
|
||||
// 回放模式不保存
|
||||
if (this.isReplay) return;
|
||||
|
||||
// 原故事:直接保存
|
||||
if (!this.draftId) {
|
||||
this.savePlayRecord();
|
||||
return;
|
||||
}
|
||||
|
||||
// AI草稿:检查是否已发布,已发布才保存
|
||||
try {
|
||||
const userId = this.main.userManager.userId;
|
||||
const drafts = await this.main.storyManager.getDrafts(userId) || [];
|
||||
const draft = drafts.find(d => d.id === this.draftId);
|
||||
if (draft?.publishedToCenter) {
|
||||
this.savePlayRecord();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查草稿发布状态失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCollectStatus() {
|
||||
try {
|
||||
if (this.draftId) {
|
||||
// AI草稿:获取草稿收藏状态
|
||||
this.isCollected = await this.main.userManager.getDraftCollectStatus(this.draftId);
|
||||
} else {
|
||||
// 原故事:获取故事收藏状态
|
||||
const progress = await this.main.userManager.getProgress(this.storyId);
|
||||
this.isCollected = progress?.isCollected || false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载收藏状态失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async savePlayRecord() {
|
||||
try {
|
||||
// 获取当前游玩路径
|
||||
const pathHistory = this.main.storyManager.pathHistory || [];
|
||||
const endingName = this.ending?.name || '未知结局';
|
||||
const endingType = this.ending?.type || '';
|
||||
|
||||
// 调用保存接口(传入 draftId 区分原故事和AI草稿)
|
||||
await this.main.userManager.savePlayRecord(
|
||||
this.storyId,
|
||||
endingName,
|
||||
endingType,
|
||||
pathHistory,
|
||||
this.draftId // AI草稿ID,原故事为null
|
||||
);
|
||||
console.log('游玩记录保存成功');
|
||||
} catch (e) {
|
||||
console.error('保存游玩记录失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadQuota() {
|
||||
@@ -84,6 +156,10 @@ export default class EndingScene extends BaseScene {
|
||||
if (this.showRewritePanel) {
|
||||
this.renderRewritePanel(ctx);
|
||||
}
|
||||
// AI续写面板
|
||||
if (this.showContinuePanel) {
|
||||
this.renderContinuePanel(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
renderBackground(ctx) {
|
||||
@@ -256,15 +332,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 +580,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 +864,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 +878,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 +912,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 +962,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 +1014,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 +1067,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 +1091,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 +1213,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;
|
||||
@@ -855,6 +1233,77 @@ export default class EndingScene extends BaseScene {
|
||||
|
||||
handleCollect() {
|
||||
this.isCollected = !this.isCollected;
|
||||
if (this.draftId) {
|
||||
// AI草稿:收藏草稿
|
||||
this.main.userManager.collectDraft(this.draftId, this.isCollected);
|
||||
} else {
|
||||
// 原故事:收藏故事
|
||||
this.main.userManager.collectStory(this.storyId, this.isCollected);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动草稿完成轮询(每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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 首页场景 - 支持UGC
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
import { getStaticUrl } from '../utils/http';
|
||||
|
||||
export default class HomeScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
@@ -13,6 +14,9 @@ export default class HomeScene extends BaseScene {
|
||||
this.lastTouchY = 0;
|
||||
this.scrollVelocity = 0;
|
||||
|
||||
// 封面图片缓存
|
||||
this.coverImages = {};
|
||||
|
||||
// 底部Tab: 首页/发现/创作/我的
|
||||
this.bottomTab = 0;
|
||||
|
||||
@@ -35,6 +39,23 @@ export default class HomeScene extends BaseScene {
|
||||
async init() {
|
||||
this.storyList = this.main.storyManager.storyList;
|
||||
this.calculateMaxScroll();
|
||||
// 预加载封面图片
|
||||
this.preloadCoverImages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载故事封面图片
|
||||
*/
|
||||
preloadCoverImages() {
|
||||
this.storyList.forEach(story => {
|
||||
if (story.cover_url && !this.coverImages[story.id]) {
|
||||
const img = wx.createImage();
|
||||
img.onload = () => {
|
||||
this.coverImages[story.id] = img;
|
||||
};
|
||||
img.src = getStaticUrl(story.cover_url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFilteredStories() {
|
||||
@@ -65,7 +86,7 @@ export default class HomeScene extends BaseScene {
|
||||
const stories = this.getFilteredStories();
|
||||
const cardHeight = 130;
|
||||
const gap = 12;
|
||||
const startY = 175;
|
||||
const startY = 190;
|
||||
const tabHeight = 60;
|
||||
const contentBottom = startY + stories.length * (cardHeight + gap);
|
||||
const visibleBottom = this.screenHeight - tabHeight;
|
||||
@@ -130,7 +151,7 @@ export default class HomeScene extends BaseScene {
|
||||
}
|
||||
|
||||
renderContentTabs(ctx) {
|
||||
const tabY = 60;
|
||||
const tabY = 75;
|
||||
const tabWidth = (this.screenWidth - 30) / 4;
|
||||
const padding = 15;
|
||||
|
||||
@@ -160,7 +181,7 @@ export default class HomeScene extends BaseScene {
|
||||
}
|
||||
|
||||
renderCategories(ctx) {
|
||||
const startY = 95;
|
||||
const startY = 110;
|
||||
const tagHeight = 28;
|
||||
let x = 15 - this.categoryScrollX;
|
||||
|
||||
@@ -207,7 +228,7 @@ export default class HomeScene extends BaseScene {
|
||||
}
|
||||
|
||||
renderStoryList(ctx) {
|
||||
const startY = 140;
|
||||
const startY = 155;
|
||||
const cardHeight = 130;
|
||||
const cardMargin = 12;
|
||||
const stories = this.getFilteredStories();
|
||||
@@ -248,18 +269,32 @@ export default class HomeScene extends BaseScene {
|
||||
|
||||
// 封面
|
||||
const coverW = 80, coverH = height - 20;
|
||||
const coverGradient = ctx.createLinearGradient(x + 10, y + 10, x + 10 + coverW, y + 10 + coverH);
|
||||
const coverX = x + 10, coverY = y + 10;
|
||||
|
||||
// 尝试显示封面图片
|
||||
const coverImg = this.coverImages[story.id];
|
||||
if (coverImg) {
|
||||
// 有图片,绘制图片
|
||||
ctx.save();
|
||||
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
|
||||
ctx.clip();
|
||||
ctx.drawImage(coverImg, coverX, coverY, coverW, coverH);
|
||||
ctx.restore();
|
||||
} else {
|
||||
// 无图片,显示渐变占位
|
||||
const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH);
|
||||
const colors = this.getCategoryGradient(story.category);
|
||||
coverGradient.addColorStop(0, colors[0]);
|
||||
coverGradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = coverGradient;
|
||||
this.roundRect(ctx, x + 10, y + 10, coverW, coverH, 10);
|
||||
this.roundRect(ctx, coverX, coverY, coverW, coverH, 10);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(story.category || '故事', x + 10 + coverW / 2, y + 10 + coverH / 2 + 4);
|
||||
ctx.fillText(story.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 4);
|
||||
}
|
||||
|
||||
const textX = x + 100;
|
||||
const maxW = width - 115;
|
||||
@@ -425,7 +460,7 @@ export default class HomeScene extends BaseScene {
|
||||
this.touchStartY = touch.clientY;
|
||||
this.hasMoved = false;
|
||||
|
||||
if (touch.clientY >= 90 && touch.clientY <= 130) {
|
||||
if (touch.clientY >= 105 && touch.clientY <= 145) {
|
||||
this.isCategoryDragging = true;
|
||||
this.isDragging = false;
|
||||
} else {
|
||||
@@ -489,7 +524,7 @@ export default class HomeScene extends BaseScene {
|
||||
}
|
||||
|
||||
// 分类点击
|
||||
if (y >= 90 && y <= 130) {
|
||||
if (y >= 105 && y <= 145) {
|
||||
this.handleCategoryClick(x);
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +565,7 @@ export default class HomeScene extends BaseScene {
|
||||
}
|
||||
|
||||
handleStoryClick(x, y) {
|
||||
const startY = 140;
|
||||
const startY = 155;
|
||||
const cardHeight = 130;
|
||||
const cardMargin = 12;
|
||||
const stories = this.getFilteredStories();
|
||||
|
||||
257
client/js/scenes/LoginScene.js
Normal file
257
client/js/scenes/LoginScene.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 登录场景 - 微信授权登录
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
|
||||
export default class LoginScene extends BaseScene {
|
||||
constructor(main, params = {}) {
|
||||
super(main, params);
|
||||
this.isLoading = false;
|
||||
this.userInfoButton = null;
|
||||
}
|
||||
|
||||
loadAssets() {
|
||||
// 不加载外部图片,使用 Canvas 绘制
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log('[LoginScene] 初始化登录场景');
|
||||
// 创建微信授权按钮
|
||||
this.createUserInfoButton();
|
||||
}
|
||||
|
||||
// 创建微信用户信息授权按钮
|
||||
createUserInfoButton() {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
const btnWidth = 280;
|
||||
const btnHeight = 50;
|
||||
const btnX = (screenWidth - btnWidth) / 2;
|
||||
const btnY = screenHeight * 0.55;
|
||||
|
||||
// 创建透明的用户信息按钮,覆盖在登录按钮上
|
||||
this.userInfoButton = wx.createUserInfoButton({
|
||||
type: 'text',
|
||||
text: '',
|
||||
style: {
|
||||
left: btnX,
|
||||
top: btnY,
|
||||
width: btnWidth,
|
||||
height: btnHeight,
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
borderRadius: 25,
|
||||
color: 'transparent',
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
lineHeight: 50
|
||||
}
|
||||
});
|
||||
|
||||
// 监听点击事件
|
||||
this.userInfoButton.onTap(async (res) => {
|
||||
console.log('[LoginScene] 用户信息授权回调:', res);
|
||||
|
||||
if (res.userInfo) {
|
||||
// 用户同意授权,获取到了头像昵称
|
||||
await this.doLoginWithUserInfo(res.userInfo);
|
||||
} else {
|
||||
// 未获取到用户信息,静默登录
|
||||
console.log('[LoginScene] 未获取到用户信息,使用静默登录');
|
||||
await this.doLoginWithUserInfo(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(ctx) {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
|
||||
// 绘制背景渐变
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, screenHeight);
|
||||
gradient.addColorStop(0, '#1a1a2e');
|
||||
gradient.addColorStop(0.5, '#16213e');
|
||||
gradient.addColorStop(1, '#0f0f23');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, screenWidth, screenHeight);
|
||||
|
||||
// 绘制装饰性光效
|
||||
this.renderGlow(ctx);
|
||||
|
||||
// 绘制Logo
|
||||
this.renderLogo(ctx);
|
||||
|
||||
// 绘制应用名称
|
||||
this.renderTitle(ctx);
|
||||
|
||||
// 绘制登录按钮
|
||||
this.renderLoginButton(ctx);
|
||||
|
||||
// 绘制底部提示
|
||||
this.renderFooter(ctx);
|
||||
}
|
||||
|
||||
renderGlow(ctx) {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
|
||||
// 顶部光晕
|
||||
const topGlow = ctx.createRadialGradient(
|
||||
screenWidth / 2, -100, 0,
|
||||
screenWidth / 2, -100, 400
|
||||
);
|
||||
topGlow.addColorStop(0, 'rgba(106, 90, 205, 0.3)');
|
||||
topGlow.addColorStop(1, 'rgba(106, 90, 205, 0)');
|
||||
ctx.fillStyle = topGlow;
|
||||
ctx.fillRect(0, 0, screenWidth, 400);
|
||||
}
|
||||
|
||||
renderLogo(ctx) {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
const logoSize = 120;
|
||||
const logoX = (screenWidth - logoSize) / 2;
|
||||
const logoY = screenHeight * 0.2;
|
||||
|
||||
// 绘制Logo背景圆
|
||||
ctx.beginPath();
|
||||
ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10, 0, Math.PI * 2);
|
||||
const logoGradient = ctx.createRadialGradient(
|
||||
screenWidth / 2, logoY + logoSize / 2, 0,
|
||||
screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10
|
||||
);
|
||||
logoGradient.addColorStop(0, 'rgba(106, 90, 205, 0.4)');
|
||||
logoGradient.addColorStop(1, 'rgba(106, 90, 205, 0.1)');
|
||||
ctx.fillStyle = logoGradient;
|
||||
ctx.fill();
|
||||
|
||||
// 绘制内圆
|
||||
ctx.fillStyle = '#6a5acd';
|
||||
ctx.beginPath();
|
||||
ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 - 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 绘制书本图标
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `${logoSize * 0.5}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('📖', screenWidth / 2, logoY + logoSize / 2);
|
||||
}
|
||||
|
||||
renderTitle(ctx) {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
const titleY = screenHeight * 0.2 + 160;
|
||||
|
||||
// 应用名称
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 32px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('AI互动故事', screenWidth / 2, titleY);
|
||||
|
||||
// 副标题
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.fillText('探索无限可能的剧情世界', screenWidth / 2, titleY + 40);
|
||||
}
|
||||
|
||||
renderLoginButton(ctx) {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
|
||||
// 按钮位置和尺寸
|
||||
const btnWidth = 280;
|
||||
const btnHeight = 50;
|
||||
const btnX = (screenWidth - btnWidth) / 2;
|
||||
const btnY = screenHeight * 0.55;
|
||||
|
||||
// 绘制按钮背景
|
||||
const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
|
||||
if (this.isLoading) {
|
||||
btnGradient.addColorStop(0, '#666666');
|
||||
btnGradient.addColorStop(1, '#888888');
|
||||
} else {
|
||||
btnGradient.addColorStop(0, '#07c160');
|
||||
btnGradient.addColorStop(1, '#06ae56');
|
||||
}
|
||||
|
||||
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25);
|
||||
ctx.fillStyle = btnGradient;
|
||||
ctx.fill();
|
||||
|
||||
// 绘制按钮阴影效果
|
||||
if (!this.isLoading) {
|
||||
ctx.shadowColor = 'rgba(7, 193, 96, 0.4)';
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowOffsetY = 5;
|
||||
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25);
|
||||
ctx.fill();
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
}
|
||||
|
||||
// 绘制按钮文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
if (this.isLoading) {
|
||||
ctx.fillText('登录中...', screenWidth / 2, btnY + btnHeight / 2);
|
||||
} else {
|
||||
ctx.fillText('微信一键登录', screenWidth / 2, btnY + btnHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
renderFooter(ctx) {
|
||||
const { screenWidth, screenHeight } = this;
|
||||
const footerY = screenHeight - 60;
|
||||
|
||||
// 用户协议提示
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('登录即表示同意《用户协议》和《隐私政策》', screenWidth / 2, footerY);
|
||||
}
|
||||
|
||||
// 不再需要手动处理点击事件,由 userInfoButton 处理
|
||||
onTouchEnd(e) {}
|
||||
|
||||
async doLoginWithUserInfo(userInfo) {
|
||||
if (this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
console.log('[LoginScene] 开始登录,用户信息:', userInfo);
|
||||
|
||||
try {
|
||||
// 调用 UserManager 的登录方法,传入用户信息
|
||||
await this.main.userManager.doLogin(userInfo);
|
||||
|
||||
console.log('[LoginScene] 登录成功,加载数据并进入首页');
|
||||
|
||||
// 隐藏授权按钮
|
||||
if (this.userInfoButton) {
|
||||
this.userInfoButton.hide();
|
||||
}
|
||||
|
||||
// 登录成功,加载数据并进入首页
|
||||
await this.main.loadAndEnterHome();
|
||||
} catch (error) {
|
||||
console.error('[LoginScene] 登录失败:', error);
|
||||
this.isLoading = false;
|
||||
|
||||
wx.showToast({
|
||||
title: error.message || '登录失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
console.log('[LoginScene] 销毁登录场景');
|
||||
// 销毁授权按钮
|
||||
if (this.userInfoButton) {
|
||||
this.userInfoButton.destroy();
|
||||
this.userInfoButton = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import EndingScene from './EndingScene';
|
||||
import ProfileScene from './ProfileScene';
|
||||
import ChapterScene from './ChapterScene';
|
||||
import AICreateScene from './AICreateScene';
|
||||
import LoginScene from './LoginScene';
|
||||
|
||||
export default class SceneManager {
|
||||
constructor(main) {
|
||||
@@ -18,7 +19,8 @@ export default class SceneManager {
|
||||
ending: EndingScene,
|
||||
profile: ProfileScene,
|
||||
chapter: ChapterScene,
|
||||
aiCreate: AICreateScene
|
||||
aiCreate: AICreateScene,
|
||||
login: LoginScene
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,95 @@
|
||||
/**
|
||||
* 网络请求工具
|
||||
* 网络请求工具 - 支持本地/云托管切换
|
||||
*/
|
||||
|
||||
// API基础地址(开发环境)
|
||||
const BASE_URL = 'http://localhost:3000/api';
|
||||
// ============================================
|
||||
// 环境配置(切换这里即可)
|
||||
// ============================================
|
||||
const ENV = 'local'; // 'local' = 本地后端, 'cloud' = 微信云托管
|
||||
|
||||
const CONFIG = {
|
||||
local: {
|
||||
baseUrl: 'http://localhost:8000/api',
|
||||
staticUrl: 'http://localhost:8000'
|
||||
},
|
||||
cloud: {
|
||||
env: 'prod-6gjx1rd4c40f5884',
|
||||
serviceName: 'express-fuvd',
|
||||
staticUrl: 'https://7072-prod-6gjx1rd4c40f5884-1409819450.tcb.qcloud.la'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
*/
|
||||
export function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutMs = options.timeout || 30000;
|
||||
if (ENV === 'local') {
|
||||
return requestLocal(options);
|
||||
} else {
|
||||
return requestCloud(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地后端请求(wx.request)
|
||||
*/
|
||||
function requestLocal(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: BASE_URL + options.url,
|
||||
url: CONFIG.local.baseUrl + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
timeout: timeoutMs,
|
||||
timeout: options.timeout || 30000,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header
|
||||
},
|
||||
success(res) {
|
||||
if (res.data.code === 0) {
|
||||
if (res.data && res.data.code === 0) {
|
||||
resolve(res.data.data);
|
||||
} else {
|
||||
reject(new Error(res.data.message || '请求失败'));
|
||||
console.error('[HTTP-Local] 响应异常:', res.statusCode, res.data);
|
||||
reject(new Error(res.data?.message || res.data?.detail || `请求失败(${res.statusCode})`));
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
console.error('[HTTP-Local] 请求失败:', err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 云托管请求(wx.cloud.callContainer)
|
||||
*/
|
||||
function requestCloud(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.cloud.callContainer({
|
||||
config: {
|
||||
env: CONFIG.cloud.env
|
||||
},
|
||||
path: '/api' + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header: {
|
||||
'X-WX-SERVICE': CONFIG.cloud.serviceName,
|
||||
'Content-Type': 'application/json',
|
||||
...options.header
|
||||
},
|
||||
success(res) {
|
||||
if (res.data && res.data.code === 0) {
|
||||
resolve(res.data.data);
|
||||
} else if (res.data) {
|
||||
console.error('[HTTP-Cloud] 响应异常:', res.statusCode, res.data);
|
||||
reject(new Error(res.data.message || res.data.detail || `请求失败(${res.statusCode})`));
|
||||
} else {
|
||||
console.error('[HTTP-Cloud] 响应数据异常:', res);
|
||||
reject(new Error('响应数据异常'));
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
console.error('[HTTP-Cloud] 请求失败:', err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
@@ -49,4 +110,75 @@ 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 function put(url, data) {
|
||||
return request({ url, method: 'PUT', data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取静态资源完整URL(图片等)
|
||||
* @param {string} path - 相对路径,如 /uploads/stories/1/characters/1.jpg
|
||||
* @returns {string} 完整URL
|
||||
*/
|
||||
export function getStaticUrl(path) {
|
||||
if (!path) return '';
|
||||
// 如果已经是完整URL,直接返回
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
const config = ENV === 'local' ? CONFIG.local : CONFIG.cloud;
|
||||
return config.staticUrl + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色头像URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {number} characterId - 角色ID
|
||||
*/
|
||||
export function getCharacterAvatar(storyId, characterId) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/characters/${characterId}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取故事封面URL
|
||||
* @param {number} storyId - 故事ID
|
||||
*/
|
||||
export function getStoryCover(storyId) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/cover/cover.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点背景图URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {string} nodeKey - 节点key
|
||||
*/
|
||||
export function getNodeBackground(storyId, nodeKey) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/background.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点角色立绘URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {string} nodeKey - 节点key
|
||||
*/
|
||||
export function getNodeCharacter(storyId, nodeKey) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/nodes/${nodeKey}/character.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿节点背景图URL
|
||||
* @param {number} storyId - 故事ID
|
||||
* @param {number} draftId - 草稿ID
|
||||
* @param {string} nodeKey - 节点key
|
||||
*/
|
||||
export function getDraftNodeBackground(storyId, draftId, nodeKey) {
|
||||
return getStaticUrl(`/uploads/stories/${storyId}/drafts/${draftId}/${nodeKey}/background.jpg`);
|
||||
}
|
||||
|
||||
export default { request, get, post, put, del, getStaticUrl, getCharacterAvatar, getStoryCover, getNodeBackground, getNodeCharacter, getDraftNodeBackground };
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -36,6 +36,19 @@ class Settings(BaseSettings):
|
||||
wx_appid: str = ""
|
||||
wx_secret: str = ""
|
||||
|
||||
# 微信云托管配置
|
||||
wx_cloud_env: str = ""
|
||||
|
||||
# Gemini 图片生成配置
|
||||
gemini_api_key: str = ""
|
||||
|
||||
# JWT 配置
|
||||
jwt_secret_key: str = "your-super-secret-key-change-in-production"
|
||||
jwt_expire_hours: int = 168 # 7天
|
||||
|
||||
# 文件上传配置
|
||||
upload_path: str = "./uploads"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
|
||||
@@ -23,6 +23,9 @@ AsyncSessionLocal = sessionmaker(
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# 后台任务使用的会话工厂
|
||||
async_session_factory = AsyncSessionLocal
|
||||
|
||||
# 基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
星域故事汇 - Python后端服务
|
||||
"""
|
||||
import os
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import get_settings
|
||||
from app.routers import story, user
|
||||
from app.routers import story, user, drafts, upload
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -29,6 +31,13 @@ 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.include_router(upload.router, prefix="/api", tags=["上传"])
|
||||
|
||||
# 静态文件服务(用于访问上传的图片)
|
||||
upload_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', settings.upload_path))
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
Binary file not shown.
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):
|
||||
@@ -25,6 +26,28 @@ class Story(Base):
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
nodes = relationship("StoryNode", back_populates="story", cascade="all, delete-orphan")
|
||||
characters = relationship("StoryCharacter", back_populates="story", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class StoryCharacter(Base):
|
||||
"""故事角色表"""
|
||||
__tablename__ = "story_characters"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(50), nullable=False)
|
||||
role_type = Column(String(20), default="supporting") # protagonist/antagonist/supporting
|
||||
gender = Column(String(10), default="")
|
||||
age_range = Column(String(20), default="")
|
||||
appearance = Column(Text) # 外貌描述
|
||||
personality = Column(Text) # 性格描述
|
||||
background = Column(Text) # 背景故事
|
||||
avatar_prompt = Column(Text) # AI绘图提示词
|
||||
avatar_url = Column(String(500), default="")
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
story = relationship("Story", back_populates="characters")
|
||||
|
||||
|
||||
class StoryNode(Base):
|
||||
@@ -64,3 +87,46 @@ 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) # 用户是否已查看
|
||||
published_to_center = Column(Boolean, default=False) # 是否发布到创作中心
|
||||
draft_type = Column(String(20), default="rewrite") # 草稿类型: rewrite/continue/create
|
||||
is_collected = Column(Boolean, default=False) # 用户是否收藏
|
||||
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
completed_at = Column(TIMESTAMP, default=None)
|
||||
|
||||
# 关联
|
||||
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,22 @@ 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)
|
||||
draft_id = Column(Integer, default=None) # AI草稿ID,原故事为空
|
||||
ending_name = Column(String(100), nullable=False) # 结局名称
|
||||
ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite)
|
||||
path_history = Column(JSON, nullable=False) # 完整的选择路径
|
||||
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.
Binary file not shown.
1014
server/app/routers/drafts.py
Normal file
1014
server/app/routers/drafts.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
108
server/app/routers/upload.py
Normal file
108
server/app/routers/upload.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
文件上传路由
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
||||
from ..config import get_settings
|
||||
from ..utils.jwt_utils import get_current_user_id
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["上传"])
|
||||
|
||||
# 允许的图片格式
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
# 最大文件大小 (5MB)
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""检查文件扩展名是否允许"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@router.post("/avatar")
|
||||
async def upload_avatar(
|
||||
file: UploadFile = File(...),
|
||||
user_id: int = Depends(get_current_user_id)
|
||||
):
|
||||
"""上传用户头像"""
|
||||
# 检查文件类型
|
||||
if not file.filename or not allowed_file(file.filename):
|
||||
raise HTTPException(status_code=400, detail="不支持的文件格式")
|
||||
|
||||
# 读取文件内容
|
||||
content = await file.read()
|
||||
|
||||
# 检查文件大小
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)")
|
||||
|
||||
# 生成唯一文件名
|
||||
ext = file.filename.rsplit('.', 1)[1].lower()
|
||||
filename = f"{user_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
|
||||
# 创建上传目录
|
||||
settings = get_settings()
|
||||
upload_dir = os.path.join(settings.upload_path, "avatars")
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# 保存文件
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 返回访问URL
|
||||
# 使用相对路径,前端拼接 baseUrl
|
||||
avatar_url = f"/uploads/avatars/{filename}"
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"url": avatar_url,
|
||||
"filename": filename
|
||||
},
|
||||
"message": "上传成功"
|
||||
}
|
||||
|
||||
@router.post("/image")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
user_id: int = Depends(get_current_user_id)
|
||||
):
|
||||
"""上传通用图片"""
|
||||
# 检查文件类型
|
||||
if not file.filename or not allowed_file(file.filename):
|
||||
raise HTTPException(status_code=400, detail="不支持的文件格式")
|
||||
|
||||
# 读取文件内容
|
||||
content = await file.read()
|
||||
|
||||
# 检查文件大小
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)")
|
||||
|
||||
# 生成唯一文件名
|
||||
ext = file.filename.rsplit('.', 1)[1].lower()
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
filename = f"{date_str}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
|
||||
# 创建上传目录
|
||||
settings = get_settings()
|
||||
upload_dir = os.path.join(settings.upload_path, "images")
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# 保存文件
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 返回访问URL
|
||||
image_url = f"/uploads/images/{filename}"
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"url": image_url,
|
||||
"filename": filename
|
||||
},
|
||||
"message": "上传成功"
|
||||
}
|
||||
@@ -3,13 +3,16 @@
|
||||
"""
|
||||
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
|
||||
import httpx
|
||||
|
||||
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
|
||||
from app.config import get_settings
|
||||
from app.utils.jwt_utils import create_token, get_current_user_id, get_optional_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -46,13 +49,45 @@ class CollectRequest(BaseModel):
|
||||
isCollected: bool
|
||||
|
||||
|
||||
class PlayRecordRequest(BaseModel):
|
||||
userId: int
|
||||
storyId: int
|
||||
draftId: Optional[int] = None # AI草稿ID,原故事为空
|
||||
endingName: str
|
||||
endingType: str = ""
|
||||
pathHistory: list
|
||||
|
||||
|
||||
# ========== API接口 ==========
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""微信登录"""
|
||||
# 实际部署时需要调用微信API获取openid
|
||||
# 这里简化处理:用code作为openid
|
||||
settings = get_settings()
|
||||
|
||||
# 打印配置状态
|
||||
print(f"[Login] wx_appid配置: {'已配置' if settings.wx_appid else '未配置'}")
|
||||
print(f"[Login] wx_secret配置: {'已配置' if settings.wx_secret else '未配置'}")
|
||||
|
||||
# 调用微信API获取openid
|
||||
if settings.wx_appid and settings.wx_secret:
|
||||
try:
|
||||
url = f"https://api.weixin.qq.com/sns/jscode2session?appid={settings.wx_appid}&secret={settings.wx_secret}&js_code={request.code}&grant_type=authorization_code"
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
resp = await client.get(url, timeout=10.0)
|
||||
data = resp.json()
|
||||
|
||||
if "errcode" in data and data["errcode"] != 0:
|
||||
# 微信API返回错误,使用code作为openid(开发模式)
|
||||
print(f"[Login] 微信API错误: {data}")
|
||||
openid = request.code
|
||||
else:
|
||||
openid = data.get("openid", request.code)
|
||||
except Exception as e:
|
||||
print(f"[Login] 调用微信API失败: {e}")
|
||||
openid = request.code
|
||||
else:
|
||||
# 未配置微信密钥,开发模式:用code作为openid
|
||||
openid = request.code
|
||||
|
||||
# 查找或创建用户
|
||||
@@ -71,6 +106,55 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# 生成 JWT Token
|
||||
token = create_token(user.id, user.openid)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"userId": user.id,
|
||||
"openid": user.openid,
|
||||
"nickname": user.nickname,
|
||||
"avatarUrl": user.avatar_url,
|
||||
"gender": user.gender,
|
||||
"total_play_count": user.total_play_count,
|
||||
"total_endings": user.total_endings,
|
||||
"token": token
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh-token")
|
||||
async def refresh_token(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)):
|
||||
"""刷新 Token"""
|
||||
# 查找用户
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 生成新 Token
|
||||
new_token = create_token(user.id, user.openid)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": new_token,
|
||||
"userId": user.id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)):
|
||||
"""获取当前用户信息(通过 Token 验证)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
@@ -281,24 +365,79 @@ async def toggle_collect(request: CollectRequest, db: AsyncSession = Depends(get
|
||||
|
||||
@router.get("/collections")
|
||||
async def get_collections(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||
"""获取收藏列表"""
|
||||
result = await db.execute(
|
||||
select(Story)
|
||||
"""获取收藏列表(包含原故事和AI改写草稿)"""
|
||||
from app.models.story import StoryDraft, DraftStatus
|
||||
|
||||
# 查询收藏的原故事
|
||||
original_result = await db.execute(
|
||||
select(Story, UserProgress.updated_at)
|
||||
.join(UserProgress, Story.id == UserProgress.story_id)
|
||||
.where(UserProgress.user_id == user_id, UserProgress.is_collected == True)
|
||||
.order_by(UserProgress.updated_at.desc())
|
||||
)
|
||||
stories = result.scalars().all()
|
||||
original_stories = original_result.all()
|
||||
|
||||
data = [{
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"cover_url": s.cover_url,
|
||||
"description": s.description,
|
||||
"category": s.category,
|
||||
"play_count": s.play_count,
|
||||
"like_count": s.like_count
|
||||
} for s in stories]
|
||||
# 查询收藏的草稿
|
||||
draft_result = await db.execute(
|
||||
select(StoryDraft, Story.title.label('story_title'), Story.category, Story.cover_url)
|
||||
.join(Story, StoryDraft.story_id == Story.id)
|
||||
.where(
|
||||
StoryDraft.user_id == user_id,
|
||||
StoryDraft.is_collected == True,
|
||||
StoryDraft.status == DraftStatus.completed
|
||||
)
|
||||
.order_by(StoryDraft.created_at.desc())
|
||||
)
|
||||
drafts = draft_result.all()
|
||||
|
||||
# 按 story_id 分组
|
||||
collections = {}
|
||||
|
||||
# 处理原故事
|
||||
for story, updated_at in original_stories:
|
||||
story_id = story.id
|
||||
if story_id not in collections:
|
||||
collections[story_id] = {
|
||||
"storyId": story_id,
|
||||
"storyTitle": story.title,
|
||||
"category": story.category,
|
||||
"coverUrl": story.cover_url,
|
||||
"versions": []
|
||||
}
|
||||
collections[story_id]["versions"].append({
|
||||
"type": "original",
|
||||
"id": story_id,
|
||||
"title": story.title,
|
||||
"draftId": None
|
||||
})
|
||||
|
||||
# 处理草稿
|
||||
for row in drafts:
|
||||
draft = row[0]
|
||||
story_title = row[1]
|
||||
category = row[2]
|
||||
cover_url = row[3]
|
||||
story_id = draft.story_id
|
||||
|
||||
if story_id not in collections:
|
||||
collections[story_id] = {
|
||||
"storyId": story_id,
|
||||
"storyTitle": story_title,
|
||||
"category": category,
|
||||
"coverUrl": cover_url,
|
||||
"versions": []
|
||||
}
|
||||
collections[story_id]["versions"].append({
|
||||
"type": draft.draft_type or "rewrite",
|
||||
"id": draft.id,
|
||||
"title": draft.title or f"{story_title}-{draft.draft_type}",
|
||||
"draftId": draft.id
|
||||
})
|
||||
|
||||
# 转换为列表,添加版本数量
|
||||
data = []
|
||||
for item in collections.values():
|
||||
item["versionCount"] = len(item["versions"])
|
||||
data.append(item)
|
||||
|
||||
return {"code": 0, "data": data}
|
||||
|
||||
@@ -419,3 +558,177 @@ 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
|
||||
|
||||
# 查找该用户该故事的所有记录(区分原故事和草稿)
|
||||
query = select(PlayRecord).where(
|
||||
PlayRecord.user_id == request.userId,
|
||||
PlayRecord.story_id == request.storyId
|
||||
)
|
||||
if request.draftId:
|
||||
query = query.where(PlayRecord.draft_id == request.draftId)
|
||||
else:
|
||||
query = query.where(PlayRecord.draft_id == None)
|
||||
|
||||
result = await db.execute(query)
|
||||
existing_records = result.scalars().all()
|
||||
|
||||
# 检查是否有相同路径的记录
|
||||
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,
|
||||
draft_id=request.draftId,
|
||||
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)
|
||||
):
|
||||
"""获取游玩记录列表"""
|
||||
from app.models.story import StoryDraft
|
||||
|
||||
if story_id:
|
||||
# 获取指定故事的记录(包含草稿发布状态)
|
||||
result = await db.execute(
|
||||
select(PlayRecord)
|
||||
.where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id)
|
||||
.order_by(PlayRecord.created_at.desc())
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
# 获取相关草稿的发布状态
|
||||
draft_ids = [r.draft_id for r in records if r.draft_id]
|
||||
draft_status = {}
|
||||
if draft_ids:
|
||||
draft_result = await db.execute(
|
||||
select(StoryDraft.id, StoryDraft.published_to_center, StoryDraft.title)
|
||||
.where(StoryDraft.id.in_(draft_ids))
|
||||
)
|
||||
for d in draft_result.all():
|
||||
draft_status[d.id] = {"published": d.published_to_center, "title": d.title}
|
||||
|
||||
data = []
|
||||
for r in records:
|
||||
item = {
|
||||
"id": r.id,
|
||||
"draftId": r.draft_id,
|
||||
"endingName": r.ending_name,
|
||||
"endingType": r.ending_type,
|
||||
"createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else ""
|
||||
}
|
||||
# 如果是草稿记录,添加发布状态
|
||||
if r.draft_id and r.draft_id in draft_status:
|
||||
item["isPublished"] = draft_status[r.draft_id]["published"]
|
||||
item["draftTitle"] = draft_status[r.draft_id]["title"]
|
||||
else:
|
||||
item["isPublished"] = True # 原故事视为"已发布"
|
||||
data.append(item)
|
||||
else:
|
||||
# 获取所有玩过的故事(按故事分组,取最新一条)
|
||||
result = await db.execute(
|
||||
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,32 @@
|
||||
AI服务封装模块
|
||||
支持多种AI提供商:DeepSeek, OpenAI, Claude, 通义千问
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
import httpx
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def format_characters_prompt(characters: List[Dict]) -> str:
|
||||
"""格式化角色信息为prompt文本"""
|
||||
if not characters:
|
||||
return ""
|
||||
|
||||
text = "\n【故事角色设定】\n"
|
||||
for char in characters:
|
||||
text += f"- {char.get('name', '未知')}({char.get('role_type', '配角')}):"
|
||||
if char.get('gender'):
|
||||
text += f" {char.get('gender')},"
|
||||
if char.get('age_range'):
|
||||
text += f" {char.get('age_range')},"
|
||||
if char.get('appearance'):
|
||||
text += f" 外貌:{char.get('appearance')[:100]},"
|
||||
if char.get('personality'):
|
||||
text += f" 性格:{char.get('personality')[:100]}"
|
||||
text += "\n"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
class AIService:
|
||||
def __init__(self):
|
||||
@@ -13,11 +37,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
|
||||
@@ -44,7 +71,8 @@ class AIService:
|
||||
story_category: str,
|
||||
ending_name: str,
|
||||
ending_content: str,
|
||||
user_prompt: str
|
||||
user_prompt: str,
|
||||
characters: List[Dict] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI改写结局
|
||||
@@ -53,14 +81,19 @@ class AIService:
|
||||
if not self.enabled or not self.api_key:
|
||||
return None
|
||||
|
||||
# 格式化角色信息
|
||||
characters_text = format_characters_prompt(characters) if characters else ""
|
||||
|
||||
# 构建Prompt
|
||||
system_prompt = """你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。
|
||||
system_prompt = f"""你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。
|
||||
{characters_text}
|
||||
要求:
|
||||
1. 保持原故事的世界观和人物性格
|
||||
2. 结局要有张力和情感冲击
|
||||
3. 结局内容字数控制在200-400字
|
||||
4. 为新结局取一个4-8字的新名字,体现改写后的剧情走向
|
||||
5. 输出格式必须是JSON:{"ending_name": "新结局名称", "content": "结局内容"}"""
|
||||
5. 如果有角色设定,必须保持角色性格和外貌描述的一致性
|
||||
6. 输出格式必须是JSON:{{"ending_name": "新结局名称", "content": "结局内容"}}"""
|
||||
|
||||
user_prompt_text = f"""故事标题:{story_title}
|
||||
故事分类:{story_category}
|
||||
@@ -86,6 +119,900 @@ 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,
|
||||
characters: List[Dict] = None
|
||||
) -> 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] characters数量={len(characters) if characters else 0}")
|
||||
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
|
||||
|
||||
# 格式化角色信息
|
||||
characters_text = format_characters_prompt(characters) if characters else ""
|
||||
|
||||
# 构建路径历史文本
|
||||
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_header = f"""你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
|
||||
{characters_text}
|
||||
【任务】
|
||||
请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。
|
||||
|
||||
【写作要求】
|
||||
1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 严格符合用户的改写意图,围绕用户指令展开剧情
|
||||
6. 保持原故事的人物性格、语言风格和世界观
|
||||
7. 如果有角色设定,必须保持角色性格和外貌的一致性
|
||||
8. 对话要自然生动,描写要有画面感
|
||||
|
||||
【关于结局 - 极其重要!】
|
||||
★★★ 每一条分支路径的尽头必须是结局节点 ★★★
|
||||
- 结局节点必须设置 "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你的心跳漏了一拍,一时间不知该如何回应。"
|
||||
"""
|
||||
|
||||
system_prompt_json = """【输出格式】(严格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"
|
||||
}"""
|
||||
|
||||
system_prompt = system_prompt_header + system_prompt_json
|
||||
|
||||
# 构建用户提示词
|
||||
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,
|
||||
characters: List[Dict] = None
|
||||
) -> 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] characters数量={len(characters) if characters else 0}")
|
||||
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
|
||||
|
||||
# 格式化角色信息
|
||||
characters_text = format_characters_prompt(characters) if characters else ""
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt_header = f"""你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。
|
||||
{characters_text}
|
||||
【任务】
|
||||
请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。
|
||||
|
||||
【写作要求】
|
||||
1. 第一个节点必须紧密衔接原结局,像是结局之后自然发生的事
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 严格符合用户的续写意图,围绕用户指令展开剧情
|
||||
6. 保持原故事的人物性格、语言风格和世界观
|
||||
7. 如果有角色设定,必须保持角色性格和外貌的一致性
|
||||
8. 对话要自然生动,描写要有画面感
|
||||
|
||||
【关于新结局 - 极其重要!】
|
||||
★★★ 每一条分支路径的尽头必须是新结局节点 ★★★
|
||||
- 结局节点必须设置 "is_ending": true
|
||||
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
|
||||
- 结局名称 4-8 字,体现剧情走向
|
||||
- 如果有2个选项分支,最终必须有2个不同的结局
|
||||
- 每个结局必须有 "ending_score" 评分(0-100)
|
||||
"""
|
||||
|
||||
system_prompt_json = """【输出格式】(严格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"
|
||||
}"""
|
||||
|
||||
system_prompt = system_prompt_header + system_prompt_json
|
||||
|
||||
# 构建用户提示词
|
||||
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
|
||||
|
||||
async def create_story(
|
||||
self,
|
||||
genre: str,
|
||||
keywords: str,
|
||||
protagonist: str = None,
|
||||
conflict: str = None,
|
||||
user_id: int = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI创作全新故事
|
||||
:return: 包含完整故事结构的字典,或 None
|
||||
"""
|
||||
print(f"\n[create_story] ========== 开始创作 ==========")
|
||||
print(f"[create_story] genre={genre}, keywords={keywords}")
|
||||
print(f"[create_story] protagonist={protagonist}, conflict={conflict}")
|
||||
print(f"[create_story] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
|
||||
|
||||
if not self.enabled or not self.api_key:
|
||||
print(f"[create_story] 服务未启用或API Key为空,返回None")
|
||||
return None
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt = """你是一个专业的互动故事创作专家。请根据用户提供的题材和关键词,创作一个完整的互动故事。
|
||||
|
||||
【故事结构要求】
|
||||
1. 故事要有吸引人的标题(10字以内)和简介(50-100字)
|
||||
2. 创建2-3个主要角色,每个角色需要详细设定
|
||||
3. 故事包含6-8个节点,形成多分支结构
|
||||
4. 必须有2-4个不同类型的结局(good/bad/neutral/special)
|
||||
5. 每个非结局节点有2个选项,选项要有明显的剧情差异
|
||||
|
||||
【角色设定要求】
|
||||
每个角色需要:
|
||||
- name: 角色名(2-4字)
|
||||
- role_type: 角色类型(protagonist/antagonist/supporting)
|
||||
- gender: 性别(male/female)
|
||||
- age_range: 年龄段(youth/adult/middle_aged/elderly)
|
||||
- appearance: 外貌描述(50-100字,包含发型、眼睛、身材、穿着等)
|
||||
- personality: 性格特点(30-50字)
|
||||
|
||||
【节点内容要求】
|
||||
- 每个节点150-300字,分2-3段(用\\n\\n分隔)
|
||||
- 包含场景描写、人物对话、心理活动
|
||||
- 对话要自然生动,描写要有画面感
|
||||
|
||||
【结局要求】
|
||||
- 结局内容200-400字,有情感冲击力
|
||||
- 结局名称4-8字,体现剧情走向
|
||||
- 结局需要评分(ending_score):good 80-100, bad 20-50, neutral 50-70, special 70-90
|
||||
|
||||
【输出格式】严格JSON,不要有任何额外文字:
|
||||
{
|
||||
"title": "故事标题",
|
||||
"description": "故事简介(50-100字)",
|
||||
"category": "题材分类",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"role_type": "protagonist",
|
||||
"gender": "male",
|
||||
"age_range": "youth",
|
||||
"appearance": "外貌描述...",
|
||||
"personality": "性格特点..."
|
||||
}
|
||||
],
|
||||
"nodes": {
|
||||
"start": {
|
||||
"content": "开篇内容...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项A", "nextNodeKey": "node_1a"},
|
||||
{"text": "选项B", "nextNodeKey": "node_1b"}
|
||||
]
|
||||
},
|
||||
"node_1a": {
|
||||
"content": "...",
|
||||
"speaker": "旁白",
|
||||
"choices": [...]
|
||||
},
|
||||
"ending_good": {
|
||||
"content": "好结局内容...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
"ending_type": "good",
|
||||
"ending_score": 90
|
||||
}
|
||||
},
|
||||
"startNodeKey": "start"
|
||||
}"""
|
||||
|
||||
# 构建用户提示词
|
||||
protagonist_text = f"\n主角设定:{protagonist}" if protagonist else ""
|
||||
conflict_text = f"\n核心冲突:{conflict}" if conflict else ""
|
||||
|
||||
user_prompt_text = f"""请创作一个互动故事:
|
||||
|
||||
【题材】{genre}
|
||||
【关键词】{keywords}{protagonist_text}{conflict_text}
|
||||
|
||||
请创作完整的故事(输出JSON格式):"""
|
||||
|
||||
print(f"[create_story] 提示词构建完成,开始调用AI...")
|
||||
|
||||
try:
|
||||
result = None
|
||||
if self.provider == "openai":
|
||||
result = await self._call_openai_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "claude":
|
||||
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
|
||||
elif self.provider == "qwen":
|
||||
result = await self._call_qwen_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "deepseek":
|
||||
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
|
||||
|
||||
print(f"[create_story] AI调用完成,result存在={result is not None}")
|
||||
|
||||
if result and result.get("content"):
|
||||
print(f"[create_story] AI返回内容长度={len(result.get('content', ''))}")
|
||||
|
||||
# 解析JSON响应
|
||||
parsed = self._parse_story_json(result["content"])
|
||||
print(f"[create_story] JSON解析结果: parsed存在={parsed is not None}")
|
||||
|
||||
if parsed:
|
||||
parsed["tokens_used"] = result.get("tokens_used", 0)
|
||||
print(f"[create_story] 成功! title={parsed.get('title')}, nodes数量={len(parsed.get('nodes', {}))}")
|
||||
return parsed
|
||||
else:
|
||||
print(f"[create_story] JSON解析失败!")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[create_story] 异常: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _parse_story_json(self, content: str) -> Optional[Dict]:
|
||||
"""解析AI返回的故事JSON"""
|
||||
print(f"[_parse_story_json] 开始解析,内容长度={len(content)}")
|
||||
|
||||
# 移除 markdown 代码块标记
|
||||
clean_content = content.strip()
|
||||
if clean_content.startswith('```'):
|
||||
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
|
||||
clean_content = re.sub(r'\s*```$', '', clean_content)
|
||||
|
||||
result = None
|
||||
|
||||
# 方法1: 直接解析
|
||||
try:
|
||||
result = json.loads(clean_content)
|
||||
if all(k in result for k in ['title', 'nodes', 'startNodeKey']):
|
||||
print(f"[_parse_story_json] 直接解析成功!")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[_parse_story_json] 直接解析失败: {e}")
|
||||
result = None
|
||||
|
||||
# 方法2: 提取JSON块
|
||||
if not result:
|
||||
try:
|
||||
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
|
||||
if brace_match:
|
||||
json_str = brace_match.group(0)
|
||||
result = json.loads(json_str)
|
||||
if all(k in result for k in ['title', 'nodes', 'startNodeKey']):
|
||||
print(f"[_parse_story_json] 花括号块解析成功!")
|
||||
else:
|
||||
result = None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[_parse_story_json] 花括号块解析失败: {e}")
|
||||
# 尝试修复截断的JSON
|
||||
try:
|
||||
result = self._try_fix_story_json(json_str)
|
||||
if result:
|
||||
print(f"[_parse_story_json] JSON修复成功!")
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[_parse_story_json] 提取解析失败: {e}")
|
||||
|
||||
if not result:
|
||||
print(f"[_parse_story_json] 所有解析方法都失败了")
|
||||
return None
|
||||
|
||||
# 验证并修复故事结构
|
||||
result = self._validate_and_fix_story(result)
|
||||
return result
|
||||
|
||||
def _validate_and_fix_story(self, story: Dict) -> Dict:
|
||||
"""验证并修复故事结构,确保每个分支都有结局"""
|
||||
nodes = story.get('nodes', {})
|
||||
if not nodes:
|
||||
return story
|
||||
|
||||
print(f"[_validate_and_fix_story] 开始验证,节点数={len(nodes)}")
|
||||
|
||||
# 1. 找出所有结局节点
|
||||
ending_nodes = [k for k, v in nodes.items() if v.get('is_ending')]
|
||||
print(f"[_validate_and_fix_story] 已有结局节点: {ending_nodes}")
|
||||
|
||||
# 2. 找出所有被引用的节点(作为 nextNodeKey)
|
||||
referenced_keys = set()
|
||||
for node_key, node_data in nodes.items():
|
||||
choices = node_data.get('choices', [])
|
||||
if isinstance(choices, list):
|
||||
for choice in choices:
|
||||
if isinstance(choice, dict) and 'nextNodeKey' in choice:
|
||||
referenced_keys.add(choice['nextNodeKey'])
|
||||
|
||||
# 3. 找出"叶子节点":没有 choices 或 choices 为空,且不是结局
|
||||
leaf_nodes = []
|
||||
broken_refs = [] # 引用了不存在节点的选项
|
||||
|
||||
for node_key, node_data in nodes.items():
|
||||
choices = node_data.get('choices', [])
|
||||
is_ending = node_data.get('is_ending', False)
|
||||
|
||||
# 检查 choices 中引用的节点是否存在
|
||||
if isinstance(choices, list):
|
||||
for choice in choices:
|
||||
if isinstance(choice, dict):
|
||||
next_key = choice.get('nextNodeKey')
|
||||
if next_key and next_key not in nodes:
|
||||
broken_refs.append((node_key, next_key))
|
||||
|
||||
# 没有有效选项且不是结局的节点
|
||||
if not is_ending and (not choices or len(choices) == 0):
|
||||
leaf_nodes.append(node_key)
|
||||
|
||||
print(f"[_validate_and_fix_story] 叶子节点(无选项非结局): {leaf_nodes}")
|
||||
print(f"[_validate_and_fix_story] 断裂引用: {broken_refs}")
|
||||
|
||||
# 4. 修复:将叶子节点标记为结局
|
||||
for node_key in leaf_nodes:
|
||||
node = nodes[node_key]
|
||||
print(f"[_validate_and_fix_story] 修复节点 {node_key} -> 标记为结局")
|
||||
node['is_ending'] = True
|
||||
if not node.get('ending_name'):
|
||||
node['ending_name'] = '命运的转折'
|
||||
if not node.get('ending_type'):
|
||||
node['ending_type'] = 'neutral'
|
||||
if not node.get('ending_score'):
|
||||
node['ending_score'] = 60
|
||||
|
||||
# 5. 修复:处理断裂引用(选项指向不存在的节点)
|
||||
for node_key, missing_key in broken_refs:
|
||||
node = nodes[node_key]
|
||||
choices = node.get('choices', [])
|
||||
|
||||
# 移除指向不存在节点的选项
|
||||
valid_choices = [c for c in choices if c.get('nextNodeKey') in nodes]
|
||||
|
||||
if len(valid_choices) == 0:
|
||||
# 没有有效选项了,标记为结局
|
||||
print(f"[_validate_and_fix_story] 节点 {node_key} 所有选项失效 -> 标记为结局")
|
||||
node['is_ending'] = True
|
||||
node['choices'] = []
|
||||
if not node.get('ending_name'):
|
||||
node['ending_name'] = '未知结局'
|
||||
if not node.get('ending_type'):
|
||||
node['ending_type'] = 'neutral'
|
||||
if not node.get('ending_score'):
|
||||
node['ending_score'] = 50
|
||||
else:
|
||||
node['choices'] = valid_choices
|
||||
|
||||
# 6. 最终检查:确保至少有一个结局
|
||||
ending_count = sum(1 for v in nodes.values() if v.get('is_ending'))
|
||||
print(f"[_validate_and_fix_story] 修复后结局数: {ending_count}")
|
||||
|
||||
if ending_count == 0:
|
||||
# 如果还是没有结局,找最后一个节点标记为结局
|
||||
last_key = list(nodes.keys())[-1]
|
||||
print(f"[_validate_and_fix_story] 强制将最后节点 {last_key} 标记为结局")
|
||||
nodes[last_key]['is_ending'] = True
|
||||
nodes[last_key]['ending_name'] = '故事的终点'
|
||||
nodes[last_key]['ending_type'] = 'neutral'
|
||||
nodes[last_key]['ending_score'] = 60
|
||||
nodes[last_key]['choices'] = []
|
||||
|
||||
return story
|
||||
|
||||
def _try_fix_story_json(self, json_str: str) -> Optional[Dict]:
|
||||
"""尝试修复不完整的故事JSON"""
|
||||
try:
|
||||
# 尝试找到最后一个完整的节点
|
||||
# 查找 "nodes": { 的位置
|
||||
nodes_match = re.search(r'"nodes"\s*:\s*\{', json_str)
|
||||
if not nodes_match:
|
||||
return None
|
||||
|
||||
# 找所有看起来完整的节点(有 "content" 字段的)
|
||||
node_pattern = r'"(\w+)"\s*:\s*\{[^{}]*"content"[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
|
||||
nodes = list(re.finditer(node_pattern, json_str[nodes_match.end():]))
|
||||
|
||||
if len(nodes) < 2:
|
||||
return None
|
||||
|
||||
# 取到最后一个完整节点的位置
|
||||
last_node_end = nodes_match.end() + nodes[-1].end()
|
||||
|
||||
# 尝试提取基本信息
|
||||
title_match = re.search(r'"title"\s*:\s*"([^"]+)"', json_str)
|
||||
desc_match = re.search(r'"description"\s*:\s*"([^"]+)"', json_str)
|
||||
category_match = re.search(r'"category"\s*:\s*"([^"]+)"', json_str)
|
||||
start_match = re.search(r'"startNodeKey"\s*:\s*"([^"]+)"', json_str)
|
||||
|
||||
title = title_match.group(1) if title_match else "AI创作故事"
|
||||
description = desc_match.group(1) if desc_match else ""
|
||||
category = category_match.group(1) if category_match else "都市言情"
|
||||
startNodeKey = start_match.group(1) if start_match else "start"
|
||||
|
||||
# 提取角色
|
||||
characters = []
|
||||
char_match = re.search(r'"characters"\s*:\s*\[([\s\S]*?)\]', json_str)
|
||||
if char_match:
|
||||
try:
|
||||
characters = json.loads('[' + char_match.group(1) + ']')
|
||||
except:
|
||||
pass
|
||||
|
||||
# 提取节点
|
||||
nodes_content = json_str[nodes_match.start():last_node_end] + '}'
|
||||
try:
|
||||
nodes_obj = json.loads('{' + nodes_content + '}')
|
||||
nodes_dict = nodes_obj.get('nodes', {})
|
||||
except:
|
||||
return None
|
||||
|
||||
if len(nodes_dict) < 2:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"category": category,
|
||||
"characters": characters,
|
||||
"nodes": nodes_dict,
|
||||
"startNodeKey": startNodeKey
|
||||
}
|
||||
|
||||
print(f"[_try_fix_story_json] 修复成功! 节点数={len(nodes_dict)}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[_try_fix_story_json] 修复失败: {e}")
|
||||
return None
|
||||
|
||||
def _parse_branch_json(self, content: str) -> Optional[Dict]:
|
||||
"""解析AI返回的分支JSON"""
|
||||
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
||||
|
||||
# 移除 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": 8192 # DeepSeek 最大输出限制
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
139
server/app/services/image_gen.py
Normal file
139
server/app/services/image_gen.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
图片生成服务 - 使用 Gemini API 生图,存储到本地/云托管
|
||||
"""
|
||||
import httpx
|
||||
import base64
|
||||
import time
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
# 图片尺寸规范(基于前端展示尺寸 × 3倍清晰度)
|
||||
IMAGE_SIZES = {
|
||||
"cover": {"width": 240, "height": 330, "desc": "竖版封面图,3:4比例"},
|
||||
"avatar": {"width": 150, "height": 150, "desc": "正方形头像,1:1比例"},
|
||||
"background": {"width": 1120, "height": 840, "desc": "横版背景图,4:3比例"},
|
||||
"character": {"width": 512, "height": 768, "desc": "竖版角色立绘,2:3比例,透明背景"}
|
||||
}
|
||||
|
||||
|
||||
class ImageGenService:
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.api_key = settings.gemini_api_key
|
||||
self.base_url = "https://work.poloapi.com/v1beta"
|
||||
# 计算绝对路径
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
|
||||
self.upload_dir = os.path.join(base_dir, "images")
|
||||
os.makedirs(self.upload_dir, exist_ok=True)
|
||||
|
||||
def get_size_prompt(self, image_type: str) -> str:
|
||||
"""获取尺寸描述提示词"""
|
||||
size = IMAGE_SIZES.get(image_type, IMAGE_SIZES["background"])
|
||||
return f"Image size: {size['width']}x{size['height']} pixels, {size['desc']}."
|
||||
|
||||
async def generate_image(self, prompt: str, image_type: str = "background", style: str = "anime") -> Optional[dict]:
|
||||
"""
|
||||
调用 Gemini 生成图片
|
||||
prompt: 图片描述
|
||||
image_type: 图片类型(cover/avatar/background/character)
|
||||
style: 风格(anime/realistic/illustration)
|
||||
"""
|
||||
style_prefix = {
|
||||
"anime": "anime style, high quality illustration, vibrant colors, ",
|
||||
"realistic": "photorealistic, high detail, cinematic lighting, ",
|
||||
"illustration": "digital art illustration, beautiful artwork, "
|
||||
}
|
||||
|
||||
# 组合完整提示词:风格 + 尺寸 + 内容
|
||||
size_prompt = self.get_size_prompt(image_type)
|
||||
full_prompt = f"{style_prefix.get(style, '')}{size_prompt} {prompt}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/models/gemini-3-pro-image-preview:generateContent",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": self.api_key
|
||||
},
|
||||
json={
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": f"Generate an image: {full_prompt}"
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["TEXT", "IMAGE"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
for part in parts:
|
||||
if "inlineData" in part:
|
||||
return {
|
||||
"success": True,
|
||||
"image_data": part["inlineData"]["data"],
|
||||
"mime_type": part["inlineData"].get("mimeType", "image/png")
|
||||
}
|
||||
return {"success": False, "error": "No image in response"}
|
||||
else:
|
||||
error_text = response.text[:200]
|
||||
return {"success": False, "error": f"API error: {response.status_code} - {error_text}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def save_image(self, image_data: str, filename: str) -> Optional[str]:
|
||||
"""保存图片到本地,返回访问URL"""
|
||||
try:
|
||||
image_bytes = base64.b64decode(image_data)
|
||||
file_path = os.path.join(self.upload_dir, filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# 返回可访问的URL路径
|
||||
return f"/uploads/images/{filename}"
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
return None
|
||||
|
||||
async def generate_and_save(self, prompt: str, image_type: str = "background", style: str = "anime") -> dict:
|
||||
"""生成图片并保存"""
|
||||
result = await self.generate_image(prompt, image_type, style)
|
||||
|
||||
if not result or not result.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.get("error", "生成失败") if result else "生成失败"
|
||||
}
|
||||
|
||||
# 生成文件名
|
||||
timestamp = int(time.time() * 1000)
|
||||
hash_str = hashlib.md5(prompt.encode()).hexdigest()[:8]
|
||||
ext = "png" if "png" in result.get("mime_type", "") else "jpg"
|
||||
filename = f"{image_type}_{timestamp}_{hash_str}.{ext}"
|
||||
|
||||
url = await self.save_image(result["image_data"], filename)
|
||||
|
||||
if url:
|
||||
return {"success": True, "url": url, "filename": filename}
|
||||
else:
|
||||
return {"success": False, "error": "保存失败"}
|
||||
|
||||
|
||||
# 延迟初始化单例
|
||||
_service_instance = None
|
||||
|
||||
def get_image_gen_service():
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = ImageGenService()
|
||||
return _service_instance
|
||||
6
server/app/utils/__init__.py
Normal file
6
server/app/utils/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from .jwt_utils import create_token, verify_token, get_current_user_id, get_optional_user_id
|
||||
|
||||
__all__ = ["create_token", "verify_token", "get_current_user_id", "get_optional_user_id"]
|
||||
BIN
server/app/utils/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
server/app/utils/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
server/app/utils/__pycache__/jwt_utils.cpython-312.pyc
Normal file
BIN
server/app/utils/__pycache__/jwt_utils.cpython-312.pyc
Normal file
Binary file not shown.
78
server/app/utils/jwt_utils.py
Normal file
78
server/app/utils/jwt_utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
JWT 工具函数
|
||||
"""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def create_token(user_id: int, openid: str) -> str:
|
||||
"""
|
||||
创建 JWT Token
|
||||
"""
|
||||
settings = get_settings()
|
||||
expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours)
|
||||
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"openid": openid,
|
||||
"exp": expire,
|
||||
"iat": datetime.utcnow()
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
|
||||
return token
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict:
|
||||
"""
|
||||
验证 JWT Token
|
||||
返回 payload 或抛出异常
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token已过期,请重新登录")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="无效的Token")
|
||||
|
||||
|
||||
def get_current_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> int:
|
||||
"""
|
||||
从 Header 中获取并验证 Token,返回 user_id
|
||||
用作 FastAPI 依赖注入
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="未提供身份令牌")
|
||||
|
||||
# 支持 "Bearer xxx" 格式
|
||||
token = authorization
|
||||
if authorization.startswith("Bearer "):
|
||||
token = authorization[7:]
|
||||
|
||||
payload = verify_token(token)
|
||||
return payload.get("user_id")
|
||||
|
||||
|
||||
def get_optional_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> Optional[int]:
|
||||
"""
|
||||
可选的用户验证,未提供 Token 时返回 None
|
||||
用于不强制要求登录的接口
|
||||
"""
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
try:
|
||||
token = authorization
|
||||
if authorization.startswith("Bearer "):
|
||||
token = authorization[7:]
|
||||
|
||||
payload = verify_token(token)
|
||||
return payload.get("user_id")
|
||||
except HTTPException:
|
||||
return None
|
||||
@@ -9,3 +9,4 @@ pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
httpx==0.27.0
|
||||
PyJWT==2.8.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,204 @@
|
||||
-- 星域故事汇数据库初始化脚本
|
||||
-- 创建数据库
|
||||
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 '用户是否已查看',
|
||||
`published_to_center` TINYINT(1) DEFAULT 0 COMMENT '是否发布到创作中心',
|
||||
`draft_type` VARCHAR(20) DEFAULT 'rewrite' COMMENT '草稿类型: rewrite/continue/create',
|
||||
`is_collected` TINYINT(1) DEFAULT 0 COMMENT '用户是否收藏',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
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. 游玩记录表
|
||||
-- ============================================
|
||||
c
|
||||
|
||||
-- ============================================
|
||||
-- 9. 故事角色表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `story_characters` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`story_id` INT NOT NULL COMMENT '所属故事ID',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '角色名称',
|
||||
`role_type` VARCHAR(20) DEFAULT 'supporting' COMMENT '角色类型: protagonist/antagonist/supporting',
|
||||
`gender` VARCHAR(10) DEFAULT '' COMMENT '性别: male/female/unknown',
|
||||
`age_range` VARCHAR(20) DEFAULT '' COMMENT '年龄段: child/teen/young/middle/old',
|
||||
`appearance` TEXT COMMENT '外貌描述(用于生成图片)',
|
||||
`personality` TEXT COMMENT '性格描述',
|
||||
`background` TEXT COMMENT '背景故事',
|
||||
`avatar_prompt` TEXT COMMENT 'AI绘图提示词',
|
||||
`avatar_url` VARCHAR(500) DEFAULT '' COMMENT '角色头像URL',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_story_id` (`story_id`),
|
||||
CONSTRAINT `story_characters_ibfk_1` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事角色表';
|
||||
|
||||
-- ============================================
|
||||
-- 10. 节点角色关联表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `node_characters` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`story_id` INT NOT NULL COMMENT '故事ID',
|
||||
`node_key` VARCHAR(50) NOT NULL COMMENT '节点key',
|
||||
`character_id` INT NOT NULL COMMENT '角色ID',
|
||||
`is_speaker` TINYINT(1) DEFAULT 0 COMMENT '是否为该节点说话者',
|
||||
`emotion` VARCHAR(30) DEFAULT 'neutral' COMMENT '该节点情绪: happy/sad/angry/neutral等',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_story_node` (`story_id`, `node_key`),
|
||||
KEY `idx_character` (`character_id`),
|
||||
CONSTRAINT `node_characters_ibfk_1` FOREIGN KEY (`character_id`) REFERENCES `story_characters` (`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改写草稿表';
|
||||
|
||||
209
server/sql/seed_characters.sql
Normal file
209
server/sql/seed_characters.sql
Normal file
@@ -0,0 +1,209 @@
|
||||
-- 角色种子数据
|
||||
USE stardom_story;
|
||||
|
||||
-- ============================================
|
||||
-- 故事1: 总裁的替身新娘
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(1, 1, '林诗语', 'protagonist', 'female', 'young', '清秀温婉的面容,一双明亮的杏眼,乌黑长发及腰,身材纤细,气质如兰', '善良隐忍,聪慧坚韧,温柔但有底线', 'young chinese woman, gentle beauty, long black hair, bright almond eyes, slender figure, elegant temperament, wedding dress'),
|
||||
(2, 1, '陆景深', 'protagonist', 'male', 'young', '剑眉星目,薄唇紧抿,高大挺拔,气势凌厉,眼神冰冷如寒潭', '外冷内热,霸道专一,深情而克制', 'handsome chinese man, sharp eyebrows, thin lips, tall and imposing, cold piercing eyes, black suit, CEO style'),
|
||||
(3, 1, '林诗韵', 'supporting', 'female', 'young', '容貌出众,浓妆艳抹,身姿妖娆', '任性自私,贪慕虚荣', 'beautiful chinese woman, heavy makeup, glamorous figure, designer clothes');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(1, 'start', 1, 0, 'nervous'),
|
||||
(1, 'start', 2, 0, 'neutral'),
|
||||
(1, 'choice1_a', 1, 0, 'anxious'),
|
||||
(1, 'choice1_a', 2, 1, 'cold'),
|
||||
(1, 'choice1_b', 1, 0, 'scared'),
|
||||
(1, 'choice1_b', 2, 1, 'amused'),
|
||||
(1, 'choice2_a', 1, 1, 'honest'),
|
||||
(1, 'choice2_a', 2, 0, 'interested'),
|
||||
(1, 'choice2_b', 1, 1, 'nervous'),
|
||||
(1, 'choice2_b', 2, 0, 'smirking'),
|
||||
(1, 'ending_good', 1, 0, 'happy'),
|
||||
(1, 'ending_good', 2, 1, 'loving'),
|
||||
(1, 'ending_normal', 1, 1, 'calm'),
|
||||
(1, 'ending_normal', 2, 0, 'sad'),
|
||||
(1, 'ending_bad', 1, 0, 'heartbroken'),
|
||||
(1, 'ending_bad', 2, 1, 'cruel');
|
||||
|
||||
-- ============================================
|
||||
-- 故事2: 密室中的第四个人
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(4, 2, '王教授', 'supporting', 'male', 'middle', '戴眼镜的中年男人,头发花白,穿着整洁的西装', '学者气质,表面儒雅,实际隐藏着秘密', 'middle-aged chinese man, glasses, graying hair, neat suit, scholarly appearance'),
|
||||
(5, 2, '苏小姐', 'supporting', 'female', 'young', '穿着名牌,妆容精致,身上有淡淡香水味', '看似高傲,内心脆弱', 'young elegant chinese woman, designer clothes, delicate makeup, wealthy appearance'),
|
||||
(6, 2, '李明', 'antagonist', 'male', 'young', '沉默寡言的青年,眼神阴沉,嘴角常带冷笑', '隐忍复仇,城府极深', 'young chinese man, gloomy eyes, cold expression, dark casual clothes');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(2, 'start', 4, 0, 'dead'),
|
||||
(2, 'start', 5, 0, 'shocked'),
|
||||
(2, 'start', 6, 0, 'calm'),
|
||||
(2, 'investigate_room', 4, 0, 'dead'),
|
||||
(2, 'question_su', 5, 1, 'nervous'),
|
||||
(2, 'question_li', 6, 1, 'cold'),
|
||||
(2, 'accuse_su', 5, 1, 'crying'),
|
||||
(2, 'accuse_li', 6, 1, 'vengeful'),
|
||||
(2, 'ending_truth', 6, 1, 'resigned'),
|
||||
(2, 'ending_wrong', 5, 0, 'wronged');
|
||||
|
||||
-- ============================================
|
||||
-- 故事3: 凤临天下
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(7, 3, '林清婉', 'protagonist', 'female', 'young', '眉目如画,肤若凝脂,身姿窈窕,举止端庄', '外柔内刚,隐忍聪慧,心思缜密', 'beautiful ancient chinese woman, delicate features, fair skin, graceful posture, palace costume, elegant hairpin'),
|
||||
(8, 3, '皇帝', 'protagonist', 'male', 'young', '龙袍加身,眉宇威严,俊朗不凡,气势天成', '深沉多疑,霸道深情', 'handsome ancient chinese emperor, dragon robe, majestic presence, piercing eyes, imperial crown'),
|
||||
(9, 3, '皇后', 'antagonist', 'female', 'middle', '珠翠环绕,气度雍容,凤冠霞帔,笑容不达眼底', '城府极深,手段狠辣', 'ancient chinese empress, phoenix crown, luxurious robes, dignified bearing, calculating smile');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(3, 'start', 7, 0, 'nervous'),
|
||||
(3, 'meet_emperor', 7, 1, 'respectful'),
|
||||
(3, 'meet_emperor', 8, 1, 'curious'),
|
||||
(3, 'meet_consort', 7, 1, 'cautious'),
|
||||
(3, 'meet_consort', 9, 1, 'scrutinizing'),
|
||||
(3, 'choice_emperor', 7, 1, 'humble'),
|
||||
(3, 'choice_emperor', 8, 1, 'pleased'),
|
||||
(3, 'choice_queen', 7, 1, 'calculating'),
|
||||
(3, 'choice_queen', 9, 1, 'satisfied'),
|
||||
(3, 'ending_empress', 7, 0, 'triumphant'),
|
||||
(3, 'ending_empress', 8, 1, 'loving'),
|
||||
(3, 'ending_concubine', 7, 0, 'peaceful'),
|
||||
(3, 'ending_tragic', 7, 0, 'despairing');
|
||||
|
||||
-- ============================================
|
||||
-- 故事4: 暗恋那件小事
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(10, 4, '你', 'protagonist', 'female', 'teen', '扎着马尾的高中女生,青涩可爱,眼神里藏着小秘密', '内向害羞,暗恋成痴,纯真善良', 'teenage chinese girl, ponytail, school uniform, shy expression, cute appearance'),
|
||||
(11, 4, '沈昼', 'protagonist', 'male', 'teen', '校园男神,阳光帅气,篮球服下身材修长,笑起来眼睛弯弯', '温柔体贴,学霸气质,暖心细腻', 'handsome teenage chinese boy, school uniform, basketball jersey, warm smile, athletic build'),
|
||||
(12, 4, '同桌', 'supporting', 'female', 'teen', '活泼开朗的女生,表情丰富,是个话痨', '热心肠,爱八卦,仗义', 'cheerful teenage chinese girl, expressive face, school uniform, lively personality');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(4, 'start', 10, 0, 'daydreaming'),
|
||||
(4, 'start', 11, 0, 'neutral'),
|
||||
(4, 'start', 12, 1, 'teasing'),
|
||||
(4, 'library', 10, 1, 'flustered'),
|
||||
(4, 'library', 11, 1, 'gentle'),
|
||||
(4, 'basketball', 10, 0, 'excited'),
|
||||
(4, 'basketball', 11, 1, 'playful'),
|
||||
(4, 'rooftop', 10, 0, 'nervous'),
|
||||
(4, 'rooftop', 11, 1, 'serious'),
|
||||
(4, 'confess_yes', 10, 1, 'brave'),
|
||||
(4, 'confess_yes', 11, 0, 'happy'),
|
||||
(4, 'confess_no', 10, 0, 'tearful'),
|
||||
(4, 'ending_friends', 10, 0, 'regretful');
|
||||
|
||||
-- ============================================
|
||||
-- 故事5: 废柴逆袭录
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(13, 5, '陈风', 'protagonist', 'male', 'young', '相貌普通的少年,眼神坚定,穿着破旧的外门弟子服', '隐忍不屈,心性坚韧,大智若愚', 'young chinese man, ordinary appearance, determined eyes, worn-out cultivation robes, underdog aura'),
|
||||
(14, 5, '天元大帝', 'supporting', 'male', 'old', '白发苍苍的虚影,仙风道骨,眼中闪烁着智慧的光芒', '睿智从容,亦师亦友', 'ancient chinese immortal, white long hair, ethereal figure, wise eyes, flowing robes, ghostly appearance'),
|
||||
(15, 5, '赵天龙', 'antagonist', 'male', 'young', '内门弟子,华服锦衣,面带傲色,眼高于顶', '嚣张跋扈,欺软怕硬', 'arrogant young chinese man, luxurious cultivation robes, haughty expression, inner sect disciple');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(5, 'start', 13, 0, 'oppressed'),
|
||||
(5, 'inheritance', 13, 0, 'shocked'),
|
||||
(5, 'inheritance', 14, 1, 'wise'),
|
||||
(5, 'challenge', 13, 0, 'calm'),
|
||||
(5, 'challenge', 15, 1, 'mocking'),
|
||||
(5, 'show_power', 13, 1, 'confident'),
|
||||
(5, 'show_power', 15, 0, 'terrified'),
|
||||
(5, 'hide_power', 13, 0, 'scheming'),
|
||||
(5, 'hide_power', 14, 1, 'approving'),
|
||||
(5, 'ending_immortal', 13, 0, 'transcendent'),
|
||||
(5, 'ending_mortal', 13, 0, 'peaceful');
|
||||
|
||||
-- ============================================
|
||||
-- 故事6: 回到高考前一天
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(16, 6, '你', 'protagonist', 'male', 'teen', '穿着高中校服的少年,眼神带着超越年龄的沧桑', '成熟稳重,心怀遗憾,想要改变', 'teenage chinese boy, school uniform, eyes with wisdom beyond age, determined expression'),
|
||||
(17, 6, '林小雨', 'supporting', 'female', 'teen', '瘦弱的女孩,眼眶微红,神情木然', '敏感脆弱,承受着巨大压力', 'fragile teenage chinese girl, red-rimmed eyes, blank expression, thin figure, school uniform'),
|
||||
(18, 6, '爷爷', 'supporting', 'male', 'old', '慈祥的老人,满头银发,笑容温暖', '和蔼可亲,疼爱孙辈', 'kind elderly chinese man, silver hair, warm smile, traditional chinese clothes');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(6, 'start', 16, 0, 'shocked'),
|
||||
(6, 'choice_study', 16, 0, 'focused'),
|
||||
(6, 'choice_relax', 16, 0, 'thoughtful'),
|
||||
(6, 'visit_girl', 16, 1, 'concerned'),
|
||||
(6, 'visit_girl', 17, 1, 'vulnerable'),
|
||||
(6, 'ending_perfect', 16, 0, 'fulfilled'),
|
||||
(6, 'ending_changed', 16, 0, 'content'),
|
||||
(6, 'ending_changed', 17, 1, 'grateful');
|
||||
|
||||
-- ============================================
|
||||
-- 故事7: 逆风翻盘
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(19, 7, '陈墨', 'protagonist', 'male', 'young', '改头换面后的复仇者,西装革履,眼神锐利如鹰', '隐忍多年,城府极深,复仇心切', 'handsome chinese businessman, sharp suit, eagle-like eyes, confident posture, mysterious aura'),
|
||||
(20, 7, '周明', 'antagonist', 'male', 'middle', '发福的中年男人,眼神世故贪婪,穿着名牌却掩不住小人嘴脸', '背信弃义,贪婪无耻', 'overweight chinese businessman, greedy eyes, expensive suit, treacherous appearance');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(7, 'start', 19, 0, 'vengeful'),
|
||||
(7, 'meeting', 19, 1, 'calculating'),
|
||||
(7, 'meeting', 20, 1, 'obsequious'),
|
||||
(7, 'expose', 19, 1, 'revealing'),
|
||||
(7, 'expose', 20, 1, 'terrified'),
|
||||
(7, 'ending_revenge', 19, 0, 'empty'),
|
||||
(7, 'ending_forgive', 19, 0, 'liberated');
|
||||
|
||||
-- ============================================
|
||||
-- 故事8: 2099最后一班地铁
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(21, 8, '你', 'protagonist', 'male', 'young', '末世的普通人,穿着简朴,眼神迷茫又带着一丝希望', '迷茫困惑,渴望改变', 'young man in futuristic simple clothes, contemplative expression, sci-fi subway setting'),
|
||||
(22, 8, 'Zero', 'supporting', 'female', 'young', '穿白裙的神秘女孩,眼睛明亮,不属于这个时代', '超然世外,引导者', 'mysterious girl in white dress, bright luminous eyes, ethereal beauty, futuristic subway conductor');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(8, 'start', 21, 0, 'curious'),
|
||||
(8, 'start', 22, 1, 'welcoming'),
|
||||
(8, 'talk_girl', 21, 1, 'questioning'),
|
||||
(8, 'talk_girl', 22, 1, 'mysterious'),
|
||||
(8, 'choose_past', 21, 1, 'hopeful'),
|
||||
(8, 'choose_past', 22, 1, 'guiding'),
|
||||
(8, 'ending_past', 21, 0, 'determined'),
|
||||
(8, 'ending_past', 22, 1, 'encouraging'),
|
||||
(8, 'ending_future', 21, 0, 'amazed'),
|
||||
(8, 'ending_future', 22, 1, 'serene');
|
||||
|
||||
-- ============================================
|
||||
-- 故事9: 第七夜
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(23, 9, '你', 'protagonist', 'male', 'young', '被困旅馆的旅客,神情紧张,黑眼圈明显', '胆小谨慎,求生欲强', 'nervous young man, dark circles under eyes, casual travel clothes, fearful expression'),
|
||||
(24, 9, '老板娘', 'supporting', 'female', 'middle', '面容苍老,眼神奇怪,穿着老旧的旗袍', '神秘莫测,知道真相', 'mysterious middle-aged chinese woman, old-fashioned qipao, strange knowing eyes, creepy inn keeper'),
|
||||
(25, 9, '白裙女孩', 'antagonist', 'female', 'young', '苍白的脸,穿着白裙,笑容诡异,若隐若现', '怨念深重,寻找陪伴', 'pale ghost girl in white dress, eerie smile, translucent figure, long black hair covering face, horror style');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(9, 'start', 23, 0, 'uneasy'),
|
||||
(9, 'start', 24, 1, 'cryptic'),
|
||||
(9, 'night_sound', 23, 0, 'terrified'),
|
||||
(9, 'night_sound', 25, 1, 'pleading'),
|
||||
(9, 'open_door', 23, 0, 'horrified'),
|
||||
(9, 'open_door', 25, 1, 'sinister'),
|
||||
(9, 'not_open', 23, 0, 'anxious'),
|
||||
(9, 'ending_death', 23, 0, 'dying'),
|
||||
(9, 'ending_death', 25, 1, 'satisfied'),
|
||||
(9, 'ending_escape', 23, 0, 'relieved');
|
||||
|
||||
-- ============================================
|
||||
-- 故事10: 我的室友是只猫
|
||||
-- ============================================
|
||||
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
|
||||
(26, 10, '你', 'protagonist', 'male', 'young', '普通大学生,一脸懵逼,被室友变猫吓到', '善良搞笑,容易慌张', 'confused chinese college student, dorm room setting, panicked expression, casual clothes'),
|
||||
(27, 10, '室友/橘猫', 'protagonist', 'male', 'young', '胖胖的橘色大猫,会说话,表情丰富,原本是个学霸', '聪明话多,倒霉体质,吃货', 'fat orange tabby cat, expressive face, sitting like human, funny expression, college dorm background');
|
||||
|
||||
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
|
||||
(10, 'start', 26, 0, 'shocked'),
|
||||
(10, 'start', 27, 1, 'frustrated'),
|
||||
(10, 'panic', 26, 1, 'panicking'),
|
||||
(10, 'panic', 27, 1, 'embarrassed'),
|
||||
(10, 'exam_plan', 26, 0, 'thinking'),
|
||||
(10, 'exam_plan', 27, 1, 'excited'),
|
||||
(10, 'bring_cat', 26, 0, 'nervous'),
|
||||
(10, 'bring_cat', 27, 1, 'whispering'),
|
||||
(10, 'ending_funny', 26, 0, 'relieved'),
|
||||
(10, 'ending_funny', 27, 1, 'smug'),
|
||||
(10, 'ending_caught', 26, 0, 'doomed'),
|
||||
(10, 'ending_caught', 27, 1, 'defeated');
|
||||
@@ -1,7 +1,51 @@
|
||||
-- 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);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(2, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(3, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(4, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(5, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(6, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(7, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(8, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(9, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(10, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(11, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(12, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(13, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(14, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(15, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(16, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(17, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(18, 'test_user', '测试用户', '', 0, 0, 0);
|
||||
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
|
||||
(19, '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