1
This commit is contained in:
@@ -1456,10 +1456,10 @@
|
||||
- 验证:`curl.exe -i http://127.0.0.1:8082/api/creation-entry/config` 返回 `200` 且包含 `baby-object-match`;前端草稿页作品架重新渲染。
|
||||
- 关联:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 存档选择入口不要只藏在“玩过”弹窗里
|
||||
## 个人中心不再保留直达“存档”按钮入口
|
||||
|
||||
- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口。
|
||||
- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口。
|
||||
- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/useRpgEntryBootstrap.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
- 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。
|
||||
- 原因:产品布局收口后,个人中心只保留设置、扫码、常用功能和条件性次级入口,存档恢复继续以后端 `/api/profile/save-archives` 真相为准,但不再作为页面直达入口。
|
||||
- 处理:后续如果需要重新暴露存档入口,优先评估是否应回到“玩过”或别的独立弹窗流程,不要默认把存档再塞回常用功能宫格或设置列表。
|
||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
拼图生成页步骤真进度、步骤内假进度和精简展示口径见 [【玩法创作】拼图生成页进度口径-2026-05-23.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E6%8B%BC%E5%9B%BE%E7%94%9F%E6%88%90%E9%A1%B5%E8%BF%9B%E5%BA%A6%E5%8F%A3%E5%BE%84-2026-05-23.md)。
|
||||
|
||||
从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。
|
||||
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||
|
||||
184
docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md
Normal file
184
docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案
|
||||
|
||||
更新时间:`2026-05-25`
|
||||
|
||||
> 本文为内部发明专利交底稿,目标是把“文字需求 -> 画面图 -> 透明 spritesheet -> 自动边界检测 -> 元素绑定”这一条高一致性美术素材生成链路抽象为可申请的通用技术方案。
|
||||
|
||||
## 摘要
|
||||
|
||||
本发明涉及一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的解决方案。该方案接收文字形态的需求描述,调用图片生成模型生成一张用于表达整体视觉关系的游戏画面图;再以所述游戏画面图作为参考,继续调用图片生成模型生成一张透明背景的 spritesheet 图片,所述 spritesheet 图片承载需要随不同设备分辨率自适应调整位置的素材;随后基于自动边界检测算法对所述 spritesheet 图片中的素材进行逐一解析,按照从上到下、从左到右的顺序,将解析出的素材与文字形态需求描述中的画面元素一一对应,并将代码中的元素标识与对应素材绑定。该方案通过单次文字输入驱动画面图生成、基于画面图派生透明 spritesheet、自动边界检测替代人工切图、顺序映射替代手工命名和手工对图,从而在较少人工干预和较低重复生成成本下,快速得到风格统一、可直接绑定代码的高一致性美术素材。
|
||||
|
||||
## 技术领域
|
||||
|
||||
本发明属于人工智能图像生成、2D 小游戏美术素材生产、图像分割解析和元素绑定技术领域,具体涉及一种根据文字需求描述自动生成游戏画面图、spritesheet 美术素材和元素映射关系的方法及系统。
|
||||
|
||||
## 背景技术
|
||||
|
||||
现有 2D 小游戏的美术素材生产通常包含以下步骤:先由设计人员撰写文字需求,再由美术人员分别绘制画面图、按钮图、状态图和装饰图,之后由前端或游戏程序员进行切图、命名、排布和代码绑定。该流程存在如下问题:
|
||||
|
||||
1. 素材往往分散生成,整体风格不统一。
|
||||
2. 多分辨率适配时,需要人工调整大量元素位置,维护成本高。
|
||||
3. 切图和命名依赖人工,容易出现遗漏、错位和绑定错误。
|
||||
4. 文字需求与最终代码元素之间缺少稳定映射,后续修改代价大。
|
||||
5. 若每个素材分别生成,会增加生成次数和等待成本。
|
||||
|
||||
因此,需要一种能够把文字需求直接转化为成套美术素材,并且能够自动解析、自动映射、自动绑定到代码中的方法。
|
||||
|
||||
## 发明内容
|
||||
|
||||
### 要解决的技术问题
|
||||
|
||||
本发明主要解决以下技术问题:
|
||||
|
||||
1. 如何根据文字形态的需求描述快速生成一张完整游戏画面图。
|
||||
2. 如何基于该画面图进一步生成透明背景的 spritesheet 图片。
|
||||
3. 如何基于自动边界检测算法逐一解析 spritesheet 中的素材。
|
||||
4. 如何按照从上到下、从左到右的顺序将素材与文字描述中的画面元素一一对应。
|
||||
5. 如何将映射结果稳定绑定到代码,减少人工切图和手工配置成本。
|
||||
|
||||
### 技术方案
|
||||
|
||||
本发明提供一种高一致性美术素材生成方法,包括如下步骤:
|
||||
|
||||
```text
|
||||
文字形态需求描述
|
||||
-> 游戏画面图生成
|
||||
-> 透明背景 spritesheet 生成
|
||||
-> 自动边界检测解析素材
|
||||
-> 顺序映射文字元素
|
||||
-> 代码绑定
|
||||
```
|
||||
|
||||
其中,所述文字形态需求描述至少包含画面元素名称、语义说明、布局意图和顺序信息。所述游戏画面图用于表达整体视觉风格和元素关系;所述 spritesheet 图片用于承载需要随不同设备分辨率自适应调整位置的素材;所述自动边界检测算法用于把 spritesheet 中的独立素材一一切分出来;所述顺序映射用于将解析结果与文字描述中的元素一一对应;所述代码绑定用于将元素标识、资源地址、边界框或布局参数写入代码配置或元素表。
|
||||
|
||||
### 有益效果
|
||||
|
||||
与现有技术相比,本发明至少具有以下效果:
|
||||
|
||||
1. 降低人工切图成本。
|
||||
2. 降低人工命名和代码绑定成本。
|
||||
3. 提升整体美术素材一致性。
|
||||
4. 提升多分辨率适配效率。
|
||||
5. 减少重复生成和重复调整次数。
|
||||
6. 让文字需求到代码元素的映射更稳定、更可维护。
|
||||
|
||||
## 附图说明
|
||||
|
||||
图 1 为本发明从文字需求描述到元素绑定的总体流程图。
|
||||
|
||||
图 2 为游戏画面图生成与 spritesheet 生成的派生关系示意图。
|
||||
|
||||
图 3 为自动边界检测算法解析透明背景 spritesheet 的流程图。
|
||||
|
||||
图 4 为素材顺序与文字形态需求描述中的画面元素一一对应的映射关系示意图。
|
||||
|
||||
## 具体实施方式
|
||||
|
||||
### 一、系统组成
|
||||
|
||||
本发明的系统可以包括如下模块:
|
||||
|
||||
1. 输入采集模块:用于接收文字形态需求描述。
|
||||
2. 图像生成模块:用于根据文字形态需求描述生成游戏画面图。
|
||||
3. 图集生成模块:用于根据游戏画面图生成透明背景 spritesheet 图片。
|
||||
4. 边界检测模块:用于对 spritesheet 图片执行自动边界检测算法。
|
||||
5. 顺序映射模块:用于将解析出的素材按照从上到下、从左到右的顺序与文字描述中的画面元素对应。
|
||||
6. 代码绑定模块:用于将元素标识与对应素材绑定。
|
||||
|
||||
### 二、方法步骤
|
||||
|
||||
#### S100:接收文字形态需求描述
|
||||
|
||||
系统接收用户输入的文字形态需求描述。该需求描述可写成一段自然语言,也可写成按元素顺序排列的结构化文本。需求描述中应至少能够识别出画面元素名称、语义含义和布局顺序。
|
||||
|
||||
#### S200:生成游戏画面图
|
||||
|
||||
图像生成模块调用图片生成模型,根据所述文字形态需求描述生成一张完整游戏画面图。所述游戏画面图用于表达整体视觉关系、主次层级和风格基调,为后续 spritesheet 生成提供统一参考。
|
||||
|
||||
#### S300:生成透明背景 spritesheet 图片
|
||||
|
||||
图集生成模块以所述游戏画面图为参考,再次调用图片生成模型,生成一张透明背景的 spritesheet 图片。所述 spritesheet 图片中包含需要随不同设备分辨率自适应调整位置的素材,例如按钮、状态条、提示气泡、装饰元素或其他需要由代码控制位置的元素。
|
||||
|
||||
#### S400:自动边界检测解析素材
|
||||
|
||||
边界检测模块对所述 spritesheet 图片执行自动边界检测算法,对透明背景中的每个独立素材进行逐一解析,输出素材边界框、素材索引和必要的资源属性。所述自动边界检测算法优选采用 alpha 通道连通域检测、边界矩形检测或二者组合;在一个优选实施方式中,可复用拼图场景中已验证的自动边界检测思路,以提高解析稳定性。
|
||||
|
||||
#### S500:按照顺序映射文字元素
|
||||
|
||||
顺序映射模块将解析出的素材按照从上到下、从左到右的顺序进行排列,并与文字形态需求描述中的画面元素内容一一对应。若需求描述中已显式给出元素顺序,则优先按该顺序映射;若仅给出自然语言描述,则可先抽取元素列表,再按布局顺序排序。由此形成元素索引与语义名称之间的稳定映射关系。
|
||||
|
||||
#### S600:代码绑定
|
||||
|
||||
代码绑定模块将所述映射关系写入代码配置、元素表或资源清单中。代码侧只需读取元素标识,即可找到对应素材的资源地址、边界框和布局参数,从而完成美术素材与程序逻辑之间的直接绑定。
|
||||
|
||||
#### S700:输出美术素材包
|
||||
|
||||
系统最终输出至少包括游戏画面图、透明背景 spritesheet 图片、素材映射表和代码绑定结果。由于 spritesheet 图片与游戏画面图来自同一视觉链路,且素材顺序与文字描述顺序一一对应,因此可得到风格统一、可直接绑定、可适配多分辨率的高一致性美术素材包。
|
||||
|
||||
### 三、核心机制
|
||||
|
||||
1. 文字驱动:一次文字描述即可驱动画面图和 spritesheet 生成。
|
||||
2. 单图派生:spritesheet 以游戏画面图为参考生成,减少风格漂移。
|
||||
3. 自动解析:边界检测算法替代人工切图。
|
||||
4. 顺序对应:素材顺序与文字元素顺序一致,减少命名和对图错误。
|
||||
5. 代码绑定:映射结果可直接进入代码配置或资源表。
|
||||
|
||||
### 四、实施例
|
||||
|
||||
#### 实施例一:界面型 2D 小游戏素材生成
|
||||
|
||||
用户输入“科技实验室界面,顶部标题栏,中部主角色,底部三个操作按钮,右侧状态提示”。系统先生成一张完整游戏画面图,再生成一张透明背景 spritesheet 图片。边界检测模块解析出标题栏、主角色、操作按钮和状态提示等素材,顺序映射模块按从上到下、从左到右的顺序将其与文字描述对应,代码绑定模块将这些元素写入代码配置,最终形成可直接用于界面装配的素材包。
|
||||
|
||||
#### 实施例二:需要自适应位置的素材生成
|
||||
|
||||
用户输入“横版战斗界面,血条、技能按钮、提示气泡、道具栏”。系统将这些需要随设备分辨率自适应调整位置的元素集中生成到同一张 spritesheet 图片中。运行时,代码根据元素绑定结果对血条、按钮和提示元素进行位置调整,而不改变它们对应的语义关系。
|
||||
|
||||
#### 实施例三:代码与元素一一绑定
|
||||
|
||||
系统为解析出的每个素材分配唯一元素标识,例如 `top_title_bar`、`center_character`、`bottom_actions`、`right_status_hint`。代码侧通过元素标识直接读取对应素材的边界框和资源路径,从而消除人工对图和人工命名的步骤。
|
||||
|
||||
## 权利要求书草案
|
||||
|
||||
1. 一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的方法,其特征在于,包括:接收文字形态需求描述;根据所述文字形态需求描述调用图片生成模型生成游戏画面图;以所述游戏画面图为参考图再次调用图片生成模型生成透明背景的 spritesheet 图片;对所述 spritesheet 图片执行自动边界检测算法,逐一解析素材边界;按照从上到下、从左到右的顺序将解析出的素材与所述文字形态需求描述中的画面元素一一对应;将代码中的元素标识与对应素材绑定。
|
||||
|
||||
2. 根据权利要求 1 所述的方法,其特征在于,所述文字形态需求描述至少包括画面元素名称、语义说明和布局顺序。
|
||||
|
||||
3. 根据权利要求 1 所述的方法,其特征在于,所述游戏画面图用于表达整体视觉风格和元素关系,所述 spritesheet 图片用于承载需要随不同设备分辨率自适应调整位置的素材。
|
||||
|
||||
4. 根据权利要求 1 所述的方法,其特征在于,所述 spritesheet 图片具有透明背景,且素材之间通过透明区域分隔。
|
||||
|
||||
5. 根据权利要求 1 所述的方法,其特征在于,所述自动边界检测算法包括基于 alpha 通道的连通域检测、边界矩形检测或二者组合。
|
||||
|
||||
6. 根据权利要求 5 所述的方法,其特征在于,所述自动边界检测算法复用拼图场景中已验证的素材边界解析思路。
|
||||
|
||||
7. 根据权利要求 1 所述的方法,其特征在于,所述从上到下、从左到右的顺序用于建立元素索引与语义名称之间的映射表。
|
||||
|
||||
8. 根据权利要求 1 所述的方法,其特征在于,所述代码绑定包括为每一素材写入唯一元素标识,并在代码中通过所述元素标识读取对应素材的资源地址、边界框或布局参数。
|
||||
|
||||
9. 根据权利要求 1 所述的方法,其特征在于,所述 spritesheet 图片中的素材包括按钮、状态条、提示元素、装饰元素或其他需要自适应布局的画面元素。
|
||||
|
||||
10. 根据权利要求 1 所述的方法,其特征在于,所述游戏画面图与所述 spritesheet 图片由同一视觉链路生成,以保持美术素材的一致性。
|
||||
|
||||
11. 根据权利要求 1 所述的方法,其特征在于,所述元素绑定结果用于在不同设备分辨率下动态调整素材位置,而不改变元素语义对应关系。
|
||||
|
||||
12. 一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的系统,其特征在于,包括输入采集模块、图像生成模块、图集生成模块、边界检测模块、顺序映射模块和代码绑定模块;所述各模块被配置为执行权利要求 1 至 11 任一项所述的方法。
|
||||
|
||||
13. 一种电子设备,包括处理器和存储器,所述存储器中存储有计算机程序,其特征在于,所述计算机程序被所述处理器执行时实现权利要求 1 至 11 任一项所述的方法。
|
||||
|
||||
14. 一种计算机可读存储介质,其上存储有计算机程序,其特征在于,所述计算机程序被处理器执行时实现权利要求 1 至 11 任一项所述的方法。
|
||||
|
||||
## 可重点保护的创新点
|
||||
|
||||
1. 文字需求直接驱动一张游戏画面图。
|
||||
2. 基于该画面图再生成透明背景 spritesheet。
|
||||
3. 自动边界检测替代人工切图。
|
||||
4. 按从上到下、从左到右的顺序把素材与文字元素一一对应。
|
||||
5. 通过元素标识直接绑定代码与素材,减少人工命名和对图成本。
|
||||
|
||||
## 正式申请前建议
|
||||
|
||||
1. 检索是否已有“文字生成画面图 + spritesheet 自动解析 + 元素绑定”的相近专利,再确定独立权利要求的保护重心。
|
||||
2. 将“极低成本”“高质量”等效果性表述尽量放在说明书效果部分,权利要求中改写为“减少人工切图”“减少重复生成”“提高一致性”等技术特征。
|
||||
3. 避免在权利要求中绑定特定供应商或模型名称;模型名称可保留在实施例中。
|
||||
4. 如需扩大保护范围,可将“2D 小游戏”进一步上位为“交互式图像驱动应用”的美术素材生成方法。
|
||||
5. 如需增强授权稳定性,可将“文字驱动生成 + 透明 spritesheet + 自动边界检测 + 顺序映射 + 代码绑定”组合为主权利要求的必要技术特征。
|
||||
@@ -66,7 +66,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口
|
||||
|
||||
RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。
|
||||
|
||||
RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 存档` 和设置入口区保留为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。
|
||||
RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码、常用功能和条件性次级入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。
|
||||
|
||||
## 拼图
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ server-rs + Axum + SpacetimeDB
|
||||
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||
10. 移动端“我的”页按参考图顺序组织为顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;存档和填邀请码保留在次级入口带,不挤入五宫格。
|
||||
11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,内容不换行,不在统计区底部展示“更新于”时间,字号维持平台普通 UI 档位;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
|
||||
12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
|
||||
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、可选次级入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,填邀请码仅在新用户可填写窗口内展示为次级入口。
|
||||
11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
|
||||
12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、可选次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
|
||||
13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
|
||||
14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
|
||||
|
||||
|
||||
@@ -1035,6 +1035,15 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => undefined),
|
||||
});
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Reflect.deleteProperty(globalThis as Record<string, unknown>, 'BarcodeDetector');
|
||||
window.wx = undefined;
|
||||
document
|
||||
.querySelectorAll(
|
||||
@@ -1832,10 +1841,68 @@ test('profile daily task shortcut opens task center and claims reward', async ()
|
||||
});
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile task center keeps only the highest priority actionable task', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockGetRpgProfileTasks.mockResolvedValueOnce(
|
||||
mockBuildTaskCenter({
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'claimed_low',
|
||||
title: '低优先级已完成',
|
||||
description: '',
|
||||
eventKey: 'profile.task.claimed_low',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 1,
|
||||
rewardPoints: 5,
|
||||
status: 'claimed',
|
||||
dayKey: 20260503,
|
||||
claimedAt: '2026-05-03T08:01:00Z',
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
},
|
||||
{
|
||||
taskId: 'claimable_mid',
|
||||
title: '中优先级可领取',
|
||||
description: '',
|
||||
eventKey: 'profile.task.claimable_mid',
|
||||
cycle: 'daily',
|
||||
threshold: 2,
|
||||
progressCount: 2,
|
||||
rewardPoints: 10,
|
||||
status: 'claimable',
|
||||
dayKey: 20260503,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
},
|
||||
{
|
||||
taskId: 'incomplete_high',
|
||||
title: '高优先级未完成',
|
||||
description: '',
|
||||
eventKey: 'profile.task.incomplete_high',
|
||||
cycle: 'daily',
|
||||
threshold: 3,
|
||||
progressCount: 1,
|
||||
rewardPoints: 20,
|
||||
status: 'incomplete',
|
||||
dayKey: 20260503,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
expect(await screen.findByText('中优先级可领取')).toBeTruthy();
|
||||
expect(screen.queryByText('高优先级未完成')).toBeNull();
|
||||
expect(screen.queryByText('低优先级已完成')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile total play time card always uses hours', () => {
|
||||
@@ -1882,21 +1949,35 @@ test('profile stats cards are centered without update timestamp', () => {
|
||||
});
|
||||
|
||||
test('mobile profile page matches the reference layout sections', async () => {
|
||||
mockWechatMobileLayout();
|
||||
mockNarrowMobileLayout();
|
||||
|
||||
const { container } = renderProfileView(vi.fn(), {
|
||||
walletBalance: 70,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
}, { createdAt: buildFreshProfileCreatedAt() });
|
||||
});
|
||||
|
||||
const profilePage = container.querySelector('.platform-profile-page');
|
||||
expect(profilePage).toBeTruthy();
|
||||
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
|
||||
expect(profilePage?.classList.contains('platform-page-stage')).toBe(false);
|
||||
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
|
||||
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
|
||||
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
|
||||
|
||||
const topbar = container.querySelector('.platform-mobile-topbar');
|
||||
expect(topbar).toBeTruthy();
|
||||
expect(
|
||||
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(topbar as HTMLElement).getByRole('button', { name: '打开设置' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(topbar as HTMLElement).queryByRole('button', {
|
||||
name: /充值/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const membershipCard = screen.getByRole('button', { name: '查看权益' });
|
||||
expect(membershipCard.className).toContain('platform-profile-membership-card');
|
||||
expect(
|
||||
@@ -1914,6 +1995,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(
|
||||
within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className,
|
||||
).toContain('platform-profile-stat-card');
|
||||
expect(statPanel.querySelectorAll('.platform-profile-stat-card__icon')).toHaveLength(3);
|
||||
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
|
||||
@@ -1953,18 +2035,11 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
});
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await within(secondaryShortcuts).findByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
|
||||
const profileHeader = profilePage?.querySelector('.platform-profile-header');
|
||||
expect(profileHeader).toBeTruthy();
|
||||
@@ -1982,6 +2057,46 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile scan action opens camera scanner instead of recharge panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const stopTrack = vi.fn();
|
||||
const stream = {
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
} as unknown as MediaStream;
|
||||
const getUserMedia = vi.fn(async () => stream);
|
||||
|
||||
mockNarrowMobileLayout();
|
||||
Object.defineProperty(globalThis, 'BarcodeDetector', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
async detect() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
|
||||
renderProfileView();
|
||||
const topbar = document.querySelector('.platform-mobile-topbar');
|
||||
expect(topbar).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '扫码' })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(getUserMedia).toHaveBeenCalledWith({
|
||||
audio: false,
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
});
|
||||
});
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
@@ -2195,7 +2310,7 @@ test('opens reward code modal from profile action on mobile', async () => {
|
||||
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile page shows legal entries and ICP record link', async () => {
|
||||
test('profile page shows legal entries and hides archive shortcuts', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
@@ -2221,18 +2336,9 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
expect(
|
||||
within(settingsRegion).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
});
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(secondaryShortcuts).queryByRole('button', { name: /填邀请码/u }),
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(
|
||||
@@ -2697,7 +2803,7 @@ test('logged out mobile shell defaults to discover tab', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('logged out recommend tab opens login modal and shows cover only', async () => {
|
||||
test('logged out recommend tab opens recommend runtime directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
@@ -2715,17 +2821,17 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
container.querySelector('.platform-recommend-cover-only'),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-entry-shell--recommend'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
||||
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
expect(screen.getByLabelText('奇幻拼图 作品信息')).toBeTruthy();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('logged out recommend cover opens login modal again', async () => {
|
||||
test('logged out recommend meta keeps gallery detail gated', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const { openLoginModal } = renderStatefulLoggedOutHomeView({
|
||||
@@ -2741,12 +2847,9 @@ test('logged out recommend cover opens login modal again', async () => {
|
||||
await user.click(
|
||||
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }),
|
||||
);
|
||||
await user.click(screen.getByLabelText('奇幻拼图 作品信息'));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(2);
|
||||
expect(openLoginModal).toHaveBeenLastCalledWith();
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3082,7 +3185,7 @@ test('mobile recommend meta loads real author avatar from public user summary',
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document
|
||||
.querySelector('.platform-recommend-cover-only__author img')
|
||||
.querySelector('.platform-recommend-work-meta__avatar img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,AUTHOR');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
@@ -123,6 +122,7 @@ import {
|
||||
SquareImageCropModal,
|
||||
type SquareImageCropRect,
|
||||
} from '../common/SquareImageCropModal';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_WORK_TAG,
|
||||
@@ -131,7 +131,6 @@ import {
|
||||
findPublicWorkForHistoryEntry,
|
||||
isEdutainmentEntryEnabled,
|
||||
} from '../platform-entry/platformEdutainmentVisibility';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
@@ -225,6 +224,8 @@ const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
||||
const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const MOBILE_PROFILE_PAGE_STAGE_CLASS =
|
||||
'platform-remap-surface min-w-0 space-y-4 pb-2';
|
||||
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
|
||||
@@ -253,9 +254,36 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
|
||||
claimable: 2,
|
||||
incomplete: 1,
|
||||
disabled: 0,
|
||||
claimed: -1,
|
||||
};
|
||||
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
||||
|
||||
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
|
||||
.sort(
|
||||
(left, right) =>
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
|
||||
left.index - right.index,
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(({ task }) => task);
|
||||
}
|
||||
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type BarcodeDetectorLike = {
|
||||
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
||||
};
|
||||
type BarcodeDetectorConstructorLike = new (options?: {
|
||||
formats?: string[];
|
||||
}) => BarcodeDetectorLike;
|
||||
type RechargeTab = 'points' | 'membership';
|
||||
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||
type WechatPayResult = {
|
||||
@@ -269,6 +297,13 @@ type RechargePaymentResult = {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
|
||||
const maybeDetector = (globalThis as unknown as {
|
||||
BarcodeDetector?: BarcodeDetectorConstructorLike;
|
||||
}).BarcodeDetector;
|
||||
return typeof maybeDetector === 'function' ? maybeDetector : null;
|
||||
}
|
||||
type NativeWechatPaymentState = WechatNativePayment & {
|
||||
orderId: string;
|
||||
isConfirming: boolean;
|
||||
@@ -756,69 +791,6 @@ function WorldCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendCoverOnlyCard({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
onClick,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
authorAvatarUrl?: string | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const typeLabel = describePublicGalleryCardKind(entry);
|
||||
const authorName = entry.authorDisplayName.trim() || '玩家';
|
||||
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={`登录后游玩 ${entry.worldName}`}
|
||||
className="platform-recommend-cover-only"
|
||||
>
|
||||
{coverImage ? (
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackCoverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.04),rgba(0,0,0,0.42))]" />
|
||||
<div className="platform-recommend-cover-only__body">
|
||||
<span className="platform-public-work-card__kind">{typeLabel}</span>
|
||||
<span className="platform-recommend-cover-only__title">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="platform-recommend-cover-only__author">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="platform-public-work-card__author-avatar"
|
||||
>
|
||||
{normalizedAuthorAvatarUrl ? (
|
||||
<img
|
||||
src={normalizedAuthorAvatarUrl}
|
||||
alt=""
|
||||
className="platform-public-work-card__author-avatar-image"
|
||||
/>
|
||||
) : (
|
||||
authorAvatarLabel
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{authorName}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreationLibraryCard({
|
||||
entry,
|
||||
onClick,
|
||||
@@ -3283,7 +3255,7 @@ function ProfileTaskCenterModal({
|
||||
onRetry: () => void;
|
||||
onClaim: (taskId: string) => void;
|
||||
}) {
|
||||
const tasks = center?.tasks ?? [];
|
||||
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
|
||||
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
||||
|
||||
return (
|
||||
@@ -3459,6 +3431,160 @@ function RewardCodeRedeemModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileQrScannerModal({
|
||||
error,
|
||||
result,
|
||||
onClose,
|
||||
onError,
|
||||
onResult,
|
||||
}: {
|
||||
error: string | null;
|
||||
result: string | null;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
onResult: (value: string) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
let scanTimer: number | null = null;
|
||||
const detectorCtor = getBarcodeDetectorConstructor();
|
||||
const detector = detectorCtor
|
||||
? new detectorCtor({ formats: ['qr_code'] })
|
||||
: null;
|
||||
|
||||
const clearScanTimer = () => {
|
||||
if (scanTimer !== null) {
|
||||
window.clearTimeout(scanTimer);
|
||||
scanTimer = null;
|
||||
}
|
||||
};
|
||||
const stopCamera = () => {
|
||||
const stream = streamRef.current;
|
||||
streamRef.current = null;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
videoElement.srcObject = null;
|
||||
};
|
||||
|
||||
const scanVideo = async () => {
|
||||
if (!isMounted || !detector || videoElement.readyState < 2) {
|
||||
if (isMounted && detector) {
|
||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const codes = await detector.detect(videoElement);
|
||||
const rawValue = codes[0]?.rawValue?.trim();
|
||||
if (rawValue) {
|
||||
clearScanTimer();
|
||||
stopCamera();
|
||||
onResult(rawValue);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
onError('扫码识别失败,请调整二维码位置');
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const startCamera = async () => {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
||||
onError('当前浏览器不支持摄像头扫码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
});
|
||||
|
||||
if (!isMounted) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = stream;
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
if (!detector) {
|
||||
onError('当前浏览器暂不支持二维码识别');
|
||||
return;
|
||||
}
|
||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||
} catch {
|
||||
onError('无法打开摄像头,请检查权限');
|
||||
}
|
||||
};
|
||||
|
||||
void startCamera();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearScanTimer();
|
||||
stopCamera();
|
||||
};
|
||||
}, [onError, onResult]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="扫码"
|
||||
>
|
||||
<div className="platform-qr-scanner-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-black">扫码</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭扫码"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
<div className="platform-qr-scanner-modal__viewport">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-cover"
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
<span className="platform-qr-scanner-modal__frame" />
|
||||
</div>
|
||||
{result ? (
|
||||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
已识别:{result}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileReferralModal({
|
||||
panel,
|
||||
center,
|
||||
@@ -3936,6 +4062,9 @@ export function RpgEntryHomeView({
|
||||
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
|
||||
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
|
||||
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
|
||||
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
|
||||
const [profilePopupPanel, setProfilePopupPanel] =
|
||||
useState<ProfilePopupPanel | null>(null);
|
||||
const [referralCenter, setReferralCenter] =
|
||||
@@ -4702,6 +4831,16 @@ export function RpgEntryHomeView({
|
||||
setTaskClaimSuccess(null);
|
||||
loadTaskCenter();
|
||||
};
|
||||
const openQrScannerPanel = () => {
|
||||
if (!authUi?.user) {
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setQrScannerError(null);
|
||||
setQrScannerResult(null);
|
||||
setIsQrScannerOpen(true);
|
||||
};
|
||||
const loadReferralCenter = useCallback(() => {
|
||||
setIsLoadingReferral(true);
|
||||
setIsReferralCenterInitialized(false);
|
||||
@@ -5264,23 +5403,6 @@ export function RpgEntryHomeView({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openActiveRecommendEntry = useCallback(() => {
|
||||
if (!activeRecommendEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
openRecommendGalleryDetail(activeRecommendEntry);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
authUi,
|
||||
isAuthenticated,
|
||||
openRecommendGalleryDetail,
|
||||
]);
|
||||
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -5924,28 +6046,10 @@ export function RpgEntryHomeView({
|
||||
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
|
||||
|
||||
const profileContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-profile-page`}>
|
||||
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section className="platform-profile-header">
|
||||
<div className="platform-profile-header__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="打开充值入口"
|
||||
>
|
||||
<ScanLine className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openSettingsModal()}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="打开设置"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
src={profileStillLifeImage}
|
||||
alt=""
|
||||
@@ -6198,36 +6302,21 @@ export function RpgEntryHomeView({
|
||||
icon={Settings}
|
||||
onClick={() => authUi.openSettingsModal()}
|
||||
/>
|
||||
<ProfileSettingsRow
|
||||
label="存档"
|
||||
icon={Archive}
|
||||
onClick={() => setProfilePopupPanel('saveArchives')}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{canShowReferralRedeemShortcut ? (
|
||||
<section
|
||||
className="platform-profile-secondary-shortcuts"
|
||||
aria-label="次级入口"
|
||||
>
|
||||
<ProfileSecondaryShortcutButton
|
||||
label="存档"
|
||||
subLabel={
|
||||
saveEntries.length > 0
|
||||
? `${saveEntries.length}个可继续`
|
||||
: '继续游玩'
|
||||
}
|
||||
icon={Archive}
|
||||
onClick={() => setProfilePopupPanel('saveArchives')}
|
||||
/>
|
||||
{canShowReferralRedeemShortcut ? (
|
||||
<ProfileSecondaryShortcutButton
|
||||
label="填邀请码"
|
||||
subLabel="新用户奖励"
|
||||
icon={Ticket}
|
||||
onClick={() => openProfilePopupPanel('redeem')}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
@@ -6695,6 +6784,22 @@ export function RpgEntryHomeView({
|
||||
onClose={() => setIsCategoryFilterPanelOpen(false)}
|
||||
/>
|
||||
) : null;
|
||||
const qrScannerModal: ReactNode = isQrScannerOpen ? (
|
||||
<ProfileQrScannerModal
|
||||
error={qrScannerError}
|
||||
result={qrScannerResult}
|
||||
onClose={() => {
|
||||
setIsQrScannerOpen(false);
|
||||
setQrScannerError(null);
|
||||
setQrScannerResult(null);
|
||||
}}
|
||||
onError={setQrScannerError}
|
||||
onResult={(value) => {
|
||||
setQrScannerError(null);
|
||||
setQrScannerResult(value);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
@@ -6706,7 +6811,26 @@ export function RpgEntryHomeView({
|
||||
{!isMobileRecommendTab ? (
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{isAuthenticated && activeTab === 'create' ? (
|
||||
{isAuthenticated && activeTab === 'profile' ? (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openQrScannerPanel}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="扫码"
|
||||
>
|
||||
<ScanLine className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi?.openSettingsModal()}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="打开设置"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : isAuthenticated && activeTab === 'create' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
@@ -6799,6 +6923,7 @@ export function RpgEntryHomeView({
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{rechargePaymentResultModal}
|
||||
{qrScannerModal}
|
||||
{categoryFilterDialog}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
|
||||
@@ -5685,26 +5685,17 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
box-shadow: 0 20px 50px rgba(112, 57, 30, 0.12);
|
||||
}
|
||||
|
||||
.platform-profile-header__actions {
|
||||
position: absolute;
|
||||
right: 0.8rem;
|
||||
top: 0.72rem;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border: 0;
|
||||
border: 1px solid rgba(232, 214, 201, 0.82);
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
background: rgba(255, 252, 248, 0.9);
|
||||
color: #1e120c;
|
||||
box-shadow: 0 8px 18px rgba(112, 57, 30, 0.06);
|
||||
}
|
||||
|
||||
.platform-profile-scene-decor {
|
||||
@@ -5725,8 +5716,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding-top: 2.6rem;
|
||||
padding-right: 6.75rem;
|
||||
padding-top: 0.2rem;
|
||||
padding-right: 4.25rem;
|
||||
}
|
||||
|
||||
.platform-profile-edit-button {
|
||||
@@ -5825,14 +5816,40 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 243, 230, 0.9);
|
||||
color: #bc5f34;
|
||||
}
|
||||
|
||||
.platform-qr-scanner-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: var(--platform-modal-fill);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 24px 80px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.platform-qr-scanner-modal__viewport {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 1.1rem;
|
||||
background: rgba(18, 16, 14, 0.92);
|
||||
}
|
||||
|
||||
.platform-qr-scanner-modal__frame {
|
||||
position: absolute;
|
||||
inset: 18%;
|
||||
border: 2px solid rgba(255, 244, 230, 0.92);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 0 0 999px rgba(0, 0, 0, 0.18),
|
||||
0 0 24px rgba(244, 138, 70, 0.28);
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6011,15 +6028,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
border-radius: 1.4rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__actions {
|
||||
right: 0.64rem;
|
||||
top: 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__identity {
|
||||
padding-top: 2.45rem;
|
||||
padding-right: 4.9rem;
|
||||
padding-top: 0;
|
||||
padding-right: 3.55rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__identity-row {
|
||||
@@ -6103,8 +6114,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-stat-card__icon {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
width: 1.95rem;
|
||||
height: 1.95rem;
|
||||
}
|
||||
|
||||
.platform-profile-stat-card__value {
|
||||
|
||||
Reference in New Issue
Block a user