From 4ac47c84748bf784dda6d2e23fd34609b99f60c8 Mon Sep 17 00:00:00 2001 From: liangguodong Date: Wed, 11 Mar 2026 18:41:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E6=94=B9=E5=86=99=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E9=9B=86=E6=88=90=E8=A7=92=E8=89=B2=E6=95=B0=E6=8D=AE=20+=20UI?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增story_characters表和seed_characters.sql种子数据(27个角色) - AI改写/续写功能注入角色信息(性别/年龄/外貌/性格) - 首页UI下移避让微信退出按钮 - 个人中心页面布局重构 --- client/js/scenes/HomeScene.js | 14 +-- client/js/scenes/ProfileScene.js | 75 +++++++++-- server/app/models/story.py | 22 ++++ server/app/routers/drafts.py | 41 +++++- server/app/routers/story.py | 40 +++++- server/app/services/ai.py | 86 ++++++++++--- server/sql/schema.sql | 38 ++++++ server/sql/seed_characters.sql | 209 +++++++++++++++++++++++++++++++ 8 files changed, 478 insertions(+), 47 deletions(-) create mode 100644 server/sql/seed_characters.sql diff --git a/client/js/scenes/HomeScene.js b/client/js/scenes/HomeScene.js index 31ca7d6..9536c4c 100644 --- a/client/js/scenes/HomeScene.js +++ b/client/js/scenes/HomeScene.js @@ -65,7 +65,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 +130,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 +160,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 +207,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(); @@ -425,7 +425,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 +489,7 @@ export default class HomeScene extends BaseScene { } // 分类点击 - if (y >= 90 && y <= 130) { + if (y >= 105 && y <= 145) { this.handleCategoryClick(x); return; } @@ -530,7 +530,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(); diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 1b5ef3b..bfe21b1 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -140,6 +140,7 @@ export default class ProfileScene extends BaseScene { this.renderUserCard(ctx); this.renderTabs(ctx); this.renderList(ctx); + this.renderLogoutButton(ctx); } renderBackground(ctx) { @@ -160,12 +161,6 @@ export default class ProfileScene extends BaseScene { ctx.textAlign = 'center'; ctx.font = 'bold 17px sans-serif'; ctx.fillText('个人中心', this.screenWidth / 2, 35); - - // 设置按钮 - ctx.fillStyle = 'rgba(255,255,255,0.6)'; - ctx.font = '18px sans-serif'; - ctx.textAlign = 'right'; - ctx.fillText('⚙', this.screenWidth - 20, 35); } renderUserCard(ctx) { @@ -223,10 +218,24 @@ export default class ProfileScene extends BaseScene { ctx.textAlign = 'left'; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 16px sans-serif'; - ctx.fillText(user.nickname || '游客用户', avatarX + avatarSize + 15, avatarY + 22); + const nickname = user.nickname || '游客用户'; + ctx.fillText(nickname, avatarX + avatarSize + 15, avatarY + 22); + + // 昵称旁边的编辑按钮 + const nicknameWidth = ctx.measureText(nickname).width; + const editNicknameBtnX = avatarX + avatarSize + 15 + nicknameWidth + 8; + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + this.roundRect(ctx, editNicknameBtnX, avatarY + 8, 24, 20, 10); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('✎', editNicknameBtnX + 12, avatarY + 22); + this.editNicknameRect = { x: editNicknameBtnX, y: avatarY + 8, width: 24, height: 20 }; ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '11px sans-serif'; + ctx.textAlign = 'left'; ctx.fillText(`ID: ${user.userId || '未登录'}`, avatarX + avatarSize + 15, avatarY + 42); // 创作者标签 @@ -367,6 +376,34 @@ export default class ProfileScene extends BaseScene { ctx.restore(); } + // 渲染底部退出登录按钮 + renderLogoutButton(ctx) { + const btnW = 120; + const btnH = 40; + const btnX = (this.screenWidth - btnW) / 2; + const btnY = this.screenHeight - 70; + + // 按钮背景 + ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'; + this.roundRect(ctx, btnX, btnY, btnW, btnH, 20); + ctx.fill(); + + // 按钮边框 + ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)'; + ctx.lineWidth = 1; + this.roundRect(ctx, btnX, btnY, btnW, btnH, 20); + ctx.stroke(); + + // 按钮文字 + ctx.fillStyle = '#ef4444'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('退出登录', this.screenWidth / 2, btnY + 26); + + // 保存按钮区域 + this.logoutBtnRect = { x: btnX, y: btnY, width: btnW, height: btnH }; + } + // 渲染版本列表头部(返回按钮+故事标题) renderVersionListHeader(ctx, startY) { const headerY = startY - 5; @@ -764,17 +801,29 @@ export default class ProfileScene extends BaseScene { return; } - // 设置按钮(右上角) - if (y < 50 && x > this.screenWidth - 50) { - this.showSettingsMenu(); - return; + // 退出登录按钮 + if (this.logoutBtnRect) { + const btn = this.logoutBtnRect; + if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { + this.confirmLogout(); + return; + } } - // 头像点击 + // 头像点击(修改头像) if (this.avatarRect) { const rect = this.avatarRect; if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height) { - this.showAvatarOptions(); + this.chooseAndUploadAvatar(); + return; + } + } + + // 昵称编辑按钮点击 + if (this.editNicknameRect) { + const rect = this.editNicknameRect; + if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height) { + this.showEditNicknameDialog(); return; } } diff --git a/server/app/models/story.py b/server/app/models/story.py index 6a73bf0..5d69824 100644 --- a/server/app/models/story.py +++ b/server/app/models/story.py @@ -26,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): diff --git a/server/app/routers/drafts.py b/server/app/routers/drafts.py index 1cdfa26..ed2cf2f 100644 --- a/server/app/routers/drafts.py +++ b/server/app/routers/drafts.py @@ -10,11 +10,32 @@ from typing import List, Optional from datetime import datetime from app.database import get_db -from app.models.story import Story, StoryDraft, DraftStatus +from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter router = APIRouter(prefix="/drafts", tags=["草稿箱"]) +# ============ 辅助函数 ============ + +async def get_story_characters(db: AsyncSession, story_id: int) -> List[dict]: + """获取故事的所有角色并转为字典列表""" + result = await db.execute( + select(StoryCharacter).where(StoryCharacter.story_id == story_id) + ) + characters = result.scalars().all() + return [ + { + "name": c.name, + "role_type": c.role_type, + "gender": c.gender, + "age_range": c.age_range, + "appearance": c.appearance, + "personality": c.personality + } + for c in characters + ] + + # ============ 请求/响应模型 ============ class PathHistoryItem(BaseModel): @@ -98,6 +119,9 @@ async def process_ai_rewrite(draft_id: int): await db.commit() return + # 获取故事角色 + characters = await get_story_characters(db, story.id) + # 转换路径历史格式 path_history = draft.path_history or [] @@ -107,7 +131,8 @@ async def process_ai_rewrite(draft_id: int): story_category=story.category or "未知", path_history=path_history, current_content=draft.current_content or "", - user_prompt=draft.user_prompt + user_prompt=draft.user_prompt, + characters=characters ) if ai_result and ai_result.get("nodes"): @@ -171,6 +196,9 @@ async def process_ai_rewrite_ending(draft_id: int): await db.commit() return + # 获取故事角色 + characters = await get_story_characters(db, story.id) + # 从草稿字段获取结局信息 ending_name = draft.current_node_key or "未知结局" ending_content = draft.current_content or "" @@ -181,7 +209,8 @@ async def process_ai_rewrite_ending(draft_id: int): story_category=story.category or "未知", ending_name=ending_name, ending_content=ending_content, - user_prompt=draft.user_prompt + user_prompt=draft.user_prompt, + characters=characters ) if ai_result and ai_result.get("content"): @@ -266,6 +295,9 @@ async def process_ai_continue_ending(draft_id: int): await db.commit() return + # 获取故事角色 + characters = await get_story_characters(db, story.id) + # 从草稿字段获取结局信息 ending_name = draft.current_node_key or "未知结局" ending_content = draft.current_content or "" @@ -276,7 +308,8 @@ async def process_ai_continue_ending(draft_id: int): story_category=story.category or "未知", ending_name=ending_name, ending_content=ending_content, - user_prompt=draft.user_prompt + user_prompt=draft.user_prompt, + characters=characters ) if ai_result and ai_result.get("nodes"): diff --git a/server/app/routers/story.py b/server/app/routers/story.py index 3f1686c..66b5e7c 100644 --- a/server/app/routers/story.py +++ b/server/app/routers/story.py @@ -9,7 +9,7 @@ from typing import Optional, List from pydantic import BaseModel from app.database import get_db -from app.models.story import Story, StoryNode, StoryChoice +from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter router = APIRouter() @@ -214,6 +214,22 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes if not story: raise HTTPException(status_code=404, detail="故事不存在") + # 获取故事角色 + char_result = await db.execute( + select(StoryCharacter).where(StoryCharacter.story_id == story_id) + ) + characters = [ + { + "name": c.name, + "role_type": c.role_type, + "gender": c.gender, + "age_range": c.age_range, + "appearance": c.appearance, + "personality": c.personality + } + for c in char_result.scalars().all() + ] + # 调用 AI 服务 from app.services.ai import ai_service @@ -222,7 +238,8 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes story_category=story.category or "未知", ending_name=request.ending_name or "未知结局", ending_content=request.ending_content or "", - user_prompt=request.prompt + user_prompt=request.prompt, + characters=characters ) if ai_result and ai_result.get("content"): @@ -301,6 +318,22 @@ async def ai_rewrite_branch( if not story: raise HTTPException(status_code=404, detail="故事不存在") + # 获取故事角色 + char_result = await db.execute( + select(StoryCharacter).where(StoryCharacter.story_id == story_id) + ) + characters = [ + { + "name": c.name, + "role_type": c.role_type, + "gender": c.gender, + "age_range": c.age_range, + "appearance": c.appearance, + "personality": c.personality + } + for c in char_result.scalars().all() + ] + # 将 Pydantic 模型转换为字典列表 path_history = [ {"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice} @@ -315,7 +348,8 @@ async def ai_rewrite_branch( story_category=story.category or "未知", path_history=path_history, current_content=request.currentContent, - user_prompt=request.prompt + user_prompt=request.prompt, + characters=characters ) if ai_result and ai_result.get("nodes"): diff --git a/server/app/services/ai.py b/server/app/services/ai.py index c7513fb..924f347 100644 --- a/server/app/services/ai.py +++ b/server/app/services/ai.py @@ -7,6 +7,28 @@ 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): from app.config import get_settings @@ -49,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改写结局 @@ -58,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} @@ -97,7 +125,8 @@ class AIService: story_category: str, path_history: List[Dict[str, str]], current_content: str, - user_prompt: str + user_prompt: str, + characters: List[Dict] = None ) -> Optional[Dict[str, Any]]: """ AI改写中间章节,生成新的剧情分支 @@ -107,12 +136,16 @@ class AIService: 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): @@ -121,19 +154,20 @@ class AIService: path_text += f" → 用户选择:{item['choice']}\n" # 构建系统提示词 - system_prompt = """你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。 - + system_prompt_header = f"""你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。 +{characters_text} 【任务】 请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。 【写作要求】 1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句 2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合) -3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动 +3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔),包含:场景描写、人物对话、心理活动 4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果 5. 严格符合用户的改写意图,围绕用户指令展开剧情 6. 保持原故事的人物性格、语言风格和世界观 -7. 对话要自然生动,描写要有画面感 +7. 如果有角色设定,必须保持角色性格和外貌的一致性 +8. 对话要自然生动,描写要有画面感 【关于结局 - 极其重要!】 ★★★ 每一条分支路径的尽头必须是结局节点 ★★★ @@ -149,9 +183,10 @@ class AIService: - special 特殊结局:70-90分 【内容分段示例】 -"content": "他的声音在耳边响起,像是一阵温柔的风。\n\n\"我喜欢你。\"他说,目光坚定地看着你。\n\n你的心跳漏了一拍,一时间不知该如何回应。" - -【输出格式】(严格JSON,不要有任何额外文字) +"content": "他的声音在耳边响起,像是一阵温柔的风。\\n\\n\\"我喜欢你。\\"他说,目光坚定地看着你。\\n\\n你的心跳漏了一拍,一时间不知该如何回应。" +""" + + system_prompt_json = """【输出格式】(严格JSON,不要有任何额外文字) { "nodes": { "branch_1": { @@ -179,7 +214,7 @@ class AIService: ] }, "branch_ending_good": { - "content": "好结局内容(200-400字)...\n\n【达成结局:xxx】", + "content": "好结局内容(200-400字)...\\n\\n【达成结局:xxx】", "speaker": "旁白", "is_ending": true, "ending_name": "结局名称", @@ -187,7 +222,7 @@ class AIService: "ending_score": 90 }, "branch_ending_bad": { - "content": "坏结局内容...\n\n【达成结局:xxx】", + "content": "坏结局内容...\\n\\n【达成结局:xxx】", "speaker": "旁白", "is_ending": true, "ending_name": "结局名称", @@ -195,7 +230,7 @@ class AIService: "ending_score": 40 }, "branch_ending_neutral": { - "content": "中立结局...\n\n【达成结局:xxx】", + "content": "中立结局...\\n\\n【达成结局:xxx】", "speaker": "旁白", "is_ending": true, "ending_name": "结局名称", @@ -203,7 +238,7 @@ class AIService: "ending_score": 60 }, "branch_ending_special": { - "content": "特殊结局...\n\n【达成结局:xxx】", + "content": "特殊结局...\\n\\n【达成结局:xxx】", "speaker": "旁白", "is_ending": true, "ending_name": "结局名称", @@ -213,6 +248,8 @@ class AIService: }, "entryNodeKey": "branch_1" }""" + + system_prompt = system_prompt_header + system_prompt_json # 构建用户提示词 user_prompt_text = f"""【原故事信息】 @@ -280,7 +317,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续写结局,从结局开始续写新的剧情分支 @@ -289,15 +327,19 @@ class AIService: 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 - # 构建系统提示词 - system_prompt = """你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。 + # 格式化角色信息 + characters_text = format_characters_prompt(characters) if characters else "" + # 构建系统提示词 + system_prompt_header = f"""你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。 +{characters_text} 【任务】 请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。 @@ -308,7 +350,8 @@ class AIService: 4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果 5. 严格符合用户的续写意图,围绕用户指令展开剧情 6. 保持原故事的人物性格、语言风格和世界观 -7. 对话要自然生动,描写要有画面感 +7. 如果有角色设定,必须保持角色性格和外貌的一致性 +8. 对话要自然生动,描写要有画面感 【关于新结局 - 极其重要!】 ★★★ 每一条分支路径的尽头必须是新结局节点 ★★★ @@ -317,8 +360,9 @@ class AIService: - 结局名称 4-8 字,体现剧情走向 - 如果有2个选项分支,最终必须有2个不同的结局 - 每个结局必须有 "ending_score" 评分(0-100) +""" -【输出格式】(严格JSON,不要有任何额外文字) + system_prompt_json = """【输出格式】(严格JSON,不要有任何额外文字) { "nodes": { "continue_1": { @@ -357,6 +401,8 @@ class AIService: "entryNodeKey": "continue_1" }""" + system_prompt = system_prompt_header + system_prompt_json + # 构建用户提示词 user_prompt_text = f"""【原故事信息】 故事标题:{story_title} diff --git a/server/sql/schema.sql b/server/sql/schema.sql index 553f163..923133b 100644 --- a/server/sql/schema.sql +++ b/server/sql/schema.sql @@ -176,3 +176,41 @@ CREATE TABLE IF NOT EXISTS `play_records` ( CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表'; + +-- ============================================ +-- 9. 故事角色表 +-- ============================================ +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='节点角色关联表'; diff --git a/server/sql/seed_characters.sql b/server/sql/seed_characters.sql new file mode 100644 index 0000000..5b9b6ab --- /dev/null +++ b/server/sql/seed_characters.sql @@ -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');