This commit is contained in:
2026-04-26 17:34:52 +08:00
104 changed files with 5086 additions and 2142 deletions

View File

@@ -239,6 +239,7 @@ function buildNpcFirstContactOptionCatalog(
- 角色第一次真正对玩家开口时说什么,必须由 `npc_chat` 对应的 prompt 约束来生成,并要求首句是自然招呼或开场判断。
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
- 负好感或敌对关系不应跳过主动开口;如果玩家从 NPC 交互面板点击 `npc_chat`,且该角色尚未完成 `firstMeaningfulContactResolved`,仍要走同一条 NPC 主动开场链路。负好感只影响语气、敌对聊天指令与后续可选功能,不影响“由角色先发言”的首遇行为。
4. 首遇状态下,不允许前两项直接变成:
- 深背景追问

View File

@@ -96,7 +96,9 @@
- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。
- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。
- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。
- `密码登录` 页签包含手机号/邮箱、密码、主按钮和忘记密码入口。
- `密码登录` 页签包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号
- 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。
- `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`
- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。
- 移动端页签保持等分点击区域,输入框与按钮宽度仍随弹窗收缩。
@@ -112,6 +114,7 @@
- 用户主动关闭弹窗时,只关闭弹窗,不改变当前平台页面
- 不清空首页浏览状态
- 不自动跳转到其他 tab
- 登录弹窗下次重新打开时必须恢复初始表单状态:回到默认登录页签、关闭重置密码面板、清空密码 / 验证码 / 图形验证码 / 提示 / 倒计时等本次草稿状态;只允许保留“最近一次成功登录手机号”的本地回填能力。
---
@@ -149,6 +152,8 @@
- 不再提供 `AuthGate` 层右上角固定悬浮的全局登录 / 账号信息入口
- 登录触发统一来自页面内受保护动作、个人页、存档页等明确入口
- 账号信息面板只通过页面内按钮打开,不在平台右上角常驻悬浮
- 未登录移动端底部导航不展示“我的”时,平台页头必须保留一个直接可点的 `登录` 入口,避免用户只能通过受保护动作被动触发弹窗
- 桌面端平台页头的账号胶囊在未登录时主文案必须直接显示 `登录`,不能只显示“进入账户”这类弱入口
## 4.2 平台首页数据加载
@@ -221,3 +226,4 @@
3. 未登录选择 RPG 创作类型时,直接弹出登录弹窗,登录后自动进入创作工作台。
4. 登录弹窗内没有介绍性大段文字,只剩必要输入与按钮。
5. 未登录态首页不会因个人接口失败而出现“读取个人看板失败”“读取作品库失败”之类报错。
6. 未登录移动端首页页头存在明确 `登录` 入口,点击后打开同一个登录弹窗。

View File

@@ -11,6 +11,7 @@
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。

View File

@@ -0,0 +1,93 @@
# 统一模态窗口设计 2026-04-25
## 背景
当前前端已有两套稳定视觉资产:
- 平台侧:`platform-overlay``platform-modal-shell``platform-auth-card` 等主题变量。
- RPG 运行时:`pixel-nine-slice``pixel-modal-shell``UI_CHROME.modalPanel` 九宫格边框。
但弹窗结构仍分散在业务组件内,常见重复包括遮罩层、点击遮罩关闭、`role="dialog"``aria-modal`、移动端底部贴边、桌面居中、最大高度、滚动区域和关闭按钮。新增弹窗时容易出现 z-index、无障碍属性、移动端高度和视觉边界不一致。
## 目标
新增统一组件 `UnifiedModal`,只负责弹窗外壳和交互边界,不接管业务内容:
- 统一遮罩、面板、标题区、内容区、底部区结构。
- 支持平台风与像素风两种外观,不混用两套视觉资产。
- 默认移动端优先,平台风移动端底部弹出、桌面居中;像素风保持游戏内居中弹窗。
- 默认提供 `role="dialog"``aria-modal`、标题关联、Escape 关闭和遮罩点击关闭。
- 支持禁用关闭,用于生成中、保存中等不可打断流程。
- 支持 Portal 渲染到 `document.body`,避免被父层 `overflow` 裁剪。
## 非目标
- 不一次性迁移所有旧弹窗,避免运行时大面积回归。
- 不把业务按钮、表单、状态文案放进通用组件。
- 不改变现有主题变量、九宫格素材、平台和 RPG 的视觉风格。
- 不新增第三方弹窗库。
## 组件接口
`UnifiedModal` 核心参数:
| 参数 | 说明 |
| --- | --- |
| `open` | 是否显示。为 `false` 时返回 `null`。 |
| `variant` | `platform``pixel`。默认 `platform`。 |
| `title` | 标题,同时作为默认 `aria-label` 来源。 |
| `description` | 可选副标题,显示在标题下方。 |
| `children` | 主内容区。 |
| `footer` | 可选底部操作区。 |
| `onClose` | 关闭回调。 |
| `closeDisabled` | 禁止遮罩、Escape 和关闭按钮触发关闭。 |
| `closeOnBackdrop` | 是否允许点击遮罩关闭,默认允许。 |
| `showCloseButton` | 是否显示右上关闭按钮,默认显示。 |
| `size` | `sm``md``lg``xl``fullscreen`。 |
| `zIndexClassName` | z-index class默认 `z-[90]`。 |
| `panelClassName` / `bodyClassName` / `footerClassName` | 局部样式扩展。 |
| `portal` | 是否渲染到 `document.body`,默认开启。 |
## 使用边界
### 平台风弹窗
用于平台首页、登录注册、作品结果、创作工作台等非 RPG 运行时界面。
要求:
- 使用 `variant="platform"`
- 面板使用 `platform-modal-shell` 主题变量。
- 移动端优先底部贴边,大屏居中。
- 不在弹窗内放功能说明式长文案,只放任务所需信息。
### 像素风弹窗
用于 RPG 运行时、地图、背包、角色详情、NPC 交易等游戏内面板。
要求:
- 使用 `variant="pixel"`
- 面板使用 `pixel-nine-slice pixel-modal-shell`
- 默认使用 `getNineSliceStyle(UI_CHROME.modalPanel)`
- 标题、内容和底部仍由业务方控制,避免通用组件内写入玩法解释。
## 首批迁移
首批只迁移平台入口创作类型弹窗:
- 文件:`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
- 目的:验证平台风布局、关闭禁用、标题区、内容区与错误区都可由统一组件承载。
后续可按风险由低到高迁移:
1. 结果页小弹窗:`PuzzleResultView``BigFishResultView`
2. 平台创作页编辑器弹窗:`RpgCreationEntityEditorShared` 内局部 `ModalShell`
3. RPG 运行时像素风弹窗:`RpgAdventurePanelOverlays``AdventureEntityModal``NpcModals`
## 验收标准
- 新增弹窗优先使用 `UnifiedModal`,不再手写完整 overlay + panel 结构。
- 迁移后的弹窗保留原有移动端和桌面布局。
- 关闭按钮、遮罩关闭、Escape 行为一致,`closeDisabled` 时都不会关闭。
- 类型检查、编码检查通过。

View File

@@ -215,3 +215,4 @@
- 怪物精灵帧的空白、体型和脚底位置差异很大,运行画面应按帧高分档下沉,让怪物视觉底边落在主角同一条地面线上。
- 后续新增怪物资源时,先检查红圈标注的实际落点,再调整锚点分档或单怪物偏移,避免出现“悬在地面上方”的状态。
- 自定义世界里敌对角色已经先作为场景 NPC 存在,即使它同时携带 `characterId``monsterPresetId`,画布也不能直接沿用模板角色的 `groundOffsetY`;只要 encounter 自身有 `imageSrc``visual`,就按场景 NPC 自定义形象锚点处理。
- 幕预览运行时还会构造“无 `characterId`、但有 `visual` 的场景 NPC”这类和平相遇分支同样必须套用场景 NPC 自定义形象锚点,否则会停在画面中上部。

View File

@@ -0,0 +1,23 @@
# RPG 场景幕角色配置面板布局经验 2026-04-26
## 背景
场景多幕配置里的“配置角色”面板是高频编辑弹层,移动端和桌面端都需要快速完成选择并保存。
## 本次约束
1. 面板底部不再放“取消”按钮,关闭统一交给标题栏关闭按钮和遮罩。
2. “保存角色”必须位于面板底部操作区,角色列表较长时只滚动内容区,不把保存按钮滚出视口。
3. 已选角色时仍允许“移除角色”,但移动端纵向排列时保存按钮保持在最底部。
4. 不在面板内新增功能说明文本,维持清爽编辑体验。
5. 吸底操作区必须使用平台语义色 token不能写死深色 Tailwind 背景,避免亮色主题下出现突兀深色底栏。
6. 已选角色时,底部操作区保持同一行:左侧“移除角色”热区占 1/4右侧“保存角色”占 3/4未选角色时保存按钮占满整行。
## 落地位置
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
- `SceneActNpcSlotPickerModal`
## 后续复用
以后新增类似独立选择弹层时,优先采用“标题栏 + 中间滚动内容 + 底部固定主动作”的结构;取消类动作不要默认占据底部按钮位,避免和主保存动作抢焦点。

View File

@@ -188,14 +188,14 @@ MVP 阶段建议采用最稳妥规则:
1. 用户名密码注册
2. 游客正式入口
3. 账号密码找回
3. 邮箱登录
4. 实名认证
5. 社交好友体系
6. 多微信绑定同一账号
说明:
当前用户名密码模式可仅保留为开发环境兜底能力,不作为正式前台入口
密码登录不是注册入口,也不是邮箱入口;它只作为手机号验证码登录的补充方式。用户必须先通过手机号验证码登录形成正式账号,并在已登录账号中心设置过密码后,后续才能用“手机号 + 密码”登录
---
@@ -388,6 +388,24 @@ MVP 阶段建议采用最稳妥规则:
MVP 阶段不需要单独设置密码。
## 6.1.1 密码登录补充方式
密码登录只补充手机号验证码登录,不建立新的账号体系。
落地规则:
- 入参只允许 `phone``password`,不支持邮箱、用户名或叙世号。
- 手机号不存在时,不创建账号,返回统一的登录失败。
- 手机号存在但账号未设置过密码时,不允许密码登录。
- 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。
- 忘记密码 / 重置密码必须先完成该手机号的短信验证码校验;手机号不存在时不创建账号。
前台约束:
- 密码页签的账号输入框文案固定为 `手机号`
- 密码页签主按钮固定为 `登录`,不能出现 `注册/登录`
- 短信验证码页签可继续承担“手机号不存在时创建正式账号并登录”的能力,但按钮文案不应暗示密码注册。
## 6.2 微信登录
微信登录按终端拆分:
@@ -611,7 +629,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
因此本期不是推翻重做,而是:
1. 保留 `users` 作为账号主表
2. 废弃“用户名密码自动注册”作为正式入口
2. 废弃“用户名密码自动注册”作为任何正式入口
3. 增加手机号与微信身份层
4. 增加验证码表与会话表
@@ -619,7 +637,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
## 8. 接口设计
所有接口均由 Express 后端承接。
所有接口均由 `server-rs` 后端承接。
## 8.1 手机号登录相关
@@ -698,6 +716,28 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
## 8.3 会话与账号信息
### `POST /api/auth/entry`
用途:
- 使用已设置密码的手机号账号登录
入参:
- `phone`
- `password`
出参:
- `token`
- `user`
约束:
- 不支持邮箱、用户名或叙世号。
- 不承担注册能力。
- 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。
### `GET /api/auth/me`
返回建议扩展为:

View File

@@ -874,6 +874,7 @@ isCardDetailLoading: boolean;
1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。
2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。
3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。
4. 进度页“已耗时”必须按服务端 operation 的创建时间 `startedAt` 与当前时间计算;刷新页面、恢复轮询或前端重挂载时不能重新从本地点击时间开始计时。只有旧 operation 缺少 `startedAt` 时,才允许使用本地记录的开始时间作为兜底。
## 12.1 生成底稿时序

View File

@@ -135,6 +135,18 @@
结果页不是一个只读总结页,而是拼图作品最小可编辑工作台。
### 5.1.1 已发布作品二次编辑
创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。
落地规则:
1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。
2. 恢复后的结果页沿用原草稿、候选图、正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成或切换图片。
3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`
4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。
5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。
## 5.2 运行时结论
拼图运行时应该是:

View File

@@ -0,0 +1,26 @@
# 创作中心退出登录私有缓存清理修复 2026-04-25
## 问题
点击退出登录后,页面未刷新时仍能切到创作中心,并看到上一位登录用户的作品。刷新页面后才恢复正常。
## 根因
1. `AuthGate` 的退出动作先等待 `/api/auth/logout` 完成,再通过全局鉴权事件重新 hydrate期间前端 context 仍可能暴露旧用户。
2. 平台创作入口里的 RPG works 会在 `canReadProtectedData=false` 时清空,但大鱼吃小鱼与拼图 works 是 `PlatformEntryFlowShellImpl` 内部 state没有在退出登录时同步清空。
3. 创作 Tab 会保持挂载以降低闪烁,因此私有作品数组只要留在内存里,就会继续被货架组件渲染。
## 修复口径
1. 用户触发退出当前设备或退出全部设备时,前端必须先本地收回 `user / canAccessProtectedData`,再等待后端吊销会话。
2. `canReadProtectedData``true` 变为未登录态 `false` 时,创作中心必须清空所有私有作品缓存:
- RPG works / library 由 `useRpgEntryBootstrap` 清空。
- Big Fish works、Puzzle works 由 `PlatformEntryFlowShellImpl` 清空。
- 当前创作工作区、结果页、删除忙碌态与生成态一并复位。
3. 公开广场与分类数据不受影响,仍按匿名公开接口读取。
## 验收
1. 点击退出登录后,不刷新页面进入创作 Tab只能看到空作品货架不再出现上一账号作品。
2. 退出登录瞬间 `AuthUiContext.user``null``canAccessProtectedData=false`
3. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。

View File

@@ -0,0 +1,31 @@
# Jenkins 部署环境文件 BOM 修复
日期:`2026-04-25`
## 1. 问题
Jenkins 部署阶段执行固定目录内的 `start.sh` 时失败:
```text
/var/lib/jenkins/deploy/Genarrative/.env.local: line 1: VITE_LLM_BASE_URL=...: No such file or directory
```
根因是 `.env.local` 第一行包含 UTF-8 BOM。旧版 `start.sh` 直接 `source .env.local`BOM 会成为变量名前缀Bash 无法按赋值语句解析,进而把整行当作命令执行。日志末尾的 sudo 提示只是 hook 执行失败后的兜底提示,不是本次失败的真实根因。
## 2. 修复口径
1. 发布包构建脚本复制 `.env``.env.local` 到发布目录和 `web/` 目录后,统一移除 UTF-8 BOM 与 CRLF。
2. Jenkins 部署脚本在移动发布产物前后,再次净化发布目录和固定部署目录中的 `.env``.env.local`,兼容已经构建出来但尚未部署成功的旧发布包。
3. 新生成的 `start.sh` 不再直接 `source` 环境文件,而是按 `KEY=value` 子集解析、导出合法变量,并跳过空行、注释和不合法行。
4. `start.sh` 仍保留 `.env` 先于 `.env.local` 的加载顺序,后加载的 `.env.local` 可以覆盖默认配置。
## 3. 运行边界
1. 环境文件应保持 UTF-8 文本,允许 UTF-8 BOM 和 CRLF但部署脚本会在发布目录中消除它们。
2. 环境变量名必须符合 `[A-Za-z_][A-Za-z0-9_]*`
3. 值支持不加引号、双引号和单引号;复杂 shell 表达式不会执行,避免把环境文件变成脚本入口。
4. 业务密钥仍通过目标服务器环境变量或发布目录 `.env.local` 管理,不写入 Jenkinsfile。
## 4. 失败现场恢复
如果 Jenkins 已经生成了失败版本,可以在拉取本次脚本修复后直接重跑部署流水线。`scripts/jenkins-deploy-release.sh` 会在执行新版本 `start.sh` 前净化已有发布目录,因此不要求手工编辑服务器上的 `.env.local`

View File

@@ -99,7 +99,7 @@ scripts/jenkins-deploy-release.sh \
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF避免 `start.sh` 在 Bash 下把首行变量名误解析成命令
### 4.3 构建并部署

View File

@@ -6,33 +6,33 @@
本轮在“我的”页面的“会员充值”入口落地账户充值弹窗,包含两个页签:
1. `积分充值`
1. `叙世币充值`
2. `会员卡充值`
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。
## 2. 产品规则
### 2.1 积分充值套餐
### 2.1 叙世币充值套餐
| productId | 积分 | 金额分 | 徽标 | 说明 |
| productId | 叙世币 | 金额分 | 徽标 | 说明 |
| --- | ---: | ---: | --- | --- |
| `points_10` | 10 | 100 | 首充送积分 | 首充送19积分 |
| `points_60` | 60 | 600 | 首充赠礼 | 首充送 |
| `points_240` | 240 | 2400 | 首充双倍 | 首充送240积分 |
| `points_450` | 450 | 4500 | 首充双倍 | 首充送450积分 |
| `points_950` | 950 | 9500 | 首充双倍 | 首充送950积分 |
| `points_1980` | 1980 | 19800 | 首充双倍 | 首充送1980积分 |
| `points_60` | 60 | 600 | 首充双倍 | 首充送60叙世币 |
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180叙世币 |
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300叙世币 |
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680叙世币 |
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280叙世币 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280叙世币 |
首充赠送只按用户维度判断一次:用户历史上没有 `points_recharge` 流水时,购买支持首充赠送的套餐才发放赠送积分。实际到账积分写入交易流水,余额以 SpacetimeDB projection 为准。
叙世币充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账叙世币为基础叙世币与等额赠送叙世币之和;已有充值流水后只到账基础叙世币。实际到账叙世币写入交易流水,余额以 SpacetimeDB projection 为准。
### 2.2 会员卡套餐
| productId | 类型 | 天数 | 金额分 | 权益 |
| --- | --- | ---: | ---: | --- |
| `member_month` | 月卡 | 30 | 2800 | 免积分回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免积分回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免积分回合数100每日签到加成210% |
| `member_month` | 月卡 | 30 | 2800 | 免叙世币回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免叙世币回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免叙世币回合数100每日签到加成210% |
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
@@ -42,8 +42,8 @@
需要 Bearer JWT。返回
1. 当前积分余额、会员状态、到期时间
2. 积分套餐与会员套餐
1. 当前叙世币余额、会员状态、到期时间
2. 叙世币套餐与会员套餐
3. 会员权益表
4. 最近订单摘要
@@ -55,7 +55,7 @@
```json
{
"productId": "points_240",
"productId": "points_300",
"paymentChannel": "mock"
}
```
@@ -64,7 +64,7 @@
1. 校验 `productId`
2. 后端创建已支付订单
3. 积分套餐写入钱包余额与流水
3. 叙世币套餐写入钱包余额与流水
4. 会员套餐写入会员状态
5. 返回最新账户中心快照与订单摘要
@@ -74,14 +74,15 @@
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
3. 默认打开 `积分充值`,可切换到 `会员卡充值`
3. 默认打开 `叙世币充值`,可切换到 `会员卡充值`
4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`
5. 弹窗内不写大段说明文案,只保留必要金额、积分、会员权益和状态反馈。
5. 弹窗内不写大段说明文案,只保留必要金额、叙世币、会员权益和状态反馈。
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
## 5. 验收
1. 普通用户打开弹窗能看到积分与会员套餐。
2. 积分购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次积分充值时生效。
1. 普通用户打开弹窗能看到叙世币与会员套餐。
2. 叙世币购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次叙世币充值时生效。
4. 会员购买后会员状态与到期时间立即更新。
5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。

View File

@@ -7,14 +7,14 @@
在现有“我的”Tab 常用功能区落地三个轻量入口:
1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 积分
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 叙世币
3. `玩家社区`:弹出面板展示微信群与 QQ 群二维码占位图,后续替换为正式图片。
## 后端边界
- 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module`
- Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。
- 前端只读取后端状态与调用提交接口,不做本地加积分
- 前端只读取后端状态与调用提交接口,不做本地加叙世币
- 钱包余额继续复用 `profile_dashboard_state.wallet_balance`
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
- `invite_inviter_reward`
@@ -42,7 +42,7 @@
- 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。
- 用户不能填写自己的邀请码。
- 用户最多填写一个邀请码,成功后不可修改。
- 被邀请者绑定成功后获得 `30` 积分
- 被邀请者绑定成功后获得 `30` 叙世币
- 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。
- 每次奖励都写入钱包流水,钱包余额以后端返回为准。
@@ -69,7 +69,7 @@
- `server-rs/crates/spacetime-module` 已新增邀请码与邀请关系表,邀请中心读取和填码绑定均通过 SpacetimeDB procedure 执行。
- `server-rs/crates/api-server` 已挂接 `/api/runtime/profile/referrals/*``/api/profile/referrals/*` 两组路由。
- 前端“我的”Tab 三个快捷入口均打开独立弹窗,玩家社区先使用空白二维码占位。
- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板积分
- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板叙世币
## 前端交互

View File

@@ -14,6 +14,7 @@
2. Rust API 必须至少提供契约兼容的后端 SSE 路由,避免回退到 server-node。
3. 任意好感度下,首次与一个 NPC 相遇都先进入 NPC 主动开场;后续再按敌对/普通分支处理。
4. 和平相遇态 NPC 固定使用已解析相遇锚点,与主角形成面对面的右侧对称表现,并强制朝向主角。
5. `npcInitiatesConversation=true` 表示 NPC 先手开口,不应要求玩家消息非空,也不能把内部占位文本写入 prompt 或好感结算。
## 代码设计
@@ -26,6 +27,8 @@
- `reply_delta`:增量文本。
- `complete``npcReply / affinityDelta / affinityText / suggestions / pendingQuestOffer / chatDirective`
3. 当前先提供后端确定性兜底回复,保证 Rust API 迁移期间链路可用;后续完整 LLM 编排应继续在 Rust API 内实现,不回接 server-node。
4. 主动开场请求允许 `playerMessage` 为空;这一轮不是玩家发言结算,`affinityDelta` 固定为 0。
5. 建议生成 prompt 在主动开场时明确“玩家尚未先开口”,避免把空消息或前端哨兵文本误描述为玩家台词。
### 前端交互
@@ -33,6 +36,7 @@
1. 首次相遇判断提前到敌对短路之前。
2. `firstMeaningfulContactResolved` 为 false 时,无论好感度或敌对状态如何,都调用 `startNpcInitiatedOpening(...)`
3. NPC 主动开场调用 `streamNpcChatTurn(...)` 时传空 `playerMessage`,只依赖 `npcInitiatesConversation` 表达“由 NPC 先发言”的语义。
调整 `src/components/game-canvas/GameCanvasEntityLayer.tsx`

View File

@@ -1,6 +1,6 @@
# 密码登录历史落地设计
# 密码登录入口历史落地设计
> 2026-04-24 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经设置密码的账号。密码修改与重置以 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准;本文中“密码自动建号”仅保留为历史基线说明,不再作为当前落地依据
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
日期:`2026-04-21`
@@ -8,17 +8,25 @@
这份文档用于指导 `M2` 中以下两条任务的首版落地:
1. `实现密码登录`
2. `实现账号自动创建 / 幂等登录兼容策略`
1. `实现手机号密码登录`
2. `移除密码登录自动注册 / 自动建号语义`
目标是先把当前 Node 已经稳定运行的 `/api/auth/entry` 语义迁到 Rust 工作区,并冻结:
目标是 `/api/auth/entry` Rust 工作区冻结为手机号验证码账号的补充登录方式
1. `api-server` 对外暴露的最小兼容接口。
2. `module-auth` 负责的密码登录用例边界
3. 自动建号与并发幂等兼容规则
1. `api-server` 对外暴露 `phone + password` 的最小接口。
2. `module-auth` 负责已存在手机号账号的密码校验
3. 密码入口不创建账号,不接收邮箱、用户名或叙世号
4. 登录成功后与 JWT、refresh cookie 的衔接方式。
## 2. 当前基线
## 1.1 当前冻结结论
1. 密码登录不是注册入口。
2. 密码登录是手机号验证码登录的补充方式。
3. 只有已存在、已绑定手机号、并已设置密码的账号可以通过密码登录。
4. 未知手机号、未设置密码、密码错误统一返回 `401 UNAUTHORIZED`,避免通过密码入口探测账号状态。
5. 手机号验证码登录仍是新用户注册/首次登录的唯一入口。
## 2. 历史基线
当前 Node `/api/auth/entry` 主链已经具备如下语义:
@@ -29,7 +37,7 @@
5. 同时创建 refresh session并把原始 refresh token 写入 HttpOnly cookie。
6. 并发创建同一用户名时,后到的请求会回退为“查已存在账号并校验密码”,不因唯一键冲突直接失败。
这条链路既是当前前端匿名/游客恢复的基础,也是真实 `/api/auth/entry` contract 的既有事实,因此 Rust 首版必须兼容
这条链路曾经是前端匿名/游客恢复的基础。2026-04-25 起该历史语义已废弃Rust 当前实现必须以“手机号账号已设置密码后登录”为准,不再兼容密码自动建号
## 3. 设计输入
@@ -41,12 +49,12 @@
4. [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
5. [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
关键冻结点:
当前冻结点:
1. `password_hash` 当前继续由 `user_account` 承担,不进入 `auth_identity`
2. `sub` 必须是稳定 `user_id`
3. 登录成功后必须继续同时生成 access token 和 refresh session。
4. 自动建号兼容必须保留,不能因为迁到 Rust 就删除
4. 密码登录不再保留自动建号兼容,旧开发游客自动建号链路必须迁出 `/api/auth/entry`
## 4. 首版落地范围
@@ -54,14 +62,14 @@
1. `module-auth` 中的密码登录用例。
2. `api-server` 中的 `POST /api/auth/entry`
3. 用户名校验、密码哈希校验与自动建号
3. 手机号归一化、密码哈希校验与未设置密码拒绝
4. 登录成功后的 access token 与 refresh cookie 主链打通。
本阶段明确不包含:
1. SpacetimeDB 真正的 `user_account` / `refresh_session` reducer 写入。
2. `/api/auth/me``/api/auth/logout``/api/auth/refresh` 的正式业务闭环。
3. 手机验证码与微信登录链路。
3. 新增邮箱登录或独立密码注册链路。
## 5. crate 边界
@@ -69,9 +77,9 @@
负责:
1. 用户名与密码的领域校验。
1. 手机号与密码的领域校验。
2. 密码登录主用例。
3. 自动建号与并发幂等兼容策略
3. 已存在手机号账号与已设置密码约束
4. 输出登录成功所需的最小用户快照。
不负责:
@@ -106,11 +114,11 @@
### 6.1 请求体
固定沿用当前 contract
当前 contract
```json
{
"username": "guest_001",
"phone": "13800138000",
"password": "secret123"
}
```
@@ -124,9 +132,9 @@
"token": "<access-token>",
"user": {
"id": "user_xxx",
"username": "guest_001",
"displayName": "guest_001",
"phoneNumberMasked": null,
"username": "phone_xxx",
"displayName": "138****8000",
"phoneNumberMasked": "138****8000",
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false
@@ -136,11 +144,11 @@
同时响应头必须写回 refresh cookie。
## 7. 用户名与密码规则
## 7. 手机号与密码规则
当前阶段继续对齐 Node 基线
当前阶段固定
1. `username` 只允许 `3``24` 位字母、数字、下划线
1. `phone` 只接受中国大陆手机号,服务端统一归一化为 `E.164` 后查询
2. `password` 长度必须在 `6``128` 位之间。
任一校验失败时:
@@ -148,37 +156,30 @@
1. 返回 `400 BAD_REQUEST`
2. 错误文案继续保持中文
## 8. 自动建号与幂等兼容
## 8. 登录校验规则
### 8.1 自动建
### 8.1 未知手机
`username` 不存在时:
`phone` 归一化后找不到账号时:
1. 用当前请求里的 `password` 生成密码哈希
2. 创建一条本地账号。
3. `display_name = username`
4. `login_provider = password`
5. `account_status = active`
6. `token_version = 1`
1. 返回 `401 UNAUTHORIZED`
2. 创建账号。
3. 不写 `password_hash`
### 8.2 已存在账号
### 8.2 未设置密码
`username` 已存在时:
账号存在但 `password_login_enabled = false` 时:
1. 返回 `401 UNAUTHORIZED`
2. 不区分“未设置密码”和“密码错误”的外部文案。
### 8.3 已设置密码
当账号存在且已设置密码时:
1. 校验密码哈希。
2. 校验失败返回 `401 UNAUTHORIZED`
3. 校验成功继续登录
### 8.3 并发幂等兼容
若两个请求并发创建同一用户名:
1. 允许其中一个请求先创建成功。
2. 后一个请求若命中唯一键冲突,不直接失败。
3. 后一个请求必须重新查询该用户名。
4. 若查到账号,则按“已存在账号”路径继续校验密码。
这保证了当前前端重复调用 `/api/auth/entry` 时可以恢复同一账号,而不是随机失败。
3. 校验成功签发 access token 与 refresh cookie
## 9. 首版存储策略
@@ -226,10 +227,10 @@
当前阶段至少覆盖:
1. 首次密码登录自动建号成功
2. 同用户名同密码可重复登录同一账号
3.用户名不同密码返回 `401`
4. 非法用户名返回 `400`
1. 未知手机号密码登录返回 `401`,且不创建账号
2. 已登录手机号账号设置密码后可用 `phone + password` 登录
3.手机号错误密码返回 `401`
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。
@@ -239,7 +240,7 @@
1. `module-auth` 不再只是 README占位被真实 crate 实现替换。
2. `POST /api/auth/entry` 可在 Rust 侧独立跑通。
3. 自动建号与幂等兼容行为可验证。
3. 密码入口不注册、不接收邮箱/用户名的行为可验证。
4. JWT 与 refresh cookie 登录成功主链打通。
5. 文档、任务清单与测试同步完成。

View File

@@ -18,8 +18,8 @@
沿用现有 `POST /api/auth/entry`
1. 请求字段沿用 `username``password`前端固定把手机号填入 `username`
2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力
1. 请求字段固定为 `phone``password`,前端只提交手机号
2. 后端按标准手机号归一化后查找账号,兼容邮箱、用户名、叙世号或历史开发游客标识
3. 手机号不存在时返回 `401`,不创建账号。
4. 手机号存在但未设置密码时返回 `401`
5. 校验成功后签发 access token并写入 refresh cookie。
@@ -41,7 +41,7 @@
1. 不需要 Bearer 登录态。
2. 请求字段:`phone``code``newPassword`
3. 使用 `reset_password` 短信场景校验验证码。
4. 手机号不存在时返回 `404`,避免用密码重置隐式注册账号。
4. 手机号不存在时返回 `401`,避免用密码重置隐式注册账号,并避免泄露手机号注册状态
5. 重置成功后签发新的 access token并写入 refresh cookie便于用户直接进入登录态。
### 2.4 发送重置验证码
@@ -62,7 +62,7 @@
登录弹窗不再拆独立注册页签:
1. 面板直接展示手机号和密码输入,用于已设置密码账号登录。
2. 登录按钮文本固定为 `注册/登录`避免用户在登录和首次进入之间做页面切换
2. 密码登录按钮文本固定为 `登录`不允许暗示密码入口具备注册能力
3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。
4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录。
5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。

View File

@@ -0,0 +1,25 @@
# 公开作品号移动端分享入口修复 2026-04-25
## 背景
公开编号设计已要求详情页和创作中心展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。
## 落地规则
1. 移动端首页在 Logo 下方提供紧凑搜索条,复用现有 `onSearchPublicCode` 行为,不新增页面或新系统。
2. 首页、分类、趋势等公开外部列表不直接展示作品号,卡片 badge 展示推荐、分类或作品类型,不再用发布时间充当主 badge。
3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。
4. 创作页作品卡在已发布作品上展示作品号RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。
5. 作品号复制统一使用兼容复制工具:优先 Clipboard API权限失败时降级到隐藏文本框选区复制并在按钮内短暂显示复制结果。
6. 作品详情返回必须恢复打开详情前的平台来源 Tab从分类进入回分类从首页进入回首页从创作中心进入回创作中心。
7. 所有入口保持轻量 UI不写规则说明文案不改变发布、下架、进入游戏的后端语义。
## 验收
1. 399px 竖屏首页能直接看到并使用搜索入口。
2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号,也不直接显示作品号。
3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。
4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制``复制失败`
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。

View File

@@ -4,7 +4,9 @@
## 文档列表
- [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
- [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。
- [RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md](./RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md):记录 RPG 作品删除时报 `No such procedure` 的根因,补齐 `delete_custom_world_agent_session` 在有效 SpacetimeDB 模块入口中的导出,并要求发布后核验 Maincloud schema。
- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):冻结当前后端唯一落地口径,明确新功能以 `server-rs + Axum + SpacetimeDB` 为准,旧 `server-node` / Express / PostgreSQL 与 Go 方向只允许作为迁移参考。
- [RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md](./RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md):记录世界草稿生成失败/中断后进度不再误到 `100%`、主按钮改为“继续生成草稿”并复用已保存底稿续跑,以及按阶段耗时模型估算预计等待时间的修复口径。
@@ -14,6 +16,7 @@
- [CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md](./CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md):记录角色主形象遇到 DashScope `IPInfringementSuspect` 时自动改用原创安全 prompt 兜底重试的修复口径,并保留供应商原始错误便于排查。
- [CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md](./CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md):记录创作 Agent 用户发送消息后立刻展示三点等待动画的前端展示条件,避免首个 SSE token 到达前聊天区无反馈。
- [CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md](./CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md):冻结 Agent 创作页上传文本类文档并解析为输入框内容的前后端边界、接口、支持范围和验收标准。
- [CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md](./CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md):记录退出登录后创作中心仍显示上一账号作品的前端缓存根因,并冻结退出时立即收回鉴权上下文、清空三类私有作品货架缓存的修复口径。
- [CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md](./CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md):冻结三类作品创作 Agent client 通用工厂与平台轻量流程 controller 的复用边界,明确本轮只收口 HTTP/SSE 骨架和大鱼/拼图会话流程,不合并 RPG 自动保存主链。
- [BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md](./BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md):冻结后端创作 Agent LLM turn 公共化边界,收口模型可用性检查、流式 JSON 回复抽取、最终 JSON 解析与中文错误映射,玩法 schema 和写回逻辑继续留在各自模块。
- [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
@@ -42,6 +45,7 @@
- [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。
- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust``npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。

View File

@@ -0,0 +1,25 @@
# 路由首屏图片等待门闩 2026-04-25
## 背景
平台首页、作品详情、拼图运行态等页面会在首屏展示作品封面、角色图、场景背景图或拼图原图。此前页面主体可能先进入,图片随后分批出现,移动端尤其容易看到背景图闪入、封面占位与真实图片切换。
## 落地约束
1. 入口路由组件加载完成后,页面主体先挂载但保持不可见,继续让业务数据请求、图片请求和布局计算正常发生。
2. 门闩扫描当前路由根节点内的 `<img>``src/currentSrc` 和 CSS `background-image / border-image-source` 中的 `url(...)`
3. 扫描到的图片统一通过 `Image()` 预加载;图片成功、失败或超时都视为 settled避免单张异常图片阻塞整站进入。
4. 所有已发现图片 settled 且 DOM 短暂稳定后,再一次性显示页面主体。
5. 门闩只负责前端呈现时机,不承接作品封面选择、资产读链路、玩法逻辑或后端数据裁决。
## 体验规则
- 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。
- 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。
- 图片加载失败不直接改写业务 UI后续仍由原页面的兜底图、占位图或错误态处理。
## 涉及文件
- `src/routing/RouteImageReadyGate.tsx`
- `src/routing/RouteImageReadyGate.test.ts`
- `src/main.tsx`

View File

@@ -0,0 +1,27 @@
# RPG 战斗血条与动作表现修复
更新时间:`2026-04-26`
## 背景
战斗运行态已经形成两条链路:
- `server-rs` / `module-runtime-story-compat` 负责 `battle_*``inventory_use` 的数值真相结算。
- 前端 `GameCanvasRuntime``GameCanvasEntityLayer``storyChoiceRuntime` 负责把结算结果表现为血条、动作和选项反馈。
本次修复只处理表现层与本地兜底一致性,不把战斗数值重新搬回前端。
## 落地规则
1. 战斗中双方血条必须使用角色安全区锚点,放在角色形象上方,不再贴着 112px 容器顶部渲染。
2. 自定义 NPC、模板角色、通用 NPC、怪物的脚底锚点和血条锚点分离维护避免为了落地位置牺牲血条可读性。
3. `battle_use_skill` 的本地兜底结算必须尊重 `runtimePayload.skillId`,不能重新随机挑技能。
4. `battle_recover_breath` 的本地兜底不能伪装成攻击动作;它只做恢复、冷却推进和后续敌方压力。
5. 服务端战斗回包如果带 `presentation.battle`,前端先播放一次短动作和血量变化,再落 `hydratedSnapshot.gameState`,避免选项点击后血量直接跳变。
## 验收点
- 玩家、同伴、NPC、怪物血条不遮挡头部或主体轮廓。
- 点击具体技能按钮时,播放与结算使用同一个 `skillId`
- 点击恢复时不会出现玩家同时释放攻击技能的错位表现。
- 走服务端 runtime action 的战斗选项仍以 server-rs 返回快照为最终状态。

View File

@@ -0,0 +1,24 @@
# RPG 开局场景幕预览图片同步修复2026-04-26
## 背景
世界档案场景 Tab 中,开局场景卡片和点进场景详情后的幕预览曾经存在取图口径不一致:
1. 列表侧会先解析场景主图,再把主图作为共享图传给所有幕预览。
2. 详情侧实际编辑的是 `sceneChapterBlueprints[].acts[].backgroundImageSrc`
3. 当开局场景 `camp.imageSrc` 与第二、第三幕背景不同步时,列表幕缩略图会被主图覆盖,点进详情后又显示幕自己的图。
## 落地规则
1. 开局场景和普通场景统一通过 `src/services/customWorldScenePresentation.ts` 解析展示模型。
2. 展示模型固定输出:
- `imageSrc`:场景主图,优先使用第一张幕背景,其次使用场景兼容图。
- `actPreviews`:幕预览列表,优先使用当前幕 `backgroundImageSrc`,只有该幕缺图时才回退 `imageSrc`
3. `camp.imageSrc``landmark.imageSrc` 只作为旧数据兼容字段,不反向覆盖已有幕背景。
4. 场景目录、开局场景详情、普通场景详情必须复用同一套展示解析服务;不得再为 `camp` 单独写一套幕预览取图逻辑。
## 验收点
1. 开局场景列表中的第 2 幕缩略图与详情页第 2 幕背景预览一致。
2. 普通场景仍沿用同一展示模型,列表幕缩略图不被场景主图覆盖。
3. 保存开局场景图片时,兼容字段 `camp.imageSrc` 和多幕背景仍保持已有同步规则。

View File

@@ -116,9 +116,9 @@ npm run deploy:rust:remote
3. 使用 Vite 构建前端 release 到目标目录的 `web/`
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/`;复制后统一移除 UTF-8 BOM 与 CRLF避免目标服务器 Bash 加载环境文件失败
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*``/generated-*``/healthz` 反代到本包内的 `api-server`
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先加载发布目录根部的 `.env``.env.local`,再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先`KEY=value` 子集加载发布目录根部的 `.env``.env.local`兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
发布包结构:
@@ -160,10 +160,11 @@ cd build/<timestamp>
1. 构建脚本会把仓库根目录已有的 `.env``.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。
2. 如果仓库根目录不存在 `.env``.env.local`,脚本会打印跳过日志,但不会因此失败;此时 `start.sh` 仅使用构建时写入的默认值与运行时显式传入的环境变量。
3. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发
4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`在 schema 冲突时删除旧模块数据。
5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
6. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传
3. `start.sh` 只解析合法 `KEY=value` 环境行,支持不加引号、双引号和单引号;不执行复杂 shell 表达式,避免把环境文件变成脚本入口
4. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database`追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发
5. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据
6. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
7. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。
目标服务器最小要求:

View File

@@ -206,6 +206,8 @@
1. 密码登录仍由 `user_account.password_hash` 承担
2. 本轮不引入 `password` provider identity
3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或叙世号作为登录身份
4. 密码登录不创建账号,新账号只由手机号验证码登录创建
### 9.2 `POST /api/auth/phone/login`

View File

@@ -18,7 +18,7 @@
当前 Node 鉴权主链已经依赖 `users` 主表完成以下能力:
1. `POST /api/auth/entry`用户名密码登录,不存在则自动创建账号
1. `POST /api/auth/entry`手机号密码登录,仅允许已存在且已设置密码的手机号账号登录
2. `POST /api/auth/phone/login`:手机号验证码登录,不存在则自动创建账号
3. `GET /api/auth/me`:读取当前账号基础信息
4. `POST /api/auth/logout`:提升 `token_version`,让当前 access token 失效
@@ -99,8 +99,9 @@
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | `String` | 是 | 主键,继续沿用当前 `user_*` 前缀格式。 |
| `username` | `String` | 是 | 当前密码登录用户名;手机号/微信创建的系统账号同样要写入唯一用户名。 |
| `password_hash` | `String` | | 密码登录校验字段;手机号/微信建账号时继续写随机密码哈希,保持兼容。 |
| `username` | `String` | 是 | 系统账号名;不再作为前台密码登录标识,手机号/微信创建账号时仍写入唯一系统用户名。 |
| `password_hash` | `Option<String>` | | 用户显式设置或重置密码后才写入;手机号/微信建账号默认不可用密码登录。 |
| `password_login_enabled` | `bool` | 是 | 是否允许密码登录;只有用户设置或重置密码后才为 `true`。 |
| `token_version` | `u32` | 是 | access token 统一失效计数,默认 `1`。 |
| `display_name` | `String` | 是 | 账号展示名;密码账号默认用户名,手机号账号默认脱敏手机号,微信待绑定账号默认微信昵称或“微信旅人”。 |
| `login_provider` | `String` | 是 | 当前账号的主登录归属,枚举固定为 `password``phone``wechat`。 |
@@ -131,9 +132,9 @@
### 6.2 必须具备的查询索引
1. `username`
作用:支撑 `POST /api/auth/entry`
作用:系统账号唯一约束与内部排查,不作为前台密码登录入口
2. `primary_phone_e164`
作用:支撑 `POST /api/auth/phone/login``POST /api/auth/phone/change`
作用:支撑 `POST /api/auth/entry``POST /api/auth/phone/login``POST /api/auth/phone/change`
3. `account_status + updated_at`
作用:后续管理端、审计排查与禁用账号扫描
4. `merged_to_user_id`
@@ -188,13 +189,12 @@
写入规则:
1. 先按 `username` 查询
2. 若不存在,则创建一条 `active` 账号
3. `login_provider = password`
4. `display_name = username`
5. `primary_phone_e164 = null`
6. `phone_verified_at = null`
7. `last_login_at = 当前时间`
1. 只读取请求中的 `phone``password`
2. 先把 `phone` 归一化为 `primary_phone_e164` 后查询账号
3. 若手机号不存在,返回 `401`,不创建账号。
4. 若账号存在但 `password_login_enabled = false``password_hash = null`,返回 `401`
5. 若账号存在且已设置密码,校验 `password_hash`
6. 校验成功后只更新登录会话与 `last_login_at`,不改变账号主归属。
### 8.2 `POST /api/auth/phone/login`

View File

@@ -47,7 +47,7 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
### `user_account`
- 作用:用户账号主表,保存用户名、公开叙世号、手机号掩码、登录方式、密码登录开关和 token 版本。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option<String>`, `password_login_enabled: bool`, `token_version: u64`
- 索引:`username`, `public_user_code`
```sql

View File

@@ -23,7 +23,7 @@ export type PublicUserSearchResponse = {
};
export type AuthEntryRequest = {
username: string;
phone: string;
password: string;
};

View File

@@ -66,6 +66,8 @@ export interface RpgAgentOperationRecord {
phaseDetail: string;
progress: number;
error?: string | null;
/** 操作创建时间,草稿生成进度页用它计算总耗时。 */
startedAt?: string | null;
updatedAt?: string | null;
}

View File

@@ -57,6 +57,19 @@ copy_required_file() {
cp "${source_path}" "${target_path}"
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
copy_optional_file() {
local source_path="$1"
local target_path_a="$2"
@@ -70,6 +83,8 @@ copy_optional_file() {
cp "${source_path}" "${target_path_a}"
cp "${source_path}" "${target_path_b}"
normalize_env_file "${target_path_a}"
normalize_env_file "${target_path_b}"
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a}${target_path_b}"
}
@@ -426,17 +441,47 @@ cd "${SCRIPT_DIR}"
load_env_file() {
local env_file="$1"
local line=""
local line_number=0
local key=""
local value=""
local utf8_bom=$'\xef\xbb\xbf'
if [[ ! -f "${env_file}" ]]; then
return
fi
echo "[start] 加载环境文件: ${env_file}"
set -a
# 发布包内环境文件由当前构建脚本生成,允许在启动时作为默认环境源加载
# shellcheck disable=SC1090
source "${env_file}"
set +a
# 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行
while IFS= read -r line || [[ -n "${line}" ]]; do
line_number=$((line_number + 1))
if [[ "${line_number}" -eq 1 ]]; then
line="${line#"${utf8_bom}"}"
fi
line="${line%$'\r'}"
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
continue
fi
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2
continue
fi
key="${BASH_REMATCH[2]}"
value="${BASH_REMATCH[3]}"
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
value="${value:1:${#value}-2}"
value="${value//\\\"/\"}"
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
printf -v "${key}" '%s' "${value}"
export "${key}"
done <"${env_file}"
}
usage() {
@@ -655,6 +700,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
## 环境变量
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`

View File

@@ -32,6 +32,28 @@ require_argument() {
fi
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 兼容由 Windows 编辑器或 Jenkins 参数落盘产生的 BOM/CRLF避免 start.sh 加载时报命令不存在。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
normalize_release_env_files() {
local release_dir="$1"
normalize_env_file "${release_dir}/.env"
normalize_env_file "${release_dir}/.env.local"
normalize_env_file "${release_dir}/web/.env"
normalize_env_file "${release_dir}/web/.env.local"
}
SOURCE_DIR=""
DEPLOY_DIR=""
CLEAR_DATABASE="0"
@@ -125,6 +147,8 @@ if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
exit 1
fi
normalize_release_env_files "${SOURCE_DIR}"
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
run_hook "${DEPLOY_DIR}" "stop.sh"
@@ -154,6 +178,8 @@ if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
chmod +x "${DEPLOY_DIR}/stop.sh"
fi
normalize_release_env_files "${DEPLOY_DIR}"
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
echo "[jenkins-deploy] 以清库模式启动新版本"

View File

@@ -642,13 +642,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "ai_tasks_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138100", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -659,7 +655,7 @@ mod tests {
session_id: "sess_ai_tasks".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("AI 任务用户".to_string()),

View File

@@ -1060,6 +1060,7 @@ pub fn build_router(state: AppState) -> Router {
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
@@ -1077,6 +1078,40 @@ mod tests {
use super::build_router;
const TEST_PASSWORD: &str = "secret123";
async fn seed_phone_user_with_password(
state: &AppState,
phone_number: &str,
password: &str,
) -> module_auth::AuthUser {
state
.seed_test_phone_user_with_password(phone_number, password)
.await
}
async fn password_login_request(
app: Router,
phone_number: &str,
password: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": phone_number,
"password": password
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed")
}
#[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1191,24 +1226,17 @@ mod tests {
async fn internal_auth_claims_returns_verified_claims() {
let config = AppConfig::default();
let state = AppState::new(config.clone()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "guest_auth_debug".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
user_id: seed_user.id.clone(),
session_id: "sess_auth_debug".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: seed_user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("测试用户".to_string()),
display_name: Some(seed_user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
@@ -1239,17 +1267,14 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["claims"]["sub"],
Value::String("user_00000001".to_string())
);
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
assert_eq!(
payload["claims"]["sid"],
Value::String("sess_auth_debug".to_string())
);
assert_eq!(
payload["claims"]["ver"],
Value::Number(serde_json::Number::from(1))
Value::Number(serde_json::Number::from(seed_user.token_version))
);
}
@@ -1322,26 +1347,21 @@ mod tests {
}
#[tokio::test]
async fn password_entry_creates_user_and_sets_refresh_cookie() {
async fn password_entry_rejects_unknown_phone_without_registration() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
let response = password_login_request(app, "13800138011", TEST_PASSWORD).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138012", TEST_PASSWORD).await;
let app = build_router(state);
let response = password_login_request(app, "13800138012", TEST_PASSWORD).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
@@ -1361,9 +1381,10 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(
payload["user"]["username"],
Value::String("guest_001".to_string())
payload["user"]["loginMethod"],
Value::String("password".to_string())
);
assert!(payload["token"].as_str().is_some());
}
@@ -1400,7 +1421,7 @@ mod tests {
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "wechat"])
serde_json::json!(["phone", "password", "wechat"])
);
}
@@ -2261,7 +2282,9 @@ mod tests {
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_fields() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = app
.clone()
@@ -2277,8 +2300,8 @@ mod tests {
.header("x-client-instance-id", "chrome-instance-001")
.body(Body::from(
serde_json::json!({
"username": "guest_sessions_api",
"password": "secret123"
"phone": "13800138013",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2321,8 +2344,8 @@ mod tests {
.header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger")
.body(Body::from(
serde_json::json!({
"username": "guest_sessions_api",
"password": "secret123"
"phone": "13800138013",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2375,27 +2398,13 @@ mod tests {
}
#[tokio::test]
async fn password_entry_reuses_same_user_for_same_credentials() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
async fn password_entry_reuses_same_user_for_same_phone() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138014", TEST_PASSWORD).await;
let app = build_router(state);
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("first request should succeed");
let first_response =
password_login_request(app.clone(), "13800138014", TEST_PASSWORD).await;
let first_body = first_response
.into_body()
.collect()
@@ -2405,23 +2414,7 @@ mod tests {
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first payload should be json");
let second_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("second request should succeed");
let second_response = password_login_request(app, "13800138014", TEST_PASSWORD).await;
let second_body = second_response
.into_body()
.collect()
@@ -2431,54 +2424,23 @@ mod tests {
let second_payload: Value =
serde_json::from_slice(&second_body).expect("second payload should be json");
assert_eq!(first_payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
}
#[tokio::test]
async fn password_entry_rejects_wrong_password() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138015", TEST_PASSWORD).await;
let app = build_router(state);
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("seed request should succeed");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret999"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
let response = password_login_request(app, "13800138015", "secret999").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn password_entry_rejects_invalid_username() {
async fn password_entry_rejects_email_or_username_identifier() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
@@ -2489,8 +2451,8 @@ mod tests {
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "无效用户",
"password": "secret123"
"phone": "user@example.com",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2510,24 +2472,17 @@ mod tests {
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
user_id: seed_user.id.clone(),
session_id: "sess_me_query".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: seed_user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("guest_001".to_string()),
display_name: Some(seed_user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
@@ -2558,13 +2513,10 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["user"]["id"],
Value::String("user_00000001".to_string())
);
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "wechat"])
serde_json::json!(["phone", "password", "wechat"])
);
}
@@ -2606,26 +2558,12 @@ mod tests {
#[tokio::test]
async fn refresh_session_rotates_cookie_and_returns_new_access_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138017", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_refresh",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138017", TEST_PASSWORD).await;
let first_cookie = login_response
.headers()
.get("set-cookie")
@@ -2714,26 +2652,12 @@ mod tests {
#[tokio::test]
async fn logout_clears_cookie_and_invalidates_current_access_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138018", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_api",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138018", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
@@ -2802,26 +2726,12 @@ mod tests {
#[tokio::test]
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138019", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_no_cookie",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
@@ -2859,7 +2769,9 @@ mod tests {
#[tokio::test]
async fn logout_all_clears_cookie_and_invalidates_all_sessions() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138020", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = app
.clone()
@@ -2874,8 +2786,8 @@ mod tests {
)
.body(Body::from(
serde_json::json!({
"username": "guest_logout_all_api",
"password": "secret123"
"phone": "13800138020",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2913,8 +2825,8 @@ mod tests {
.header("x-client-instance-id", "logout-all-instance-002")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_all_api",
"password": "secret123"
"phone": "13800138020",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -3005,26 +2917,12 @@ mod tests {
#[tokio::test]
async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138021", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_all_nc",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138021", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
@@ -3108,26 +3006,11 @@ mod tests {
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let state = AppState::new(config).expect("state should build");
seed_phone_user_with_password(&state, "13800138022", TEST_PASSWORD).await;
let app = build_router(state.clone());
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_admin_forbidden",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_response =
password_login_request(app.clone(), "13800138022", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()

View File

@@ -34,6 +34,7 @@ pub async fn auth_me(
user: map_auth_user_payload(user.user),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
true,
state.config.wechat_auth_enabled,
),
},

View File

@@ -64,7 +64,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidUsername
| module_auth::PasswordEntryError::InvalidPhoneNumber
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials
| module_auth::PasswordEntryError::UserNotFound => {

View File

@@ -2537,6 +2537,7 @@ fn map_custom_world_agent_operation_response(
phase_detail: operation.phase_detail,
progress: operation.progress,
error: operation.error_message,
started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)),
updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)),
}
}

View File

@@ -257,13 +257,9 @@ mod tests {
async fn seed_authenticated_state(config: AppConfig) -> AppState {
let state = AppState::new(config).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "llm_proxy_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138101", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -274,7 +270,7 @@ mod tests {
session_id: "sess_llm_proxy".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("LLM 代理用户".to_string()),

View File

@@ -15,6 +15,7 @@ pub async fn auth_login_options(
AuthLoginOptionsResponse {
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
true,
state.config.wechat_auth_enabled,
),
},

View File

@@ -29,7 +29,7 @@ pub async fn password_entry(
let result = state
.password_entry_service()
.execute(PasswordEntryInput {
username: payload.username,
phone_number: payload.phone,
password: payload.password,
})
.await
@@ -64,10 +64,10 @@ pub async fn password_entry(
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("手机号格式不正确")
.with_details(json!({
"field": "username",
"field": "phone",
})),
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间")
@@ -77,7 +77,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_details(json!({
"field": "username",
"field": "phone",
})),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")

View File

@@ -100,7 +100,7 @@ pub async fn reset_password(
fn map_password_management_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPublicUserCode => {
PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)

View File

@@ -422,13 +422,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "browse_history_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138102", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -439,7 +435,7 @@ mod tests {
session_id: "sess_runtime_browse_history".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("浏览历史用户".to_string()),

View File

@@ -59,7 +59,7 @@ pub async fn stream_runtime_npc_chat_turn(
.or_else(|| read_string_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let player_message = payload.player_message.trim();
if player_message.is_empty() {
if player_message.is_empty() && !payload.npc_initiates_conversation {
return Err(runtime_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -112,8 +112,11 @@ pub async fn stream_runtime_npc_chat_turn(
};
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
let affinity_delta =
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count);
let affinity_delta = if payload.npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count)
};
let complete_payload = json!({
"npcReply": npc_reply,
"affinityDelta": affinity_delta,
@@ -655,6 +658,21 @@ mod tests {
);
}
#[test]
fn npc_initiated_opening_keeps_neutral_affinity_delta() {
// 首遇主动开场不是玩家发言结算,不能因为空 playerMessage 或占位文本触发好感变化。
let npc_initiates_conversation = true;
let player_message = "";
let npc_reply = "你来了。先别急着走,我正有话想和你说。";
let affinity_delta = if npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply, 0.0)
};
assert_eq!(affinity_delta, 0);
}
#[test]
fn npc_chat_suggestion_parser_strips_list_markers() {
assert_eq!(

View File

@@ -262,7 +262,11 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
)),
combat_context_block,
function_options_block,
Some(format!("玩家刚刚说:{}", payload.player_message)),
if payload.npc_initiates_conversation {
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message))
},
Some(format!("NPC 刚刚回复:{npc_reply}")),
if is_hostile_model_chat {
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())

View File

@@ -164,13 +164,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_inventory_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138103", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -181,7 +177,7 @@ mod tests {
session_id: "sess_runtime_inventory".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("背包查询用户".to_string()),

View File

@@ -463,7 +463,7 @@ mod tests {
.method("POST")
.uri("/api/profile/recharge/orders")
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_10"}"#))
.body(Body::from(r#"{"productId":"points_60"}"#))
.expect("request should build"),
)
.await
@@ -597,7 +597,12 @@ mod tests {
}
async fn seed_authenticated_state() -> AppState {
AppState::new(AppConfig::default()).expect("state should build")
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
@@ -607,7 +612,7 @@ mod tests {
session_id: "sess_runtime_profile".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),

View File

@@ -380,13 +380,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_save_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138105", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -397,7 +393,7 @@ mod tests {
session_id: "sess_runtime_save".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("存档用户".to_string()),

View File

@@ -340,13 +340,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_settings_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138106", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -357,7 +353,7 @@ mod tests {
session_id: "sess_runtime_settings".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("设置用户".to_string()),

View File

@@ -1986,13 +1986,9 @@ fn runtime_story_dialogue_current_story_keeps_continue_and_deferred_options() {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_story_state_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138109", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -2003,7 +1999,7 @@ fn issue_access_token(state: &AppState) -> String {
session_id: "sess_runtime_story_state".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("运行时剧情状态用户".to_string()),

View File

@@ -38,6 +38,7 @@ pub struct AppState {
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
@@ -96,6 +97,9 @@ pub enum AppStateInitError {
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
#[cfg(test)]
let auth_store = InMemoryAuthStore::default();
#[cfg(not(test))]
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Self::new_with_auth_store(config, auth_store)
@@ -206,19 +210,27 @@ impl AppState {
}
pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
#[cfg(test)]
return Ok(());
#[cfg(not(test))]
let snapshot_json = self
.auth_store
.export_snapshot_json()
.map_err(SpacetimeClientError::Runtime)?;
#[cfg(not(test))]
let updated_at_micros = i64::try_from(
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
#[cfg(not(test))]
self.spacetime_client
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
.await?;
// ?????????????????????????????????
#[cfg(not(test))]
self.spacetime_client.import_auth_store_snapshot().await?;
#[cfg(not(test))]
Ok(())
}
@@ -401,6 +413,47 @@ impl AppState {
#[cfg(test)]
impl AppState {
pub(crate) async fn seed_test_phone_user_with_password(
&self,
phone_number: &str,
password: &str,
) -> module_auth::AuthUser {
let now = OffsetDateTime::now_utc();
self.phone_auth_service()
.send_code(
module_auth::SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: module_auth::PhoneAuthScene::Login,
},
now,
)
.await
.expect("test phone code should send");
let user = self
.phone_auth_service()
.login(
module_auth::PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("test phone login should create user")
.user;
let changed = self
.password_entry_service()
.change_password(module_auth::ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: password.to_string(),
})
.await
.expect("test password should set");
changed.user
}
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
self.test_runtime_snapshot_store
.lock()

View File

@@ -797,13 +797,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "story_battles_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138107", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -814,7 +810,7 @@ mod tests {
session_id: "sess_story_battles".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("战斗接口用户".to_string()),

View File

@@ -384,13 +384,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "story_sessions_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138108", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -401,7 +397,7 @@ mod tests {
session_id: "sess_story_sessions".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("故事会话用户".to_string()),

View File

@@ -18,8 +18,6 @@ use shared_kernel::{
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
const PASSWORD_MIN_LENGTH: usize = 6;
const PASSWORD_MAX_LENGTH: usize = 128;
const SMS_CODE_LENGTH: usize = 6;
@@ -65,7 +63,7 @@ pub struct PublicUserSearchResult {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
pub phone_number: String,
pub password: String,
}
@@ -315,7 +313,7 @@ pub struct AuthStoreSnapshotProcedureResult {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidUsername,
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
@@ -476,27 +474,16 @@ impl PasswordEntryService {
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
validate_password(&input.password)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
else {
return Err(PasswordEntryError::InvalidCredentials);
};
// 登录面板现在固定使用手机号作为密码登录标识;先走手机号索引,
// 再保留历史用户名路径给开发游客和旧测试数据使用。
if let Ok(normalized_phone) = normalize_mainland_china_phone_number(&input.username) {
let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
else {
return Err(PasswordEntryError::InvalidCredentials);
};
return verify_stored_password_user(existing_user, &input.password).await;
}
let username = normalize_username(&input.username)?;
if let Some(existing_user) = self.store.find_by_username(&username)? {
return verify_stored_password_user(existing_user, &input.password).await;
}
Err(PasswordEntryError::InvalidCredentials)
verify_stored_password_user(existing_user, &input.password).await
}
pub fn get_user_by_id(
@@ -1232,17 +1219,6 @@ impl InMemoryAuthStore {
.map_err(RefreshSessionError::Store)
}
fn find_by_username(
&self,
username: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state.users_by_username.get(username).cloned())
}
fn find_by_user_id(
&self,
user_id: &str,
@@ -2087,10 +2063,10 @@ impl AuthBindingStatus {
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
@@ -2161,7 +2137,7 @@ impl Error for LogoutError {}
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidUsername
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
@@ -2176,7 +2152,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidUsername
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
@@ -2187,7 +2163,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidUsername
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
@@ -2215,21 +2191,6 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError
}
}
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
let username = normalize_required_string(raw_username).unwrap_or_default();
let valid_length =
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
let valid_chars = username
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_');
if !valid_length || !valid_chars {
return Err(PasswordEntryError::InvalidUsername);
}
Ok(username)
}
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
@@ -2255,7 +2216,10 @@ async fn verify_stored_password_user(
}
Ok(PasswordEntryResult {
user: existing_user.user,
user: AuthUser {
login_method: AuthLoginMethod::Password,
..existing_user.user
},
created: false,
})
}
@@ -2501,7 +2465,7 @@ mod tests {
let error = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
phone_number: "13800138000".to_string(),
password: "secret123".to_string(),
})
.await
@@ -2516,7 +2480,7 @@ mod tests {
let user = create_phone_login_user(store.clone(), "13800138000").await;
let service = build_password_service(store);
let changed = service
service
.change_password(ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
@@ -2526,7 +2490,7 @@ mod tests {
.expect("phone user should set first password");
let result = service
.execute(PasswordEntryInput {
username: changed.user.username.clone(),
phone_number: "13800138000".to_string(),
password: "secret123".to_string(),
})
.await
@@ -2534,7 +2498,7 @@ mod tests {
assert!(!result.created);
assert_eq!(result.user.id, user.id);
assert_eq!(result.user.login_method, AuthLoginMethod::Phone);
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
}
#[tokio::test]
@@ -2553,7 +2517,7 @@ mod tests {
let error = service
.execute(PasswordEntryInput {
username: user.username,
phone_number: "13800138001".to_string(),
password: "secret999".to_string(),
})
.await
@@ -2651,18 +2615,18 @@ mod tests {
}
#[tokio::test]
async fn invalid_username_returns_bad_request_error() {
async fn password_entry_rejects_email_or_username_identifier() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
username: "坏用户名".to_string(),
phone_number: "user@example.com".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("invalid username should fail");
.expect_err("email should fail");
assert_eq!(error, PasswordEntryError::InvalidUsername);
assert_eq!(error, PasswordEntryError::InvalidPhoneNumber);
}
#[tokio::test]

View File

@@ -1508,59 +1508,59 @@ impl RuntimeProfileRechargeOrderStatus {
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
vec![
build_points_recharge_product(
"points_10",
"10积分",
100,
10,
19,
"首充送积分",
"首充送19积分",
),
build_points_recharge_product(
"points_60",
"60积分",
"60叙世币",
600,
60,
0,
"首充赠礼",
"首充",
60,
"首充双倍",
"首充送60叙世币",
),
build_points_recharge_product(
"points_240",
"240积分",
2400,
240,
240,
"points_180",
"180叙世币",
1800,
180,
180,
"首充双倍",
"首充送240积分",
"首充送180叙世币",
),
build_points_recharge_product(
"points_450",
"450积分",
4500,
450,
450,
"points_300",
"300叙世币",
3000,
300,
300,
"首充双倍",
"首充送450积分",
"首充送300叙世币",
),
build_points_recharge_product(
"points_950",
"950积分",
9500,
950,
950,
"points_680",
"680叙世币",
6800,
680,
680,
"首充双倍",
"首充送950积分",
"首充送680叙世币",
),
build_points_recharge_product(
"points_1980",
"1980积分",
19800,
1980,
1980,
"points_1280",
"1280叙世币",
12800,
1280,
1280,
"首充双倍",
"首充送1980积分",
"首充送1280叙世币",
),
build_points_recharge_product(
"points_3280",
"3280叙世币",
32800,
3280,
3280,
"首充双倍",
"首充送3280叙世币",
),
]
}
@@ -1609,7 +1609,7 @@ pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBene
year_value: "¥248".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "积分回合数".to_string(),
benefit_name: "叙世币回合数".to_string(),
normal_value: "30".to_string(),
month_value: "100".to_string(),
season_value: "100".to_string(),
@@ -1970,10 +1970,12 @@ mod tests {
let membership_products = runtime_profile_recharge_membership_products();
assert_eq!(point_products.len(), 6);
assert_eq!(point_products[0].product_id, "points_10");
assert_eq!(point_products[0].price_cents, 100);
assert_eq!(point_products[0].bonus_points, 19);
assert_eq!(point_products[5].points_amount, 1980);
assert_eq!(point_products[0].product_id, "points_60");
assert_eq!(point_products[0].price_cents, 600);
assert_eq!(point_products[0].bonus_points, 60);
assert_eq!(point_products[5].product_id, "points_3280");
assert_eq!(point_products[5].price_cents, 32800);
assert_eq!(point_products[5].bonus_points, 3280);
assert_eq!(membership_products.len(), 3);
assert_eq!(membership_products[0].title, "月卡");
assert_eq!(membership_products[0].price_cents, 2800);

View File

@@ -42,7 +42,7 @@ pub struct PublicUserSearchResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest {
pub username: String,
pub phone: String,
pub password: String,
}
@@ -193,12 +193,16 @@ pub struct WechatBindPhoneResponse {
pub fn build_available_login_methods(
sms_auth_enabled: bool,
password_auth_enabled: bool,
wechat_auth_enabled: bool,
) -> Vec<String> {
let mut methods = vec![AUTH_LOGIN_METHOD_PASSWORD.to_string()];
let mut methods = Vec::new();
if sms_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_PHONE.to_string());
}
if password_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_PASSWORD.to_string());
}
if wechat_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string());
}
@@ -212,13 +216,13 @@ mod tests {
#[test]
fn available_login_methods_keep_phone_then_wechat_order() {
let methods = build_available_login_methods(true, true);
let methods = build_available_login_methods(true, true, true);
assert_eq!(
methods,
vec![
AUTH_LOGIN_METHOD_PASSWORD.to_string(),
AUTH_LOGIN_METHOD_PHONE.to_string(),
AUTH_LOGIN_METHOD_PASSWORD.to_string(),
AUTH_LOGIN_METHOD_WECHAT.to_string()
]
);
@@ -227,7 +231,7 @@ mod tests {
#[test]
fn password_entry_request_uses_camel_case_fields() {
let payload = serde_json::to_value(PasswordEntryRequest {
username: "guest_001".to_string(),
phone: "13800138000".to_string(),
password: "secret123".to_string(),
})
.expect("payload should serialize");
@@ -235,7 +239,7 @@ mod tests {
assert_eq!(
payload,
json!({
"username": "guest_001",
"phone": "13800138000",
"password": "secret123"
})
);

View File

@@ -486,6 +486,7 @@ pub struct CustomWorldAgentOperationResponse {
pub phase_detail: String,
pub progress: u32,
pub error: Option<String>,
pub started_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -782,15 +783,15 @@ mod tests {
updated_at: Some("2026-04-25T10:00:00Z".to_string()),
},
point_products: vec![ProfileRechargeProductResponse {
product_id: "points_10".to_string(),
title: "10积分".to_string(),
price_cents: 100,
product_id: "points_60".to_string(),
title: "60叙世币".to_string(),
price_cents: 600,
kind: "points".to_string(),
points_amount: 10,
bonus_points: 19,
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充送积分".to_string(),
description: "首充送19积分".to_string(),
badge_label: "首充双倍".to_string(),
description: "首充送60叙世币".to_string(),
tier: "normal".to_string(),
}],
membership_products: vec![],
@@ -805,8 +806,8 @@ mod tests {
payload["membership"]["expiresAt"],
json!("2026-05-25T10:00:00Z")
);
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_10"));
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(100));
assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60"));
assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600));
assert_eq!(payload["hasPointsRecharged"], json!(false));
}

View File

@@ -1932,6 +1932,7 @@ pub(crate) fn map_custom_world_agent_operation_snapshot(
phase_detail: snapshot.phase_detail,
progress: snapshot.progress,
error_message: snapshot.error_message,
started_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
@@ -3816,6 +3817,7 @@ pub struct CustomWorldAgentOperationRecord {
pub phase_detail: String,
pub progress: u32,
pub error_message: Option<String>,
pub started_at_micros: i64,
pub updated_at_micros: i64,
}

View File

@@ -1384,7 +1384,9 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
publication_status: profile.publication_status,
play_count: profile.play_count,
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
// 广场消费数据,不能因为重新发布被清零。
play_count: existing.play_count.max(profile.play_count),
anchor_pack_json: serialize_json(&profile.anchor_pack),
publish_ready: profile.publish_ready,
created_at: existing.created_at,

View File

@@ -322,7 +322,7 @@ pub fn get_profile_referral_invite_center(
}
}
// 填码绑定、每日邀请者奖励上限和双方积分发放都在同一事务内完成。
// 填码绑定、每日邀请者奖励上限和双方叙世币发放都在同一事务内完成。
#[spacetimedb::procedure]
pub fn redeem_profile_referral_invite_code(
ctx: &mut ProcedureContext,

View File

@@ -8,27 +8,23 @@
} from 'react';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import {
buildCustomWorldFoundationEntries,
parseFoundationTagText,
} from '../services/customWorldFoundationEntries';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { buildCustomWorldScenePresentations } from '../services/customWorldScenePresentation';
import {
AnimationState,
Character,
CustomWorldProfile,
type Character,
type CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
@@ -242,45 +238,6 @@ function PendingEntityCard({
);
}
function resolveSceneEntrySceneChapters(params: {
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
sceneId: string;
sceneName: string;
}) {
const sceneChapters = params.sceneChapters ?? [];
const normalizedSceneId = params.sceneId.trim();
const normalizedSceneName = params.sceneName.trim();
const directMatches = sceneChapters.filter(
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
);
if (directMatches.length > 0) {
return directMatches;
}
const linkedMatches = sceneChapters.filter((chapter) =>
chapter.linkedLandmarkIds.some(
(landmarkId) => landmarkId.trim() === normalizedSceneId,
),
);
if (linkedMatches.length > 0) {
return linkedMatches;
}
return sceneChapters.filter((chapter) => {
const chapterTitle = chapter.title.trim();
return (
chapterTitle === normalizedSceneName ||
chapter.summary.includes(normalizedSceneName) ||
chapter.acts.some(
(act) =>
act.title.includes(normalizedSceneName) ||
act.summary.includes(normalizedSceneName),
)
);
});
}
function buildSceneActParticipantText(
act: SceneActBlueprint,
roleById: Map<
@@ -334,58 +291,6 @@ function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
)[0] ?? '';
}
function resolveSceneCardImage(params: {
sceneImageSrc?: string | null;
sceneChapters: SceneChapterBlueprint[];
}) {
const firstActImageSrc =
params.sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(
sceneChapters: SceneChapterBlueprint[],
sharedSceneImageSrc?: string | null,
) {
const sharedImageSrc = sharedSceneImageSrc?.trim() || '';
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `${index + 1}`,
imageSrc: sharedImageSrc || act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
}
function buildFallbackSceneActImagePreviews(params: {
sceneChapters: SceneChapterBlueprint[];
sceneImageSrc?: string | null;
}) {
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
const actPreviews = collectSceneActImagePreviews(
params.sceneChapters,
sceneImageSrc,
);
if (actPreviews.length > 0 || !sceneImageSrc) {
return actPreviews;
}
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc尚未回填到每一幕目录侧先用场景图兜底避免开局场景看起来没有幕图片。
return [1, 2, 3].map((actNumber) => ({
id: `fallback-scene-act-${actNumber}`,
title: `${actNumber}`,
imageSrc: sceneImageSrc,
}));
}
function SceneActPreviewStrip({
acts,
sceneName,
@@ -574,7 +479,7 @@ function resolvePlayableRolePreviewImage(
function buildOpeningSceneSearchText(
profile: CustomWorldProfile,
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
campScene: { name: string; description: string },
) {
return [
campScene.name,
@@ -694,16 +599,8 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks],
);
const landmarkImageById = useMemo(
() => resolveCustomWorldLandmarkImageMap(profile),
[profile],
);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(profile),
[profile],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(profile),
const scenePresentations = useMemo(
() => buildCustomWorldScenePresentations(profile),
[profile],
);
const previewCharacterById = useMemo(
@@ -749,18 +646,6 @@ export function CustomWorldEntityCatalog({
[deferredSearch, profile.storyNpcs],
);
const filteredLandmarks = useMemo(
() =>
profile.landmarks.filter(
(landmark) =>
!deferredSearch ||
matchText(
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
deferredSearch,
),
),
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
);
const structuredFoundationEntries = useMemo(
() => buildCustomWorldFoundationEntries(profile),
[profile],
@@ -773,66 +658,34 @@ export function CustomWorldEntityCatalog({
? profile.attributeSchema.slots
: [];
const filteredSceneEntries = useMemo(() => {
const openingSceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneImageSrc = resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
});
const openingSceneEntry = {
id: resolvedCampScene.id,
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: openingSceneImageSrc,
sceneChapters: openingSceneChapters,
sceneTaskDescription: buildSceneTaskDescriptionText(openingSceneChapters),
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters: openingSceneChapters,
sceneImageSrc: openingSceneImageSrc,
}),
const openingSceneEntry = {
...scenePresentations.camp,
sceneTaskDescription: buildSceneTaskDescriptionText(
scenePresentations.camp.sceneChapters,
),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
buildOpeningSceneSearchText(profile, scenePresentations.camp),
buildSceneChapterSearchText(
scenePresentations.camp.sceneChapters,
roleById,
),
]
.filter(Boolean)
.join(' '),
};
const landmarkEntries = profile.landmarks.map((landmark) => {
const sceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: landmark.id,
sceneName: landmark.name,
});
const firstActImageSrc =
sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
const sceneImageSrc = resolveSceneCardImage({
sceneImageSrc:
firstActImageSrc || landmarkImageById.get(landmark.id) || landmark.imageSrc,
sceneChapters,
});
const landmarkEntries = scenePresentations.landmarks.map((scene) => {
const landmark = profile.landmarks.find((entry) => entry.id === scene.id);
return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: sceneImageSrc,
sceneChapters,
sceneTaskDescription: buildSceneTaskDescriptionText(sceneChapters),
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters,
sceneImageSrc,
}),
...scene,
sceneTaskDescription: buildSceneTaskDescriptionText(
scene.sceneChapters,
),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
landmark
? buildLandmarkSearchText(landmark, storyNpcById, landmarkById)
: '',
buildSceneChapterSearchText(scene.sceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
@@ -856,12 +709,10 @@ export function CustomWorldEntityCatalog({
}, [
deferredSearch,
landmarkById,
landmarkImageById,
profile,
recentLandmarkIdSet,
resolvedCampImageSrc,
resolvedCampScene,
roleById,
scenePresentations,
storyNpcById,
]);

View File

@@ -5,18 +5,20 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import type {
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
SceneActBlueprint,
SceneChapterBlueprint,
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
type RpgCreationEditorTarget,
RpgCreationEntityEditorModal,
} from './rpg-creation-editor/RpgCreationEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
afterEach(() => {
cleanup();
@@ -261,6 +263,80 @@ function createProfileWithTwoLandmarks(): CustomWorldProfile {
} as unknown as CustomWorldProfile;
}
function createSceneAct(
sceneId: string,
index: number,
imageSrc: string,
): SceneActBlueprint {
return {
id: `${sceneId}-act-${index + 1}`,
sceneId,
title: `${index + 1}`,
summary: `${index + 1}幕摘要`,
stageCoverage: index === 0 ? ['opening'] : ['expansion'],
backgroundPromptText: '',
backgroundImageSrc: imageSrc,
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
oppositeNpcId: 'story-1',
eventDescription: `${index + 1}幕事件`,
linkedThreadIds: [],
advanceRule:
index === 0
? 'after_primary_contact'
: index >= 2
? 'after_chapter_resolution'
: 'after_active_step_complete',
actGoal: `${index + 1}幕目标`,
transitionHook: '',
};
}
function createSceneChapter(
sceneId: string,
sceneName: string,
imagePrefix: string,
): SceneChapterBlueprint {
return {
id: `${sceneId}-chapter`,
sceneId,
title: sceneName,
summary: `${sceneName}章节`,
sceneTaskDescription: `${sceneName}任务`,
linkedThreadIds: [],
linkedLandmarkIds: [sceneId],
acts: [0, 1, 2].map((index) =>
createSceneAct(sceneId, index, `${imagePrefix}-act-${index + 1}.png`),
),
};
}
function createProfileWithSceneChapters(): CustomWorldProfile {
return {
...createProfileWithLandmark(),
camp: {
id: 'custom-scene-camp',
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
imageSrc: '/generated-custom-world-scenes/camp-main.png',
sceneNpcIds: ['story-1'],
connections: [],
},
sceneChapterBlueprints: [
createSceneChapter(
'custom-scene-camp',
'潮灯居',
'/generated-custom-world-scenes/camp',
),
createSceneChapter(
'landmark-1',
'沉钟栈桥',
'/generated-custom-world-scenes/landmark',
),
],
} as unknown as CustomWorldProfile;
}
function LandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithLandmark());
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
@@ -882,6 +958,51 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
});
test('开局场景列表与详情幕预览复用同一套幕级图片', async () => {
const profile = createProfileWithSceneChapters();
const user = userEvent.setup();
render(
<>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={() => {}}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={{ kind: 'camp' }}
onClose={() => {}}
onProfileChange={() => {}}
/>
</>,
);
expect(screen.getByRole('img', { name: '潮灯居-第2幕' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
expect(screen.getByRole('img', { name: '沉钟栈桥-第2幕' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/landmark-act-2.png',
);
expect(screen.getByRole('img', { name: '第2幕幕背景' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第2幕')).toBeTruthy();
});
expect(screen.getByRole('img', { name: '第2幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
});
test('普通场景世界地图会包含开局场景并高亮当前场景', async () => {
const user = userEvent.setup();

View File

@@ -13,10 +13,11 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -34,7 +35,6 @@ vi.mock('../../services/authService', () => ({
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthLoginOptions: authMocks.getAuthLoginOptions,
@@ -44,8 +44,8 @@ vi.mock('../../services/authService', () => ({
getCaptchaChallengeFromError: vi.fn(() => null),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
@@ -96,22 +96,21 @@ beforeEach(() => {
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.ensureAutoAuthUser.mockResolvedValue({
user: mockUser,
credentials: {
username: 'guest_tester',
password: 'auto_password',
},
});
});
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
function ProtectedActionButton({
onAuthenticated,
}: {
onAuthenticated: () => void;
}) {
const authUi = useAuthUi();
return (
@@ -139,6 +138,27 @@ function PlatformTabStateProbe() {
);
}
function LogoutStateProbe() {
const authUi = useAuthUi();
return (
<div>
<div>{authUi?.user?.displayName ?? '未登录'}</div>
<div>
{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
</div>
<button
type="button"
onClick={() => {
void authUi?.logout();
}}
>
退
</button>
</div>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -153,7 +173,6 @@ test('auth gate keeps platform content visible when phone login is available', a
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});
test('auth gate waits for access token refresh before exposing restored user content', async () => {
@@ -195,7 +214,6 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
@@ -220,7 +238,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
@@ -276,6 +294,40 @@ test('auth state refresh keeps mounted platform content and local tab state', as
expect(screen.getByText('当前Tab创作')).toBeTruthy();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
let resolveLogout!: () => void;
const logoutPromise = new Promise<void>((resolve) => {
resolveLogout = resolve;
});
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '退出登录' }));
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
await act(async () => {
resolveLogout();
await logoutPromise;
});
});
test('auth gate shows sms send feedback in the login modal', async () => {
const user = userEvent.setup();
@@ -312,6 +364,72 @@ test('auth gate shows sms send feedback in the login modal', async () => {
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('login modal resets draft state every time it is reopened', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
await user.click(
within(firstDialog).getByRole('button', { name: '获取验证码' }),
);
expect(
await within(firstDialog).findByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeTruthy();
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
await user.click(
within(firstDialog).getByRole('button', { name: '忘记密码' }),
);
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
await user.click(screen.getByRole('button', { name: '进入作品' }));
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(reopenedDialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
).toBe('');
expect(
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
).toBe('');
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
expect(
within(reopenedDialog).queryByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeNull();
expect(
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
});
test('auth gate separates sms and password login by tabs', async () => {
const user = userEvent.setup();
@@ -344,9 +462,9 @@ test('auth gate separates sms and password login by tabs', async () => {
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');

View File

@@ -24,7 +24,6 @@ import {
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
@@ -42,10 +41,7 @@ import {
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import {
AuthUiContext,
type PlatformSettingsSection,
} from './AuthUiContext';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
@@ -61,11 +57,6 @@ type AuthStatus =
| 'ready'
| 'error';
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
// 开发游客兜底必须显式开启,避免抢占正式手机号验证码登录入口。
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
@@ -113,6 +104,50 @@ export function AuthGate({ children }: AuthGateProps) {
setStatus('ready');
}, []);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowSettingsModal(false);
setInitialSettingsSection(null);
setSessions([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setError('');
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
try {
await logoutAuthUser();
} catch (logoutError) {
setError(
logoutError instanceof Error
? logoutError.message
: '退出登录失败,请刷新页面确认状态。',
);
}
}, [clearLocalAuthenticatedState]);
const logoutAllSessions = useCallback(async () => {
clearLocalAuthenticatedState();
try {
await logoutAllAuthSessions();
} catch (logoutError) {
setError(
logoutError instanceof Error
? logoutError.message
: '退出全部设备失败,请刷新页面确认状态。',
);
}
}, [clearLocalAuthenticatedState]);
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
setShowLoginModal(false);
@@ -160,37 +195,6 @@ export function AuthGate({ children }: AuthGateProps) {
useEffect(() => {
let isActive = true;
const ensureAutoUser = async () => {
if (!isActive) {
return;
}
setStatus('recovering');
try {
const { user: nextUser } = await ensureAutoAuthUser();
if (!isActive) {
return;
}
await ensureStoredAccessToken();
activateReadyUser(nextUser);
setError('');
} catch (autoAuthError) {
if (!isActive) {
return;
}
setUser(null);
setStatus('error');
setError(
autoAuthError instanceof Error
? autoAuthError.message
: '自动登录失败,请稍后再试。',
);
}
};
const hydrate = async () => {
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
@@ -209,15 +213,6 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (
allowDevGuestAutoAuth &&
options &&
options.availableLoginMethods.length === 0
) {
await ensureAutoUser();
return;
}
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
@@ -225,11 +220,6 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (allowDevGuestAutoAuth) {
await ensureAutoUser();
return;
}
setAvailableLoginMethods([]);
setUser(null);
setError(
@@ -400,10 +390,7 @@ export function AuthGate({ children }: AuthGateProps) {
requireAuth,
openSettingsModal,
openAccountModal,
logout: async () => {
await logoutAuthUser();
setShowSettingsModal(false);
},
logout: logoutCurrentSession,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
@@ -418,6 +405,7 @@ export function AuthGate({ children }: AuthGateProps) {
openSettingsModal,
readyUser,
requireAuth,
logoutCurrentSession,
status,
settings.isHydratingSettings,
settings.isPersistingSettings,
@@ -431,7 +419,9 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'checking' && !canKeepPlatformContentMounted) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
>
...
</div>
);
@@ -439,8 +429,10 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'recovering' && !canKeepPlatformContentMounted) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
...
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
>
...
</div>
);
}
@@ -458,7 +450,11 @@ export function AuthGate({ children }: AuthGateProps) {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
const result = await sendPhoneLoginCode(
phone,
'bind_phone',
captcha,
);
setBindCaptchaChallenge(null);
return result;
} catch (sendError) {
@@ -494,9 +490,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
}}
onLogout={async () => {
await logoutAuthUser();
setUser(null);
setStatus('unauthenticated');
await logoutCurrentSession();
}}
/>
);
@@ -508,7 +502,9 @@ export function AuthGate({ children }: AuthGateProps) {
!canKeepPlatformContentMounted
) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
>
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
<div className="text-base font-medium text-[var(--platform-text-strong)]">
@@ -551,10 +547,7 @@ export function AuthGate({ children }: AuthGateProps) {
settingsError={settings.settingsError}
onClose={() => setShowSettingsModal(false)}
onPlatformThemeChange={settings.setPlatformTheme}
onLogout={async () => {
await logoutAuthUser();
setShowSettingsModal(false);
}}
onLogout={logoutCurrentSession}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
@@ -614,7 +607,9 @@ export function AuthGate({ children }: AuthGateProps) {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
current.filter(
(session) => session.sessionId !== sessionId,
),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
@@ -625,10 +620,7 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowSettingsModal(false);
}}
onLogoutAll={logoutAllSessions}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
@@ -640,7 +632,8 @@ export function AuthGate({ children }: AuthGateProps) {
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
const captchaChallenge =
getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
@@ -653,7 +646,10 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(nextUser);
}}
onChangePassword={async (currentPassword, newPassword) => {
const nextUser = await changePassword(currentPassword, newPassword);
const nextUser = await changePassword(
currentPassword,
newPassword,
);
setUser(nextUser);
}}
/>
@@ -676,7 +672,8 @@ export function AuthGate({ children }: AuthGateProps) {
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
const captchaChallenge =
getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
@@ -708,12 +705,12 @@ export function AuthGate({ children }: AuthGateProps) {
setLoggingIn(false);
}
}}
onPasswordSubmit={async (username, password) => {
onPasswordSubmit={async (phone, password) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await authEntry(username, password);
setStoredLastLoginPhone(username);
const nextUser = await authEntry(phone, password);
setStoredLastLoginPhone(phone);
activateReadyUser(nextUser);
} catch (loginError) {
setError(

View File

@@ -34,7 +34,7 @@ type LoginScreenProps = {
expiresInSeconds: number;
}>;
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
onPasswordSubmit: (username: string, password: string) => Promise<void>;
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
onResetPassword: (
phone: string,
code: string,
@@ -76,12 +76,40 @@ export function LoginScreen({
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
if (!isOpen) {
return;
}
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
setIsResetPanelOpen(false);
setPhone(getStoredLastLoginPhone());
setPassword('');
setCode('');
setResetPhone('');
setResetCode('');
setResetPasswordValue('');
setCaptchaAnswer('');
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
}, [isOpen, phoneLoginEnabled]);
useEffect(() => {
if (
activeLoginTab === 'phone' &&
!phoneLoginEnabled &&
passwordLoginEnabled
) {
setActiveLoginTab('password');
return;
}
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
if (
activeLoginTab === 'password' &&
!passwordLoginEnabled &&
phoneLoginEnabled
) {
setActiveLoginTab('phone');
}
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
@@ -162,7 +190,9 @@ export function LoginScreen({
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
@@ -196,13 +226,14 @@ export function LoginScreen({
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span>/</span>
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="手机号或邮箱"
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -222,10 +253,12 @@ export function LoginScreen({
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={submitDisabled || !phone.trim() || !password.trim()}
disabled={
submitDisabled || !phone.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '注册/登录'}
{loggingIn ? '登录中' : '登录'}
</button>
<button
type="button"
@@ -257,7 +290,7 @@ export function LoginScreen({
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="注册/登录"
submitLabel="登录"
enabled={phoneLoginEnabled}
showPhoneField
onPhoneChange={setPhone}
@@ -279,7 +312,9 @@ export function LoginScreen({
/>
) : null}
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
@@ -524,7 +559,9 @@ function PasswordResetPanel({
</button>
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim() || !password.trim()}
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : '重置密码'}
@@ -556,9 +593,17 @@ function WechatButton({
}
function ErrorBanner({ message }: { message: string }) {
return <div className="platform-banner platform-banner--danger text-sm">{message}</div>;
return (
<div className="platform-banner platform-banner--danger text-sm">
{message}
</div>
);
}
function SuccessBanner({ message }: { message: string }) {
return <div className="platform-banner platform-banner--success text-sm">{message}</div>;
return (
<div className="platform-banner platform-banner--success text-sm">
{message}
</div>
);
}

View File

@@ -16,6 +16,7 @@ import type {
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BigFishAssetStudioTarget =
@@ -537,38 +538,37 @@ function BigFishResultErrorModal({
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="big-fish-result-error-title"
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div
id="big-fish-result-error-title"
className="text-base font-black text-slate-950"
>
</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
<UnifiedModal
open
title="发布失败"
onClose={onClose}
closeOnBackdrop={false}
showCloseButton={false}
size="sm"
zIndexClassName="z-[160]"
overlayClassName="bg-slate-950/58"
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
bodyClassName="p-5"
footer={(
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
>
</button>
)}
footerClassName="border-t-0 px-5 pb-5 pt-0"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { UnifiedModal } from './UnifiedModal';
test('renders an accessible platform modal', () => {
render(
<UnifiedModal open title="统一弹窗" onClose={() => {}} portal={false}>
<div></div>
</UnifiedModal>,
);
expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
expect(screen.getByText('窗口内容')).toBeTruthy();
});
test('closes through backdrop and escape', () => {
const onClose = vi.fn();
const { rerender } = render(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<div></div>
</UnifiedModal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<div></div>
</UnifiedModal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
test('respects closeDisabled for every default close path', () => {
const onClose = vi.fn();
render(
<UnifiedModal
open
title="生成中"
onClose={onClose}
closeDisabled
portal={false}
>
<div></div>
</UnifiedModal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(onClose).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,220 @@
import { X } from 'lucide-react';
import {
type CSSProperties,
type ReactNode,
useEffect,
useId,
} from 'react';
import { createPortal } from 'react-dom';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type UnifiedModalVariant = 'platform' | 'pixel';
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
type UnifiedModalProps = {
open: boolean;
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
variant?: UnifiedModalVariant;
size?: UnifiedModalSize;
closeDisabled?: boolean;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
closeLabel?: string;
portal?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
panelClassName?: string;
headerClassName?: string;
bodyClassName?: string;
footerClassName?: string;
panelStyle?: CSSProperties;
};
const PLATFORM_SIZE_CLASS: Record<UnifiedModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
};
const PIXEL_SIZE_CLASS: Record<UnifiedModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
fullscreen: 'max-w-[min(96vw,64rem)]',
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
function getPanelStyle(
variant: UnifiedModalVariant,
panelStyle: CSSProperties | undefined,
) {
if (variant !== 'pixel') {
return panelStyle;
}
return {
...getNineSliceStyle(UI_CHROME.modalPanel),
...panelStyle,
};
}
function UnifiedModalContent({
open,
title,
description,
children,
footer,
onClose,
variant = 'platform',
size = 'md',
closeDisabled = false,
closeOnBackdrop = true,
showCloseButton = true,
closeLabel = '关闭',
zIndexClassName = 'z-[90]',
overlayClassName,
panelClassName,
headerClassName,
bodyClassName,
footerClassName,
panelStyle,
}: Omit<UnifiedModalProps, 'portal'>) {
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
if (!open || closeDisabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeDisabled, onClose, open]);
if (!open) {
return null;
}
const isPixel = variant === 'pixel';
const sizeClassName = isPixel
? PIXEL_SIZE_CLASS[size]
: PLATFORM_SIZE_CLASS[size];
const overlayClasses = isPixel
? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
: 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
const panelClasses = isPixel
? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
: 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
const headerClasses = isPixel
? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
: 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const titleClasses = isPixel
? 'truncate text-sm font-semibold text-white'
: 'text-base font-semibold text-[var(--platform-text-strong)]';
const descriptionClasses = isPixel
? 'mt-1 text-xs leading-5 text-zinc-400'
: 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
const bodyClasses = isPixel
? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
: 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
const footerClasses = isPixel
? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
: 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const closeButtonClasses = isPixel
? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45'
: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45';
return (
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
onClick={(event) => {
if (
closeOnBackdrop &&
!closeDisabled &&
event.target === event.currentTarget
) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)}
onClick={(event) => event.stopPropagation()}
>
<div className={joinClassNames(headerClasses, headerClassName)}>
<div className="min-w-0">
<div id={titleId} className={titleClasses}>
{title}
</div>
{description ? (
<div id={descriptionId} className={descriptionClasses}>
{description}
</div>
) : null}
</div>
{showCloseButton ? (
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
disabled={closeDisabled}
className={closeButtonClasses}
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className={joinClassNames(bodyClasses, bodyClassName)}>
{children}
</div>
{footer ? (
<div className={joinClassNames(footerClasses, footerClassName)}>
{footer}
</div>
) : null}
</div>
</div>
);
}
/**
* 统一模态窗口外壳。
* 业务组件只传入标题、内容和操作区遮罩、无障碍属性、Escape 与移动端布局在这里收口。
*/
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
if (!portal || typeof document === 'undefined') {
return <UnifiedModalContent {...props} />;
}
return createPortal(<UnifiedModalContent {...props} />, document.body);
}

View File

@@ -2,12 +2,20 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
const originalClipboard = navigator.clipboard;
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
@@ -72,7 +80,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
);
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
expect(
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});
@@ -110,10 +120,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
test('creation hub shows RPG public work code from published library entry', () => {
render(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
workId: 'published:world-public-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛已发布版',
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
]}
rpgLibraryEntries={[
{
ownerUserId: 'user-1',
profileId: 'world-public-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
profile: {} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛已发布版',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
expect(screen.getByText('CW-00000001')).toBeTruthy();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
render(
<CustomWorldCreationHub
@@ -157,7 +216,57 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
/>,
);
await user.click(screen.getByRole('button', { name: /稿/u }));
await user.click(
screen.getByRole('button', { name: /稿/u }),
);
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub work code copy button copies without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '沉钟拼图',
summary: '拼图作品会与其他创作作品一起展示。',
themeTags: ['潮雾', '沉钟'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
);
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(await screen.findByText('已复制')).toBeTruthy();
});

View File

@@ -80,5 +80,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('作品号');
expect(html).toContain('PZ-PROFILE1');
expect(html).not.toContain('我的拼图作品');
});

View File

@@ -3,6 +3,8 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
@@ -28,6 +30,7 @@ type CustomWorldCreationHubProps = {
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
@@ -61,6 +64,7 @@ export function CustomWorldCreationHub({
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
@@ -76,6 +80,7 @@ export function CustomWorldCreationHub({
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
@@ -89,9 +94,12 @@ export function CustomWorldCreationHub({
onDeletePublished,
onDeletePuzzle,
puzzleItems,
rpgLibraryEntries,
],
);
const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
const draftCount = shelfItems.filter(
(entry) => entry.status === 'draft',
).length;
const publishedCount = shelfItems.filter(
(entry) => entry.status === 'published',
).length;

View File

@@ -1,3 +1,7 @@
import { Copy } from 'lucide-react';
import { useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
@@ -23,7 +27,10 @@ type CustomWorldWorkCardProps = {
deleteBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfItem['badges'][number]['tone'], string> = {
const BADGE_TONE_CLASS: Record<
CreationWorkShelfItem['badges'][number]['tone'],
string
> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
@@ -36,6 +43,20 @@ export function CustomWorldWorkCard({
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const copyPublicWorkCode = () => {
if (!item.publicWorkCode) {
return;
}
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
return (
<div
role="button"
@@ -127,15 +148,41 @@ export function CustomWorldWorkCard({
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
<div className="min-w-0 space-y-2">
{item.publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyPublicWorkCode();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
aria-label={`复制作品号 ${item.publicWorkCode}`}
title="复制作品号"
>
{metric.label}
</span>
))}
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
>
{metric.label}
</span>
))}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (

View File

@@ -1,6 +1,9 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
export type CreationWorkShelfStatus = 'draft' | 'published';
@@ -44,6 +47,7 @@ export type CreationWorkShelfItem = {
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
publicWorkCode: string | null;
typeLabel: string;
openActionLabel: string;
canExperience: boolean;
@@ -55,6 +59,7 @@ export type CreationWorkShelfItem = {
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems: BigFishWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
canDeleteRpg?: boolean;
@@ -63,6 +68,7 @@ export function buildCreationWorkShelfItems(params: {
}) {
const {
rpgItems,
rpgLibraryEntries = [],
bigFishItems,
puzzleItems,
canDeleteRpg = false,
@@ -71,11 +77,15 @@ export function buildCreationWorkShelfItems(params: {
} = params;
return [
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
...rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
),
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
),
].sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
@@ -85,8 +95,12 @@ export function buildCreationWorkShelfItems(params: {
function mapRpgWorkToShelfItem(
item: CustomWorldWorkSummary,
canDelete: boolean,
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
): CreationWorkShelfItem {
const isDraft = item.status === 'draft';
const libraryEntry = item.profileId
? libraryEntries.find((entry) => entry.profileId === item.profileId)
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },
@@ -134,6 +148,10 @@ function mapRpgWorkToShelfItem(
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
publicWorkCode:
item.status === 'published'
? (libraryEntry?.publicWorkCode ?? null)
: null,
typeLabel: 'RPG',
openActionLabel: isDraft
? item.playableNpcCount > 0 || item.landmarkCount > 0
@@ -163,6 +181,7 @@ function mapBigFishWorkToShelfItem(
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
typeLabel: '大鱼',
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canExperience: item.status === 'published',
@@ -212,8 +231,11 @@ function mapPuzzleWorkToShelfItem(
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode:
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
typeLabel: '拼图',
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
openActionLabel:
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
canExperience: status === 'published',
canDelete,
badges: [
@@ -233,7 +255,9 @@ function mapPuzzleWorkToShelfItem(
};
}
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {
return {
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',

View File

@@ -9,12 +9,15 @@ import {
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
import {
CHARACTER_COMBAT_HP_TOP_PX,
ENTITY_CONTAINER_REM,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMirroredStageEntityLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
MONSTER_COMBAT_HP_TOP_PX,
} from './GameCanvasShared';
function createCharacter(): Character {
@@ -141,23 +144,32 @@ describe('GameCanvasEntityLayer', () => {
const character = createCharacter();
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
.toBe('calc(18% + 68px - 132px)');
.toBe('calc(18% + 68px - 78px)');
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
.toBe(-64);
.toBe(-10);
});
it('lowers scene npc custom visuals even without character ids', () => {
const sceneNpcEncounter = createEncounter({
visual: {
species: 'aquatic',
body: '章鱼形态',
attire: '深海服饰',
palette: '蓝紫',
signature: '触腕',
race: 'elf',
bodyColor: 'blue',
headIndex: 0,
hairColorIndex: 1,
hairStyleFrame: 2,
facialHairEnabled: false,
facialHairColorIndex: 0,
facialHairStyleFrame: 0,
},
});
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-132);
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-78);
});
it('keeps combat hp bars above character and monster silhouettes', () => {
expect(getNpcCombatHpTop('hero', null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
expect(getNpcCombatHpTop(null, 'monster-20')).toBe(MONSTER_COMBAT_HP_TOP_PX);
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
});
it('renders affinity effect on the matching hostile npc', () => {

View File

@@ -21,6 +21,7 @@ import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
CHARACTER_COMBAT_HP_TOP_PX,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
@@ -172,7 +173,10 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
>
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
@@ -214,7 +218,10 @@ export function GameCanvasEntityLayer({
>
<div className="relative">
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
>
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
</div>
)}

View File

@@ -72,14 +72,14 @@ export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-cent
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
export const GENERIC_NPC_SCENE_SCALE = 1.72;
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 132;
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 78;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
objectPosition: 'center bottom',
};
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
export const CHARACTER_COMBAT_HP_TOP_PX = -48;
export const MONSTER_COMBAT_HP_TOP_PX = -44;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = -48;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export type HostileNpcSceneAnchorConfig = {
@@ -226,8 +226,8 @@ export function getHostileNpcSceneBottomOffsetPx(
}
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
if (monsterPresetId) return DEFAULT_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_NPC_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
if (monsterPresetId) return MONSTER_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
}
export function getSceneEntityZIndex(bottomOffsetPx: number) {

View File

@@ -1,5 +1,6 @@
import { ArrowRight, X } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import { UnifiedModal } from '../common/UnifiedModal';
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
export interface PlatformEntryCreationTypeModalProps {
@@ -79,58 +80,40 @@ export function PlatformEntryCreationTypeModal({
}
return (
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
<UnifiedModal
open={isOpen}
title="选择创作类型"
description="先选玩法类型,再进入对应创作工作台。"
onClose={onClose}
closeDisabled={isBusy}
size="lg"
>
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</UnifiedModal>
);
}

View File

@@ -67,7 +67,10 @@ import {
getPuzzleAgentSession,
streamPuzzleAgentMessage,
} from '../../services/puzzle-agent';
import { getPuzzleGalleryDetail, listPuzzleGallery } from '../../services/puzzle-gallery';
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
@@ -75,6 +78,7 @@ import {
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
@@ -97,7 +101,10 @@ import {
} from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import { PlatformEntryHomeView } from './PlatformEntryHomeView';
import {
PlatformEntryHomeView,
type PlatformHomeTab,
} from './PlatformEntryHomeView';
import {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
@@ -115,6 +122,10 @@ type AgentResultPublishGateView = {
publishReady: boolean;
};
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
type AgentResultBlockerView = {
code?: string;
message: string;
@@ -269,8 +280,7 @@ function buildAgentResultPublishGateView(
const blockers = fallbackBlockers
.filter(
(entry) =>
!isAgentResultStructuralBlockerResolved(profile, entry.code),
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
)
.map((entry) => entry.message);
@@ -362,6 +372,8 @@ export function PlatformEntryFlowShellImpl({
>([]);
const [selectedPuzzleDetail, setSelectedPuzzleDetail] =
useState<PuzzleWorkSummary | null>(null);
const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] =
useState<PuzzleDetailReturnTarget | null>(null);
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleGenerationState, setPuzzleGenerationState] =
@@ -369,12 +381,15 @@ export function PlatformEntryFlowShellImpl({
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
const [publicSearchError, setPublicSearchError] = useState<string | null>(
null,
);
const [searchedPublicUser, setSearchedPublicUser] =
useState<PublicUserSummary | null>(null);
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const hadReadableProtectedDataRef = useRef(false);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId,
);
@@ -492,9 +507,11 @@ export function PlatformEntryFlowShellImpl({
agentSession: sessionController.agentSession,
handleCustomWorldSelect,
executePublishWorld: async () => {
const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
});
const latestSession = await autosaveCoordinator.executeAgentActionAndWait(
{
action: 'publish_world',
},
);
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
await Promise.allSettled([
platformBootstrap.refreshPublishedGallery(),
@@ -552,18 +569,15 @@ export function PlatformEntryFlowShellImpl({
return '服务端预览';
}, [agentResultPreview]);
const featuredGalleryEntries = useMemo(
() => {
const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries,
).slice(0, 6);
},
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
);
const featuredGalleryEntries = useMemo(() => {
const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries,
).slice(0, 6);
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
const latestGalleryEntries = useMemo(
() =>
mergePlatformPublicGalleryEntries(
@@ -609,86 +623,6 @@ export function PlatformEntryFlowShellImpl({
[authUi],
);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return;
}
setIsSearchingPublicCode(true);
setPublicSearchError(null);
setSearchedPublicUser(null);
const upperKeyword = normalizedKeyword.toUpperCase();
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(normalizedKeyword);
const shouldSearchWorkFirst =
!shouldSearchUserIdFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst =
shouldSearchUserIdFirst || upperKeyword.startsWith('SY') || !shouldSearchWorkFirst;
const tryOpenGalleryEntry = async () => {
const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
await detailNavigation.openGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: 'published',
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
} satisfies CustomWorldGalleryCard);
};
try {
if (shouldSearchUserIdFirst) {
const user = await getPublicAuthUserById(normalizedKeyword);
setSearchedPublicUser(user);
return;
}
if (shouldSearchWorkFirst) {
try {
await tryOpenGalleryEntry();
return;
} catch {}
}
if (shouldSearchUserFirst) {
try {
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
return;
} catch {}
}
if (!shouldSearchWorkFirst) {
await tryOpenGalleryEntry();
return;
}
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
} catch (error) {
setPublicSearchError(
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
);
} finally {
setIsSearchingPublicCode(false);
}
},
[detailNavigation],
);
const prepareCreationLaunch = useCallback(() => {
if (sessionController.isCreatingAgentSession) {
return false;
@@ -749,9 +683,7 @@ export function PlatformEntryFlowShellImpl({
return galleryResponse.items;
} catch (error) {
setPuzzleGalleryEntries([]);
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
return [];
}
}, [resolvePuzzleErrorMessage]);
@@ -836,7 +768,10 @@ export function PlatformEntryFlowShellImpl({
{ session: PuzzleAgentSessionSnapshot },
SendPuzzleAgentMessageRequest,
PuzzleAgentActionRequest,
{ operation: PuzzleAgentOperationRecord; session: PuzzleAgentSessionSnapshot }
{
operation: PuzzleAgentOperationRecord;
session: PuzzleAgentSessionSnapshot;
}
>({
client: {
createSession: createPuzzleAgentSession,
@@ -936,7 +871,13 @@ export function PlatformEntryFlowShellImpl({
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
const resetRpgSessionViewState = sessionController.resetSessionViewState;
const setRpgGeneratedCustomWorldProfile =
sessionController.setGeneratedCustomWorldProfile;
const setRpgCustomWorldError = sessionController.setCustomWorldError;
const persistRpgAgentUiState = sessionController.persistAgentUiState;
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const openBigFishAgentWorkspace = useCallback(async () => {
setBigFishRun(null);
await bigFishFlow.openWorkspace();
@@ -948,6 +889,61 @@ export function PlatformEntryFlowShellImpl({
await puzzleFlow.openWorkspace();
}, [puzzleFlow]);
useEffect(() => {
if (platformBootstrap.canReadProtectedData) {
hadReadableProtectedDataRef.current = true;
return;
}
if (authUi?.user || !hadReadableProtectedDataRef.current) {
return;
}
hadReadableProtectedDataRef.current = false;
// 创作中心只展示当前登录用户的私有作品。
// 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。
setShowCreationTypeModal(false);
setSelectedDetailEntry(null);
setBigFishWorks([]);
setBigFishRun(null);
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
setPuzzleWorks([]);
setSelectedPuzzleDetail(null);
setPuzzleRun(null);
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null);
setDeletingCreationWorkId(null);
resetRpgSessionViewState();
setRpgGeneratedCustomWorldProfile(null);
setRpgCustomWorldError(null);
persistRpgAgentUiState(null, null);
resetAutoSaveTrackingToIdle();
if (
selectionStage !== 'platform' &&
selectionStage !== 'detail' &&
selectionStage !== 'puzzle-gallery-detail'
) {
setSelectionStage('platform');
}
}, [
authUi?.user,
platformBootstrap.canReadProtectedData,
persistRpgAgentUiState,
resetAutoSaveTrackingToIdle,
resetRpgSessionViewState,
selectionStage,
setBigFishError,
setPuzzleError,
setRpgCustomWorldError,
setRpgGeneratedCustomWorldProfile,
setSelectionStage,
]);
const handleCreationHubCreateType = useCallback(
(type: PlatformCreationTypeId) => {
if (type === 'airp' || type === 'visual-novel') {
@@ -1105,11 +1101,7 @@ export function PlatformEntryFlowShellImpl({
);
const dragPuzzlePiece = useCallback(
(payload: {
pieceId: string;
targetRow: number;
targetCol: number;
}) => {
(payload: { pieceId: string; targetRow: number; targetCol: number }) => {
if (!puzzleRun || isPuzzleBusy) {
return;
}
@@ -1138,7 +1130,9 @@ export function PlatformEntryFlowShellImpl({
const { run } = await advanceLocalPuzzleNextLevel({
run: puzzleRun,
sourceSessionId:
selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null,
selectedPuzzleDetail?.sourceSessionId ??
puzzleSession?.sessionId ??
null,
});
setPuzzleRun(run);
} catch (error) {
@@ -1233,7 +1227,9 @@ export function PlatformEntryFlowShellImpl({
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry) {
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
platformBootstrap.setPlatformError(
'未找到可体验的作品,请刷新后重试。',
);
return;
}
@@ -1302,10 +1298,14 @@ export function PlatformEntryFlowShellImpl({
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
? deleteRpgEntryWorldProfile(work.profileId).then(
async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap
.refreshCustomWorldWorks()
.catch(() => []);
},
)
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
@@ -1386,7 +1386,9 @@ export function PlatformEntryFlowShellImpl({
void refreshPuzzleGallery();
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
setPuzzleError(
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
@@ -1402,14 +1404,19 @@ export function PlatformEntryFlowShellImpl({
);
const openPuzzleDetail = useCallback(
async (profileId: string) => {
async (
profileId: string,
returnTarget: PuzzleDetailReturnTarget = {
tab: platformBootstrap.platformTab,
},
) => {
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
const { item } = await getPuzzleGalleryDetail(profileId);
setSelectedPuzzleDetail(item);
enterCreateTab();
setPuzzleDetailReturnTarget(returnTarget);
setSelectionStage('puzzle-gallery-detail');
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
@@ -1417,7 +1424,11 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleBusy(false);
}
},
[enterCreateTab, resolvePuzzleErrorMessage, setSelectionStage],
[
platformBootstrap.platformTab,
resolvePuzzleErrorMessage,
setSelectionStage,
],
);
const openPuzzleDraft = useCallback(
@@ -1425,12 +1436,139 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(null);
setPuzzleRun(null);
setSelectedPuzzleDetail(null);
const restoredSession = await puzzleFlow.restoreDraft(item.sourceSessionId);
if (!item.sourceSessionId?.trim()) {
if (item.publicationStatus === 'published') {
await openPuzzleDetail(item.profileId, { tab: 'create' });
return;
}
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
return;
}
const restoredSession = await puzzleFlow.restoreDraft(
item.sourceSessionId,
);
if (!restoredSession) {
await refreshPuzzleShelf().catch(() => undefined);
}
},
[puzzleFlow, refreshPuzzleShelf],
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return;
}
setIsSearchingPublicCode(true);
setPublicSearchError(null);
setSearchedPublicUser(null);
const upperKeyword = normalizedKeyword.toUpperCase();
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
normalizedKeyword,
);
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
const shouldSearchWorkFirst =
!shouldSearchUserIdFirst &&
!shouldSearchPuzzleFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst =
shouldSearchUserIdFirst ||
upperKeyword.startsWith('SY') ||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
const tryOpenGalleryEntry = async () => {
const entry =
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
await detailNavigation.openGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: 'published',
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
} satisfies CustomWorldGalleryCard);
};
const tryOpenPuzzleGalleryEntry = async () => {
const entries =
puzzleGalleryEntries.length > 0
? puzzleGalleryEntries
: await refreshPuzzleGallery();
const matchedEntry = entries.find((entry) =>
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
);
if (!matchedEntry) {
throw new Error('未找到拼图作品。');
}
await openPuzzleDetail(matchedEntry.profileId, {
tab: platformBootstrap.platformTab,
});
};
try {
if (shouldSearchUserIdFirst) {
const user = await getPublicAuthUserById(normalizedKeyword);
setSearchedPublicUser(user);
return;
}
if (shouldSearchPuzzleFirst) {
await tryOpenPuzzleGalleryEntry();
return;
}
if (shouldSearchWorkFirst) {
try {
await tryOpenGalleryEntry();
return;
} catch {}
}
if (shouldSearchUserFirst) {
try {
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
return;
} catch {}
}
if (!shouldSearchWorkFirst) {
await tryOpenGalleryEntry();
return;
}
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
} catch (error) {
setPublicSearchError(
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
);
} finally {
setIsSearchingPublicCode(false);
}
},
[
detailNavigation,
openPuzzleDetail,
platformBootstrap.platformTab,
puzzleGalleryEntries,
refreshPuzzleGallery,
],
);
const openBigFishDraft = useCallback(
@@ -1572,6 +1710,7 @@ export function PlatformEntryFlowShellImpl({
onExperienceRpg={(item) => {
handleExperienceRpgWork(item);
}}
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
bigFishItems={bigFishWorks}
onOpenBigFishDetail={(item) => {
runProtectedAction(() => {
@@ -1589,11 +1728,7 @@ export function PlatformEntryFlowShellImpl({
puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(item) => {
runProtectedAction(() => {
if (item.publicationStatus === 'draft') {
void openPuzzleDraft(item);
return;
}
void openPuzzleDetail(item.profileId);
void openPuzzleDraft(item);
});
}}
onExperiencePuzzle={(profileId) => {
@@ -1653,7 +1788,9 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
if (isPuzzleGalleryEntry(entry)) {
void openPuzzleDetail(entry.profileId);
void openPuzzleDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
@@ -1834,13 +1971,17 @@ export function PlatformEntryFlowShellImpl({
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />}
fallback={
<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />
}
>
<CustomWorldGenerationView
settingText={
bigFishSession?.lastAssistantReply ?? '正在整理当前玩法草稿。'
}
anchorEntries={buildBigFishGenerationAnchorEntries(bigFishSession)}
anchorEntries={buildBigFishGenerationAnchorEntries(
bigFishSession,
)}
progress={buildMiniGameDraftGenerationProgress(
bigFishGenerationState,
)}
@@ -1851,7 +1992,9 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('big-fish-agent-workspace');
}}
onRetry={() => {
void executeBigFishAction({ action: 'big_fish_compile_draft' });
void executeBigFishAction({
action: 'big_fish_compile_draft',
});
}}
onInterrupt={undefined}
backLabel="返回创作中心"
@@ -1966,7 +2109,9 @@ export function PlatformEntryFlowShellImpl({
settingText={
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
}
anchorEntries={buildPuzzleGenerationAnchorEntries(puzzleSession)}
anchorEntries={buildPuzzleGenerationAnchorEntries(
puzzleSession,
)}
progress={buildMiniGameDraftGenerationProgress(
puzzleGenerationState,
)}
@@ -2030,9 +2175,22 @@ export function PlatformEntryFlowShellImpl({
isBusy={isPuzzleBusy}
error={puzzleError}
onBack={() => {
enterCreateTab();
platformBootstrap.setPlatformTab(
puzzleDetailReturnTarget?.tab ?? 'home',
);
setPuzzleDetailReturnTarget(null);
setSelectionStage('platform');
}}
onEdit={
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
? () => {
runProtectedAction(() => {
void openPuzzleDraft(selectedPuzzleDetail);
});
}
: null
}
onStartGame={() => {
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
}}
@@ -2211,15 +2369,17 @@ export function PlatformEntryFlowShellImpl({
? 'generate_landmarks'
: 'generate_characters';
const latestSession =
await autosaveCoordinator.executeAgentActionAndWait({
action,
count: 1,
...(kind === 'playable'
? { roleType: 'playable' as const }
: kind === 'story'
? { roleType: 'story' as const }
: {}),
});
await autosaveCoordinator.executeAgentActionAndWait(
{
action,
count: 1,
...(kind === 'playable'
? { roleType: 'playable' as const }
: kind === 'story'
? { roleType: 'story' as const }
: {}),
},
);
const latestProfile = latestSession
? rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession,

View File

@@ -0,0 +1,98 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const originalClipboard = navigator.clipboard;
const detailItem = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '拼图玩家',
levelName: '奇幻拼图',
summary: '一张用于公开分享的拼图作品。',
themeTags: ['奇幻'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 7,
publishReady: true,
} satisfies PuzzleWorkSummary;
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
test('shows and copies puzzle public work code in detail view', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
render(
<PuzzleGalleryDetailView
item={detailItem}
onBack={vi.fn()}
onStartGame={vi.fn()}
/>,
);
expect(screen.getByText('作品号')).toBeTruthy();
expect(screen.getByText('PZ-EPUBLIC1')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
);
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
});
test('falls back to legacy selection copy when clipboard api rejects', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => {
throw new Error('clipboard denied');
});
const execCommand = vi.fn(() => true);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
});
render(
<PuzzleGalleryDetailView
item={detailItem}
onBack={vi.fn()}
onStartGame={vi.fn()}
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
);
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
expect(execCommand).toHaveBeenCalledWith('copy');
expect(await screen.findByText('已复制')).toBeTruthy();
});

View File

@@ -1,6 +1,9 @@
import { ArrowLeft, Play, UserRound } from 'lucide-react';
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
import { useState } from 'react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { copyTextToClipboard } from '../../services/clipboard';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleGalleryDetailViewProps = {
@@ -8,6 +11,7 @@ type PuzzleGalleryDetailViewProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onEdit?: (() => void) | null;
onStartGame: () => void;
};
@@ -20,8 +24,20 @@ export function PuzzleGalleryDetailView({
isBusy = false,
error = null,
onBack,
onEdit = null,
onStartGame,
}: PuzzleGalleryDetailViewProps) {
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const copyPublicWorkCode = () => {
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
@@ -29,19 +45,33 @@ export function PuzzleGalleryDetailView({
<button
type="button"
onClick={onBack}
aria-label="返回"
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={onStartGame}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<Play className="h-4 w-4" />
1
</button>
<div className="flex flex-wrap justify-end gap-2">
{onEdit ? (
<button
type="button"
disabled={isBusy}
onClick={onEdit}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Pencil className="h-4 w-4" />
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={onStartGame}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<Play className="h-4 w-4" />
1
</button>
</div>
</div>
<div className="mt-6">
@@ -54,6 +84,22 @@ export function PuzzleGalleryDetailView({
{item.authorDisplayName}
</span>
<span>{item.playCount} </span>
<button
type="button"
onClick={copyPublicWorkCode}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/14 bg-white/10 px-3 py-1 text-sm text-amber-50/86"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{publicWorkCode}</span>
<Copy className="h-3.5 w-3.5 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0 text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
</div>
</div>
</div>

View File

@@ -25,14 +25,14 @@ import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePro
import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
import {
CORE_ACTIONS,
type CustomWorldAiActionConfig,
type EditableCustomWorldRole,
} from './roleAssetStudioModel';
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
import { useRoleAnimationWorkflow } from './useRoleAnimationWorkflow';
import { useRoleVisualCandidateWorkflow } from './useRoleVisualCandidateWorkflow';
@@ -867,7 +867,7 @@ export function RpgCreationRoleAssetStudioModal({
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 积分\n${params.description}`,
`${params.kindLabel}预计消耗 ${params.points} 叙世币\n${params.description}`,
);
};

View File

@@ -47,6 +47,7 @@ import {
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { listPuzzleWorks } from '../../services/puzzle-works';
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { GameState } from '../../types';
import {
AuthUiContext,
@@ -128,6 +129,10 @@ vi.mock('../../services/puzzle-gallery', () => ({
listPuzzleGallery: vi.fn(),
}));
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
}));
vi.mock('../../services/big-fish-creation', () => ({
createBigFishCreationSession: vi.fn(),
executeBigFishCreationAction: vi.fn(),
@@ -548,6 +553,9 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
new Error('未找到公开作品'),
);
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
@@ -1020,9 +1028,7 @@ test('opening RPG agent workspace does not refetch session snapshot in a render
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
});
test(
'create tab opens compiled agent draft in result refinement page',
async () => {
test('create tab opens compiled agent draft in result refinement page', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
@@ -1075,9 +1081,7 @@ test(
screen.queryByText('Agent工作区custom-world-agent-session-1'),
).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
10000,
);
}, 10000);
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
@@ -1274,6 +1278,101 @@ test('clicking a public work while logged out routes through requireAuth', async
expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled();
});
test('creation hub clears all private work shelves immediately after logout state', async () => {
const user = userEvent.setup();
const loggedInAuth = createAuthValue();
const loggedOutAuth = createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:rpg-logout-cache-1',
sourceType: 'agent_session',
status: 'draft',
title: 'RPG 退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条 RPG 私有作品只能在登录态展示。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'rpg-logout-cache-session',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(listBigFishWorks).mockResolvedValue({
items: [
{
workId: 'big-fish-logout-cache-1',
sourceSessionId: 'big-fish-logout-cache-session',
title: '大鱼退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条大鱼私有作品只能在登录态展示。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-25T10:05:00.000Z',
publishReady: false,
levelCount: 8,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
},
],
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-logout-cache-1',
profileId: 'puzzle-logout-cache-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-logout-cache-session',
authorDisplayName: '测试玩家',
levelName: '拼图退出缓存作品',
summary: '这条拼图私有作品只能在登录态展示。',
themeTags: ['退出态'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-04-25T10:10:00.000Z',
publishedAt: null,
playCount: 0,
publishReady: false,
},
],
});
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
await openCreationHub(user);
const createPanel = getPlatformTabPanel('create');
expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy();
expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy();
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
rerender(<TestWrapper authValue={loggedOutAuth} />);
await waitFor(() => {
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
});
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
});
test('published puzzle works appear on home and category public shelves', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
@@ -1310,12 +1409,72 @@ test('published puzzle works appear on home and category public shelves', async
await user.click(screen.getByRole('button', { name: '分类' }));
const categoryPanel = getPlatformTabPanel('category');
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
0,
);
expect(
within(categoryPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
});
test('published puzzle detail returns to the source platform tab', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(<TestWrapper withAuth />);
await user.click(await screen.findByRole('button', { name: '分类' }));
await waitFor(() => {
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
});
const categoryPanel = getPlatformTabPanel('category');
expect(
within(categoryPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
await user.click(
within(categoryPanel).getByRole('button', {
name: /.*/u,
}),
);
expect(
await screen.findByRole('button', { name: '进入第 1 关' }),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
const returnedCategoryPanel = getPlatformTabPanel('category');
expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(returnedCategoryPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
});
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -1375,8 +1534,9 @@ test('restoring an agent workspace ignores a stored session owned by another use
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'))
.toBeNull();
expect(
window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'),
).toBeNull();
});
expect(getRpgCreationSession).not.toHaveBeenCalled();
@@ -1511,6 +1671,93 @@ test('puzzle draft card restores the bound agent session and opens the result vi
expect(screen.queryByText('拼图玩法共创')).toBeNull();
});
test('published puzzle work card restores its source session for editing', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试玩家',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
publishReady: true,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
});
expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith(
'puzzle-profile-session-1',
);
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
});
test('public code search opens a published puzzle by PZ code', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
publishReady: true,
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
render(<TestWrapper withAuth />);
const searchInput =
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(
'puzzle-profile-public-1',
);
});
expect(await screen.findByText('进入第 1 关')).toBeTruthy();
expect(screen.getByText('雨夜猫塔')).toBeTruthy();
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('big fish draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup();
@@ -1557,11 +1804,11 @@ test('big fish draft card restores the bound agent session and opens the result
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('大鱼吃小鱼工作区big-fish-session-1')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(
screen.getByText('我想做机械深海里微生物互相吞并进化。'),
await screen.findByText('大鱼吃小鱼工作区big-fish-session-1'),
).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
@@ -1760,7 +2007,9 @@ test('agent result view shows publish blocker dialog before publish action when
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'publish_world',
).length;
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
expect(publishWorldCallCountAfterClick).toBe(
publishWorldCallCountBeforeClick,
);
});
test('agent draft result publishes to gallery from publish panel', async () => {
@@ -1796,6 +2045,9 @@ test('agent draft result publishes to gallery from publish panel', async () => {
} satisfies CustomWorldAgentSessionSnapshot;
let hasPublishedWorld = false;
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: publishReadyDraftSession,
});
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
operationId: 'operation-publish-world-1',
type: 'publish_world',
@@ -1850,9 +2102,13 @@ test('agent draft result publishes to gallery from publish panel', async () => {
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: '发布',
});
const actionButton = await screen.findByRole(
'button',
{
name: '发布',
},
{ timeout: 5000 },
);
await user.click(actionButton);
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
@@ -2425,8 +2681,8 @@ test('agent draft result can open from server result preview without embedded le
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
expect(
screen.getByText('结果页改为优先消费 session.resultPreview'),
).toBeTruthy();
screen.getAllByText('结果页改为优先消费 session.resultPreview').length,
).toBeGreaterThan(0);
},
{ timeout: 2500 },
);
@@ -2756,9 +3012,7 @@ test('creation hub published work experience button enters world directly', asyn
},
]);
render(
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: '体验' }));

View File

@@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
import { RpgEntryHomeView } from './RpgEntryHomeView';
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileRechargeCenter: vi.fn(async () => ({
@@ -19,15 +20,15 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
},
pointProducts: [
{
productId: 'points_10',
title: '10积分',
priceCents: 100,
productId: 'points_60',
title: '60叙世币',
priceCents: 600,
kind: 'points',
pointsAmount: 10,
bonusPoints: 19,
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充送积分',
description: '首充送19积分',
badgeLabel: '首充双倍',
description: '首充送60叙世币',
tier: 'normal',
},
],
@@ -47,7 +48,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
],
benefits: [
{
benefitName: '免积分回合数',
benefitName: '免叙世币回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
@@ -60,19 +61,19 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
createRpgProfileRechargeOrder: vi.fn(async () => ({
order: {
orderId: 'order-1',
productId: 'points_10',
productTitle: '10积分',
productId: 'points_60',
productTitle: '60叙世币',
kind: 'points',
amountCents: 100,
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 29,
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 29,
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
@@ -93,6 +94,41 @@ vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const originalMatchMedia = window.matchMedia;
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
publicWorkCode: 'PZ-EPUBLIC1',
ownerUserId: 'user-2',
authorDisplayName: '拼图玩家',
worldName: '奇幻拼图',
subtitle: '拼图关卡',
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
visibility: 'published',
publishedAt: '1777110165.990127Z',
updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(min-width: 1024px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderProfileView(onRechargeSuccess = vi.fn()) {
return render(
<AuthUiContext.Provider
@@ -157,8 +193,77 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
);
}
function renderLoggedOutHomeView(
openLoginModal = vi.fn(),
overrides: Partial<
Pick<
RpgEntryHomeViewProps,
| 'featuredEntries'
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
>
> = {},
) {
return render(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal,
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={overrides.featuredEntries ?? []}
latestEntries={overrides.latestEntries ?? []}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
</AuthUiContext.Provider>,
);
}
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
});
test('opens recharge modal and submits points product', async () => {
@@ -169,9 +274,106 @@ test('opens recharge modal and submits points product', async () => {
await user.click(screen.getByText('会员充值'));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(await screen.findByText('10积分')).toBeTruthy();
expect(await screen.findByText('60叙世币')).toBeTruthy();
await user.click(screen.getByText('首充送19积分'));
await user.click(screen.getByText('首充送60叙世币'));
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
});
test('shows a reachable login entry in logged out mobile shell', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(openLoginModal);
await user.click(screen.getByRole('button', { name: '登录' }));
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
test('mobile home search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
render(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={onSearchPublicCode}
/>
</AuthUiContext.Provider>,
);
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});
test('public gallery cards hide work code until detail is opened', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
onOpenGalleryDetail,
});
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
.toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('desktop trending list shows kind instead of work code or timestamp text', () => {
mockDesktopLayout();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
});
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});

View File

@@ -10,6 +10,7 @@ import {
Copy,
Crown,
House,
LogIn,
MessageCircle,
Pencil,
Search,
@@ -33,9 +34,9 @@ import type {
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileReferralInviteCenterResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
RedeemProfileReferralInviteCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
@@ -43,8 +44,8 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
import type { AuthUser } from '../../services/authService';
import {
createRpgProfileRechargeOrder,
getRpgProfileReferralInviteCenter,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
redeemRpgProfileReferralInviteCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -195,6 +196,48 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
);
}
function PublicCodeSearchBar({
value,
onChange,
onSubmit,
isSearching,
className,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isSearching: boolean;
className?: string;
}) {
return (
<div
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
>
<Search className="h-4 w-4 shrink-0" />
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
placeholder="输入 SY / CW / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
onClick={onSubmit}
disabled={!value.trim() || isSearching}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
>
{isSearching ? '搜索中' : '搜索'}
</button>
</div>
);
}
function EmptyShelf({ text }: { text: string }) {
return (
<div
@@ -285,7 +328,9 @@ function WorldCard({
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm">{badge}</span>
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
{badge}
</span>
<span className="platform-pill platform-pill--neutral px-2.5">
{metaLabel}
</span>
@@ -598,7 +643,9 @@ function DesktopTrendingItem({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
<span className="truncate">
{describePublicGalleryCardKind(entry)}
</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
@@ -858,6 +905,14 @@ function formatRechargePrice(priceCents: number) {
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
}
function formatMembershipDuration(days: number) {
if (days >= 365) {
return '365天';
}
return `${days}`;
}
function AccountRechargeModal({
center,
activeTab,
@@ -883,36 +938,48 @@ function AccountRechargeModal({
: (center?.membershipProducts ?? []);
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[28rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭账户充值"
>
×
</button>
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-5 pb-5 pt-4">
<div className="text-center text-2xl font-black"></div>
<div className="mt-4 grid grid-cols-2 rounded-xl bg-zinc-100 p-1">
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
WALLET
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
<button
type="button"
onClick={() => onTabChange('points')}
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'points'
? 'bg-white text-[#ff4056] shadow'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
</button>
<button
type="button"
onClick={() => onTabChange('membership')}
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'membership'
? 'bg-white text-[#ff4056] shadow'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
@@ -945,20 +1012,14 @@ function AccountRechargeModal({
key={product.productId}
disabled={Boolean(isSubmitting)}
onClick={() => onSelectProduct(product)}
className="relative min-h-[8.45rem] overflow-hidden rounded-xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
>
<div
className={`h-8 px-2 py-1.5 text-xs font-black text-white ${
product.productId === 'points_60'
? 'bg-zinc-500'
: 'bg-[#ff4056]'
}`}
>
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
{product.badgeLabel}
</div>
<div className="px-2 py-3">
<div className="text-xl font-black">
{product.pointsAmount}
{product.pointsAmount}
</div>
<div className="mt-1 text-xs text-zinc-500">
{formatRechargePrice(product.priceCents)}
@@ -975,46 +1036,66 @@ function AccountRechargeModal({
</div>
) : (
<>
<div className="mt-5 grid grid-cols-3 gap-3">
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{visibleProducts.map((product) => (
<button
type="button"
key={product.productId}
disabled={Boolean(isSubmitting)}
onClick={() => onSelectProduct(product)}
className="min-h-[6rem] rounded-xl border border-zinc-200 bg-zinc-50 px-2 py-4 text-center transition hover:border-[#ff4056] disabled:opacity-70"
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
>
<div className="text-lg font-black">{product.title}</div>
<div className="mt-2 text-xl font-black text-[#ff4056]">
{formatRechargePrice(product.priceCents)}
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
<div className="relative">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-black">
{product.title}
</div>
<div className="mt-1 text-xs font-bold text-zinc-500">
{formatMembershipDuration(product.durationDays)}
</div>
</div>
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
</div>
<div className="mt-4 text-2xl font-black text-[#ff4056]">
{formatRechargePrice(product.priceCents)}
</div>
<div className="mt-2 text-xs font-semibold text-zinc-500">
{isSubmitting === product.productId
? '处理中'
: product.description}
</div>
</div>
</button>
))}
</div>
<div className="mt-5 overflow-hidden rounded-xl border border-zinc-200">
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
</div>
<div className="grid grid-cols-5 text-center text-sm">
{center?.benefits.map((benefit) => (
<div key={benefit.benefitName} className="contents">
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
{benefit.benefitName}
<div className="overflow-x-auto">
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
{center?.benefits.map((benefit) => (
<div key={benefit.benefitName} className="contents">
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
{benefit.benefitName}
</div>
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
{benefit.normalValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
{benefit.monthValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
{benefit.seasonValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
{benefit.yearValue}
</div>
</div>
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
{benefit.normalValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
{benefit.monthValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
{benefit.seasonValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
{benefit.yearValue}
</div>
</div>
))}
))}
</div>
</div>
</div>
</>
@@ -1202,6 +1283,7 @@ export function RpgEntryHomeView({
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
'points',
@@ -1368,7 +1450,7 @@ export function RpgEntryHomeView({
setReferralCenter(response.center);
setInviteCodeInput('');
setReferralSuccess(
response.inviteeRewardGranted ? '已获得30积分' : '填写成功',
response.inviteeRewardGranted ? '已获得30叙世币' : '填写成功',
);
void onRechargeSuccess?.();
})
@@ -1387,6 +1469,14 @@ export function RpgEntryHomeView({
void onSearchPublicCode(keyword);
};
const submitMobileSearch = () => {
const keyword = mobileSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
return;
}
void onSearchPublicCode(keyword);
};
const desktopHeroEntry =
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
const desktopHeroCover = desktopHeroEntry
@@ -1414,6 +1504,13 @@ export function RpgEntryHomeView({
const mobileHomeContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
onChange={setMobileSearchKeyword}
onSubmit={submitMobileSearch}
isSearching={isSearchingPublicCode}
/>
<button
type="button"
onClick={openLeadPublicEntry}
@@ -1485,7 +1582,7 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:latest`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
/>
@@ -2093,7 +2190,7 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[17rem] w-full min-w-0"
@@ -2125,8 +2222,18 @@ export function RpgEntryHomeView({
if (!isDesktopLayout) {
return (
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<div className="mb-3 shrink-0 px-0.5">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{!isAuthenticated ? (
<button
type="button"
onClick={openUserSurface}
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
>
<LogIn className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
@@ -2141,7 +2248,7 @@ export function RpgEntryHomeView({
}}
>
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : 'grid-cols-2'}`}
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{visibleTabs.map((tab) => (
<PlatformTabButton
@@ -2193,35 +2300,15 @@ export function RpgEntryHomeView({
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<RpgEntryBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
<Search className="h-4 w-4 shrink-0" />
<input
value={desktopSearchKeyword}
onChange={(event) =>
setDesktopSearchKeyword(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDesktopSearch();
}
}}
placeholder="输入 SY 或 CW 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
onClick={submitDesktopSearch}
disabled={
!desktopSearchKeyword.trim() ||
!onSearchPublicCode ||
isSearchingPublicCode
}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
>
{isSearchingPublicCode ? '搜索中' : '搜索'}
</button>
</div>
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={setDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={
!onSearchPublicCode || Boolean(isSearchingPublicCode)
}
className="max-w-[34rem] flex-1"
/>
</div>
<div className="flex items-center gap-3">
@@ -2249,10 +2336,10 @@ export function RpgEntryHomeView({
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '进入账户'}
{authUi?.user?.displayName || '登录'}
</span>
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '登录后同步作品与进度'}
{authUi?.user ? publicUserCode : '账号入口'}
</span>
</span>
</button>

View File

@@ -1,13 +1,16 @@
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Copy } from 'lucide-react';
import { useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { copyTextToClipboard } from '../../services/clipboard';
import type { CustomWorldProfile } from '../../types';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
formatPlatformWorldTime,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -67,6 +70,10 @@ export function RpgEntryWorldDetailView({
}: RpgEntryWorldDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const canStartGame = entry.visibility === 'published';
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
@@ -79,6 +86,16 @@ export function RpgEntryWorldDetailView({
.filter(Boolean),
),
].slice(0, 3);
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
return (
<div className="flex h-full min-h-0 flex-col">
@@ -89,7 +106,7 @@ export function RpgEntryWorldDetailView({
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-4 w-4" />
广
</button>
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
{entry.visibility === 'published' ? '已发布' : '草稿'}
@@ -128,6 +145,23 @@ export function RpgEntryWorldDetailView({
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
</span>
{publicWorkCode ? (
<button
type="button"
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
</div>
<div className="mt-4 text-3xl font-black text-white">
{entry.worldName}

View File

@@ -50,6 +50,7 @@ export function createFailedRpgEntryAgentOperation(params: {
phaseDetail: params.error,
progress: 0,
error: params.error,
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}

View File

@@ -5,6 +5,7 @@ import type {
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type PlatformWorldCardLike =
@@ -16,6 +17,7 @@ export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
@@ -51,6 +53,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
sourceType: 'puzzle',
workId: work.workId,
profileId: work.profileId,
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
worldName: work.levelName,
@@ -122,6 +125,16 @@ export function formatPlatformWorldTime(value: string | null) {
});
}
export function resolvePlatformPublicWorkCode(
entry: PlatformWorldCardLike,
): string | null {
if (isPuzzleGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}
export function describePlatformThemeLabel(
themeMode: CustomWorldGalleryCard['themeMode'],
) {

View File

@@ -32,6 +32,16 @@ function createTestCharacter(): Character {
range: 1,
style: 'steady',
},
{
id: 'skill-heavy',
name: 'Heavy Strike',
animation: AnimationState.SKILL1,
damage: 18,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style: 'burst',
},
],
adventureOpenings: {},
};
@@ -171,5 +181,92 @@ describe('buildBattlePlan', () => {
expect(plan.turns.length).toBeGreaterThan(0);
expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1);
});
});
it('uses runtimePayload skillId for local battle fallback skill resolution', () => {
const state = {
...createBaseState(),
playerMana: 20,
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 80,
maxHp: 80,
},
],
};
const option = {
...createBattleOption(),
functionId: 'battle_use_skill',
runtimePayload: { skillId: 'skill-heavy' },
};
const plan = buildBattlePlan({
state,
option,
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
expect(playerTurn).toEqual(
expect.objectContaining({
selectedSkillId: 'skill-heavy',
appliedCooldowns: expect.objectContaining({ 'skill-heavy': 2 }),
}),
);
});
it('does not turn recovery fallback into a random player attack', () => {
const state = {
...createBaseState(),
playerHp: 40,
playerMana: 3,
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 80,
maxHp: 80,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_recover_breath',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
});
});

View File

@@ -278,6 +278,29 @@ function tickSkillCooldowns(character: Character, cooldowns: Record<string, numb
);
}
function getRequestedSkillId(option: StoryOption) {
return typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: null;
}
function choosePlayerSkillForOption(
character: Character,
mana: number,
cooldowns: Record<string, number>,
option: StoryOption,
) {
const requestedSkillId = getRequestedSkillId(option);
if (requestedSkillId) {
const requestedSkill = character.skills.find(skill => skill.id === requestedSkillId) ?? null;
if (!requestedSkill) return null;
if ((cooldowns[requestedSkill.id] ?? 0) > 0 || mana < requestedSkill.manaCost) return null;
return requestedSkill;
}
return chooseWeightedSkill(character, mana, cooldowns, option);
}
export function getFacingForPlayer(playerX: number, monster: SceneHostileNpc | null) {
if (!monster) return 'right' as const;
return monster.xMeters >= playerX ? 'right' : 'left';
@@ -374,6 +397,7 @@ export function buildBattlePlan({
}
const functionEffect = getFunctionEffect(option.functionId);
const isRecoveryAction = option.functionId === 'battle_recover_breath';
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
const turnOrder = buildCombatTurnOrder(
@@ -430,7 +454,10 @@ export function buildBattlePlan({
playerSkillCooldowns: cooledDown,
};
const selectedSkill = chooseWeightedSkill(character, simulatedState.playerMana, cooledDown, normalizedOption);
// 后端单技能按钮通过 runtimePayload.skillId 指定技能,本地兜底也必须保持同一语义。
const selectedSkill = isRecoveryAction
? null
: choosePlayerSkillForOption(character, simulatedState.playerMana, cooledDown, normalizedOption);
if (!selectedSkill) {
continue;
}
@@ -794,4 +821,3 @@ export function buildBattlePlan({
},
};
}

View File

@@ -231,6 +231,7 @@ export function createStoryChoiceActions({
setGameState,
setCurrentStory: (story) => setCurrentStory(story),
buildFallbackStoryForState,
turnVisualMs,
});
return;
}

View File

@@ -727,7 +727,7 @@ describe('npcEncounterActions', () => {
expect.anything(),
expect.anything(),
[],
'【NPC 主动开场】',
'',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
@@ -1138,7 +1138,7 @@ describe('npcEncounterActions', () => {
expect.anything(),
expect.anything(),
expect.anything(),
'【NPC 主动开场】',
'',
expect.anything(),
expect.objectContaining({npcInitiatesConversation: true}),
);
@@ -1196,7 +1196,154 @@ describe('npcEncounterActions', () => {
).toBe(false);
});
it('lets the model terminate hostile chat and offers a continue option', async () => {
it('streams npc-initiated opening when negative affinity chat starts from interaction options', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '别靠太近。你先把断桥旧案那句话说完整,我再决定要不要听下去。',
suggestions: ['我先把旧案说清楚'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客仍挡在桥口。',
options: [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
},
});
expect(
actions.handleNpcInteraction(
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
),
).toBe(true);
await flushAsyncWork();
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ id: 'npc-rival' }),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'',
expect.objectContaining({
affinity: -8,
}),
expect.objectContaining({
npcInitiatesConversation: true,
chatDirective: expect.objectContaining({
limitReason: 'negative_affinity',
terminationMode: 'hostile_model',
isHostileChat: true,
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
limitReason: 'negative_affinity',
terminationMode: 'hostile_model',
isHostileChat: true,
turnCount: 0,
});
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '别靠太近。你先把断桥旧案那句话说完整,我再决定要不要听下去。',
},
]);
});
it('lets player exit hostile chat and offers fight or escape instead of continuing adventure', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '你想走也行。只是下次再见,别指望我还会听你解释。',
suggestions: [],
functionSuggestions: [],
chatDirective: {
forceExit: true,
closingMode: 'foreshadow_close',
terminationReason: 'player_exit',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 3,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
}),
currentStory: createLimitedPrimaryNpcChatStory(3),
});
expect(actions.exitNpcChat()).toBe(true);
await flushAsyncWork();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_fight',
actionText: '与他对战',
interaction: expect.objectContaining({
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃跑',
}),
]);
expect(lastStory.options).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'story_continue_adventure' }),
]),
);
expect(lastStory.deferredOptions).toBeUndefined();
});
it('lets the model terminate hostile chat and offers fight or escape', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
@@ -1265,10 +1412,15 @@ describe('npcEncounterActions', () => {
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续',
functionId: 'npc_fight',
actionText: '与他对战',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃跑',
}),
]);
expect(lastStory.deferredOptions).toBeUndefined();
expect(lastStory.dialogue?.at(-1)).toEqual(
expect.objectContaining({
speaker: 'npc',
@@ -1313,23 +1465,13 @@ describe('npcEncounterActions', () => {
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'story_continue_adventure',
functionId: 'npc_fight',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
]);
expect(lastStory.deferredOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
functionId: 'idle_travel_next_scene',
actionText: '向东走前往scene-east',
runtimePayload: { targetSceneId: 'scene-east' },
}),
expect.objectContaining({
functionId: 'idle_travel_next_scene',
actionText: '向南走前往scene-south',
runtimePayload: { targetSceneId: 'scene-south' },
}),
]),
);
expect(lastStory.deferredOptions).toBeUndefined();
});
it('prepares next scene act options when hostile chat ends before the final act', async () => {

View File

@@ -344,4 +344,82 @@ describe('storyChoiceRuntime', () => {
}),
);
});
it('plays server battle presentation before committing the hydrated snapshot', async () => {
const gameState = createState({
inBattle: true,
playerHp: 30,
playerMana: 10,
sceneHostileNpcs: [
{
id: 'wolf',
name: '山狼',
action: '逼近',
description: '山狼',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 18,
maxHp: 18,
},
],
});
const finalState = createState({
...gameState,
inBattle: false,
playerHp: 26,
sceneHostileNpcs: [],
});
const setGameState = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
response: {
presentation: {
battle: {
targetId: 'wolf',
damageDealt: 18,
damageTaken: 4,
outcome: 'victory',
},
resultText: '山狼被你压制下去。',
},
},
hydratedSnapshot: {
gameState: finalState,
},
nextStory: createStory('服务端故事'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory: createStory('当前故事'),
option: createOption('battle_attack_basic'),
character: createCharacter(),
setBattleReward: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: vi.fn() as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
turnVisualMs: 1,
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
animationState: 'idle',
playerHp: 26,
sceneHostileNpcs: expect.arrayContaining([
expect.objectContaining({
id: 'wolf',
hp: 0,
animation: 'die',
}),
]),
}),
);
expect(setGameState).toHaveBeenLastCalledWith(finalState);
});
});

View File

@@ -39,7 +39,7 @@ type IncrementRuntimeStats = (
) => GameState;
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
export function buildReasonedOptionCatalog(options: StoryOption[]) {
@@ -296,19 +296,30 @@ export async function runServerRuntimeChoiceAction(params: {
setGameState: (state: GameState) => void;
setCurrentStory: (story: StoryMoment) => void;
buildFallbackStoryForState: BuildFallbackStoryForState;
turnVisualMs?: number;
}) {
params.setBattleReward(null);
params.setAiError(null);
params.setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState: params.gameState,
currentStory: params.currentStory,
option: params.option,
payload: params.option.runtimePayload,
});
if (response) {
await playServerBattlePresentation({
baseState: params.gameState,
finalState: hydratedSnapshot.gameState,
option: params.option,
response,
setGameState: params.setGameState,
turnVisualMs: params.turnVisualMs ?? 820,
});
}
params.setGameState(hydratedSnapshot.gameState);
params.setCurrentStory(nextStory);
} catch (error) {
@@ -328,3 +339,89 @@ export async function runServerRuntimeChoiceAction(params: {
params.setIsLoading(false);
}
}
function getServerBattlePlayerAnimation(option: StoryOption) {
if (option.functionId === 'battle_escape_breakout') return AnimationState.RUN;
if (option.functionId === 'battle_recover_breath') return AnimationState.IDLE;
if (option.functionId === 'inventory_use') return AnimationState.ACQUIRE;
return option.visuals?.playerAnimation ?? AnimationState.ATTACK;
}
async function playServerBattlePresentation(params: {
baseState: GameState;
finalState: GameState;
option: StoryOption;
response: Awaited<ReturnType<typeof resolveRpgRuntimeChoice>>['response'];
setGameState: (state: GameState) => void;
turnVisualMs: number;
}) {
const battle = params.response.presentation.battle;
if (!battle || !params.baseState.inBattle) {
return;
}
if (battle.outcome === 'escaped') {
params.setGameState({
...params.baseState,
animationState: AnimationState.RUN,
playerActionMode: 'idle',
scrollWorld: true,
activeCombatEffects: [],
});
await sleep(Math.max(220, Math.round(params.turnVisualMs * 0.6)));
return;
}
const targetId = battle.targetId ?? params.baseState.sceneHostileNpcs[0]?.id ?? null;
if (!targetId) {
return;
}
const isRecoveryOrItem =
params.option.functionId === 'battle_recover_breath' ||
params.option.functionId === 'inventory_use';
const actingState: GameState = {
...params.baseState,
animationState: getServerBattlePlayerAnimation(params.option),
playerActionMode: isRecoveryOrItem ? 'idle' : 'melee',
activeCombatEffects: [],
sceneHostileNpcs: params.baseState.sceneHostileNpcs.map((hostileNpc) =>
hostileNpc.id === targetId
? {
...hostileNpc,
animation: isRecoveryOrItem ? ('move' as const) : ('attack' as const),
action: params.response.presentation.resultText || hostileNpc.action,
}
: hostileNpc,
),
};
params.setGameState(actingState);
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.55)));
const finalTarget = params.finalState.sceneHostileNpcs.find(
(hostileNpc) => hostileNpc.id === targetId,
);
const targetDefeated =
battle.outcome === 'victory' ||
battle.outcome === 'spar_complete' ||
(!finalTarget && (battle.damageDealt ?? 0) > 0);
params.setGameState({
...actingState,
playerHp: params.finalState.playerHp,
playerMana: params.finalState.playerMana,
playerSkillCooldowns: params.finalState.playerSkillCooldowns,
activeBuildBuffs: params.finalState.activeBuildBuffs,
sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => {
if (hostileNpc.id !== targetId) return hostileNpc;
return {
...hostileNpc,
hp: finalTarget?.hp ?? (targetDefeated ? 0 : hostileNpc.hp),
animation: targetDefeated ? ('die' as const) : hostileNpc.animation,
characterAnimation: targetDefeated ? AnimationState.DIE : hostileNpc.characterAnimation,
};
}),
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
});
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
}

View File

@@ -1192,6 +1192,38 @@ export function createStoryNpcEncounterActions({
};
};
const shouldUseHostileNpcChatClosureOptions = (
directive: NpcChatDirective,
affinity: number,
) =>
affinity < 0 ||
directive?.limitReason === 'negative_affinity' ||
directive?.terminationMode === 'hostile_model' ||
directive?.isHostileChat === true;
// 负好感聊天结束后不能回到普通冒险分支,只允许玩家立刻战斗或逃离。
const buildNpcChatClosureOptions = (
encounter: Encounter,
character: Character,
directive: NpcChatDirective,
affinity: number,
) => {
if (!shouldUseHostileNpcChatClosureOptions(directive, affinity)) {
return [buildContinueAdventureOption()];
}
const fightOption = buildHostileNpcFightOption(encounter);
return [
{
...fightOption,
actionText: '战斗',
text: '战斗',
},
buildHostileNpcEscapeOption(character),
];
};
const enterNpcChat = (
encounter: Encounter,
selectedOption: StoryOption,
@@ -1290,7 +1322,7 @@ export function createStoryNpcEncounterActions({
encounterNpcStateOverride: npcState,
}),
existingDialogue,
'【NPC 主动开场】',
'',
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
@@ -1583,14 +1615,28 @@ export function createStoryNpcEncounterActions({
);
setCurrentStory({
text: closingDialogue.map((turn) => turn.text).join('\n'),
options: [buildContinueAdventureOption()],
options: buildNpcChatClosureOptions(
encounter,
playerCharacter,
resolvedChatDirective,
nextAffinity,
),
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
npcAffinityEffect: latestAffinityEffect,
deferredOptions: progressionResult.options,
deferredRuntimeState:
progressionResult.deferredRuntimeState ?? undefined,
deferredOptions: shouldUseHostileNpcChatClosureOptions(
resolvedChatDirective,
nextAffinity,
)
? undefined
: progressionResult.options,
deferredRuntimeState: shouldUseHostileNpcChatClosureOptions(
resolvedChatDirective,
nextAffinity,
)
? undefined
: (progressionResult.deferredRuntimeState ?? undefined),
});
return true;
}
@@ -2037,6 +2083,24 @@ export function createStoryNpcEncounterActions({
return true;
}
const npcState = getResolvedNpcState(gameState, encounter);
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: gameState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount: 0,
});
if (!npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
return enterNpcChat(encounter, resolvedOption);
}
case 'quest_accept': {

View File

@@ -6,6 +6,7 @@ import {StrictMode, Suspense} from 'react';
import {createRoot} from 'react-dom/client';
import {resolveAppRoute} from './routing/appRoutes';
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
type AppRoot = ReturnType<typeof createRoot>;
@@ -29,7 +30,12 @@ const RouteComponent = route.Component;
root.render(
<StrictMode>
<Suspense fallback={<RouteLoadingScreen eyebrow={route.loadingEyebrow} text={route.loadingText} />}>
<RouteComponent {...(route.componentProps ?? {})} />
<RouteImageReadyGate
eyebrow={route.loadingEyebrow}
text={route.loadingText}
>
<RouteComponent {...(route.componentProps ?? {})} />
</RouteImageReadyGate>
</Suspense>
</StrictMode>,
);

View File

@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectRouteImageUrls,
extractCssImageUrls,
normalizePreloadImageUrl,
} from './routeImageReadyGateUtils';
import { RouteImageReadyGate } from './RouteImageReadyGate';
afterEach(() => {
vi.useRealTimers();
});
describe('RouteImageReadyGate image url helpers', () => {
it('extracts urls from layered CSS image values', () => {
expect(
extractCssImageUrls(
'linear-gradient(#000,#111), url("/hero.png"), url("icons/card.webp")',
),
).toEqual(['/hero.png', 'icons/card.webp']);
});
it('normalizes preloadable urls against the current document', () => {
expect(normalizePreloadImageUrl('/cover.png')).toBe(
new URL('/cover.png', document.baseURI).href,
);
expect(normalizePreloadImageUrl('data:image/png;base64,abc')).toBe(
'data:image/png;base64,abc',
);
expect(normalizePreloadImageUrl('')).toBeNull();
});
it('collects img and CSS background urls from a route root', () => {
const root = document.createElement('section');
root.innerHTML = `
<img src="/images/card.png" />
<div style='background-image: url("/images/bg.webp")'></div>
<div style='border-image-source: url("/ui/frame.png")'></div>
`;
expect(collectRouteImageUrls(root)).toEqual([
new URL('/images/card.png', document.baseURI).href,
new URL('/images/bg.webp', document.baseURI).href,
new URL('/ui/frame.png', document.baseURI).href,
]);
});
it('reveals route content after a short cap when images stay pending', () => {
vi.useFakeTimers();
render(
createElement(
RouteImageReadyGate,
{
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
},
createElement(
'section',
{
'data-testid': 'route-content',
},
createElement('img', {
src: '/generated-characters/slow-cover.png',
alt: 'slow cover',
}),
),
),
);
const content = screen.getByTestId('route-content');
const visibilityGate = content.parentElement;
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true');
expect(visibilityGate?.style.visibility).toBe('hidden');
act(() => {
vi.advanceTimersByTime(1600);
});
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false');
expect(visibilityGate?.style.visibility).toBe('visible');
});
});

View File

@@ -0,0 +1,230 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import {
collectRouteImageUrls,
hasCssImageUrlChange,
type ImagePreloadResult,
} from './routeImageReadyGateUtils';
import { RouteLoadingScreen } from './RouteLoadingScreen';
type RouteImageReadyGateProps = {
children: ReactNode;
eyebrow: string;
text: string;
};
const IMAGE_GATE_QUIET_MS = 140;
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
const IMAGE_GATE_MAX_BLOCK_MS = 1400;
const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
const settledImageUrls = new Set<string>();
const imagePreloadTasks = new Map<string, Promise<ImagePreloadResult>>();
function preloadImageUrl(url: string) {
const existingTask = imagePreloadTasks.get(url);
if (existingTask) {
return existingTask;
}
const task = new Promise<ImagePreloadResult>((resolve) => {
if (typeof Image === 'undefined') {
resolve({ url, status: 'loaded' });
return;
}
const image = new Image();
let settled = false;
const settle = (status: ImagePreloadResult['status']) => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
settledImageUrls.add(url);
resolve({ url, status });
};
const timeoutId = window.setTimeout(() => {
settle('timeout');
}, IMAGE_PRELOAD_TIMEOUT_MS);
image.onload = () => settle('loaded');
image.onerror = () => settle('failed');
image.decoding = 'async';
image.src = url;
if (image.complete) {
settle(image.naturalWidth > 0 ? 'loaded' : 'failed');
}
});
imagePreloadTasks.set(url, task);
return task;
}
/**
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
* 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞。
*/
export function RouteImageReadyGate({
children,
eyebrow,
text,
}: RouteImageReadyGateProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const startTimeRef = useRef(0);
const scanTimerRef = useRef<number | null>(null);
const revealTimerRef = useRef<number | null>(null);
const scanVersionRef = useRef(0);
const revealedRef = useRef(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const root = rootRef.current;
if (!root) {
return;
}
let disposed = false;
startTimeRef.current = window.performance.now();
revealedRef.current = false;
setReady(false);
const clearScanTimer = () => {
if (scanTimerRef.current !== null) {
window.clearTimeout(scanTimerRef.current);
scanTimerRef.current = null;
}
};
const clearRevealTimer = () => {
if (revealTimerRef.current !== null) {
window.clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
}
};
const scheduleScan = () => {
clearScanTimer();
scanTimerRef.current = window.setTimeout(runScan, IMAGE_GATE_QUIET_MS);
};
const scheduleReveal = (version: number) => {
if (revealedRef.current) {
return;
}
clearRevealTimer();
const elapsed = window.performance.now() - startTimeRef.current;
const preferredDelay = Math.max(
IMAGE_GATE_QUIET_MS,
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
);
const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed);
const delay = Math.min(preferredDelay, maxRemainingDelay);
revealTimerRef.current = window.setTimeout(() => {
if (disposed || version !== scanVersionRef.current) {
return;
}
revealedRef.current = true;
setReady(true);
}, delay);
};
function runScan() {
if (disposed) {
return;
}
const version = scanVersionRef.current + 1;
scanVersionRef.current = version;
const pendingUrls = collectRouteImageUrls(root).filter(
(url) => !settledImageUrls.has(url),
);
if (pendingUrls.length > 0) {
// 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。
pendingUrls.forEach((url) => {
void preloadImageUrl(url);
});
}
scheduleReveal(version);
}
const observer = new MutationObserver((mutations) => {
if (disposed || revealedRef.current) {
return;
}
const shouldRescan = mutations.some((mutation) => {
if (mutation.type === 'childList') {
return (
mutation.addedNodes.length > 0 ||
mutation.removedNodes.length > 0
);
}
if (mutation.type !== 'attributes') {
return false;
}
if (
mutation.attributeName === 'src' ||
mutation.attributeName === 'srcset'
) {
return true;
}
return mutation.attributeName === 'style'
? hasCssImageUrlChange(mutation)
: false;
});
if (!shouldRescan) {
return;
}
clearRevealTimer();
scheduleScan();
});
observer.observe(root, {
attributes: true,
attributeFilter: ['src', 'srcset', 'style'],
attributeOldValue: true,
childList: true,
subtree: true,
});
scheduleScan();
return () => {
disposed = true;
observer.disconnect();
clearScanTimer();
clearRevealTimer();
};
}, []);
return (
<>
<div
ref={rootRef}
aria-hidden={!ready}
style={{
visibility: ready ? 'visible' : 'hidden',
}}
>
{children}
</div>
{!ready ? (
<div className="fixed inset-0 z-[9999]">
<RouteLoadingScreen eyebrow={eyebrow} text={text} />
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,111 @@
const CSS_IMAGE_URL_PATTERN =
/url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\)/gu;
export type ImagePreloadResult = {
url: string;
status: 'loaded' | 'failed' | 'timeout';
};
export function extractCssImageUrls(value: string) {
const urls: string[] = [];
CSS_IMAGE_URL_PATTERN.lastIndex = 0;
let match = CSS_IMAGE_URL_PATTERN.exec(value);
while (match) {
const rawUrl = match[1] ?? match[2] ?? match[3] ?? '';
const normalizedRawUrl = rawUrl.trim();
if (normalizedRawUrl) {
urls.push(normalizedRawUrl);
}
match = CSS_IMAGE_URL_PATTERN.exec(value);
}
return urls;
}
export function normalizePreloadImageUrl(rawUrl: string) {
const trimmedUrl = rawUrl.trim();
if (!trimmedUrl || trimmedUrl === 'none' || trimmedUrl.startsWith('#')) {
return null;
}
if (
trimmedUrl.startsWith('data:') ||
trimmedUrl.startsWith('blob:') ||
trimmedUrl.startsWith('http://') ||
trimmedUrl.startsWith('https://')
) {
return trimmedUrl;
}
const baseUrl =
typeof document === 'undefined'
? 'http://localhost/'
: document.baseURI;
try {
return new URL(trimmedUrl, baseUrl).href;
} catch {
return null;
}
}
function addNormalizedImageUrl(urls: Set<string>, rawUrl: string | null) {
if (!rawUrl) {
return;
}
const normalizedUrl = normalizePreloadImageUrl(rawUrl);
if (normalizedUrl) {
urls.add(normalizedUrl);
}
}
export function hasCssImageUrlChange(mutation: MutationRecord) {
if (!(mutation.target instanceof HTMLElement)) {
return false;
}
const previousUrls = mutation.oldValue
? extractCssImageUrls(mutation.oldValue)
: [];
const currentUrls = [
...extractCssImageUrls(mutation.target.style.backgroundImage),
...extractCssImageUrls(mutation.target.style.borderImageSource),
...extractCssImageUrls(mutation.target.style.listStyleImage),
];
if (previousUrls.length !== currentUrls.length) {
return true;
}
return currentUrls.some((url, index) => url !== previousUrls[index]);
}
export function collectRouteImageUrls(root: HTMLElement) {
const urls = new Set<string>();
const elements = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
root.querySelectorAll<HTMLImageElement>('img').forEach((image) => {
addNormalizedImageUrl(urls, image.currentSrc);
addNormalizedImageUrl(urls, image.getAttribute('src'));
});
elements.forEach((element) => {
const computedStyle = window.getComputedStyle(element);
[
element.style.backgroundImage,
element.style.borderImageSource,
element.style.listStyleImage,
computedStyle.backgroundImage,
computedStyle.borderImageSource,
computedStyle.listStyleImage,
].forEach((cssImageValue) => {
extractCssImageUrls(cssImageValue).forEach((url) => {
addNormalizedImageUrl(urls, url);
});
});
});
return Array.from(urls);
}

View File

@@ -10,8 +10,6 @@ import {
} from '../../packages/shared/src/http';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -184,7 +182,9 @@ function composeAbortSignal(
timeoutMs: number | undefined,
) {
const shouldUseTimeout =
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0;
typeof timeoutMs === 'number' &&
Number.isFinite(timeoutMs) &&
timeoutMs > 0;
if (!shouldUseTimeout) {
return {
@@ -324,7 +324,11 @@ export function isTimeoutError(error: unknown) {
return error instanceof Error && error.name === 'TimeoutError';
}
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
function shouldRetryError(
error: unknown,
attempt: number,
retry: ResolvedRetryOptions,
) {
if (attempt >= retry.maxRetries || isAbortError(error)) {
return false;
}
@@ -369,7 +373,9 @@ export class ApiClientError extends Error {
}
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
export function emitAuthStateChange() {
@@ -437,52 +443,6 @@ export function clearStoredAccessToken(
}
}
export function getStoredAutoAuthCredentials() {
if (!canUseLocalStorage()) {
return null;
}
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
if (!username || !password) {
return null;
}
return {
username,
password,
};
}
export function setStoredAutoAuthCredentials(credentials: {
username: string;
password: string;
}) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
}
export function clearStoredAutoAuthCredentials(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
if (options.emit !== false) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
@@ -588,10 +548,7 @@ export async function fetchWithApiAuth(
}
}
const timedRequest = composeAbortSignal(
requestSignal,
options.timeoutMs,
);
const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs);
let response: Response;
try {
response = await fetch(input, {
@@ -648,10 +605,7 @@ export async function fetchWithApiAuth(
}
attempt += 1;
await waitForRetry(
buildRetryDelayMs(attempt, retry),
requestSignal,
);
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
}
}

View File

@@ -6,7 +6,8 @@ const apiClientMocks = vi.hoisted(() => ({
}));
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
const actual =
await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
@@ -17,12 +18,10 @@ vi.mock('./apiClient', async () => {
import { ApiClientError } from './apiClient';
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
import {
authEntryWithStoredCredentials,
authEntry,
bindWechatPhone,
changePhoneNumber,
consumeAuthCallbackResult,
createAutoAuthCredentials,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
@@ -80,41 +79,30 @@ describe('authService', () => {
clearStoredAccessToken({ emit: false });
});
it('creates credentials that match current username/password constraints', () => {
const credentials = createAutoAuthCredentials();
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('auth entry trims guest credentials and写入 access token', async () => {
it('auth entry posts phone password credentials and 写入 access token', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-entry-token',
user: {
id: 'user_1',
publicUserCode: 'SY-00000001',
username: 'guest_abc123abc123',
displayName: 'guest_abc123abc123',
phoneNumberMasked: null,
username: 'phone_00000001',
displayName: '138****8000',
phoneNumberMasked: '138****8000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const user = await authEntryWithStoredCredentials({
username: ' guest_abc123abc123 ',
password: ' auto_secret_password ',
});
const user = await authEntry(' 138 0013 8000 ', ' secret123 ');
expect(user.username).toBe('guest_abc123abc123');
expect(user.phoneNumberMasked).toBe('138****8000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
username: 'guest_abc123abc123',
password: 'auto_secret_password',
phone: '13800138000',
password: 'secret123',
}),
}),
'登录失败',
@@ -127,62 +115,6 @@ describe('authService', () => {
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-auto-token',
user: {
id: 'user_saved',
publicUserCode: 'SY-00000002',
username: 'guest_saveduser01',
displayName: 'guest_saveduser01',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(result.user.username).toBe('guest_saveduser01');
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(result.credentials.password).toMatch(
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
);
expect(authEntryBody).toEqual(result.credentials);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent auto auth requests', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-auto-shared-token',
user: {
id: 'user_auto',
publicUserCode: 'SY-00000003',
username: 'guest_auto',
displayName: 'guest_auto',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const [firstResult, secondResult] = await Promise.all([
ensureAutoAuthUser(),
ensureAutoAuthUser(),
]);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
});
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
@@ -327,7 +259,8 @@ describe('authService', () => {
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
authorizationUrl:
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();

View File

@@ -28,22 +28,14 @@ import {
ApiClientError,
type ApiRequestOptions,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
emitAuthStateChange,
getStoredAutoAuthCredentials,
requestJson,
setStoredAccessToken,
setStoredAutoAuthCredentials,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
export type AutoAuthCredentials = {
username: string;
password: string;
};
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
@@ -59,11 +51,6 @@ export type ConsumedAuthCallback = {
error: string | null;
};
let pendingAutoAuthUser: Promise<{
user: AuthUser;
credentials: AutoAuthCredentials;
}> | null = null;
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
@@ -108,7 +95,8 @@ export function getCaptchaChallengeFromError(
typeof error.details === 'object' &&
'captchaChallenge' in error.details
) {
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
const challenge = (error.details as { captchaChallenge?: unknown })
.captchaChallenge;
if (
challenge &&
typeof challenge === 'object' &&
@@ -124,39 +112,8 @@ export function getCaptchaChallengeFromError(
return null;
}
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
return {
username: credentials.username.trim(),
password: credentials.password.trim(),
};
}
function buildRandomSegment(length: number) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const cryptoApi = globalThis.crypto;
if (!cryptoApi?.getRandomValues) {
return Array.from(
{length},
() => alphabet[Math.floor(Math.random() * alphabet.length)],
).join('');
}
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
}
export function createAutoAuthCredentials(): AutoAuthCredentials {
return {
username: `guest_${buildRandomSegment(12)}`,
password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`,
};
}
export function clearAuthSession() {
clearStoredAccessToken({ emit: false });
clearStoredAutoAuthCredentials({ emit: false });
emitAuthStateChange();
}
@@ -265,14 +222,16 @@ export async function getAuthLoginOptions() {
);
}
export async function authEntry(username: string, password: string) {
const credentials = normalizeCredentials({ username, password });
export async function authEntry(phone: string, password: string) {
const response = await requestJson<AuthEntryResponse>(
'/api/auth/entry',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
body: JSON.stringify({
phone: normalizePhoneInput(phone),
password: password.trim(),
}),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
@@ -326,37 +285,6 @@ export async function resetPassword(
return response.user;
}
export async function authEntryWithStoredCredentials(
credentials: AutoAuthCredentials,
) {
const normalizedCredentials = normalizeCredentials(credentials);
const user = await authEntry(
normalizedCredentials.username,
normalizedCredentials.password,
);
setStoredAutoAuthCredentials(normalizedCredentials);
return user;
}
export async function ensureAutoAuthUser() {
pendingAutoAuthUser ??= (async () => {
const credentials =
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
const user = await authEntryWithStoredCredentials(credentials);
return {
user,
credentials,
};
})();
try {
return await pendingAutoAuthUser;
} finally {
pendingAutoAuthUser = null;
}
}
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
if (typeof window === 'undefined') {
return null;

53
src/services/clipboard.ts Normal file
View File

@@ -0,0 +1,53 @@
export async function copyTextToClipboard(value: string) {
const text = value.trim();
if (!text) {
return false;
}
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 部分内嵌浏览器会暴露 Clipboard API但会因权限上下文拒绝写入继续走兼容路径。
}
}
if (typeof document === 'undefined') {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
const selection = document.getSelection();
const selectedRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
textarea.focus();
textarea.select();
let copied = false;
try {
copied =
typeof document.execCommand === 'function' &&
document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
if (selection) {
selection.removeAllRanges();
if (selectedRange) {
selection.addRange(selectedRange);
}
}
}
return copied;
}

Some files were not shown because too many files have changed in this diff Show More