Compare commits
243 Commits
codex/sse-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 767da0164a | |||
| a51e63415f | |||
|
|
bdf99468e7 | ||
| 5a1c1c88dd | |||
| 38babc592d | |||
| 660abff773 | |||
| cd49cb0106 | |||
| b7fd36747d | |||
| 951caac32d | |||
| 3bccfd1a83 | |||
| fe30396544 | |||
| 93e4522b65 | |||
| 2251fa2f8e | |||
| 4a6c126366 | |||
| 69815d918a | |||
| f87ae3f915 | |||
| cfc0c0eadf | |||
|
|
21add3dcbc | ||
|
|
1dd58a3d66 | ||
| 9d1dd493a6 | |||
| f7404a07ef | |||
| b54cbafc54 | |||
|
|
d78c11d5b7 | ||
| ed2c386603 | |||
| c5763fdf25 | |||
| 59facaf14b | |||
| 291ab06a5b | |||
| 9b40f6b453 | |||
| a20d7b1655 | |||
| ffcffef6d2 | |||
| f8a80cd795 | |||
|
|
86ea69f79d | ||
|
|
077b139e80 | ||
| 7c47ad3358 | |||
| d08842b576 | |||
| ef6a2b7200 | |||
| ebd958b5a0 | |||
| 193e4f0e96 | |||
| ce13bdbb02 | |||
| 0d9259b762 | |||
| 1b89611c9a | |||
| a8012109ae | |||
| 7f8400fd3a | |||
| 7431b1b9a4 | |||
| fe951a8819 | |||
| 58a3c6eb97 | |||
| 051fd6156c | |||
| 22c6edb7c2 | |||
| de0f0c1399 | |||
| 54ff839b0b | |||
| ae1a15cee0 | |||
| 84ded19f11 | |||
| ab5a0efe50 | |||
| 60ef4ead71 | |||
| 02f4982cfe | |||
| 01c0028b87 | |||
| 402b847c7f | |||
| 06bf03a28c | |||
| 0a4ccdf45c | |||
| 94122583ac | |||
| edf37d97a7 | |||
| 6163350f5c | |||
| 7005792580 | |||
| 4ef805282d | |||
| 40ff21f493 | |||
| 7dd53e95d8 | |||
| dbfdb9b99b | |||
| a33914aa5a | |||
| 31ad55b0cf | |||
| a076faf652 | |||
| 547e771f75 | |||
| f54c3ee936 | |||
| 914b74ce8e | |||
| 4e3378be65 | |||
| 08339b410b | |||
| c975d41d46 | |||
| 17cf65a1a3 | |||
| 1841eac0e1 | |||
| 2af0916c04 | |||
| fdc3b5f440 | |||
| f5536a14e6 | |||
| 488c69aecc | |||
| 701fd42777 | |||
| ba5f84d963 | |||
| 9a04ea55dc | |||
| 9141540c37 | |||
| cb01d33944 | |||
| f336352d37 | |||
| eb73ffb34d | |||
| 7411b9a435 | |||
| ebf181d53b | |||
| 53abf94635 | |||
| 1e6ecf2ea9 | |||
| fface53745 | |||
| e612b13b88 | |||
| e29992cf01 | |||
| 694b2fa209 | |||
| 9b36903021 | |||
| e019ece907 | |||
| 876fd37ce4 | |||
| eff95886ad | |||
| b9ddc07e0a | |||
| 71690c3aaa | |||
| 448b0697ee | |||
| 11193112b6 | |||
| 0db4f5a6df | |||
| 3e194c647e | |||
| 7977a19d24 | |||
| 345ace1842 | |||
| da8f2e3624 | |||
| e22cb1d06b | |||
| d5b3133c8d | |||
| 25a30a1111 | |||
| d7002929c3 | |||
| 6841b686d9 | |||
| 2bc76f0558 | |||
| 3f7748983c | |||
| 979ab10ea2 | |||
| 8157e656fa | |||
| d59654f277 | |||
| 1df16377ce | |||
| 2a9514a975 | |||
| 66f31db968 | |||
| 66b73f5a1d | |||
| c1905d52ac | |||
| 0f5e0114a9 | |||
| 930f43661b | |||
| b507302fdb | |||
| 076970828a | |||
| 7fcaf346c7 | |||
| 9389f9401f | |||
|
|
9db467d23f | ||
| b601b3b57e | |||
|
|
7aafb37f04 | ||
| 0baad9e022 | |||
| 43c66d31a2 | |||
| 1ad25e30f8 | |||
| 727aa8d353 | |||
|
|
6abb30c2ac | ||
| c3fb8f364c | |||
| 6e107200bb | |||
| 2d30fd808d | |||
|
|
585a5638db | ||
| 1e0577468e | |||
|
|
cd55eff12c | ||
|
|
68dd48be42 | ||
|
|
4ed9711b76 | ||
| 4f86c1a75b | |||
| 7eae91d7d3 | |||
|
|
facbb2074c | ||
|
|
568509027c | ||
| a0473771f1 | |||
| c9c66f046b | |||
| f69affec95 | |||
| 1dcba515b2 | |||
|
|
088470a315 | ||
|
|
11c5e3edf4 | ||
| a4ee6ff698 | |||
| 6f242a290a | |||
| 52c6f4282f | |||
| f4eee2d585 | |||
|
|
ccb5023197 | ||
| 5ea9f0a120 | |||
|
|
3ca5a460f1 | ||
| 8f991a4ac2 | |||
|
|
59b5bd1f83 | ||
|
|
3a918687c5 | ||
|
|
38d9c292ae | ||
| 49e4d085b3 | |||
| ff2ed5a59d | |||
| 498f7c9a3d | |||
| 17662916cd | |||
| e4b13d73b5 | |||
| 2a6da01307 | |||
| 7719c7e5a8 | |||
| d3a3238028 | |||
| decded991e | |||
| 48c7cce1ba | |||
| cc84656a1f | |||
| a5143fa0cb | |||
| 665f09f047 | |||
| 56a9075582 | |||
| ea4706daa6 | |||
| 78791af424 | |||
| 8dca8a6443 | |||
| c810e255a5 | |||
| 3965f34b02 | |||
| 63444d047f | |||
| 8131894bb5 | |||
| 8f460feb41 | |||
| e56a25243c | |||
| 9e1549151d | |||
| c344daba19 | |||
| f5cefc8d5f | |||
| 48ef19d518 | |||
| 4bb6d0bd1e | |||
|
|
e3ecac85f3 | ||
| ce930ee5c3 | |||
| 79d0d7a305 | |||
| b74440373f | |||
| d0be3f36aa | |||
|
|
ff7a2f6284 | ||
| d5b51a4242 | |||
| 18908609fc | |||
| 50e335ba47 | |||
| 95f17cd920 | |||
| dfa59aaf31 | |||
| 683a9115b3 | |||
| caa65bf15f | |||
|
|
b2543ba8a2 | ||
| ceb1e4b505 | |||
| 2db3a6e185 | |||
| 7e6ed91149 | |||
|
|
f4a8cc80c2 | ||
| fb3eede781 | |||
| 601c6772b7 | |||
| 7140ac72b5 | |||
|
|
36969726b4 | ||
| 3849c3ccbe | |||
|
|
8e1a62d130 | ||
| cd8088d1fd | |||
| 60709395d0 | |||
| dcbf02bbda | |||
| a215852381 | |||
| c98c3de96d | |||
|
|
ed6a59e641 | ||
| 6a03575d68 | |||
|
|
5a6d69bebe | ||
| 853d1db618 | |||
| 8d54ea3374 | |||
|
|
5150925947 | ||
| 89f596ea64 | |||
| 0edcb1b9f1 | |||
|
|
cb08c9ad20 | ||
|
|
2a271876ac | ||
| 1b39c0c5d7 | |||
| 27b30f974b | |||
| 0041b95f72 | |||
| 0c7fc0b26f | |||
| c442c3c3f0 | |||
| b9de2f2a43 | |||
| 7d2d67a3f5 | |||
| 1b5e098225 |
@@ -22,6 +22,7 @@ tmp
|
|||||||
.env.secrets.*
|
.env.secrets.*
|
||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
deploy/container/api-server.env
|
deploy/container/api-server.env
|
||||||
|
deploy/container/worker-smoke
|
||||||
|
|
||||||
server-rs/target
|
server-rs/target
|
||||||
server-rs/target-*
|
server-rs/target-*
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ WECHAT_MOCK_DISPLAY_NAME="微信旅人"
|
|||||||
WECHAT_MOCK_AVATAR_URL=""
|
WECHAT_MOCK_AVATAR_URL=""
|
||||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=""
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=""
|
||||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
|
||||||
|
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED="true"
|
||||||
|
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU"
|
||||||
|
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
|
||||||
|
|
||||||
# Model name for chat completions.
|
# Model name for chat completions.
|
||||||
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,6 +42,7 @@ temp*build*/
|
|||||||
.env.secrets.local
|
.env.secrets.local
|
||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
deploy/container/api-server.env
|
deploy/container/api-server.env
|
||||||
|
deploy/container/worker-smoke/
|
||||||
|
|
||||||
# Local load-test data extracted from private migration files
|
# Local load-test data extracted from private migration files
|
||||||
scripts/loadtest/data/*.local.json
|
scripts/loadtest/data/*.local.json
|
||||||
|
|||||||
@@ -16,10 +16,557 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-13 图片大图预览统一为黑底全屏查看器
|
||||||
|
|
||||||
|
- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
|
||||||
|
- 决策:纯图片大图预览统一使用 `src/components/common/PlatformImagePreviewModal.tsx`。该组件底层复用 `UnifiedModal` 的 dialog / portal / Escape 语义,但视觉上固定为黑底全屏查看器;图片按视口 contain 初始完整展示,缩放范围固定 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免露出背景。裁剪、选择、编辑等工具语义仍继续使用白底工具弹窗,不并入图片查看器。
|
||||||
|
- 影响范围:`CreativeImageInputPanel` 的参考图预览、主图预览,以及后续 common 级图片查看场景。
|
||||||
|
- 验证方式:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/README.md`、`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
|
||||||
|
|
||||||
|
## 2026-06-13 外部生成队列概览归属“我的”页签
|
||||||
|
|
||||||
|
- 背景:外部生成 worker 队列从单个生成页等待信息扩展为当前账号级别的后台排队 / 生成概览;继续放在生成页 / 进度页会把账号级队列与当前玩法业务进度混在一起。
|
||||||
|
- 决策:移动端用户可见的外部生成队列概览统一放在一级 `我的` 页签;生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作。队列概览只读取 BFF `GET /api/runtime/external-generation/queue-overview` 与当前前端已知单 job 状态作为等待补充,不替代玩法 session/detail 的 ready / failed 回读。
|
||||||
|
- 影响范围:平台入口壳层轮询条件、`RpgEntryHomeView` 我的页卡片、共用生成页 `CustomWorldGenerationView` / `UnifiedGenerationPage`、外部生成 worker 技术文档和本地开发验证文档。
|
||||||
|
- 验证方式:生成页不出现“生成队列”区域;登录用户进入“我的”页且队列有 pending/running 或当前 job 为 queued/running/failed 时显示队列卡;退出登录或切换账号时不保留旧账号队列概览。前端验证运行 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||||
|
|
||||||
|
## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼
|
||||||
|
|
||||||
|
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
|
||||||
|
- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。
|
||||||
|
- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。
|
||||||
|
- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。
|
||||||
|
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
|
||||||
|
|
||||||
|
- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。
|
||||||
|
- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias,不保存服务器密钥或凭据;巡检通过 `ssh <alias> sh -s` 执行只读脚本,服务操作只允许 `start`、`stop`、`restart` 并限制 systemd unit 名字符集。
|
||||||
|
- 影响范围:本地运维工具入口、`package.json` 的 `server-manager:panel`、开发运维文档和团队共享工作流。
|
||||||
|
- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。
|
||||||
|
|
||||||
|
## 2026-06-10 公开作品互动能力进入后台全局配置
|
||||||
|
|
||||||
|
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。
|
||||||
|
- 决策:公开作品点赞 / 改造能力作为 `creation_entry_config.public_work_interactions_json` 的全局矩阵保存,不进入单个 `creation_entry_type_config`。`GET /api/creation-entry/config` 下发 `publicWorkInteractions`;后台通过 `/admin/api/creation-entry/config/interactions` 按 `sourceType` 保存点赞、改造开关和关闭提示;api-server 只对已经接入后端动作的 RPG / custom-world、大鱼吃小鱼和拼图 like / remix 路由做同源熔断,公开列表、详情读取、已发布作品启动和运行态请求不受影响。
|
||||||
|
- 影响范围:`CreationEntryConfigResponse`、`AdminCreationEntryConfigResponse`、`module-runtime` 默认矩阵、`spacetime-module` 表字段和 procedure、`spacetime-client` 绑定、后台入口开关页、平台作品详情点赞 / 改造意图解析。
|
||||||
|
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime public_work_interaction_config_defaults_and_overrides --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server public_work_interactions --manifest-path server-rs/Cargo.toml`、后台和前台作品详情互动相关前端测试。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-10 dev Gitea 提供内网 HTTP 入口
|
||||||
|
|
||||||
|
- 背景:release / dev 目标 agent 需要从 dev 自托管 Gitea 拉取仓库;继续走 `https://git.genarrative.world/...` 会绕公网链路,`10.2.0.10:3000` 又受云侧端口策略影响不能作为稳定入口。
|
||||||
|
- 决策:dev 上 Gitea 进程保持 `HTTP_ADDR = 127.0.0.1`、`HTTP_PORT = 3000`,公网 `ROOT_URL = https://git.genarrative.world/` 不变;新增 Nginx 内网 vhost `/etc/nginx/conf.d/gitea-internal.conf`,只允许 `10.2.0.0/16` 与本机访问,并把 `http://10.2.0.10/` 反代到本机 Gitea。内网 agent 统一使用 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 作为可直连 Git 源。
|
||||||
|
- 影响范围:dev Gitea / Nginx 运维配置、Jenkins `SOURCE_GIT_REMOTE_URL`、release / dev 目标 agent checkout 口径。
|
||||||
|
- 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。
|
||||||
|
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-08 通用分享统一为作品分享卡片
|
||||||
|
|
||||||
|
- 背景:已发布作品的分享入口需要同时支持网页复制链接、下载可传播的分享卡,以及微信小程序内的九宫切图;推荐页在小程序内直接使用系统“分享到聊天”时,宿主快照只截页面中部,容易裁掉游戏主体,且原生分享默认只能拿到小程序页面启动参数。
|
||||||
|
- 决策:统一分享入口继续收口到 `PublishShareModal`,分享卡展示作品封面、作品类型、作品名称和公开作品号,底部提供“复制链接”和“下载卡片”。普通 H5 复制公开作品 H5 URL;微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,缺少直达参数时补 `targetPath=/works/detail` 与 `work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL。当 H5 运行在微信小程序 WebView 内且存在封面图时,额外显示“九宫切图”,跳转小程序原生 `pages/share-grid/index`,由原生页按 3x3 从左到右、从上到下裁切并保存。推荐页当前作品会通过 `wx.miniProgram.postMessage` 同步给小程序原生 `web-view` 页,右上角系统分享优先使用该目标生成带作品参数的小程序路径。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。
|
||||||
|
- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/publishShareModalModel.ts`、`src/components/common/publishShareCardImage.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`miniprogram/pages/web-view/`、`miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts`、`npm run test -- miniprogram/pages/share-grid/index.test.js`、`npm run test -- src/index.test.ts -t "mini program recommend runtime"`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
## 2026-06-08 微信能力按领域收口
|
||||||
|
|
||||||
|
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
|
||||||
|
- 决策:`api-server` 内微信相关 HTTP/BFF 适配统一收在 `server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*`;`platform-wechat` 负责微信订阅消息、微信支付 V3、虚拟支付消息推送的协议 client、header、签名、验签、解密、mock 和 payload 解析;`api-server::wechat` 只负责 AppConfig 映射、Axum handler、用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。微信 OAuth / 小程序登录 provider 暂继续在 `platform-auth`,通过 `api-server::wechat::provider` 作为组合根 adapter 接入。
|
||||||
|
- 影响范围:`server-rs/crates/api-server/src/wechat.rs`、`server-rs/crates/api-server/src/wechat/*`、`server-rs/crates/platform-wechat/src/*`、微信支付 / 订阅消息 / 小程序消息推送文档。
|
||||||
|
- 验证方式:执行 `cargo check --manifest-path server-rs/Cargo.toml -p platform-wechat`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server`、微信相关定向测试和编码检查;新增微信协议细节优先落到 `platform-wechat`。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||||
|
|
||||||
|
## 2026-06-08 后端创作 / 游玩流程先统一主干再领域分发
|
||||||
|
|
||||||
|
- 背景:前端平台入口、作品架、公开详情和推荐运行态已经持续收口,但 `api-server` 仍在 `app.rs` 逐玩法合并创作 / 运行态路由,入口开关路径判断也独立维护,新增玩法容易复制出平行链路。
|
||||||
|
- 决策:后端所有创作 / 游玩相关 HTTP 路由先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 统一主干;主干注册 `playId`、领域模块 key、创作路由前缀、运行态路由前缀和新建创作入口开关匹配规则,并在进入领域 handler 前统一挂载 `PlayFlowRequestContext`,再在最后一步分发到各玩法领域 HTTP Adapter。创作入口配置、AI task、runtime chat、运行态设置 / 存档、运行态库存、游玩历史、存档归档、游玩统计、历史素材、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理也作为创作 / 游玩支撑能力从 `play_flow` 进入;`modules/platform.rs` 只保留通用 LLM / 语音代理。`app.rs` 只合并 `modules::play_flow::router(state)`,不再逐玩法 merge;`creation_entry_config.rs` 复用 `play_flow` 的入口开关解析,不维护第二份路径表。
|
||||||
|
- 影响范围:`api-server` 路由组织、入口开关、玩法接入 SOP、后端契约文档、后续新增 / 迁移玩法。
|
||||||
|
- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation/<play>/*`、历史 `/api/runtime/<play>/agent/*` 与公开 runtime 路由外部契约不变。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-08 PlatformUiKit 弹窗与复制反馈收口
|
||||||
|
|
||||||
|
- 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。
|
||||||
|
- 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。
|
||||||
|
- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。
|
||||||
|
- 2026-06-10 追加:标准泥点消耗确认弹窗统一收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;该 Module 专门承接“确认消耗泥点 + 消耗 N 泥点”的同形态确认骨架,当前已覆盖 `PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx`、`PuzzleResultView.tsx` 与 `Match3DResultView.tsx`。后续遇到同形态泥点确认时,业务页只传点数、补充说明和确认回调,不再重复拼接 `UnifiedConfirmDialog` 正文;`RpgCreationRoleAssetStudioModalImpl` 这类节奏和内容结构不同的泥点弹层继续单独评估,留作后续轮次处理。
|
||||||
|
- 2026-06-10 追加:`RpgCreationRoleAssetStudioModalImpl.tsx` 的角色形象生成 / 动作草稿生成确认也并入 `PlatformMudPointConfirmDialog`;共享组件通过自定义 title 与补充说明承接工坊语义,工坊页不再单独维护 `UnifiedConfirmDialog` 的标准泥点文案骨架。后续同类“确认消耗泥点 + 补充说明”场景继续优先复用该 Module。
|
||||||
|
- 2026-06-10 追加:平台危险确认统一收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;该 Module 专门承接“确认 / 取消 + 危险主动作”的标准骨架,当前已覆盖 `PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认和 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认。后续删除、覆盖、清空等危险动作优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + confirmTone=\"danger\"` 组合。
|
||||||
|
- 2026-06-10 追加:平台未保存离开确认统一收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;该 Module 专门承接“继续编辑 + 确认离开”的标准骨架,当前已覆盖 `RpgCreationEntityEditorShared.tsx` 里的关闭未保存修改、生成结果未保存退出和普通结果未保存退出确认。后续同类未保存离开场景优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + cancelLabel=\"继续编辑\"` 组合和重复壳层 class。
|
||||||
|
- 2026-06-10 追加:平台单按钮已读状态统一收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;该 Module 专门承接“状态提示 + 知道了”的单按钮确认已读语义,当前已覆盖 `BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示。后续同类 status-dialog 场景优先复用该 Module,不再在业务页重复拼装 `action={{ label: '知道了', onClick: onClose }}`。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||||
|
- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的账户充值弹层统一抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;充值 tab、套餐卡片、Native 二维码生成和确认支付入口不再内联在 `RpgEntryHomeView`,后续 profile 侧充值入口优先复用同一个组件。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的泥点账单、每日任务和兑换码弹层统一抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;`RpgEntryHomeView` 只保留打开条件和数据流,标准 profile 弹层内容以后优先沉到 `platform-entry` 独立组件,不在首页继续堆叠。
|
||||||
|
- 2026-06-10 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 只保留支付结果 kind 到 `success / loading / cancel / error` 的映射、确认遮罩开关和扫码结果写回,不再内联 profile 状态弹层壳层、二维码摄像头启动或 `BarcodeDetector` 轮询。后续 profile 侧同类“状态图标 + 标题正文 + 可选主动作”弹层优先复用 `PlatformStatusDialog`,扫码类弹层优先复用 `PlatformProfileQrScannerModal`。
|
||||||
|
- 2026-06-10 追加:`PlatformStatusDialog` 支持自定义图标、图标可访问标签以及动作按钮 surface / size / className 透传,用来承接玩法结果页里保留品牌视觉但语义仍是“状态结果弹层”的场景;大鱼吃小鱼结果页的发布失败弹层已迁移到这套组件,业务页不再保留 `UnifiedConfirmDialog + PlatformIconBadge` 的专用组合。
|
||||||
|
- 2026-06-10 追加:`PlatformStatusDialog` 继续支持 header notice 布局、body content、close button、backdrop / Escape 关闭路径,用来承接“提示 / 规则阻断 / 作品不可用 / 泥点不足”这类带标题栏的状态 notice;平台入口的 `draftGenerationPointNotice`、`workNotFoundRecoveryDialog` 和 RPG 大编辑器里的 `EditorNoticeDialog` 已迁移到这套共享组件,不再各自维护 `UnifiedConfirmDialog` 壳层和关闭策略。
|
||||||
|
- 2026-06-10 追加:`CustomWorldEntityCatalog` 的 `minimum-playable` 规则阻断提示也统一迁到 `PlatformStatusDialog`,不再和删除角色 / 批量删除共用 `UnifiedConfirmDialog` 配置;同日平台入口公开编号搜索把 error 分支从用户摘要 modal 中拆出,未命中结果单独走 `PlatformStatusDialog`,命中用户继续保留 `UnifiedModal + PlatformSubpanel` 信息布局。
|
||||||
|
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续从 profile modal 与作品架扩展到 RPG 首页公开分区;`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed,以及“我的创作”分区已统一改成 `loadingState / emptyState / children` 三态 slot。页面级 `platformError` 继续留在状态壳外层,保证错误提示可以和内容并存;`recommend runtime`、分类筛选等含运行态或二级筛选语义的分支暂不硬并入这一轮。
|
||||||
|
- 2026-06-11 追加:暗色 / 像素 modal 的标准 footer 布局统一抽到 `src/components/common/PlatformDarkModalFooter.tsx`;该组件只负责 dark footer 的分隔线、padding 和常见动作区排布,不持有“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx` 的 `SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer,以及 `InventoryItemViews.tsx` 的详情 footer wrapper 已接入;sticky 工作台 footer、正文内单 CTA 收尾和 runtime HUD 工具条暂不并入这一抽象。
|
||||||
|
- 2026-06-11 追加:桌面首页里的轻量可点击扁平行开始统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;目前已覆盖 `RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行。组件只承接 `button + left content + right affordance` 结构、默认 `type="button"` 与 `leading / trailing` 插槽,暂不扩成覆盖教培 promo card、分类卡片、世界卡或 runtime 列表项的万能 row primitive。
|
||||||
|
- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 profile 设置行;`src/components/platform-entry/PlatformProfilePrimitives.tsx` 的 `ProfileSettingsRow` 已改成委托共享 `button + leading + trailing` 骨架,继续保留本地 `platform-profile-settings-row` class 承接分隔线、icon 胶囊和字号微调。后续 profile / 账户中心里的同类轻量导航行优先直接复用共享行骨架,不再回退成原生 `<button>` 手写布局。
|
||||||
|
- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 RPG 首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx` 的 `PlatformRankingItem`、`PlatformCategoryGameItem` 已改成委托共享 `button + leading + body + trailing` 骨架,同时保留 `platform-ranking-item__*` 与 `platform-category-game-item__*` 局部 class 承接封面、metric、badge、摘要和右侧 `试玩 / 进入` affordance。后续首页 / 发现页里同类浅色导航行优先沿“共享骨架 + 本地皮肤 class”推进,不再为了这类 row 回退成原生 `<button>` 手写布局。
|
||||||
|
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续补齐 RPG 首页分类分支;移动端“发现 -> 分类”、桌面发现页“分类”和桌面首页“作品分类”模块现在都统一委托共享状态壳切换外层 `loading / empty / content`,分类控制条与排序按钮继续留在内容 slot 中。筛选后无结果的“当前筛选下没有作品。”也统一改成内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自维护嵌套 ternary。
|
||||||
|
- 2026-06-11 追加:`PlatformDarkModalFooter` 不只收动作按钮区,也继续覆盖纯内容 footer;`CompanionCampModal.tsx` 底部“营地气氛”区域已改成 `layout="content"` + `padding="roomy"` 的共享 footer frame,保留原有文案和卡片布局,不再单独手写 `border-t border-white/10 px-5 py-4`。
|
||||||
|
- 2026-06-11 追加:`PlatformDarkModalFooter` 继续从标准双按钮 footer 扩到 detail / confirm 收尾;`NpcModals.tsx` 的交易详情 footer 和 `MapModal.tsx` 的场景切换确认 footer 已改成复用同一个 dark footer frame,即使只有单个“关闭”按钮也不再手写 `flex justify-end`。这条抽象继续只覆盖 dark / pixel modal 里的底部分隔线与常规动作区排布,不向白底 profile 弹窗 footer、sticky 工作台 footer 或运行态 HUD 工具条扩张。
|
||||||
|
- 2026-06-11 追加:`PlatformFilterToolbar.tsx` 作为薄结构组件收口 RPG 首页分类工具条;组件只承接“筛选按钮 + tabs + 排序按钮”的排布与 `mobile / desktop` 两种布局差异,不持有筛选状态、空态或排序逻辑。后续只有在同构壳层真的复现时才继续往 `common` 扩覆盖面;如果只是单页内局部重复、接口会越抽越胖,就优先退回文件内 helper。
|
||||||
|
- 2026-06-11 追加:`SquareImageCropModal.tsx` 的白底弹窗壳层改为复用 `UnifiedModal.tsx`,同时给 `UnifiedModal` 薄补 `titleId` 与 `closeIcon` 透传,让裁剪弹窗继续保留自定义 close icon、无 backdrop / Escape 关闭和两列 footer,而不把 `PlatformProfileModalShell` 这类带页面语义的壳层倒灌回 `common/`。这条规则适用于 `common` 级工具弹窗:先看 `UnifiedModal` 能不能承接,再决定是否需要新的薄壳。
|
||||||
|
- 2026-06-11 追加:`CreativeImageInputPanel.tsx` 里参考图预览、主图预览和移除图片确认都继续并回 `UnifiedModal` 体系:两个预览弹窗直接复用 `UnifiedModal`,删除确认直接复用 `UnifiedConfirmDialog`,不再在图片面板里手写三段 `platform-modal-backdrop + platform-modal-shell`。当前没有新增 `PlatformImagePreviewModal`,因为这批差异还只在尺寸与文案层,继续组合已有 modal 原语的 leverage 更高。
|
||||||
|
- 2026-06-11 追加:`src/components/common/PlatformUtilityInfoModal.tsx` 作为 `UnifiedModal` 之上的薄壳,统一承接 `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗骨架:平台主题 overlay、白底 panel,以及 body / footer 间距与标准 footer frame。该壳层不继续向上吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌 icon;后续 `common` 级工具信息弹窗若只是重复这套白底信息壳,优先复用 `PlatformUtilityInfoModal`,业务正文和 footer 交互继续留在调用方。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
- 2026-06-11 追加:profile 白底副弹层里的摘要头、列表骨架和内容行继续沉到 `PlatformProfileSummaryHeader.tsx`、`PlatformProfileSkeletonList.tsx` 与 `PlatformProfileContentRow.tsx`;这组组件只承接 `kicker + title + badge` 摘要层次、重复 skeleton 行以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或状态切换逻辑。后续 profile modal 若只是重复这三类白底内容骨架,优先复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
- 2026-06-11 追加:`PlatformProfileModalShell` 继续补齐标准 footer 插槽,直接透传 `UnifiedModal.footer` 与 `footerClassName`;`RpgEntryHomeView.tsx` 的昵称修改弹窗已改成标准 profile footer,不再把双按钮动作区手写在 body 末尾。后续个人中心里同类“表单内容 + 底部双按钮”弹窗优先走壳层 footer 接法。
|
||||||
|
- 2026-06-11 追加:`PlatformProfileModalShell` 的标准 footer 接法继续扩展到单 CTA 表单收尾;`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换按钮已迁到壳层 footer,body 只保留输入和反馈消息。`PlatformAsyncStatePanel` 同日继续扩展到 `PlatformAssetPickerGrid`、`VisualNovelSavePanel.tsx` 与 `AccountModal.tsx` 的账号安全三个子区块;其中公共素材网格继续把 `error` banner 放在状态壳外层,保持错误提示可与加载态或内容并存的原语义。
|
||||||
|
- 2026-06-11 追加:按钮层继续补齐轻量漏网项。`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留透明背景和原 chip 高度;`RpgEntryCharacterSelectView.tsx` 的两处“返回”按钮统一沉到局部 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`。同日 `GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已开始复用这套暖色生成页返回入口骨架;后续同类轻量返回按钮与 chip 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
|
||||||
|
- 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。
|
||||||
|
- 2026-06-10 追加:creation-agent composer 的上传文档 / 上传参考图入口使用 `PlatformIconButton` 默认 `platformIcon`;工作台只保留动态 label、title、busy 状态和 picker 回调,发送按钮继续保留主题色动作布局。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-10 追加:作品详情顶部返回 / 分享和封面轮播上一张 / 下一张入口使用 `PlatformIconButton variant="platformIcon"`;详情页保留原 `platform-work-detail__*` 局部 class 控制位置和尺寸,点赞、复制三态等专用动作暂不迁移。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-09 追加:通用输入 Composer 普通 panel 外壳迁移到 `PlatformSubpanel`,文本域迁移到 `PlatformTextField variant="textarea"`,读图错误迁移到 `PlatformStatusMessage surface="profile"`;浮动胶囊 Composer 保留专用外壳和 CSS 覆盖。
|
||||||
|
- 2026-06-10 追加:`PlatformStatusMessage` 根节点固定带 `platform-status-message` 类名,供业务测试断言公共状态条接入;RPG 大编辑器中的场景背景生成、作品封面生成和封面上传错误 / 成功提示先使用 `surface="tinted"` 加局部暗色 class 保留编辑器视觉,后续普通暗色编辑 / 运行面板状态提示统一迁入 `surface="editorDark"`。
|
||||||
|
- 2026-06-10 追加:`PlatformStatusMessage surface="editorDark"` 承接 RPG 暗色面板里的普通错误 / 成功 / 信息 / 警告 / 中性提示;背包故事档案 QA 提示、角色聊天错误提示、营地编组战斗中提示和自定义选择弹窗错误 / 生成中提示已迁移,业务 JSX 不再手写暗色 `border-*-300/15 bg-*-500/10 text-*-50/90` 状态条 chrome。
|
||||||
|
- 2026-06-10 追加:NPC 交易 / 赠礼 / 招募弹窗里的叙事提示使用 `PlatformStatusMessage surface="editorDark"`;弹窗只保留 introText 数据和业务 tone 选择,不再手写暗色提示条边框、底色、圆角、字号和换行 class。
|
||||||
|
- 2026-06-10 追加:creation-agent composer 错误条使用 `PlatformStatusMessage surface="platform"`;工作台只保留错误来源合并和局部外边距 / 圆角,不再手写红色边框、底色和文字 class。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 追加:creative-agent 首页错误提示使用 `PlatformStatusMessage tone="error" surface="platform" size="md"`;首页只保留宽度对齐局部 class 和错误文案,不再手写 danger panel chrome。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 追加:大鱼吃小鱼结果页发布校验阻断项使用 `PlatformStatusMessage tone="warning" surface="platform" size="xs"`;结果页只保留阻断项裁剪和文案,不再手写 amber 文本列表。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-09 追加:通用创作图片面板中覆盖在图片或输入区上的更换主图、移除主图、历史入口短标签按钮和提示词参考图上传入口,以及抓大鹅封面编辑中覆盖在封面图上的移除入口,使用 `PlatformIconButton variant="surfaceFloating"`;白底圆形 / 短标签浮动图标动作的 `border-white/80`、`bg-white/94`、`backdrop-blur`、hover 和禁用态不再在业务 JSX 中重复拼。
|
||||||
|
- 2026-06-10 追加:`PlatformIconButton variant="darkMini"` 承接覆盖在缩略图上的暗色小型图标动作;`PlatformUploadPreviewCard` 的 square 右上移除按钮已迁移到该 variant,上传预览卡不再手写黑底圆形移除按钮 chrome。
|
||||||
|
- 2026-06-09 追加:图片编辑面板中的白底胶囊开关统一使用 `src/components/common/PlatformPillSwitch.tsx` 承载 label + `role="switch"` 输入语义、轨道、圆点、白底浮层和禁用态;通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已先迁移,业务页只保留受控布尔值和状态变更回调。
|
||||||
|
- 2026-06-09 追加:设置面板、结果页配置和工作台白底配置项里的整行开关统一使用 `src/components/common/PlatformToggleRow.tsx` 承载 label、checkbox、只读状态 pill、可选 icon、可选点击状态行、禁用态和 soft / plain 两类白底 surface;视觉小说结果页运行配置 / 玩家可见开关、视觉小说 runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移,业务页只保留字段写回和点击动作。
|
||||||
|
- 2026-06-09 追加:公开编号搜索结果弹窗关闭按钮使用 `PlatformModalCloseButton variant="platformIcon"`,平台壳不再手写 `platform-icon-button` + 关闭文本。
|
||||||
|
- 2026-06-10 追加:RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`,暗色编辑器保留 `platform-icon-button` 视觉 token,但业务 JSX 不再手写关闭按钮 aria、默认 X 图标和禁用态拼接。
|
||||||
|
- 2026-06-10 追加:`PlatformModalCloseButton variant="editorDark"` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口,根节点固定带 `platform-modal-close-button--editor-dark` 稳定类名;自定义选择弹窗头部关闭按钮已迁移,并补齐 `aria-label`,业务 JSX 不再手写暗色关闭按钮边框、底色、hover 和默认 X 图标。验证命令:`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`。
|
||||||
|
- 2026-06-10 追加:`PlatformModalCloseButton variant="pixel"` 承接 `UnifiedModal variant="pixel"` 头部圆形关闭入口;`UnifiedModal` 只选择 `platformIcon / pixel` 变体并保留 closeDisabled、Backdrop、Escape 和 portal 语义,不再手写 X 图标、aria 和关闭按钮 class。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`。
|
||||||
|
- 2026-06-10 追加:`UnifiedModal` 新增 `closeVariant`、`closeOnEscape`、`titleClassName` 和 `descriptionClassName`,用于在收口标准平台弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、原有标题层级和“不响应 Escape / backdrop”的交互语义;RPG 首页个人中心里的昵称修改、账户充值、每日任务和兑换码弹窗已迁移到 `UnifiedModal`,支付结果 / 支付确认遮罩 / 泥点账单这类头部结构不同的弹窗继续保留专用实现。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
- 2026-06-10 追加:`UnifiedModal` 新增 `showHeader`,用于收口不需要标准头部但仍要保留 dialog 无障碍语义、遮罩和层级控制的轻量弹窗;RPG 首页个人中心的支付结果提示与支付确认遮罩已迁移到 `showHeader={false}` 模式,业务页只保留 icon badge、文案与按钮,不再手写 backdrop、aria 和白底壳层。个人中心移动端顶栏“扫码”“打开设置”入口统一使用 `PlatformIconButton`,并继续保留 `.platform-profile-header__icon-button` 局部 class 控制位置与主题色。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformIconButton.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 首页发现页分类筛选弹窗和个人中心扫码面板改用 `UnifiedModal` 承接 backdrop、dialog 语义和层级;分类筛选保留本地选项 / 动作布局,扫码面板继续使用 `showHeader={false}` 保留深色自定义头部与摄像头 viewport,并显式维持 `closeOnBackdrop={false}`、`closeOnEscape={false}`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/UnifiedModal.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心泥点账单改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留渐变面板、`PlatformModalCloseButton variant="floating"`、余额 badge 与账单列表布局;账单继续显式维持 `closeOnBackdrop={false}`、`closeOnEscape={false}`,测试改为直接断言具名 dialog 和关闭后卸载。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "opens wallet ledger modal from narrative coin card|wallet ledger modal shows empty and error states" src/components/common/UnifiedModal.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心“玩过作品”面板改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留 `PLAYED` kicker、总时长 badge、`PlatformModalCloseButton variant="floating"`、`可继续 / 玩过` 双分区与作品卡布局;存档入口继续留在同一个“玩过”面板内,不再回退成独立 `SAVE ARCHIVE` / `ARCHIVE` 壳层。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile played modal summary and work type use platform pill badges|profile played modal empty state uses platform empty state" src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel|profile page keeps save archives inside played stats panel" src/components/common/UnifiedModal.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心邀请相关弹层里的 live `community / redeem` 分支改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留 `PlatformModalCloseButton variant="floatingPlain"`、居中标题、社区二维码卡片、邀请码输入 / 已填写空态和成功 / 失败提示;历史 `invite` 分支没有新的入口,当前只随同一壳层维持现状。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login" src/components/common/UnifiedModal.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心昵称旁的铅笔入口改用 `PlatformIconButton`,继续保留 `.platform-profile-edit-button` 局部尺寸、边框和浅色底样式;昵称编辑入口不再手写原生 `<button>` 的 `type`、`aria-label` 和图标壳。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal uses platform text field and submits with Enter" src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-09 追加:RPG 大编辑器暗色面板内的保存和角色槽动作继续走本地 `ActionButton`,不再混用白底平台 `platform-button` class;平台白底动作收口和编辑器暗色动作收口保持两套视觉边界。
|
||||||
|
- 2026-06-10 追加:`PlatformActionButton surface="editorDark"` 承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,支持 `size="xxs"` 与 `tone="success" | "warning"`;`tone="accent"` 承接暗色壳层内的琥珀实心 CTA,`tone="accentSoft"` 承接依赖局部 accent 变量的柔和强调按钮。角色自定义 footer、自定义世界生成 footer、地图切换确认、营地编组普通动作和角色聊天刷新动作已迁移。暗色可选项卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。验证命令:`npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 首页创作 / 草稿顶栏的钱包快捷入口通过同文件 `TopbarWalletShortcutButton` 复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"` 与 `PlatformIconBadge`;移动端 / 桌面端继续保留 `.platform-mobile-create-wallet-chip`、`.platform-desktop-create-wallet-chip` 和 `.platform-desktop-search` 兼容 class,承接余额截断、桌面顶栏胶囊壳和既有测试锚点,点击语义仍统一走 `openRechargeOrRewardCodeModal`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 大编辑器里的当前角色、可选角色、预设背景和场景连接关系等暗色信息面板通过本地 `EditorInfoPanel` 复用 `PlatformSubpanel surface="dark"`;有右侧动作的面板也只向适配器传 actions,不再在业务 JSX 中重复手写暗色面板边框、底色、圆角、标题行和内容间距。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`。
|
||||||
|
- 2026-06-10 追加:作品详情底部“作品改造 / 作品编辑”和“启动”使用 `PlatformActionButton surface="platform" shape="pill" size="lg" fullWidth`;详情页保留 `platform-work-detail__remix / start` 局部 class 控制 sticky 底部栏位置、比例和品牌背景。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
||||||
|
- 2026-06-10 追加:作品详情点赞按钮使用 `PlatformActionButton tone="accentSoft"`;详情页只保留纵向排布、尺寸和 `--platform-action-accent` 局部变量,不再手写点赞按钮边框、底色、文字和阴影 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
||||||
|
- 2026-06-09 追加:大鱼吃小鱼结果页白底平台动作迁移到 `PlatformActionButton shape="pill" size="xs"`;资产工坊关闭 / 生成正式图、关卡主图 / 待机 / 移动入口和场地背景生成只保留业务回调,深色 hero 返回 / 测试 / 发布按钮继续保留玩法品牌布局。
|
||||||
|
- 2026-06-10 追加:大鱼吃小鱼结果页 hero 顶部的玩法摘要 chip 使用 `PlatformPillBadge tone="lightOverlay"`,并只保留局部 `bg-white/10` 覆盖;hero 只保留 `coreFun / ecologyTheme / levelCount` 文案,不再手写三段白色静态标签。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx -t "renders generated formal previews with accurate status copy"`。
|
||||||
|
- 2026-06-10 追加:反馈页“查看反馈与投诉记录”这类页面内次级文本动作使用 `PlatformActionButton tone="ghost" shape="pill" size="xs"`;反馈页只保留提示回调,不再手写居中、字号、内边距和冷色文本按钮 class。验证命令:`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
||||||
|
- 2026-06-10 追加:创作中心作品卡积分激励的“领取积分 / 领取中”按钮使用 `PlatformActionButton tone="secondary" size="xxs"`;作品卡保留 `creation-work-card-incentive__button` 局部 class 承接三列布局、移动端跨列、紧凑高度和玻璃底,同时保留点击 / 键盘冒泡拦截,避免触发整卡打开。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`。
|
||||||
|
- 2026-06-09 追加:敲木鱼 fallback 返回、跳一跳结算、拼消消 runtime header / 结算弹窗等白底 HUD 动作使用 `PlatformActionButton`,拼消消 runtime 白底错误条使用 `PlatformStatusMessage surface="platform"`;深色半透明游戏提示和强品牌按钮仍可保留 runtime 专用布局。
|
||||||
|
- 2026-06-10 追加:运行态短错误 / 成功 / 命中反馈 chip 使用 `PlatformRuntimeStatusToast` 承接圆角、字号、阴影、色值和 `role="alert/status"` 语义;跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态短 toast 已迁移。玩法专属返回按钮、计分牌、蓄力提示和强品牌主按钮仍留在 runtime 壳层,不把位置和玩法资产耦合进公共 Module。验证命令:`npm run test -- src/components/common/PlatformRuntimeStatusToast.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`。
|
||||||
|
- 2026-06-09 追加:历史图片 / 历史素材 / 可引用素材选择统一使用 `src/components/common/PlatformAssetPickerCard.tsx` 中的 `PlatformAssetPickerCard` 与 `PlatformAssetPickerGrid`,由该 Module 承载缩略图、禁用态、选中态、边框、hover、主副文案、`ResolvedAssetImage` 壳层、错误态、读取态、空态和网格布局;拼图历史图片弹窗、方洞历史生成、视觉小说历史素材选择器、RPG 大编辑器历史素材弹窗和抓大鹅封面编辑可引用素材网格已先迁移,业务页只传素材数组、素材地址、文案、可访问名称、surface、选中判断和选择回调。RPG 大编辑器等暗色弹窗使用 `surface="editorDark"`,不混用白底平台卡片视觉;场景横图通过 `imageShellClassName` 保留 16:9。
|
||||||
|
- 2026-06-09 追加:平台白底圆角输入框和文本域统一使用 `src/components/common/PlatformTextField.tsx` 承载 input / textarea 语义、基础边框、背景、内边距、字号 / 行高、密度和禁用态;同组下拉框使用 `PlatformSelectField` 复用同一输入 chrome。抓大鹅结果页作品名称 / 描述、封面描述、素材名称、批量新增 / 批量重生成物品名称,方洞结果页主信息表单和形状 / 洞口选项字段,拼图结果页作品信息 / 关卡名称 / 智能修订输入,敲木鱼结果页作品标题 / 简介,敲木鱼创作工作台功德词条输入,creative-agent 模板确认调整弹层关卡数输入,拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题,以及视觉小说结果页音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段已先迁移,业务页只保留受控值、事件、可访问名称、占位符、选项和局部布局 class。同一面板内的主图上传和提示词参考图上传必须使用不同可访问名称,避免多个同名“上传参考图”入口让测试和读屏语义混淆;拼图关卡编辑中的描述参考图入口使用“上传描述参考图”。
|
||||||
|
- 2026-06-09 追加:通用创作图片输入面板的提示词文本域也使用 `PlatformTextField variant="textarea" density="roomy"`;图片面板只通过局部 class 保留高度、`pb-14` 和浮动参考图上传按钮避让,不再自己维护白底 textarea 边框、背景、字号和禁用态。
|
||||||
|
- 2026-06-09 追加:`PlatformTextField` / `PlatformSelectField` 的 `tone="warm" | "rose" | "emerald"` 统一承接平台表单焦点色;视觉小说创作工作台、统一抓大鹅创作工作台、汪汪声浪轻配置编辑器和宝贝识物工作台普通输入 / 文本域 / 下拉框已先迁移,玩法调性焦点色通过 tone 表达,不在业务 JSX 中重复拼 `focus:border-* focus:ring-*`。
|
||||||
|
- 2026-06-10 追加:`PlatformTextField` / `PlatformSelectField` 支持 `surface="editorDark"` 和 `tone="sky"`,承接 RPG 暗色弹窗 / 运行面板里的普通输入框、文本域、下拉框、禁用态、密度、字号和焦点色;自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述和角色聊天草稿已迁移,业务 JSX 不再手写暗色 `border-white/10 bg-black/30 px-4 py-3` 或 `focus:border-*` 输入 chrome。验证命令:`npm run test -- src/components/common/PlatformTextField.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CharacterChatModal.test.tsx`。
|
||||||
|
- 2026-06-10 追加:`PlatformTagEditor` 内部新增标签输入框也使用 `PlatformTextField density="compact" size="xs"`;标签编辑器只保留新增状态、解析、Enter / Escape 行为和按钮组合,不再手写白底 input chrome。
|
||||||
|
- 2026-06-10 追加:认证图形验证码答案输入使用 `PlatformTextField density="compact"`;验证码组件只保留 challenge 展示、答案受控值和变更回调,不再手写 `platform-input` 输入框 chrome。
|
||||||
|
- 2026-06-10 追加:认证入口的短信 / 密码登录、重置密码、绑定手机号、邀请码和账号安全表单字段使用 `PlatformTextField surface="platform"` 与 `PlatformFieldLabel variant="form"`;认证业务组件只保留受控值、登录 / 绑定流程、原生 input 属性和校验提示,字段可访问名称继续由外层原生 `label` 承接,不再手写 `platform-input` 或表单标题 class。
|
||||||
|
- 2026-06-10 追加:个人中心兑换码和邀请兑换输入使用 `PlatformTextField surface="platform"`;业务组件只保留兑换 / 邀请码提交、归一化、大写展示、Enter 提交和原生可访问名称,不再手写 `platform-profile-input` 或白底 input chrome。
|
||||||
|
- 2026-06-10 追加:个人中心昵称弹窗输入框使用 `PlatformTextField surface="editorDark" size="lg" density="roomy"`;业务组件保留原生 `label` / sr-only “新昵称”、`autoFocus`、`maxLength`、Enter 提交、昵称校验和保存流程,不再手写暗色 input chrome。
|
||||||
|
- 2026-06-10 追加:平台反馈页问题描述和联系电话字段使用 `PlatformTextField surface="platform"`,标题使用 `PlatformFieldLabel variant="form"`;反馈页保留外层原生 label、受控值、长度限制、透明嵌入式局部 class 和提交校验,不再手写 textarea / input / 字段标题 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
||||||
|
- 2026-06-09 追加:平台字段标签统一使用 `src/components/common/PlatformFieldLabel.tsx` 承载 `field`、`section`、`form`、`pill` 与 `accentPill` 五类字段标题视觉;视觉小说结果页、汪汪声浪轻配置编辑器和宝贝识物工作台已先迁移,业务页只保留字段文案和必要局部布局 class,不再重复拼普通字段名、分区标题、表单标题、普通胶囊和强调胶囊 class。
|
||||||
|
- 2026-06-10 追加:通用创作图片输入面板的主图标题和提示词标题使用 `PlatformFieldLabel variant="form"`;提示词字段保留外层原生 `label htmlFor`,业务组件只保留字段文案、布局和上传 / 生成交互,不再手写 `mb-2 block text-sm font-black` 标题 class。
|
||||||
|
- 2026-06-10 追加:个人中心存档 / 玩过弹窗里的简单空态使用 `PlatformEmptyState surface="subpanel" size="inline"`,玩过弹窗的“可继续 / 玩过”分区标题使用 `PlatformFieldLabel variant="section"`,已玩作品白底按钮卡使用 `PlatformSubpanel as="button" surface="flat" radius="sm" padding="md" interactive`;`SaveArchiveCard` 因含图片遮罩和加载态暂不并入本轮。
|
||||||
|
- 2026-06-10 追加:creative-agent 首页抽屉无创作记录使用 `PlatformEmptyState surface="subpanel" size="inline"`;抽屉只保留历史记录分组和点击行为,不再手写 bordered empty chrome。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
||||||
|
- 2026-06-10 追加:平台入口壳纯 Suspense fallback 使用 `PlatformSubpanel radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳;带恢复动作、错误语义或运行态遮罩的提示面板不和纯加载 fallback 同批迁移。
|
||||||
|
- 2026-06-10 追加:平台入口作品详情读取 / 错误提示、Agent 工作区恢复提示和生成结果恢复面板也迁移到 `PlatformSubpanel`;普通提示使用 `radius="sm" padding="none"`,带恢复动作的 `CreationResultRecoveryPanel` 使用 `radius="xl" padding="none"`,玩法 runtime overlay 继续保留专用层级语义。验证命令:`npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`。
|
||||||
|
- 2026-06-10 追加:RPG runtime 主阶段路由里的平台首页、角色选择和冒险面板懒加载提示使用 `PlatformSubpanel radius="sm" padding="none"`;路由器只保留 Suspense 分流和提示文案,运行态 HUD / overlay 不并入该普通提示面板规则。验证命令:`npm run test -- src/components/rpg-runtime-shell/RpgRuntimeStageRouter.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:个人中心钱包账单弹窗的“暂无账单记录”使用 `PlatformEmptyState surface="subpanel" size="inline"`,账单行使用 `PlatformSubpanel as="div" surface="flat" radius="xs" padding="none"`;业务 JSX 只保留来源、时间、收支色值、余额右对齐和局部间距 / 阴影。
|
||||||
|
- 2026-06-10 追加:个人中心邀请弹窗里的社区二维码卡、邀请码展示卡、成功邀请容器和邀请用户行使用 `PlatformSubpanel`,简单空态使用 `PlatformEmptyState`,小标题使用 `PlatformFieldLabel variant="section"`;外层弹窗、query 自动打开、复制邀请和提交邀请码状态机不随 UI chrome 收口改动。
|
||||||
|
- 2026-06-10 追加:个人中心邀请弹窗里的邀请奖励说明使用 `PlatformStatusMessage tone="warning" surface="profile" size="md"`;弹窗只保留奖励文案和两行排版,不再手写 amber 提示块。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users"`。
|
||||||
|
- 2026-06-10 追加:个人中心任务中心任务条目使用 `PlatformSubpanel radius="sm" padding="md"` 承接原 `platform-subpanel` 外壳;业务组件只保留任务标题、进度、奖励、状态和领取按钮逻辑。
|
||||||
|
- 2026-06-10 追加:个人中心充值弹窗微信 Native 支付二维码确认面板使用 `PlatformSubpanel radius="sm" padding="md"`;业务组件只保留二维码生成、扫码展示和确认支付按钮流程。
|
||||||
|
- 2026-06-10 追加:个人中心充值弹窗商品整卡按钮使用 `PlatformSubpanel as="button" surface="platform" radius="sm" padding="none" interactive`;商品标题、金额、角标、购买中态和购买回调留在业务组件,按钮壳、hover、focus、默认 type 与 disabled chrome 归公共组件。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal trusts per-product first bonus display after points recharge"`、`npm run test -- src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:个人中心充值商品卡里的“购买 / 处理中”胶囊暂不抽共享组件;该胶囊位于 `PlatformSubpanel as="button"` 内部,直接复用 `PlatformActionButton` 会形成嵌套交互,当前也还没有第二个同形态的非交互 action chip 证明需要单独沉淀共享展示基元。
|
||||||
|
- 2026-06-09 追加:抓大鹅结果页作品信息、发布封面和物品素材详情中的 section 字段标题迁移到 `PlatformFieldLabel variant="section"`;业务页不再重复拼 `text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]`。
|
||||||
|
- 2026-06-09 追加:方洞结果页主信息、形状选项、洞口选项和历史生成标题迁移到 `PlatformFieldLabel variant="section"`;业务页只保留字段文案、图标和按钮布局,不再重复拼 section 标题 class。
|
||||||
|
- 2026-06-09 追加:拼图结果页关卡详情的“关卡名称”和发布弹窗的“发布检查 / 封面关卡”标题迁移到 `PlatformFieldLabel variant="section"`;业务页保留 label 关联和弹窗布局,不再重复拼 section 标题 class。
|
||||||
|
- 2026-06-09 追加:拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt 和 RPG 发布弹窗发布检查 / 封面设置迁移到 `PlatformFieldLabel variant="section"`;业务组件内不再直接出现 `text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]` section 标题 class,后续同类标题只从公共 Module 扩展。
|
||||||
|
- 2026-06-09 追加:平台白底分段 Tab / 二选一统一使用 `src/components/common/PlatformSegmentedTabs.tsx` 承载选项、当前 id、变更回调、响应式列数、尺寸、圆角、surface、截断标签、禁用态和 `aria-pressed`;拼图结果页、抓大鹅结果页、抓大鹅素材配置、视觉小说结果页和 creative-agent 模板确认弹窗已先迁移,业务页不再重复拼 `grid + border + bg-white/62 + button aria-pressed`。
|
||||||
|
- 2026-06-09 追加:`PlatformSegmentedTabs` 支持 `columns="four"`、`size="choice"`、`tone="warm" | "rose"`、`surface="transparent"` 和 `frame="bare"`,用于承接创作 / 结果页里的四选一配置项;抓大鹅创作工作台和结果页难度选择已迁移,业务页只保留难度选项、当前值和派生回调。
|
||||||
|
- 2026-06-09 追加:`PlatformSegmentedTabs` 支持 `columns="one"`、`size="tab"`、`tone="underline"` 和 `semantics="tabs"`,用于承接认证入口短信 / 密码登录切换的真实 Tab 语义;认证页不再维护本地 `LoginTabButton`、`role="tab"`、`aria-selected` 和下划线选中态。登录入口不可用的白底提示也迁移到 `PlatformSubpanel`。
|
||||||
|
- 2026-06-09 追加:平台结果页统计小卡和轻量状态 chip 统一使用 `src/components/common/PlatformStatGrid.tsx` 承载 `items`、响应式列数、密度、surface、对齐和 label/value 顺序;拼消消结果页素材摘要、方洞结果页封面状态 chip 和抓大鹅结果页难度摘要已迁移,业务页不再重复拼统计卡 `grid + rounded + bg-white/* + text-xl/text-xs`。
|
||||||
|
- 2026-06-09 追加:平台单个胶囊状态 / 标签 chip 统一使用 `src/components/common/PlatformPillBadge.tsx` 承载 tone、尺寸、图标、圆角、边框、底色和字号;宝贝识物结果页发布状态、主题标签与占位资源 overlay,宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 过程计数 / 条目 meta chip、通用音频输入面板限制标签、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、汪汪声浪生成页和通用生成页右上状态 badge、RPG 开发资产诊断数量 / 加载状态 badge、RPG 发布弹窗封面来源 badge、账号弹窗主题状态 / 会话数量 / 设备状态 badge、创作类型弹层锁定 badge、拼图图库详情页题材标签、自定义世界作品卡二级 badge 和生成失败 chip 已先迁移,业务页不再重复拼 `rounded-full border bg-* text-* px-* py-*`。多项数值 / 标签摘要仍归 `PlatformStatGrid`,可交互标签编辑仍归 `PlatformTagEditor`。
|
||||||
|
- 2026-06-09 追加:`PlatformPillBadge` 支持 `profile` / `profileAccent` 个人中心玫瑰色 chip tone;泥点账单余额、玩过总时长和玩过作品类型 chip 已迁移,个人中心后续轻量状态 / 分类胶囊不再在业务 JSX 中重复拼 rose / zinc 胶囊 class。
|
||||||
|
- 2026-06-10 追加:`PlatformPillBadge` 支持 `neutralSolid` 实心中性 tone,承接无强调的只读状态胶囊;`PlatformToggleRow mode="status"` 的开启 / 关闭状态已迁移到 `platformPillBadgeModel`,整行开关不再手写中性 pill class。
|
||||||
|
- 2026-06-10 追加:`PlatformPillBadge` 支持 `lightOverlay` 浅色叠层 tone,承接主动作按钮内部的泥点消耗等小胶囊;通用创作图片面板和抓大鹅创作工作台提交按钮内的消耗标签已迁移,业务 JSX 不再手写 `rounded-full bg-white/24 px-2 py-0.5`。
|
||||||
|
- 2026-06-10 追加:`PlatformPillBadge` 支持 `size="xxs"` 承接密集目录元信息 chip;自定义世界实体目录的新生成、生成中进度、开局 CG 消耗 / 时长 / 已生成、批量删除已选数量和可扮演角色元信息 chip 已迁移,实体目录不再手写 `platform-pill platform-pill--* px-2.5 py-1 text-[10px]`。
|
||||||
|
- 2026-06-10 追加:creative-agent 工作台顶部阶段状态 chip 迁移到 `PlatformPillBadge tone="cool" size="xs"`;工作台只保留阶段枚举到文案的映射,不再手写 `platform-pill platform-pill--cool` 外观。
|
||||||
|
- 2026-06-10 追加:RPG 首页公开作品卡标签、趋势卡标签、公开作品搜索结果类型、充值商品角标、移动端创建入口、桌面发现 hero / 今日 / 最近作品 / 最近浏览 chip 迁移到 `PlatformPillBadge`,首页不再手写 `platform-pill platform-pill--neutral / warm / cool`。
|
||||||
|
- 2026-06-10 追加:RPG 世界详情页的发布状态、主题、作者、发布时间 / 可见性和展示标签等静态元信息 chip 迁移到 `PlatformPillBadge`;作品号复制和分享入口仍保留 `CopyCodeButton` / `CopyFeedbackButton` 管复制状态。
|
||||||
|
- 2026-06-10 追加:`CopyFeedbackButton` 支持 `actionAppearance="pill"`,`CopyCodeButton` 透传同一入口,并复用 `platformPillBadgeModel.ts` 的 `getPlatformPillBadgeClassName` 视觉 chrome;可点击复制 / 分享胶囊 chip 不再在业务 JSX 中手写 `platform-pill`,RPG 世界详情作品号复制 / 分享入口和抓大鹅批量新增 / 重生成物品名称预览已迁移。
|
||||||
|
- 2026-06-10 追加:平台作品详情页主题标签使用 `PlatformPillBadge tone="neutralSolid" size="sm"`,作品号复制按钮使用 `CopyCodeButton actionAppearance="pill" actionPillTone="neutralSolid" actionPillSize="sm"`;详情页只保留标签映射、作品号复制状态和顶部外边距,不再手写 `platform-work-detail__chip / code` 基础 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`。
|
||||||
|
- 2026-06-10 追加:平台作品详情页分享复制反馈使用 `PlatformStatusMessage surface="platform"`,按 `shareState` 映射 `success / error`;详情页保留 `useCopyFeedback` 状态机和文案,不再让失败态复用成功 toast chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 追加:平台错误弹窗和生成完成弹窗的“字段展示 + 复制整段报告”能力统一收口到 `src/components/common/PlatformReportDialog.tsx`;`PlatformErrorDialog` 与 `PlatformTaskCompletionDialog` 只保留标题、字段语义和错误黑名单过滤,不再各自组合 `UnifiedModal`、`PlatformInfoBlock`、`CopyFeedbackButton` 与 `useCopyFeedback`。验证命令:`npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformTaskCompletionDialog.test.tsx`。
|
||||||
|
- 2026-06-10 追加:`CopyFeedbackButton` 支持 `actionShape`,用于共享复制状态按钮直接对齐 `PlatformActionButton` 的圆角外观;拼图广场详情页 hero 的分享按钮已使用 `actionSurface="editorDark" actionShape="pill"`,修改作品 / 进入第 1 关动作使用 `PlatformActionButton`,返回和封面轮播前后按钮使用 `PlatformIconButton darkMini`。验证命令:`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`。
|
||||||
|
- 2026-06-10 追加:creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`,但继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏和抽屉品牌视觉;收口按钮语义时不强行同时抹平定制视觉。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx`。
|
||||||
|
- 2026-06-10 追加:像 `creative-agent-drawer__history-item` 这种纯文本轻量列表行,当前不为了单点场景单独新建共享组件;现阶段优先沿用 `PlatformActionButton` 承接动作行、`PlatformSubpanel as="button" interactive` 承接有壳列表行,等出现更多同构透明列表行再评估独立 row primitive。
|
||||||
|
- 2026-06-10 追加:绑定手机号页左侧“当前登录身份”提示块迁移到 `PlatformSubpanel radius="sm" padding="md"`;认证页只保留身份文案和绑定流程,不再手写 `platform-subpanel` 信息块壳。验证命令:`npm run test -- src/components/auth/BindPhoneScreen.test.tsx`。
|
||||||
|
- 2026-06-10 追加:大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton darkMini`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布状态和提交语义,不再手写 hero 顶栏按钮壳。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx`。
|
||||||
|
- 2026-06-10 追加:`PlatformPillBadge` 支持 `darkSoft` / `darkNeutral` / `darkSky` / `darkEmerald` / `darkAmber` / `darkRose` 暗色 tone,用于 RPG 暗色弹窗和角色详情里的纯展示 chip;角色身份 / 等级、技能列表出手方式、技能详情方式 / 风格 / 状态标签、地图节点方向标签、地图场景切换方向标签和营地编组状态数值已迁移。暗色动作按钮、runtime HUD、属性加成动态 pill 和按钮内部消耗 chip 暂不直接套静态 badge。
|
||||||
|
- 2026-06-10 追加:背景故事已解锁 / 需好感状态和好感等级 badge 也使用 `PlatformPillBadge` 的 `dark*` tone;好感进度时间轴刻度、runtime HUD 和带点击卡片视觉的标签仍保留专用布局。
|
||||||
|
- 2026-06-10 追加:RPG 角色资产工作室动作列表的生成中 / 已生成 / 待生成状态 chip 直接使用 `PlatformPillBadge` 的 `darkAmber` / `darkEmerald` / `darkNeutral` tone;父弹窗不再维护本地 `StatusBadge` 浅封装,动作生成按钮仍保留工作室专用暗色按钮布局。
|
||||||
|
- 2026-06-10 追加:NPC 交易物品数量、赠礼好感增量和背包工坊材料需求状态使用 `PlatformPillBadge` 的 `dark*` tone;这些只是纯展示 chip,交易 / 赠礼列表按钮和工坊锻造 / 合成动作按钮继续保留各自交互布局。
|
||||||
|
- 2026-06-10 追加:RPG 角色编辑器技能列表里的动作已生成 / 待生成动作状态直接使用 `PlatformPillBadge` 的 `darkEmerald` / `darkNeutral` tone;本地 `StatusBadge` 浅封装删除,技能编辑按钮卡片仍保留原有点击布局。
|
||||||
|
- 2026-06-10 追加:RPG 角色编辑器两处重复的已应用主图 / 已应用动作 chip 合并为局部 `RoleAssetAppliedBadges`,内部复用 `PlatformPillBadge darkEmerald / darkAmber`;场景角色选择列表的选择 / 已选中和地标连接列表的当前连接也使用 `PlatformPillBadge dark*`,但外层按钮卡片仍保留原交互语义。
|
||||||
|
- 2026-06-10 追加:RPG 作品封面来源状态使用 `PlatformPillBadge darkNeutral`,角色开局物品标签合并为局部 `RoleInitialItemTagBadges` 并复用 `PlatformPillBadge darkNeutral`;物品编辑弹窗和开局物品列表不再重复维护标签 chip class。
|
||||||
|
- 2026-06-10 追加:RPG 世界地图节点中的当前状态使用 `PlatformPillBadge tone="muted"` 复用平台白底柔和 badge chrome;地图节点位置、连线和整体卡片仍保留地图专用布局。
|
||||||
|
- 2026-06-10 追加:媒体 / 舞台预览上的非交互悬浮短标签使用 `src/components/common/PlatformOverlayBadge.tsx`,复合控件内部的紧凑槽位编号使用 `src/components/common/PlatformSlotBadge.tsx`;RPG 场景幕预览左上幕标签和每幕角色槽位“主 / 2 / 3”已迁移。普通状态 chip 继续使用 `PlatformPillBadge`,外层按钮卡片、人物舞台位置和运行态 HUD 不迁入这两个小 Module。
|
||||||
|
- 2026-06-10 追加:拼图结果页智能修订条的白底图标圆槽使用 `PlatformIconBadge tone="soft" size="sm"`,外层编辑条使用 `PlatformSubpanel radius="lg"`;结果页只保留提交、禁用和错误提示语义,不再手写 `platform-subpanel rounded-[1.35rem] p-3 sm:p-4` 或 `hidden h-9 w-9 rounded-full bg-white/72`。
|
||||||
|
- 2026-06-10 追加:拼图结果页关卡卡片外壳使用 `PlatformSubpanel radius="lg" padding="none"`,关卡列表只保留图片、生成中状态、标题打开和删除动作,不再手写 `platform-subpanel overflow-hidden rounded-[1.35rem] p-0`。
|
||||||
|
- 2026-06-10 追加:`PlatformOverlayBadge` 支持 `tone="muted"`、`size="compact"` 和 `offset="tight"`,用于素材缩略图右上角“占位图”等紧凑非交互浮层;宝贝识物结果页占位资源标记已从绝对定位的 `PlatformPillBadge` 迁移到 overlay badge。
|
||||||
|
- 2026-06-10 追加:`PlatformSlotBadge` 支持 `tone="soft"` 和 `size="md"`,用于 creative-agent 阶段时间线的白底柔和步骤圆点;阶段卡片本体与 active / done / idle 语义仍保留在 `CreativeAgentStageTimeline`。
|
||||||
|
- 2026-06-10 追加:物品格、奖励格等缩略图右下角数量使用 `src/components/common/PlatformQuantityBadge.tsx`;背包物品格和 RPG 冒险面板 / 覆盖层奖励物品数量已迁移。该 Module 只承接数量角标 chrome,物品按钮、稀有度边框、选中态和详情弹窗仍归业务 Module。
|
||||||
|
- 2026-06-10 追加:RPG 冒险面板和覆盖层里的任务目标状态、任务日志状态、当前幕、剩余交谈等暗色纯展示 chip 使用 `PlatformPillBadge dark*`;任务 presentation / 日志状态只返回语义 tone,不再直接返回整段 `border / bg / text` class。运行态动作按钮、任务面板打开按钮和带 hover / click 语义的胶囊仍保留专用布局。任务日志状态补充验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|supports dark RPG badge tones"`。
|
||||||
|
- 2026-06-10 追加:RPG 角色面板里的标签数、适配倍数、性别和装备稀有度等暗色纯展示 chip 使用 `PlatformPillBadge darkNeutral / darkEmerald / darkAmber`;角色面板只保留标签数和 multiplier 计算,不再手写这些胶囊 chrome。
|
||||||
|
- 2026-06-10 追加:RPG 首页作品卡里的发布状态、元信息、主标签,以及存档卡右上恢复 / 最近游玩时间等暗色静态 chip 使用 `PlatformPillBadge dark*`;作品卡 / 存档卡只保留可点击卡片、删除动作、进入 / 继续创作箭头和业务文案。
|
||||||
|
- 2026-06-10 追加:自定义世界实体目录里的基础设定词条标签使用 `PlatformPillBadge darkSoft`;目录页只保留词条解析和空值展示逻辑,不再手写白字暗底 tag chrome。
|
||||||
|
- 2026-06-10 追加:RPG 实体编辑器基本设定里的拆分标签也使用 `PlatformPillBadge darkSoft`;编辑器只保留字段草稿、文本解析和保存逻辑,不再手写暗色静态 tag chrome。
|
||||||
|
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="dark"`、`radius="xs"` 和 `padding="xs"`,用于 RPG 暗色编辑器 / 运行态里的非交互小信息卡;任务目标、区域、进度、描述、角色维度和角色形象状态已先迁移。暗色 HUD、动作按钮、可点击卡片和强玩法品牌面板继续保留业务布局。
|
||||||
|
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于 RPG 暗色编辑器 / 运行态里带业务色强调的结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板和 `CharacterInfoShared.MultiplierContributionList` 状态标签外壳已迁移。地图场景切换当前 / 前往摘要、营地编组分区、同行者卡和营地气氛小卡走 `surface="dark"` 非强调信息卡。后续同类 sky / emerald / amber / rose 暗色信息壳不再手写 `border-*-400/18 bg-*-500/8`,普通暗色信息卡不再手写 `border-white/* bg-black/*`。
|
||||||
|
- 2026-06-10 追加:自定义选择弹窗当前角色信息块使用 `PlatformSubpanel surface="dark"`;弹窗只保留角色标签文案,不再手写 `rounded-2xl border border-white/10 bg-black/20 px-4 py-3` 暗色纯展示块。验证命令:`npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 队伍面板和实体详情弹窗里的构筑标签效果详情统一由 `CharacterInfoShared.BuildContributionDetailPanel` 承接;标签概览、属性加成明细和无明细提示组合 `PlatformSubpanel surface="dark"`,业务弹窗只保留选中状态和属性 rows,不再复制同一段标签效果暗色面板 JSX。
|
||||||
|
- 2026-06-10 追加:`CharacterInfoShared.CharacterSkillsList` 的空态使用 `PlatformEmptyState surface="editorDark"`,可点击和只读技能卡使用 `PlatformSubpanel surface="dark"`;角色信息共享模块只保留技能 render id、选择回调、数值字段和标签展示语义,不再手写技能空态 / 技能卡暗色外壳。验证命令:`npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "CharacterSkillsList|supports dark compact subpanel cards"`。
|
||||||
|
- 2026-06-10 验证补充:共享构筑状态标签外壳收口到 `PlatformSubpanel surface="darkSky"` 后,补跑 `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 实体详情弹窗的物品空态使用 `PlatformEmptyState surface="editorDark"`,技能预览 fallback、技能数值卡、技能说明和附带状态标签区使用 `PlatformSubpanel surface="dark"`;实体详情只保留技能 / 物品数据和业务文案,不再手写这些暗色小卡 chrome。
|
||||||
|
- 2026-06-10 追加:RPG 实体详情弹窗最近回响中的后果、编年、载体和场景残留纯展示卡使用 `PlatformSubpanel surface="dark"`;实体详情只保留 story memory / 场景 residue 数据映射,队友收束等强调态继续保留业务语义样式。验证命令:`npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "最近回响|supports dark compact subpanel cards"`。
|
||||||
|
- 2026-06-10 追加:RPG 实体详情弹窗本地 `Section` 适配到 `PlatformSubpanel surface="dark"`;立绘、关系、私聊、最近回响、属性、技能和物品等主分区只保留标题与内容插槽,不再由业务组件维护 `rounded-2xl border border-white/8 bg-black/20 p-4` 外壳。验证命令:`npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "主分区|supports dark compact subpanel cards"`。
|
||||||
|
- 2026-06-10 追加:RPG 冒险统计弹窗的总览和统计卡使用 `PlatformSubpanel surface="dark"`;统计弹窗只保留统计字段、图标和总览文案,设置弹窗里的 range input、保存退出按钮和入口按钮继续保留运行态专用交互布局。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "adventure statistics panel|supports dark compact subpanel cards"`。
|
||||||
|
- 2026-06-10 追加:RPG 覆盖层里的任务完成领奖提示、任务奖励缓存、战斗结束提示、战利品缓存和奖励物品详情描述 / 效果 / 标签使用 `PlatformSubpanel surface="dark"`,战斗结算敌人名使用 `PlatformPillBadge darkEmerald`;覆盖层只保留奖励数据、物品选择和弹窗层级语义,不再手写奖励缓存暗色面板和敌人名胶囊 chrome。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|quest completion notice|battle reward modal|supports dark compact subpanel cards|supports dark RPG badge tones"`。
|
||||||
|
- 2026-06-10 追加:RPG 覆盖层里的任务摘要卡和任务奖励条使用 `PlatformSubpanel surface="dark"`,奖励条内物品数量使用 `PlatformQuantityBadge`;覆盖层只保留任务文案、奖励数据和物品选择语义,不再手写任务摘要 / 奖励条暗色外壳或数量角标 chrome。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformQuantityBadge.test.tsx -t "quest reward strip|supports dark compact subpanel cards|renders a dark bottom-right quantity badge"`。
|
||||||
|
- 2026-06-10 追加:RPG 覆盖层里的任务奖励好感度、货币和经验数值卡使用 `PlatformSubpanel surface="darkRose" | "darkAmber" | "darkSky"`;覆盖层不再手写三套 `rounded-xl border bg-* px-3 py-2.5` 数值卡 chrome,也不再通过局部 class 覆盖 tint 调性。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 角色详情弹窗的装备格、背包格、旅程原因 / 目标、背景和性格小卡使用 `PlatformSubpanel surface="dark"`,候选人和性别静态 badge 使用 `PlatformPillBadge dark*` tone;角色详情只保留资料、属性、技能和动画展示语义,立绘框与属性网格暂保留原布局。验证命令:`npm run test -- src/components/CharacterDetailModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-10 追加:RPG 角色面板详情里的个人线阶段、背景故事、性格纯展示块和装备行使用 `PlatformSubpanel surface="dark"`;角色面板只保留选中成员、个人线状态、展示文本和装备字段映射,像素外层面板与动作入口继续保留业务布局。验证命令:`npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-10 追加:好感状态卡的等级摘要和好感进度外壳使用 `PlatformSubpanel surface="dark"`;好感卡只保留等级推导、进度刻度和文案,不再手写 `rounded-xl border border-white/8 bg-black/20 px-* py-*` 暗色面板 chrome。验证命令:`npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/AffinityStatusCard.test.tsx`。
|
||||||
|
- 2026-06-10 追加:背景故事公开印象、已解锁章节和锁定章节外壳使用 `PlatformSubpanel surface="dark"`,无背景线索空档案使用 `PlatformEmptyState surface="editorDark"`;背景档案只保留章节状态、好感阈值和故事文案,不再手写这些暗色小卡 / 空态 chrome。验证命令:`npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/BackstoryArchive.test.tsx`。
|
||||||
|
- 2026-06-10 追加:NPC 交易弹窗的数量 stepper 外壳、库存计数条、详情容器和总价卡使用 `PlatformSubpanel surface="dark"`;交易弹窗只保留交易数量、库存、价格和禁用原因语义,交易物品 / 礼物 / 招募可选列表按钮改由 `PlatformDarkOptionCard` 承接暗色 selected / idle / hover chrome。验证命令:`npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "NPC 交易静态信息卡|supports dark compact subpanel cards"`。
|
||||||
|
- 2026-06-10 追加:背包文书、故事档案和工坊分区外壳,以及文书按钮、故事档案条目和工坊配方卡使用 `PlatformSubpanel surface="dark"`;工坊材料需求状态使用 `PlatformPillBadge dark*` tone,故事档案 QA 提示使用 `PlatformStatusMessage surface="editorDark"`。锻造 / 合成动作按钮继续保留业务交互布局。验证命令:`npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx -t "背包文书|背包工坊|supports dark compact subpanel cards|supports editor dark surface"`。
|
||||||
|
- 2026-06-10 追加:NPC 交易详情里的装备位、即时使用和标签属性格使用 `PlatformSubpanel surface="dark" padding="row"`,使用效果提示使用 `PlatformStatusMessage surface="editorDark"`;物品详情弹窗只保留物品属性、效果和标签计算,不再手写 `rounded-lg border border-white/8 bg-black/20 px-3 py-2` 或 emerald 提示条 chrome。
|
||||||
|
- 2026-06-10 追加:新增 `PlatformDarkOptionCard` 承接 RPG 暗色弹窗 / 面板中的可选项按钮卡 selected / idle / hover / disabled chrome;NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格和营地编组替换位按钮已迁移。业务组件只保留选中判断、tone、点击回调和卡片内容,不再手写 `rounded-* border px-3 py-*`、`border-*-400/* bg-*-500/10` 或 `border-white/* bg-black/20 hover:border-white/15`。
|
||||||
|
- 2026-06-10 追加:角色聊天弹窗的状态 / 总结卡使用 `PlatformSubpanel surface="dark"`,空聊天记录使用 `PlatformEmptyState surface="editorDark"`,建议回复按钮使用 `PlatformDarkOptionCard tone="sky"`;弹窗只保留角色状态、聊天记录和建议语义,不再手写这些暗色信息卡、空态或建议按钮 chrome。验证命令:`npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
||||||
|
- 2026-06-10 追加:拼图首访 onboarding 提示词文本域使用 `PlatformTextField surface="editorDark"`,输入错误和登录保存错误使用 `PlatformStatusMessage surface="editorDark"`,生成 / 登录 CTA 使用 `PlatformActionButton surface="editorDark" tone="accent"`,跳过按钮使用 `PlatformActionButton surface="editorDark" tone="ghost" shape="pill"`;onboarding 保留全屏沉浸壳层、登录 / 生成状态机和跳过行为,不再手写 textarea / 错误条 / 按钮 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
||||||
|
- 2026-06-10 追加:RPG 大编辑器本地 `SectionPanel` 适配到 `PlatformSubpanel surface="dark"`;可扮演角色背景故事 / 关系 / 技能 / 物品、世界基础设定等编辑分区只保留标题、subtitle、右侧动作和内容插槽,不再由本地适配器手写外层暗色面板 chrome。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "可扮演角色技能动作状态|supports dark compact subpanel cards"`。
|
||||||
|
- 2026-06-10 验证补充:RPG 大编辑器上传封面中提示收口到 `PlatformSubpanel surface="darkSky"` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "作品封面上传|tinted dark information panels"`。
|
||||||
|
- 2026-06-10 追加:RPG 角色形象参考图缩略框使用 `PlatformMediaFrame surface="editorDark"`;角色形象面板只保留参考图数组、上传 / 清空回调和状态文案,不再手写 `img + overflow-hidden + border` 缩略图 chrome。
|
||||||
|
- 2026-06-10 追加:营地编组同行者头像框使用 `PlatformMediaFrame surface="editorDark"` 和固定尺寸 class,保留角色图片 `object-contain`、放大比例与 pixelated 渲染;编组卡只保留角色数据和操作语义,不再手写头像框 `border-white/10 bg-black/25` 外壳。验证命令:`npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
||||||
|
- 2026-06-09 追加:平台普通进度条统一使用 `src/components/common/PlatformProgressBar.tsx` 承载 `progressbar` 语义、`platform-progress-track` 壳、填充宽度、最小可见宽度、尺寸、条内覆盖层、未知进度语义和局部主题色;creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移,业务页只保留进度值、显示文案、状态配色和必要覆盖内容。没有准确百分比的脉冲占位条使用 `indeterminate`,不暴露假的 `aria-valuenow`;生成页环形总进度继续保留 `GenerationProgressHero` 专用 SVG。
|
||||||
|
- 2026-06-09 追加:creation-agent operation banner 的状态外壳迁移到 `PlatformStatusMessage surface="platform" remapSurface`,进度条继续使用 `PlatformProgressBar`;局部 platform token 作用域需要重映射时由 `remapSurface` 承接,不在业务 JSX 中继续手写 `platform-remap-surface platform-banner` 和 `platform-banner--*`。
|
||||||
|
- 2026-06-09 追加:平台只读信息块统一使用 `src/components/common/PlatformInfoBlock.tsx` 承载短标签、无标签纯正文、白底圆角边框、单行 / 多行正文排版和横向只读信息行的标签 / 值局部排版;错误弹窗和生成完成弹窗的来源、错误、状态展示、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已迁移,业务页不再重复拼 `rounded-[1rem] border ... bg-white/72 px-3 py-2`、`rounded-[1.25rem] border ... bg-white/72 p-4` 或 `rounded-[0.85rem] bg-white/74 px-* py-*`。
|
||||||
|
- 2026-06-10 追加:`PlatformInfoBlock` 支持 `variant="compactRow"` 承接预览卡密集横向 label / value 行;汪汪声浪预览卡四个信息行只保留 label 和内容,不再维护本地 `PREVIEW_INFO_*` class 常量。
|
||||||
|
- 2026-06-09 追加:平台白底子面板统一使用 `src/components/common/PlatformSubpanel.tsx` 承载 `platform-subpanel` 外壳、标题行、右侧动作区、强标题、圆角和响应式内边距;静态 element 透传 `aria-*` / `data-*` 等原生属性,便于结果页预览卡保留可访问名称。拼图结果页作品信息 / 标签编辑 / 智能修订条 / 关卡卡片、拼图图库详情页封面轮播壳 / 题材标签 / 关卡摘要、拼图图片生成模式选择器菜单外壳、敲木鱼结果页元信息 / 标签 / 飘字 / 音效、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、通用音频输入面板和 RPG 个人中心未登录提示已先迁移。`surface="soft" padding="tight"` 用于标签编辑新增输入行等白底柔和紧凑行,不再手写 `rounded-[1rem] border ... bg-white/68 p-2`;`surface="soft" padding="row"` 用于上传预览横向已选素材条等白底柔和横向行,不再手写 `rounded-[1rem] border ... bg-white/68 px-3 py-2`;静态封面轮播壳使用 `radius="xl" padding="none"` 保留内部固定比例和轮播按钮;抓大鹅物品详情五视角面板使用 `radius="xl" padding="sm"` 加局部 `sm:p-5` 保留响应式间距。后续仅表达“白底子面板 + 标题 / 右侧动作 + 内容”或小型浮层菜单的片段优先使用该 Module;暗色运行态 HUD、媒体预览和强玩法品牌面板继续保留专用布局。
|
||||||
|
- 2026-06-10 追加:发布分享弹窗渠道 tile 按钮使用 `PlatformSubpanel as="button" surface="flat" radius="sm" padding="tight" interactive`;弹窗只保留渠道枚举、品牌图标和复制分享文本回调,不再手写白底 tile 圆角、边框、底色、hover 或 focus chrome。验证命令:`npm run test -- src/components/common/PublishShareModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:平台入口创作类型弹层玩法卡片使用 `PlatformSubpanel as="button" surface="platform" radius="xl" padding="none"`;弹层只保留玩法图片、蒙版、锁定 badge、标题副标题和分流回调,外层按钮语义、标准圆角和已开放卡 hover / focus chrome 归公共子面板。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:creation-agent 工作台聊天区外壳使用 `PlatformSubpanel radius="xl" padding="none"`;工作台只保留消息列表、引用图预览、错误提示和输入区语义,不再手写聊天面板外层圆角、边框和底色。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 追加:creation-agent 无 session / 加载提示块迁移到 `PlatformSubpanel radius="sm" padding="lg"`;工作台只保留提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4` 普通居中提示面板。
|
||||||
|
- 2026-06-10 追加:拼图结果页空草稿提示块迁移到 `PlatformSubpanel radius="sm" padding="lg"`;结果页只保留提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4` 普通居中提示面板。
|
||||||
|
- 2026-06-09 追加:敲木鱼结果页主预览面板也迁移到 `PlatformSubpanel`,页面只保留标题、简介和资源叠放语义,不再手写 `platform-subpanel rounded-[1.25rem] p-4`。
|
||||||
|
- 2026-06-09 追加:拼消消创作工作台左侧结构化表单面板迁移到 `PlatformSubpanel`,工作台只保留字段、开关、错误和提交语义,不再手写 `platform-subpanel rounded-[1.25rem] p-4`。
|
||||||
|
- 2026-06-09 追加:抓大鹅创作工作台难度选择小面板迁移到 `PlatformSubpanel surface="flat"`,工作台只保留难度选项和 payload 派生,不再手写小白底面板边框、圆角、内边距和 inset 高光。
|
||||||
|
- 2026-06-09 追加:视觉小说创作工作台画风选择小面板迁移到 `PlatformSubpanel surface="flat"`,横向滚动、选中态和移动端 touch 行为仍由业务滚动区与样式按钮承接,不再手写外层白底面板 chrome。
|
||||||
|
- 2026-06-09 追加:创作中心作品架整块无作品 / 无筛选结果空态迁移到 `PlatformEmptyState surface="soft" size="panel"`,加载骨架卡迁移到 `PlatformSubpanel as="div"`;Hub 只保留筛选、列表和打开 / 删除 / 分享语义,不再直接拼空态 `platform-subpanel` 或 skeleton 卡片外壳。
|
||||||
|
- 2026-06-09 追加:视觉小说上传资产弹窗的无历史素材本地上传占位迁移到 `PlatformEmptyState surface="dashed"`;弹窗只保留上传、AI 生成、历史素材和选择回调语义,不再手写 dashed 空态面板 chrome。
|
||||||
|
- 2026-06-09 追加:creative-agent 工作台目录、目标就绪、空消息、过程、关卡计划和模板确认理由等标准白底面板迁移到 `PlatformSubpanel`;模板确认的“关卡模式 / 计划关卡”摘要迁移到 `PlatformStatGrid`,creative-agent 内不再直接拼 `platform-subpanel rounded-[1.35rem] p-4` / `rounded-[1.25rem] p-4` / `rounded-[1.15rem] p-4`。
|
||||||
|
- 2026-06-09 追加:拼消消结果页预览、统计和操作三个标准白底面板迁移到 `PlatformSubpanel`;页面只保留图片预览、统计项和动作回调,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4` 或 `platform-subpanel mt-auto rounded-[1.25rem] p-4`。
|
||||||
|
- 2026-06-09 追加:跳一跳结果页预览和结果操作两个标准白底面板迁移到 `PlatformSubpanel`,公开排行榜小卡迁移到 `PlatformSubpanel surface="flat"`;操作面板标题走 `PlatformFieldLabel variant="section"`,页面只保留资源预览、排行榜数据、状态提示和动作回调,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4` 或 `rounded-[1rem] border ... bg-white/70 p-3`。
|
||||||
|
- 2026-06-09 追加:跳一跳结果页角色 / 图集 / 路径预览框和拼消消结果页场地底图 / 素材图集预览框使用 `PlatformSubpanel surface="flat" padding="none"`;白底媒体框只保留内部图片、占位和尺寸,不再重复拼 `rounded-[1rem] border ... bg-white/80`。
|
||||||
|
- 2026-06-09 追加:`PlatformSubpanel` 支持 `radius="xl"`,用于承接方洞结果页等 `rounded-[1.5rem]` 的标准大面板;方洞结果页封面、主信息、形状选项和洞口选项面板已迁移到 `PlatformSubpanel radius="xl" padding="lg"`,页面只保留图片、字段、选项和动作逻辑。
|
||||||
|
- 2026-06-09 追加:方洞结果页形状 / 洞口选项卡迁移到 `PlatformSubpanel surface="flat"`,贴图缩略图按钮迁移到 `PlatformSubpanel as="button" interactive surface="flat"`;选项卡只保留字段写回、目标洞口选择、删除和图片槽位打开逻辑,不再重复小卡边框、白底、圆角、缩略图 hover / disabled chrome。
|
||||||
|
- 2026-06-09 追加:敲木鱼创作工作台的“功德有什么”词条面板迁移到 `PlatformSubpanel`,词条输入迁移到 `PlatformTextField`,删除词条圆形浮动入口迁移到 `PlatformIconButton variant="surfaceFloating"`;工作台只保留词条输入、新增和删除交互,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4`、本地标题 class、白底输入框 chrome 或白底圆形图标按钮 chrome。
|
||||||
|
- 2026-06-09 追加:视觉小说结果页作品、开场、运行配置和世界观标准编辑面板迁移到 `PlatformSubpanel radius="lg"`;页面只保留表单字段、资产预览和运行配置写回,不再直接拼 `platform-subpanel rounded-[1.35rem] p-4`。
|
||||||
|
- 2026-06-09 追加:抓大鹅结果页作品信息、难度配置、难度统计、UI 素材预览和物品图集预览标准面板迁移到 `PlatformSubpanel radius="lg" padding="lg"`;页面只保留表单、滑杆、统计项和素材预览逻辑,不再直接拼 `platform-subpanel rounded-[1.35rem] p-4 sm:p-5`。
|
||||||
|
- 2026-06-09 追加:`PlatformSubpanel` 支持 `surface="flat"`、`padding="sm"` 和 `radius="sm"`,用于承接素材 / 音频等小型白底卡片的圆角、边框、`bg-white/72`、标题行和右侧图标动作;视觉小说结果页素材选择 / 音频生成小面板已迁移,业务页不再重复手写 `rounded-[1rem] border ... bg-white/72 p-3`。
|
||||||
|
- 2026-06-09 追加:抓大鹅结果页难度配置里的当前难度摘要小卡迁移到 `PlatformSubpanel surface="flat" radius="sm" padding="sm"`;结果页只保留当前难度标题、消除次数、物品种类和难度 badge,不再手写 `rounded-[1rem] border ... bg-white/62 px-3 py-3` 小卡 chrome。
|
||||||
|
- 2026-06-09 追加:RPG 结果页开发资产诊断面板里的摘要卡、资产条目和空态迁移到 `PlatformSubpanel`;开发开关判定拆到 `rpgCreationAssetDebugPanelModel.ts`,组件文件只保留诊断面板渲染和图片加载状态。
|
||||||
|
- 2026-06-09 追加:RPG 发布弹窗封面预览壳迁移到 `PlatformSubpanel padding="none"`;发布弹窗只保留封面 presentation、设置封面和发布动作语义,不再直接手写 `platform-subpanel rounded-[1.25rem] p-2`。
|
||||||
|
- 2026-06-09 追加:creative-agent 关卡计划小卡和抓大鹅结果页物品 spritesheet 分组卡迁移到 `PlatformSubpanel surface="flat" radius="sm"`;普通信息 / 图集分组小卡不再直接拼 `rounded-[1rem] border ... bg-white/58 p-3` 或 `px-3 py-3`。
|
||||||
|
- 2026-06-09 追加:抓大鹅批量物品素材生成状态卡迁移到 `PlatformSubpanel surface="flat" radius="sm"`,内部进度条迁移到 `PlatformProgressBar`;局部进度状态不再手写白底边框和 track / fill div。
|
||||||
|
- 2026-06-09 追加:平台反馈页问题描述、上传凭证和联系方式三个普通白底区块迁移到 `PlatformSubpanel radius="md"`;平台表单页只表达字段、上传和提交语义,不再直接拼 `platform-subpanel rounded-[1.2rem] px-4 py-4`。
|
||||||
|
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="dark"`、`radius="xs"` 和 `padding="xs"`,用于暗色编辑 / 运行面板里的小型信息卡;RPG 冒险面板 / 覆盖层任务目标、区域、进度和描述卡,以及自定义世界实体目录角色维度小卡已迁移。后续同类暗色小信息卡只保留标题、图标和值,不再手写 `rounded-xl border border-white/10 bg-black/* px-* py-*`。
|
||||||
|
- 2026-06-09 追加:`PlatformSubpanel` 支持 `as="button"` 与 `interactive`,用于承接普通白底整卡点击列表项的 hover、focus、disabled 和默认 `type="button"`;视觉小说 runtime 历史条目和存档列表已迁移,业务页不再重复手写 `rounded-[1rem] border ... bg-white/78 p-3 hover:bg-white disabled:cursor-not-allowed disabled:opacity-55`。
|
||||||
|
- 2026-06-09 追加:视觉小说结果页角色 / 场景 / 阶段列表项和空态迁移到 `PlatformSubpanel`;列表项使用 `as="button" interactive` 保留整卡点击、hover / focus / disabled chrome 和默认 button type,空态使用静态 `PlatformSubpanel`,结果页不再直接手写 `platform-subpanel min-h-32` 列表卡片。
|
||||||
|
- 2026-06-09 追加:账号设置入口卡、主题选择卡、当前主题状态、账号绑定卡、密码 / 安全 / 设备 / 操作记录区块,以及设备 / 操作记录内的白底列表行迁移到 `PlatformSubpanel`;账号弹窗只保留换绑、撤销会话、刷新和日志展示语义,不再直接拼 `platform-subpanel rounded-2xl` 或内层白底列表边框。
|
||||||
|
- 2026-06-09 追加:RPG 世界详情页的世界信息统计卡、关键角色 / 关键场景预览卡和操作区标题迁移到 `PlatformSubpanel` 与 `PlatformFieldLabel variant="section"`;详情页只保留作品展示、启动、编辑、发布、下架和删除动作语义,不再直接拼小型 `platform-subpanel` 卡片或本地 section 标题 class。
|
||||||
|
- 2026-06-10 追加:RPG 运行态任务覆盖层里的任务更新提示、地点 / 人物提示和任务日志条目迁移到 `PlatformSubpanel surface="dark"`;运行态只保留任务文案、任务选择和奖励条交互,暗色边框、底色、圆角和条目 hover 外壳不再在业务 JSX 中重复拼。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain"`。
|
||||||
|
- 2026-06-09 追加:大鱼吃小鱼结果页的关卡卡片、场地背景卡、发布校验卡、空草稿提示和素材工坊 PROMPT 信息块迁移到 `PlatformSubpanel`;结果页只保留大鱼玩法的青色主题按钮、预览背景、素材生成动作和发布校验语义,不再直接拼大圆角白底边框卡片。
|
||||||
|
- 2026-06-09 追加:汪汪声浪结果页草稿编译小卡迁移到 `PlatformSubpanel surface="flat"`,跳一跳结果页排行榜行卡迁移到 `PlatformSubpanel surface="flat"`,排行榜无成绩空态迁移到 `PlatformEmptyState surface="subpanel"`;结果页只保留玩法文案、排行榜字段和错误 / 空态文案,不再手写白底小卡圆角、边框、底色和 padding。
|
||||||
|
- 2026-06-09 追加:自定义世界实体目录世界页的档案规模统计迁移到 `PlatformStatGrid`,世界基调、角色维度和基本设定条目迁移到 `PlatformSubpanel`;目录只保留世界资料读取、编辑入口和标签展示语义,不再直接拼统计卡 grid 或 `platform-subpanel rounded-2xl` 设定块。
|
||||||
|
- 2026-06-09 追加:自定义世界实体目录场景幕级缩略图迁移到 `PlatformSubpanel padding="none"`;目录只保留场景名、幕标题和图片来源语义,不再手写 `platform-subpanel h-12 w-[5.25rem]` 预览框 chrome。
|
||||||
|
- 2026-06-09 追加:自定义世界实体目录 `CatalogCard` 的角色 / 场景媒体框迁移到 `PlatformSubpanel padding="none"`;目录卡片只保留图片、角色动画或占位内容,不再手写媒体框 `platform-subpanel rounded-[1rem]` / `rounded-[1.1rem]` chrome。
|
||||||
|
- 2026-06-09 追加:`PlatformSubpanel` 支持 `surface="danger"` 承接整卡危险选中态,`PlatformPillBadge` 支持 `tone="muted"` 承接白底柔和选择 badge;自定义世界实体目录 `CatalogCard` 整卡壳迁移到 `PlatformSubpanel as="button"`,批量选择的“选择 / 已选”迁移到 `PlatformPillBadge`,目录只保留选择状态和点击回调,不再手写卡片 `role="button"` / 危险选中边框 / 选择 badge chrome。
|
||||||
|
- 2026-06-09 追加:平台媒体预览框统一使用 `src/components/common/PlatformMediaFrame.tsx` 承载图片源、fallback 图、fallback 文案、固定比例、refreshKey、warm / editorDark / plain / soft / bright / none / bare surface 和 overlay;自定义世界实体目录场景图片框、RPG 实体编辑器 `ImagePreview` 和拼图结果页关卡列表正式图框已先迁移,业务页只保留素材地址、可访问名称和业务覆盖层。`surface="soft"` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/68` 的白底柔和预览,`surface="bright"` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/82` 的亮白素材槽,`surface="none"` 用于嵌在已有按钮 / 卡片交互壳里的纯图片与 fallback 内容;`PlatformSubpanel` 继续负责白底面板 / 轻量媒体壳 / 整卡点击列表项,不承接需要 fallback 或 overlay 的图片预览状态。
|
||||||
|
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `aspect="portrait"` 承接 9:16 竖版预览;拼消消结果页场地底图 / 素材图集预览已迁移到 `PlatformMediaFrame surface="none"`,外层仍用 `PlatformSubpanel surface="flat" padding="none"` 提供白底边框、圆角和 `bg-white/80` 媒体壳,页面不再手写 `ResolvedAssetImage` 与无图占位分支。
|
||||||
|
- 2026-06-09 追加:平台媒体缩略格网格统一使用 `src/components/common/PlatformMediaTileGrid.tsx` 承载列数、间距、白底容器、tile 圆角、边框、图片、refreshKey、可选 tile `testId` 和 fallback 格;跳一跳结果页地块池 / 无图集 fallback 地块池、拼消消结果页卡片预览网格和抓大鹅物品 spritesheet 解析预览分组已先迁移。结果页只保留素材数组切片、素材地址、fallback 内容和玩法色值,不再重复手写 `grid-cols-*`、`rounded-[0.45rem] border border-white/80 bg-white/78` 或直接依赖底层 `ResolvedAssetImage`;网格内部 tile chrome 由 `tileSurface` 承接,内层 `PlatformMediaFrame` 统一使用 `surface="none"`,不再重复加公共 subpanel fill。
|
||||||
|
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `fallbackContent` 承接图标型无图占位;方洞结果页图片查看弹窗的 4:3 预览已迁移到 `PlatformMediaFrame aspect="standard" surface="plain"`,页面不再手写图片 / 图标占位分支。
|
||||||
|
- 2026-06-09 追加:宝贝识物结果页素材卡图片框迁移到 `PlatformMediaFrame aspect="square" surface="none"`,占位资源 badge 作为 `previewOverlay` 传入;素材卡只保留外层 `PlatformSubpanel`、素材名、渐变槽局部样式和业务状态,不再手写 `ResolvedAssetImage` 绝对铺满与 overlay 分支。
|
||||||
|
- 2026-06-09 追加:视觉小说结果页封面 4:3 预览和资产字段 16:9 图片预览迁移到 `PlatformMediaFrame`;封面使用 `surface="editorDark"` 和图标型 `fallbackContent`,资产字段使用 `aspect="landscape" surface="none"` 嵌入现有小型白底卡片,页面不再手写 `ResolvedAssetImage`、`aspect-[4/3]` / `aspect-[16/9]` 和无图占位分支。
|
||||||
|
- 2026-06-09 追加:跳一跳结果页地块图集整图 fallback 预览迁移到 `PlatformMediaFrame aspect="square" surface="none"`;单个地块网格和路径平台预览保留专用组合布局,只有纯图片源 + 正方形比例的 atlas 分支进入公共媒体框,图集底色作为局部 `bg-white/78` 保留在媒体框 class。
|
||||||
|
- 2026-06-09 追加:方洞结果页封面和背景两个点击预览按钮内部迁移到 `PlatformMediaFrame aspect="standard" / "landscape" surface="none"`;按钮继续负责打开图片槽位弹窗和承接渐变边框交互壳,公共媒体框只负责 4:3 / 16:9 比例、图片读取和图标型 fallback,占位和图片分支不再写在业务 JSX 中。
|
||||||
|
- 2026-06-09 追加:方洞结果页形状 / 洞口选项里的 80px 贴图缩略图迁移到 `PlatformMediaFrame aspect="square" surface="none"`;外层 `PlatformSubpanel as="button"` 继续负责打开素材弹窗和亮白交互壳,业务页不再直接依赖底层 `ResolvedAssetImage`,内层媒体框也不再重复承接背景。
|
||||||
|
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `aspect="wide"` 承接 9:5 宽图预览;大鱼吃小鱼素材工坊候选预览迁移到 `PlatformMediaFrame aspect="wide" surface="none"`,工坊只保留 prompt、生成动作和 cyan 主题外观适配,虚线边框与浅青底作为局部 class 保留。
|
||||||
|
- 2026-06-09 追加:拼图发布弹窗封面关卡预览迁移到 `PlatformMediaFrame aspect="square" surface="soft"`;发布弹窗只保留发布检查、泥点提示和发布动作,不再手写封面图片框 `aspect-square`、`ResolvedAssetImage`、白底柔和边框和空图分支。
|
||||||
|
- 2026-06-09 追加:大鱼吃小鱼结果页场地背景竖版预览迁移到 `PlatformMediaFrame aspect="portrait" surface="none"`;结果页保留青色深海背景主题和生成背景动作,不再手写 9:16 图片框与 `ResolvedAssetImage` 分支。
|
||||||
|
- 2026-06-09 追加:大鱼吃小鱼结果页关卡主图缩略图迁移到 `PlatformMediaFrame aspect="square" surface="none"`;关卡卡片只保留关卡文案、状态和工坊入口,不再直接依赖底层 `ResolvedAssetImage`。
|
||||||
|
- 2026-06-10 追加:抓大鹅结果页物品素材列表缩略图和详情大图迁移到 `PlatformMediaFrame aspect="square" surface="bright"`,详情视角缩略图嵌在保留选中态的按钮壳内并使用 `surface="none"`;素材列表卡只保留打开详情、素材名和删除动作,详情预览只保留视角切换状态,不再手写正方形图片 / 图标 fallback / 亮白边框槽;需要测试 id / aria 时通过媒体框容器属性透传。
|
||||||
|
- 2026-06-10 追加:抓大鹅结果页 UI 素材子 Tab 的游戏背景、UI spritesheet 和物品 spritesheet 主图预览迁移到 `PlatformMediaFrame surface="none"`;外层按钮 / 白底预览壳继续负责交互、边框、底色和内边距,媒体框只承接图片读取、fallback 和固定比例。
|
||||||
|
- 2026-06-10 追加:`PlatformMediaFrame` 根节点固定带 `platform-media-frame` 类名,供业务测试断言公共媒体框接入;拼图图库详情页封面轮播的内层正方形图片 / 暂无封面 fallback / 轮播 overlay 迁移到 `PlatformMediaFrame aspect="square" surface="none"`,外层 `PlatformSubpanel radius="xl" padding="none"` 继续承接面板边框、圆角和裁切。
|
||||||
|
- 2026-06-10 追加:认证图形验证码图片使用 `PlatformMediaFrame aspect="auto" surface="soft"`;验证码组件只保留图片 data URL、可访问名称和固定尺寸 class,不再手写 `img + platform-subpanel` 图片框。
|
||||||
|
- 2026-06-09 追加:敲木鱼结果页主 9:16 背景 + 敲击物叠层预览迁移到 `PlatformMediaFrame aspect="portrait" surface="plain"`;页面保留背景图和敲击物的叠放顺序,不再手写固定比例外框、白底边框和无图占位。
|
||||||
|
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `fallbackShellClassName` 承接无图 fallback 区域的局部背景 / 渐变;creative-agent 模板确认预览迁移到 `PlatformMediaFrame aspect="landscape" surface="soft"`,弹窗只保留模板标题、泥点、调整和确认语义,不再手写 16:9 图片 / 图标占位容器,也不再在业务 JSX 中重复拼基础边框和 `bg-white/68`。
|
||||||
|
- 2026-06-09 追加:creative-agent 模板目录卡迁移到 `PlatformSubpanel as="button" interactive surface="flat"`,卡内 16:9 预览迁移到 `PlatformMediaFrame aspect="landscape" surface="none"`;工作台只保留模板选择、标题、摘要、预览渐变局部样式和泥点范围,不再手写白底按钮卡、16:9 图片框或图标 fallback 容器。
|
||||||
|
- 2026-06-09 追加:非交互中性 / 柔和 / hero / 暗色琥珀 / 成功 / 危险图标槽统一使用 `src/components/common/PlatformIconBadge.tsx` 承载图标、尺寸、圆角、neutral / soft / softBright / hero / heroMuted / darkAmber / success / danger 底色和可访问隐藏语义;视觉小说 runtime 面板标题、存档列表项,creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标、大鱼吃小鱼发布失败弹窗图标槽、通用创作图片面板空主图上传占位图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移,业务页不再重复拼 `grid h-* w-* place-items-center bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]`、白底柔和小圆槽、目标完成图标槽、暗色琥珀图标槽或危险提示红色圆槽。
|
||||||
|
- 2026-06-10 追加:宝贝识物工作台静态玩法预览卡迁移到 `PlatformSubpanel surface="soft"`,卡内礼物图标槽迁移到 `PlatformIconBadge tone="softBright"`;工作台只保留玩法渐变、装饰层和文案,不再手写白底柔和面板边框 / 圆角 / 内边距或图标槽 chrome。
|
||||||
|
- 2026-06-09 追加:平台标签编辑统一使用 `src/components/common/PlatformTagEditor.tsx` 承载标签 chip、删除按钮、新增输入、Enter 提交、Escape 取消、空态、可选 AI 生成动作和错误提示;拼图结果页作品标签、敲木鱼结果页主题标签和抓大鹅结果页作品标签已先迁移。业务页只保留标签 parse / normalize 规则、最大数量和最终写回,不再重复维护标签编辑 JSX 与本地新增状态机。
|
||||||
|
- 2026-06-10 追加:标签编辑 Module 内部的新增输入行由 `PlatformSubpanel surface="soft" padding="tight"` 承接外壳,输入框由 `PlatformTextField` 承接;公共标签编辑不再把子面板和输入框 chrome 混写在同一段本地 JSX class 中。
|
||||||
|
- 2026-06-09 追加:方形上传入口和紧凑虚线新增入口统一使用 `src/components/common/PlatformUploadTile.tsx` 承载虚线方块、图标、主副文案、button / label 语义和禁用态;`size="compact" showLabel={false}` 用于工作台里的纯图标虚线新增入口,仍保留隐藏可访问名称。上传后的图片预览统一使用 `src/components/common/PlatformUploadPreviewCard.tsx` 承载缩略图壳、预览图片、可选标题行、可选预览点击、横向已选素材条和移除按钮。默认 `layout="square"` 用于方形缩略图,`layout="inline"` 用于“缩略图 + 文件名 / 素材名 + 移除”的已选参考图条,内部横向行复用 `PlatformSubpanel surface="soft" padding="row"`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条和 creation-agent 已选参考图条已先迁移,业务页只保留文件选择、预览数组、预览回调、删除回调、新增回调和校验逻辑。工具栏小图标上传仍使用 `PlatformIconButton asChild="label"`,带大面积缩略图选择的历史素材仍使用 `PlatformAssetPickerGrid`。
|
||||||
|
- 2026-06-09 追加:拼图结果页关卡详情中的只读引用图横条也使用 `PlatformUploadPreviewCard layout="inline"`,由公共组件承载缩略图、`ResolvedAssetImage` 换签、素材名截断和横向白底条 chrome;只读场景不传 `onRemove`,避免结果页额外出现删除按钮。历史素材弹窗仍使用 `PlatformAssetPickerGrid`,结果页只展示选择后的引用关系。
|
||||||
|
- 2026-06-09 追加:白底平台子面板内的无操作空态使用 `PlatformEmptyState surface="subpanel" size="inline"`,由 Module 承载圆角、边框、`bg-white/74`、居中、字号和 soft 文本色;视觉小说 runtime 历史、属性、存档读取 / 空态已先迁移,业务页不再重复拼白底空态 class。
|
||||||
|
- 2026-06-10 追加:个人中心充值弹窗的“暂无可购买套餐”和每日任务弹窗的“暂无任务”使用 `PlatformEmptyState surface="subpanel" size="inline"`;业务组件只保留数据分支,不再手写 `platform-subpanel rounded-2xl px-4 py-8` 空态 chrome。
|
||||||
|
- 2026-06-10 追加:`PlatformEmptyState` 根节点固定带 `platform-empty-state` 类名,并支持 `surface="editorDark"` 承接 RPG 大编辑器和运行态弹窗 / 面板里的暗色虚线纯展示空态;角色槽位、可选角色、关系、技能、物品、交易空列表、赠礼空列表、招募替换空列表、奖励物品空态、任务日志空态、运行态设置保存禁用提示和营地编组空队列只保留业务文案,不再重复拼 `rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500`、`rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500` 或 `rounded-xl border border-dashed border-white/10 bg-black/20 px-3 py-4 text-center text-xs text-zinc-500`。
|
||||||
|
- 2026-06-09 追加:自定义世界实体目录搜索框迁移到 `PlatformTextField density="compact"`,搜索无结果空态迁移到 `PlatformEmptyState surface="dashed"`;目录只保留搜索值、占位符和过滤语义,不再直接拼 `platform-subpanel rounded-2xl` 输入壳或虚线空态。
|
||||||
|
- 2026-06-10 追加:creation-agent 聊天区“暂无消息”迁移到 `PlatformEmptyState surface="subpanel" size="compact"`,composer 文本域迁移到 `PlatformTextField variant="textarea" size="md" density="compact"`;工作台保留消息列表滚动、受控输入、禁用条件、Enter 提交和 Shift+Enter 换行语义,不再手写空态和 textarea chrome。
|
||||||
|
- 2026-06-10 追加:大鱼吃小鱼结果页缺少可编辑草稿提示迁移到 `PlatformEmptyState surface="subpanel" size="compact"`;结果页只保留草稿分支和文案,不再为白底无操作提示手写 `PlatformSubpanel` 空面板。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
||||||
|
- 2026-06-09 追加:视觉小说 runtime 普通白底面板里的保存主按钮和历史重生成行内动作使用 `PlatformActionButton surface="platform"`;保存使用默认主动作,行内重生成使用 `tone="secondary" size="xs" shape="pill"`,业务页只保留图标、禁用条件和回调。
|
||||||
|
- 影响范围:`src/components/common/UnifiedConfirmDialog.tsx`、`src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx`、`src/components/common/CopyFeedbackMessage.tsx`、`src/components/common/PlatformStatusMessage.tsx`、`src/components/common/PlatformEmptyState.tsx`、`src/components/common/PlatformActionButton.tsx`、`src/components/common/platformActionButtonModel.ts`、`src/components/common/PlatformIconButton.tsx`、`src/components/common/PlatformUploadTile.tsx`、`src/components/common/PlatformUploadPreviewCard.tsx`、`src/components/common/PlatformMediaFrame.tsx`、`src/components/common/PlatformModalCloseButton.tsx`、平台入口壳、公共错误 / 完成 / 分享弹窗、公开详情页、大鱼 runtime / result、账号个人资料区、自定义世界实体目录、RPG 结果页重新生成确认、RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪 / 视觉小说结果页普通按钮和状态提示、历史图片选择弹窗 / RPG 发布检查弹窗 / creative-agent 侧边栏 / creation-agent 参考图 / 敲木鱼结果页 / 拼图结果页普通图标按钮、方洞结果页图片素材弹窗关闭按钮、视觉小说结果页资产 / 音频 / 编辑器弹窗和 runtime 普通面板关闭按钮、统一创作页壳层、拼图创作工作台、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creation-agent 推荐回复、creative-agent 工作台、creative-agent 模板确认弹窗、自定义世界实体目录小动作和状态提示、创作中心错误重试、反馈页 header 返回、认证入口 / 邀请码弹窗关闭按钮、通用生成页重试 / 中断动作、RPG 详情页删除确认、RPG 角色素材工作室泥点确认、RPG 场景编辑器阻断提示、RPG 角色背景章节阻断提示、RPG 编辑器未保存关闭确认、RPG 场景背景 / 作品封面生成退出确认、公开作品深链失效恢复、账户充值 / 泥点账单 / 每日任务 / 兑换码 / 扫码 / 存档 / 玩过作品等个人中心弹窗、RPG 首页 / 公开广场 / 作品架和历史素材选择弹窗空态、个人中心充值 / 任务 / 兑换 / 邀请 / 支付结果弹窗主动作按钮、RPG 作品详情和生成结果恢复面板平台动作按钮、法律信息弹窗 footer、通用创作图片 / 音频输入面板动作按钮和上传 label、统一创作工作台返回 / 生成按钮和错误提示、短信登录 / 密码登录 / 绑定手机号认证表单动作按钮和状态提示、账号安全弹窗动作按钮和状态提示、验证码提示、邀请码弹窗提交按钮和错误提示、错误 / 完成 / 分享弹窗复制按钮外观、结果页 / 工作台后续简单弹窗迁移。
|
||||||
|
- 验证方式:`npm run test -- src/components/common/UnifiedConfirmDialog.test.tsx src/components/common/useCopyFeedback.test.tsx src/components/common/CopyFeedbackButton.test.tsx src/components/common/CopyCodeButton.test.tsx src/components/common/CopyFeedbackMessage.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`,迁移页面时补跑对应页面交互测试;实体目录删除确认、角色背景章节阻断与场景编辑器提示补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`;公开作品深链失效恢复补跑 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail"`;RPG 结果页重新生成确认补跑 `npm run test -- src/components/CustomWorldResultView.test.tsx`;RPG 详情页删除 hook 补跑 `npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`;角色素材工作室泥点确认补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`;个人中心弹窗关闭按钮迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|reward code|task center|recharge|save archive|played works"`;认证入口 / 邀请码弹窗关闭按钮迁移补跑 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`;RPG 首页 / 公开广场 / 作品架空态迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile discover|desktop logged in home|profile played works|logged in draft bottom tab|ranking"`;历史素材选择弹窗空态迁移补跑 `npm run test -- src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx`;结果页普通动作和状态提示迁移补跑 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`、`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`、`npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`、`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`;玩法创作工作台普通动作和错误提示迁移补跑 `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`;creation-agent 推荐回复动作迁移补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`;创作中心重试和反馈页返回按钮迁移补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`;通用生成页动作迁移补跑 `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformActionButton.test.tsx`;统一创作页壳层补跑 `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx`;拼图创作工作台返回按钮补跑 `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`;个人中心主动作按钮迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recharge|wallet ledger|task center|reward code|invite|community"`;复制弹窗外观迁移补跑 `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/common/PublishShareModal.test.tsx`;阶段完成前复扫 `rg -n "window\\.confirm|window\\.alert" src/components src/services src/hooks -g '*.tsx' -g '*.ts'`。
|
||||||
|
- 2026-06-09 验证补充:通用输入 Composer 图标按钮迁移补跑 `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 首页抽屉空态和首页错误提示收口后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 过程面板空态收口到 `PlatformEmptyState surface="subpanel" size="compact"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 工作台消息空态收口到 `PlatformEmptyState surface="subpanel" size="compact"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:作品详情顶部和封面轮播图标按钮收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:作品详情底部启动 / 改造动作收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:作品详情点赞按钮收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 模板确认弹层“关卡数”行内标题收口到 `PlatformFieldLabel variant="inlineForm"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:平台入口公开编号搜索结果弹层收口到 `UnifiedModal`、`PlatformStatusMessage` 和 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public code search"`。
|
||||||
|
- 2026-06-10 验证补充:平台作品详情主题标签和作品号复制 chip 收口后,补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:平台作品详情分享复制反馈按状态映射到 `PlatformStatusMessage surface="platform"` 后,补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:大鱼吃小鱼结果页缺草稿空态收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:大鱼吃小鱼结果页发布校验阻断项收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:通用输入 Composer 面板、文本域和读图错误状态收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creation-agent composer 错误条收口补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:通用创作图片面板历史入口和抓大鹅封面编辑浮动图标按钮收口补跑 `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:AI 重绘胶囊开关收口补跑 `npm run test -- src/components/common/PlatformPillSwitch.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:白底整行开关收口补跑 `npm run test -- src/components/common/PlatformToggleRow.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:RPG 大编辑器动作按钮收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "保存修改|保存角色"`。
|
||||||
|
- 2026-06-09 验证补充:runtime 白底 HUD 收口补跑 `npm run test -- src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:历史素材选择卡片收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:RPG 大编辑器历史素材弹窗收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:抓大鹅封面编辑可引用素材网格收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:抓大鹅结果页白底输入框和文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞结果页主信息表单白底输入框和文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞结果页形状 / 洞口选项紧凑输入、文本域和下拉框收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼图 / 敲木鱼结果页作品信息输入、拼图关卡名称和智能修订输入收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:通用创作图片输入面板提示词文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:创作工作台白底字段输入和焦点色 tone 收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx` 与 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:白底分段 Tab / 二选一收口补跑 `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:抓大鹅难度四选一收口补跑 `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台统计小卡收口补跑 `npm run test -- src/components/common/PlatformStatGrid.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:自定义世界实体目录搜索框和空态收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 大编辑器暗色纯展示空态迁移到 `PlatformEmptyState surface="editorDark"` 后,补跑 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色空态复用暗色平台空态"`。
|
||||||
|
- 2026-06-10 验证补充:角色聊天错误提示收口到 `PlatformStatusMessage surface="editorDark"` 后,补跑 `npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:营地编组战斗中提示、状态数值、分区 / 同行者卡、空队列和替换位按钮分别收口到 `PlatformStatusMessage surface="editorDark"`、`PlatformPillBadge darkNeutral`、`PlatformSubpanel surface="dark" / "darkSky"`、`PlatformEmptyState surface="editorDark"` 和 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:自定义选择弹窗错误 / 生成中提示收口到 `PlatformStatusMessage surface="editorDark"` 和 `PlatformProgressBar` 后,补跑 `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformProgressBar.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:地图场景切换目标场景面板、当前 / 前往摘要和方向标签收口到 `PlatformSubpanel surface="darkAmber" / "dark"` 与 `PlatformPillBadge dark*` 后,补跑 `npm run test -- src/components/MapModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 构筑标签效果详情收口到 `CharacterInfoShared.BuildContributionDetailPanel` 和 `PlatformSubpanel surface="dark"` 后,补跑 `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx -t "BuildContributionDetailPanel|技能详情静态标签"`。
|
||||||
|
- 2026-06-10 验证补充:RPG 实体详情弹窗物品空态和技能详情暗色小卡收口后,补跑 `npm run test -- src/components/AdventureEntityModal.test.tsx -t "物品空态|技能详情静态标签"`。
|
||||||
|
- 2026-06-09 验证补充:创作中心作品架空态和加载骨架卡收口补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台胶囊状态标签和宝贝识物结果页白底卡片收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台胶囊状态标签扩展到宝贝识物 / 拼图 / 汪汪声浪工作台和结果页 chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台胶囊状态标签扩展到视觉小说 / 抓大鹅工作台 BETA chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台胶囊状态标签扩展到敲木鱼结果页飘字 chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台胶囊状态标签扩展到 creative-agent 过程计数 / 条目 meta chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:实心中性状态胶囊和整行状态开关收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformToggleRow.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:媒体紧凑占位浮层收口补跑 `npm run test -- src/components/common/PlatformOverlayBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 阶段时间线柔和步骤圆点收口补跑 `npm run test -- src/components/common/PlatformSlotBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 过程条目柔和图标圆槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 模板 / hero / 目标就绪图标圆槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:创作类型弹层锁定卡小圆锁图标收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:大鱼吃小鱼发布失败弹窗危险图标槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx -t "shows publish failures in a dismissible modal"`。
|
||||||
|
- 2026-06-10 追加:`PlatformIconBadge` 根节点固定带 `platform-icon-badge` 稳定类名;个人中心充值结果弹窗和支付确认遮罩里的 56px 圆形图标槽使用 `PlatformIconBadge size="xl"` 并保留局部 `bg-white/10` 与状态文字色覆盖,支付弹窗不再手写圆形图标容器。验证命令:`npm run test -- src/components/common/PlatformIconBadge.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "confirms virtual payment after returning without hash result|releases submitting state after cancelled wechat pay result"`。
|
||||||
|
- 2026-06-10 验证补充:宝贝识物工作台静态玩法预览卡和图标槽收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformIconBadge.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:通用创作图片面板空主图上传占位图标槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:GameCanvas 宝箱遭遇图标槽收口到 `PlatformIconBadge size="xxl" shape="xl" tone="darkAmber"` 后,补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/game-canvas/GameCanvasEntityLayer.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:通用创作图片面板按钮内泥点消耗胶囊收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:抓大鹅创作工作台按钮内泥点消耗胶囊收口补跑 `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:标签编辑新增输入行 soft 子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformTagEditor.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:标签编辑新增输入框收口到 `PlatformTextField` 后,补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:个人中心昵称弹窗输入框收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal"` 与 `npm run test -- src/components/common/PlatformTextField.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:认证图形验证码图片和答案输入分别收口到 `PlatformMediaFrame` 与 `PlatformTextField` 后,补跑 `npm run test -- src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:认证登录、重置密码、绑定手机号、邀请码和账号安全表单字段收口到 `PlatformTextField` 与 `PlatformFieldLabel` 后,补跑 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/auth/AccountModal.test.tsx src/components/auth/BindPhoneScreen.test.tsx src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:通用创作图片输入面板主图 / 提示词字段标题收口到 `PlatformFieldLabel` 后,补跑 `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:个人中心存档 / 玩过弹窗简单空态、分区标题和已玩作品按钮卡收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile played modal|profile page keeps save archives inside played stats panel"`。
|
||||||
|
- 2026-06-10 验证补充:平台入口壳纯 Suspense fallback 收口到 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`。
|
||||||
|
- 2026-06-10 验证补充:个人中心钱包账单空态和账单行收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger"` 与 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:个人中心邀请弹窗内部卡片、标题和空态收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut|profile redeem invite"` 与 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformFieldLabel.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:个人中心任务中心任务条目收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile daily task"` 与 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:个人中心充值弹窗 Native 支付二维码确认面板收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal shows native qr code"` 与 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:个人中心兑换码 / 邀请码输入和充值 / 任务空态收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "reward code|invite query|profile redeem invite|daily task"`。
|
||||||
|
- 2026-06-10 验证补充:背包文书按钮收口到暗色 `PlatformSubpanel`、故事档案 QA 提示收口到 `PlatformStatusMessage surface="editorDark"` 后,补跑 `npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:NPC 叙事提示和交易详情属性格收口后,补跑 `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:NPC 暗色可选项按钮卡收口到 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:角色素材工作室动作预览格收口到 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:上传预览横向已选素材条 soft row 子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creation-agent 无 session / 加载提示块收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creation-agent 聊天空态和 composer 文本域收口后,补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformTextField.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:拼图首访 onboarding 提示词文本域、输入错误和登录保存错误收口后,补跑 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformTextField.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:拼图首访 onboarding 生成 / 登录 / 跳过按钮收口到 `PlatformActionButton` 后,补跑 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
||||||
|
- 2026-06-10 验证补充:拼图结果页空草稿提示块收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 个人中心未登录提示子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`,并对 `src/components/rpg-entry/RpgEntryHomeView.tsx` 执行 ESLint / typecheck;游客态当前不暴露“我的”Tab,不新增不可达业务断言。
|
||||||
|
- 2026-06-10 验证补充:拼图图库详情页封面轮播壳收口到 `PlatformSubpanel radius="xl" padding="none"` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:抓大鹅物品详情五视角面板收口到 `PlatformSubpanel radius="xl" padding="sm"` 后,补跑 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼图 / 方洞结果页自动保存 badge 收口补跑 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:抓大鹅结果页自动保存 / 当前难度 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼图结果页关卡生成中 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:大鱼吃小鱼结果页终局 / 发布校验成功 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:大鱼吃小鱼结果页关卡元信息标签收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:宝贝识物占位资源 overlay 和方洞选项删除图标按钮收口补跑 `npm run test -- src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx` 与 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台普通进度条收口补跑 `npm run test -- src/components/common/PlatformProgressBar.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡收口到 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:跳一跳结果页公开排行榜小卡收口到 `PlatformSubpanel surface="flat"` 后,补跑 `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:汪汪声浪草稿编译小卡、跳一跳排行榜行卡和排行榜空态收口后,补跑 `npm run test -- src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:跳一跳 / 拼消消结果页媒体预览框收口到 `PlatformSubpanel surface="flat" padding="none"` 后,补跑 `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞结果页标准大面板收口到 `PlatformSubpanel radius="xl"` 后,补跑 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞结果页形状 / 洞口选项卡和缩略图按钮收口后,补跑 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 大编辑器场景背景 / 作品封面生成和封面上传状态提示收口到 `PlatformStatusMessage surface="tinted"` 后,补跑 `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`。
|
||||||
|
- 2026-06-09 验证补充:creation-agent operation banner 状态外壳收口补跑 `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台只读信息块收口补跑 `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:汪汪声浪预览卡横向只读信息行收口补跑 `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台白底子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼消消创作工作台左侧表单面板收口补跑 `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:抓大鹅创作工作台难度小面板收口补跑 `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:视觉小说创作工作台画风选择小面板收口补跑 `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼消消结果页白底面板收口补跑 `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:creative-agent 标准白底面板收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:creative-agent 模板目录卡和 16:9 预览收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:creative-agent 模板确认预览使用 `PlatformMediaFrame surface="soft"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:通用音频输入面板限制标签收口补跑 `npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:RPG 世界详情页白底信息卡与 section 标题收口补跑 `npm run test -- src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:大鱼吃小鱼结果页白底卡片收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:大鱼吃小鱼结果页白底动作按钮收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:RPG 结果页开发资产诊断面板收口补跑 `npm run test -- src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:自定义世界实体目录世界页统计和基本设定收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:自定义世界实体目录场景幕级缩略图收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:自定义世界实体目录卡片媒体框收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:自定义世界实体目录卡片整卡壳和批量选择 badge 收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 实体编辑器基本设定 tag 和角色形象参考图 / 状态小卡收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台媒体预览框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞图片查看弹窗媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼消消结果页卡片预览网格收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:宝贝识物结果页素材卡媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:视觉小说结果页封面和资产字段媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:跳一跳结果页地块图集整图媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台媒体缩略格网格收口补跑 `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞结果页封面 / 背景点击预览媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:方洞结果页形状 / 洞口贴图缩略图媒体框收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:方洞封面 / 背景、拼消消场地底图 / 素材图集、宝贝识物素材卡、跳一跳图集整图和大鱼媒体槽统一收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:拼图发布封面收口到 `surface="soft"`,拼图关卡列表、视觉小说资产字段和 creative-agent 模板目录卡收口到 `surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`;业务页面不再直接使用 `PlatformMediaFrame surface="bare"`。
|
||||||
|
- 2026-06-10 验证补充:`PlatformMediaTileGrid` 内部媒体框改用 `surface="none"` 并支持 item `testId`,抓大鹅物品 spritesheet 解析分组迁移后,补跑 `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:抓大鹅 UI 素材子 Tab 的背景、UI spritesheet 和物品 spritesheet 主图迁移到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/common/PlatformMediaTileGrid.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:拼图图库详情页封面轮播内层媒体框收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:`PlatformMediaFrame` 增加 `aspect="auto"`、容器 `ref` 和 `imageProps` 后,RPG 封面上传裁剪操作区 / 裁剪结果、角色素材工作室形象预览和动作静态预览迁移到公共媒体框,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面上传会先进入 16:9 裁剪面板再提交到后端"` 与 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 编辑器场景幕背景预设、技能编辑 fallback 预览、技能列表缩略图和角色编辑顶部形象预览继续收口到 `PlatformMediaFrame` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色技能动作状态复用暗色平台胶囊标签|场景编辑器会在场景内展示槽位化多幕配置并保存"`。
|
||||||
|
- 2026-06-10 验证补充:RPG 大编辑器场景幕角色槽位当前角色 / 可选角色面板,以及幕背景预览 / 预设背景面板收口到本地 `EditorInfoPanel` + `PlatformSubpanel surface="dark"` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`。
|
||||||
|
- 2026-06-09 验证补充:大鱼吃小鱼素材工坊宽图候选预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼图发布弹窗封面关卡预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:大鱼吃小鱼场地背景竖版预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:大鱼吃小鱼关卡主图缩略图收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:抓大鹅结果页物品素材列表缩略图和详情大图收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:敲木鱼结果页主预览面板和 9:16 叠层预览收口补跑 `npm run test -- src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:平台标签编辑器收口补跑 `npm run test -- src/components/common/PlatformTagEditor.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:反馈页上传方块和上传预览收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:反馈页查看记录次级动作收口补跑 `npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:创作中心作品卡积分激励领取按钮收口补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`。
|
||||||
|
- 2026-06-10 验证补充:UnifiedModal 头部关闭按钮收口到 `PlatformModalCloseButton platformIcon / pixel` 后,补跑 `npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:上传预览卡右上移除按钮收口到 `PlatformIconButton darkMini` 后,补跑 `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:RPG 大编辑器参考图和封面上传入口收口到 `PlatformUploadTile surface="editorDark"`、参考图预览条收口到 `PlatformUploadPreviewCard surface="editorDark"` 后,补跑 `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`。
|
||||||
|
- 2026-06-10 验证补充:角色素材工作室参考图入口收口到 `PlatformUploadTile surface="editorDark"` 后,补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:敲木鱼工作台新增功德词条虚线入口收口补跑 `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:通用创作图片面板参考图缩略图收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:抓大鹅封面编辑参考图缩略图收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:横向已选参考图条收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||||
|
- 2026-06-09 验证补充:拼图结果页关卡引用图横条收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:汪汪声浪预览 VS chip 收口到 `PlatformPillBadge` 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`。
|
||||||
|
- 2026-06-10 验证补充:拼图结果页智能修订条 / 关卡卡片收口到 `PlatformSubpanel` / `PlatformIconBadge` 后,补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 关联文档:`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
|
||||||
|
|
||||||
|
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
|
||||||
|
|
||||||
|
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。
|
||||||
|
- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉,不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示,不允许从旧预览卡面再淡入到同一张卡面;runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成,ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。遮罩层级必须隔离下层 runtime,防止高 z-index HUD、canvas 或子运行态穿透到封面上;ready 前保留无说明文案的加载条 / 动效,不展示“加载中”文案。推荐 rail 切换完成后归零不能走反向过渡动画。
|
||||||
|
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页 runtime 生命周期、平台玩法链路文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-07 登录态身份边界变更后刷新当前页
|
||||||
|
|
||||||
|
- 背景:推荐页运行态、作品架、个人数据和私有 query 都可能在页面内缓存当前身份;如果登录或退出只改 React 上下文,当前页可能继续拿旧身份的局部状态渲染。
|
||||||
|
- 决策:H5 登录态从未登录变为已登录,或从已登录变为未登录后,前端必须刷新当前页面一次,让平台壳和运行态按新身份重新初始化。普通 access token refresh、账号资料更新、主题或音量设置变化不触发整页刷新。
|
||||||
|
- 影响范围:`src/components/auth/AuthGate.tsx`、平台入口身份初始化、项目基线文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/auth/AuthGate.test.tsx`。
|
||||||
|
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-07 多端登录以 refresh session 为粒度互不顶号
|
||||||
|
|
||||||
|
- 背景:同一账号在多端登录后,若单设备退出或请求被打到尚未见过该 session 的 api-server 进程,旧设备会被误判为登录态失效。
|
||||||
|
- 决策:普通登录只新增当前设备 refresh session,不撤销其它 active session;`POST /api/auth/logout` 只撤销当前 refresh session,不再提升账号级 `token_version`;`POST /api/auth/logout-all`、改密和重置密码继续吊销全端 session 并提升 `token_version`。api-server 鉴权和 refresh cookie 轮换在本进程工作集未命中 session 时,先从 SpacetimeDB 正式认证表按需刷新一次工作集再复查,支持多实例和滚动重启下的新会话被所有进程识别。
|
||||||
|
- 影响范围:`module-auth` refresh session 语义、`api-server` Bearer 鉴权和 `/api/auth/refresh`、账号安全页多端会话。
|
||||||
|
- 验证方式:`cargo test -p module-auth logout_current_session --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth refresh_from_snapshot_json_merges_session_created_by_another_process --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server logout_current_device_keeps_other_device_session_alive --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键
|
||||||
|
|
||||||
|
- 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。
|
||||||
|
- 决策:`jump_hop_leaderboard_entry.player_id` 只作为 SpacetimeDB read model 的去重和 `viewerBest` 匹配字段,HTTP 契约新增并强制使用 `displayName` 作为排行榜展示字段。api-server 出口按账号 `displayName` 补齐展示名;匿名 runtime guest 固定展示“游客玩家”;账号失效或不可解析时展示“失效玩家”;前端排行榜 UI 禁止兜底展示 `playerId` / `user_id`。
|
||||||
|
- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`server-rs/crates/shared-contracts/src/jump_hop.rs`、`server-rs/crates/api-server/src/jump_hop.rs`、跳一跳结果页和运行态排行榜组件、跳一跳 PRD 与后端契约文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx -t "排行榜"`、`npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx -t "排行榜"`、`cargo test -p api-server jump_hop_leaderboard_display_name_never_falls_back_to_player_id --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-07 generated 图片读取坚持 OSS 源站与签名缓存链路
|
||||||
|
|
||||||
|
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
|
||||||
|
- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
|
||||||
|
- 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
|
||||||
|
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。
|
||||||
|
|
||||||
|
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
|
||||||
|
|
||||||
|
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
||||||
|
- 决策:小程序登录页先展示原生 `input type="nickname"`,将昵称作为 `displayName` 随 `/api/auth/wechat/miniprogram-login` 提交;若还需要绑定手机号,再随 `/api/auth/wechat/bind-phone` 一并提交。`wechatDisplayName` 只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生昵称组件,不能用系统账号显示名或“微信旅人”兜底。小程序侧拿不到昵称时,前端使用后端下发的 `wechatAccount`(openid / provider_uid)尾号展示,避免只显示裸“已绑定”。
|
||||||
|
- 影响范围:`platform-auth` 小程序登录 profile、`module-auth` 微信身份持久化、`api-server` 小程序登录 / 绑定响应、账号信息面板、项目基线和后端契约文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/auth/AccountModal.test.tsx`、`cargo test -p platform-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wechat_miniprogram`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-03 拼消消收敛为单关 6x6 与 4-sheet 素材策略
|
||||||
|
|
||||||
|
- 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。
|
||||||
|
- 决策:拼消消运行态收敛为单关 `6x6 / 35 次消除 / 600 秒`,直接解锁 `1x2`、`1x3`、`2x2`、`2x3`;素材生成改为 4 张 `1024x1536` 竖版 sheet,每张按 `4x6`、每格 `256x256` 切片,再由服务端合成 `10x10 / 2560x2560` 最终 atlas。形状配比固定为 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`,总计 35 个复合图案组和 95 个 1x1 卡牌切片。
|
||||||
|
- 影响范围:`module-puzzle-clear` 关卡与图案组规划、api-server 拼消消素材生成编排、前端草稿试玩本地 runtime、结果页 atlas 预览、拼消消 PRD / 技术方案 / 平台链路文档。
|
||||||
|
- 验证方式:`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-05-30 拼消消按独立玩法公开闭环接入
|
||||||
|
|
||||||
|
- 背景:拼消消以拼图交换手感为基础,但核心规则从“拼完整单图过关”变为“拼成多个复合图案组后逐个消除”,同时需要顶部补牌、防死局、半锁定局部拼接组和正式统计,不能继续复用拼图运行态规则本体。
|
||||||
|
- 决策:`puzzle-clear` 作为独立玩法域接入,公开作品码前缀固定为 `PC-`;创作链路采用表单 / 图片输入工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime。领域规则落在 `module-puzzle-clear`,SpacetimeDB 新增 `puzzle_clear_*` 表 / procedure / view,并接入统一 `public_work_gallery_entry` / `public_work_detail_entry`;前端只表现后端 snapshot/action 结果,不把胜负、补牌或消除裁决做成前端事实源。
|
||||||
|
- 补充约束:草稿编译和发布都必须拒绝缺失或 `placeholder` atlas / card assets,不允许后端 facade 或 SpacetimeDB 合成临时素材;当前单关正式 runtime 终态事件使用 `run-finished`、`level-failed`,并写入包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs` 的结果 JSON。
|
||||||
|
- 补充约束:拼消消结果页草稿试玩使用前端本地 `runtimeMode=draft` snapshot,不调用 `/api/runtime/puzzle-clear/runs`,不写正式 run 统计;公开详情和推荐流正式运行继续走后端 `/api/runtime/puzzle-clear/*`,客户端需要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`。
|
||||||
|
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared`、`api-server`、`spacetime-module`、`spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
|
||||||
|
- 验证方式:PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run typecheck`、`npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
|
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
|
||||||
|
|
||||||
- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
|
- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
|
||||||
- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
|
- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;当前 OpenSSL 3.2 独立运行时自举会安装 `build-essential` 等最小工具,这是满足 api-server/libcurl 运行时符号的受控例外,不代表 provision 承担 api-server 构建职责。非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
|
||||||
|
- 追加决策(2026-06-10):`Prepare Provision Tools` 必须先读取目标机现状,再准备需要的文件。目标机 `/usr/local/bin/otelcol-contrib` 版本匹配 `OTELCOL_VERSION` 时直接复用;`${SPACETIME_ROOT}/bin/current/spacetimedb-cli` 和 `spacetimedb-standalone` 存在且 CLI 版本匹配 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 中的版本时,直接复用当前安装生成 `provision-tools/`。只有目标机缺失、不可执行或版本不匹配时,才消费 `PROVISION_DOWNLOADS_DIR` 中的本地包或进入下载分支。
|
||||||
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
|
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
|
||||||
- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
|
- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
|
||||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
@@ -40,6 +587,14 @@
|
|||||||
- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss` 与 `operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。
|
- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss` 与 `operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。
|
||||||
- 关联文档:`server-rs/crates/platform-oss/README.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`server-rs/crates/platform-oss/README.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-05 跳一跳返回按钮改为独立主题资产
|
||||||
|
|
||||||
|
- 背景:跳一跳运行态曾把左上角返回按钮视觉锚点写进背景 image2 prompt,导致返回按钮像静态背景元素,不能替代真实可点击按钮。
|
||||||
|
- 决策:跳一跳背景 prompt 禁止生成任何 UI 或左上角图标;返回按钮由 `backButtonAsset` 单独生成 1:1 纯绿 key 图,后端去绿后作为透明 PNG 持久化到作品 profile,运行态左上角真实按钮优先渲染该资产。顶部得分 HUD 复用拼图模板结构,包含陶泥儿 IP logo、标题牌和下挂数字卡。
|
||||||
|
- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`shared-contracts`、`spacetime-module` / `spacetime-client` bindings、`api-server` 跳一跳生成链路、`JumpHopRuntimeShell`、玩法链路文档和后端数据契约文档。
|
||||||
|
- 验证方式:`npm run spacetime:generate`、`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`npm run check:spacetime-schema`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-03 创作入口关闭不下架已发布作品
|
## 2026-06-03 创作入口关闭不下架已发布作品
|
||||||
|
|
||||||
- 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。
|
- 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。
|
||||||
@@ -48,6 +603,24 @@
|
|||||||
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。
|
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-03 外部内容生成改为持久队列加 worker 角色
|
||||||
|
|
||||||
|
- 背景:拼图首图、图集、音频等外部生成链路长期占用 `api-server` HTTP handler,导致扩容只能放大 API 进程,且 HTTP 超时和外部 provider 波动会直接影响创作入口。
|
||||||
|
- 决策:外部生成任务统一进入 SpacetimeDB `external_generation_job` 持久队列,由 `api-server` 的 `external-generation-worker` 进程角色 claim lease 后执行;HTTP 角色只做鉴权、表单/状态初始化、入队和返回 `queued/running/completed/failed` 操作状态。生产通过 systemd worker 模板增加实例数或提高 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩容,`GENARRATIVE_PROCESS_ROLE=all` 仅用于本地 smoke。拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与 `generate_puzzle_ui_background` 已接入 worker;业务写回必须在 SpacetimeDB transaction 内校验 `external_generation_job` 的 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,其中首图 worker 的前置 `compile_puzzle_agent_draft` 也必须带 guard。worker 核心业务写回失败不能返回内存快照并把 job 标成 completed;失败态业务写回成功后才能把 job 标成 failed,失败态未写回则保留租约等待后续重领。拼图业务失败不自动重试,只保留 lease 过期后的崩溃重领,避免钱包扣退费幂等漂移。生产发布会启用默认 `genarrative-external-generation-worker@1.service` 并等待 worker active,worker 停机时停止 claim 新任务并 drain 当前任务。
|
||||||
|
- 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 使用 `queue|inline` 显式策略;生产和容器扩缩容验证保持 `queue`。本地开发若需要同步等待结果,应通过 `.env.local` 或本机环境显式配置为 `inline`,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容;脚本不得硬编码该策略。拼图写回 guard 字段改为可选,queue 路径仍必须完整校验 `job_id + worker_id + lease_token`;inline 路径只允许三项同时为空,半空 guard 仍拒绝。
|
||||||
|
- 2026-06-11 追加:生产新增固定 `external-generation-controller` 进程角色和 `genarrative-external-generation-controller.service`。controller 只读取 `get_external_generation_queue_stats_and_return` 队列统计并管理 `genarrative-external-generation-worker@N.service`,不监听 HTTP、不执行外部生成任务;默认保留 `@1`,按 `claimable_pending + running_active + expired_running` 计算目标实例数,上限由 `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS` 控制,缩容需要连续空闲轮数且每轮只停最高编号一个实例。
|
||||||
|
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。
|
||||||
|
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并在 queue 模式下用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路;本地 inline 排查只确认不创建 `external_generation_job`。
|
||||||
|
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-03 外部生成 worker lease 使用 SpacetimeDB 时间和 token 栅栏
|
||||||
|
|
||||||
|
- 背景:外部生成 worker 支持多进程动态缩扩容后,长任务超过单次 lease、worker 本机时钟漂移或复用 worker id 都可能导致同一任务被重复领取并被过期执行者回写。
|
||||||
|
- 决策:`external_generation_job` 新增末尾字段 `lease_token`;`claim` 使用 SpacetimeDB `ctx.timestamp` 计算 lease,生成本次 claim token;worker 执行期间调用 `renew_external_generation_job_lease_and_return` 续租;`complete/fail` 必须带 `worker_id + lease_token` 才能回写。拼图 `compile_puzzle_draft` 的 dedupe key 包含本次 `extgen-` job id,避免同一 session 的失败或完成 job 吞掉后续重新生成。拼图首图前置 `compile_puzzle_agent_draft`、图片保存、UI 背景与失败态业务写回同样必须携带 lease guard,并在 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed`、`mark_puzzle_level_generation_failed` 的 SpacetimeDB 事务内校验。
|
||||||
|
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/module-puzzle/src/commands.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`。
|
||||||
|
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`GENARRATIVE_PROCESS_ROLE=all npm run dev` 后检查 `/healthz`。
|
||||||
|
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
|
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
|
||||||
|
|
||||||
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
|
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
|
||||||
@@ -173,7 +746,7 @@
|
|||||||
- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。
|
- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。
|
||||||
- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。
|
- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。
|
||||||
- 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。
|
- 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。
|
||||||
- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。
|
- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard / pause / props 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;只要调用方传入 Runtime Guest Token,所有正式 runtime 请求都统一带局部 Authorization、`skipAuth` 与 `skipRefresh`。
|
||||||
- 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。
|
- 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。
|
||||||
- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。
|
- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。
|
||||||
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
||||||
@@ -322,7 +895,7 @@
|
|||||||
## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式
|
## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式
|
||||||
|
|
||||||
- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。
|
- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。
|
||||||
- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。
|
- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo-runtime-hud.webp` 产品 logo 小图;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。
|
||||||
- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。
|
- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。
|
||||||
- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。
|
- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
@@ -604,7 +1177,7 @@
|
|||||||
|
|
||||||
- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
|
- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
|
||||||
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
|
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
|
||||||
- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||||
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
|
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
|
||||||
- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
|
- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
|
||||||
- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。
|
- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。
|
||||||
@@ -678,7 +1251,7 @@
|
|||||||
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。
|
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块
|
## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块
|
||||||
|
|
||||||
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
||||||
- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。
|
- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。
|
||||||
@@ -812,7 +1385,7 @@
|
|||||||
## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线
|
## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线
|
||||||
|
|
||||||
- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。
|
- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。
|
||||||
- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。
|
- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session;自 2026-06-07 起单设备退出也不再递增 `token_version`,避免误伤其它设备,只有退出全部设备和改密类安全动作提升账号级版本。
|
||||||
- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。
|
- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。
|
||||||
- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run dev:api-server` 检查 `/healthz`。
|
- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run dev:api-server` 检查 `/healthz`。
|
||||||
- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。
|
- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。
|
||||||
@@ -1309,13 +1882,38 @@
|
|||||||
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分
|
## 2026-05-28 跳一跳重设计为 5x5 地块图集与弹弓拖拽
|
||||||
|
|
||||||
- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。
|
- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。
|
||||||
- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。
|
- 决策:`jump-hop` v1 创作端只保留主题输入;image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
||||||
- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。
|
||||||
- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。
|
- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍
|
||||||
|
|
||||||
|
- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。
|
||||||
|
- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。
|
||||||
|
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。
|
||||||
|
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲
|
||||||
|
|
||||||
|
- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。
|
||||||
|
- 决策:`jump-hop` 的 `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
|
||||||
|
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
|
||||||
|
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG
|
||||||
|
|
||||||
|
- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。
|
||||||
|
- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。
|
||||||
|
- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。
|
||||||
|
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
|
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
|
||||||
|
|
||||||
@@ -1557,6 +2155,33 @@
|
|||||||
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
|
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
|
||||||
- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
|
- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
|
||||||
|
|
||||||
|
## 2026-06-11 前端组件收口补记
|
||||||
|
|
||||||
|
- 背景:个人中心 profile 弹层已抽成独立组件,但 `error / loading / empty / content` 仍在多个 modal 中重复分支,继续沿业务页各写一套会让后续 profile 面板收口越来越碎。
|
||||||
|
- 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfileRechargeModal.tsx`、`PlatformProfilePlayedWorksModal.tsx` 与 `PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。
|
||||||
|
- 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件先负责 tab 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心中重复出现时,沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset,业务页不再重复复制长 `itemClassName`。
|
||||||
|
- 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。
|
||||||
|
- 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。
|
||||||
|
- 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 与 `Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx` 和 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。
|
||||||
|
- 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。
|
||||||
|
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
|
||||||
|
- 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。
|
||||||
|
- 决策:`CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续收口到 `PlatformActionButton`,`pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。
|
||||||
|
- 决策:白底 / 暗色面板里的轻量空态和普通 CTA 继续向共享组件收口。`PuzzleResultView.tsx` 的缺草稿提示、`RpgCreationAssetDebugPanel.tsx` 的空诊断提示、`VisualNovelEntityGrid` 的空实体列表、`AccountModal.tsx` 里账号安全分区的“无安全限制 / 无登录设备 / 无操作记录”以及 `LoginScreen.tsx` 的“当前登录入口暂不可用”都改为 `PlatformEmptyState`,`Match3DResultView.tsx` 的引用素材列表直接复用 `PlatformAssetPickerGrid` 自己的空态;`AdventureEntityModal.tsx` 的私聊按钮、`InventoryPanel.tsx` 的锻造 / 合成按钮、`RpgCreationRoleAssetStudioModalImpl.tsx`、`RpgCreationEntityEditorShared.tsx` 里的局部 `ActionButton` 包装层,以及 `RpgAdventurePanel.tsx` / `RpgAdventurePanelOverlays.tsx` 里标准 runtime CTA 都改为委托 `PlatformActionButton surface="editorDark"`。后续白底子面板里的只读空态优先使用 `PlatformEmptyState surface="subpanel"`;暗色编辑 / 运行面板里的普通动作优先使用 `PlatformActionButton surface="editorDark"`,若业务仍需 `stopPropagation`、tone 映射、运行态 icon 排版或局部字号,可保留薄包装层,但不要再直接写原生 `<button>` 基础 chrome。
|
||||||
|
- 决策:白底 / 浅色结果页和工作台顶部的“左箭头 + 返回文案”轻量返回入口统一收口到 `src/components/common/PlatformBackActionButton.tsx`;共享组件固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的返回按钮骨架,并只开放 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前已覆盖 `PuzzleResultView.tsx`、`SquareHoleResultView.tsx`、`Match3DResultView.tsx`、`VisualNovelResultView.tsx`、`PuzzleClearResultView.tsx`、`JumpHopResultView.tsx`、`WoodenFishResultView.tsx` 与 `BabyObjectMatchResultView.tsx`;暖色生成页继续走 `GenerationHeaderBackButton`,`BigFishResultView.tsx` 这类 dark hero / 强品牌返回入口继续走 `PlatformIconButton darkMini`,不把三条视觉语义线硬并成一个组件。
|
||||||
|
- 决策:`CustomWorldNpcVisualEditor.tsx` 的本地 `ActionButton` 和 `SkillEffectPreview.tsx` 的“重新预览”按钮也继续并入这条暗色按钮收口线,统一委托 `PlatformActionButton surface="editorDark"`;局部包装层只保留 `stopPropagation`、图标排布、`tone` 映射和极少量视觉微调。后续暗色编辑器里的局部动作按钮若只是普通 CTA,不再新增原生 `<button>` 实现,优先沿用“薄包装 + 共享按钮本体”模式。
|
||||||
|
- 决策:RPG 创作侧标准 dark header / footer 动作也继续纳入同一条按钮收口线。`RpgCreationRoleAssetStudioModalImpl.tsx` 的 header“关闭”、`RpgCreationEntityEditorShared.tsx` 的 footer“取消”以及 `RpgCreationRoleAssetStudioFooter.tsx` 的“保存到当前角色”都改为委托 `PlatformActionButton surface="editorDark"`;局部壳层只保留布局、宽度/字号贴合和少量 tone 语义,不再为标准 dark close / cancel / save CTA 单独维护原生 `<button>` 基础 chrome。
|
||||||
|
- 决策:RPG runtime overlay 里的标准 dark CTA 和可点击 dark row 也继续纳入这条收口线。`RpgAdventurePanelOverlays.tsx` 的 goal panel“知道了”、任务详情里的“领取任务 / 返回交付”、任务完成提示里的“打开任务日志”都改为委托 `PlatformActionButton surface="editorDark"`;设置面板里的“运行统计”入口改为 `PlatformSubpanel as="button" surface="dark"`。像素风 choice button、HUD launcher、奖励物品格和输入 composer 保持 runtime 专属语义,不继续硬并到普通平台按钮。
|
||||||
|
- 决策:`PlatformToolModalShell` 继续承接 RPG 结果页发布检查弹窗;`RpgCreationResultActionBar.tsx` 只保留发布检查、封面预览、封面设置和发布动作语义,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close、body/footer spacing 和遮罩关闭逻辑。后续结果页 / 工具页里同形态的白底 portal 弹窗优先迁移到 `PlatformToolModalShell`;编辑器大壳、暗色 runtime overlay 和需要专属布局的面板继续保留局部 shell。
|
||||||
|
- 决策:`PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
|
||||||
|
- 决策:`PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭,实体编辑器弹窗需要保留编辑 footer,后续逐个迁移并补对应交互测试。
|
||||||
|
- 决策:认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该壳层只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 只保留各自表单状态和提交流程。
|
||||||
|
- 决策:账号弹窗可以继续复用 `PlatformAuthModalShell` 的平台主题 overlay 与 auth card 壳层,但通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号 direct mode 的唯一 dialog 语义与 safe-area 布局,不把账号安全详情、换绑手机号或修改密码子面板并进登录表单语义。
|
||||||
|
- 决策:运行态弹窗先按玩法目录沉淀薄壳,只有跨玩法接口真正稳定后才上升到 `common/`。拼图运行态用 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx` 承接道具确认、设置、退出改造、失败和通关结算的 overlay / dialog / footer / button 骨架;抓大鹅和跳一跳结算分别保留在各自 runtime shell 内抽本地 settlement shell / summary / actions。`PlatformToolModalShell` 继续只服务平台白底工具弹窗,不强塞到像素风或游戏运行态 overlay;拖拽 ghost、飞行动画、原图查看和全屏 runtime 容器不按旧 modal 债务处理。
|
||||||
|
- 决策:NPC dark modal footer 和暗色明细空态也继续纳入同一条收口线。`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"`;`CharacterInfoShared.tsx` 的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带独立业务语义的控件继续保留局部实现。
|
||||||
|
- 决策:详情页头部动作组合统一收口到 `src/components/common/PlatformDetailTopbar.tsx` 与 `src/components/common/PlatformDetailShareActions.tsx`。`PlatformDetailTopbar` 只负责返回按钮、标题居中槽位和右侧动作槽位的布局,可在 `pill` / `icon` 返回入口之间切换;`PlatformDetailShareActions` 只负责“前置 badge 区块 + 作品号复制 + 分享复制”这组稳定动作,并允许按页面关闭复制或分享其中一项。`RpgEntryWorldDetailView.tsx` 已接入 overlay 版完整动作组,`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,同时继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续详情页若只是复用返回、标题、作品号复制或分享动作排列,优先组合这两个薄组件,不把作者、摘要、封面、轮播或业务 CTA 塞进共享配置对象。
|
||||||
|
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
|
||||||
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
||||||
|
|
||||||
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
||||||
@@ -1572,3 +2197,44 @@
|
|||||||
- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
|
- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
|
||||||
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-07 创作入口泥点消耗改由统一契约驱动
|
||||||
|
|
||||||
|
- 背景:创作入口玩法卡封面右下角长期固定显示 `10-20泥点数`,无法在后台按玩法调整,也容易和真实钱包余额或活动奖池混淆。
|
||||||
|
- 决策:`creationTypes[].unifiedCreationSpec.mudPointCost` 作为入口卡泥点消耗数量字段,旧契约缺失时后端和前端都兜底为 `10`;入口卡由前端格式化为 `X泥点数` 展示,后端和后台不保存单位文案。该字段同时作为玩法新建草稿初始生成的扣费真相源,前端余额前置校验、拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成必须读取同一份后台入口配置;结果页单图重生成、发布、道具使用和其它独立资产操作继续使用各自业务成本。
|
||||||
|
- 决策补充:后台创作入口开关页不再直接暴露统一创作契约 JSON textarea;页面按契约结构展示为卡片和字段列表,点击“修改契约”后通过弹窗表单编辑 `title`、`mudPointCost` 和 fields,再组装回统一契约 payload 保存。`workspaceStage`、`generationStage` 和 `resultStage` 属于内部阶段标识,后台不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的固定阶段映射自动带出。
|
||||||
|
- 影响范围:`shared-contracts` 的 `UnifiedCreationSpecResponse`、`/api/creation-entry/config` 响应、前端入口卡派生、后台入口开关页、玩法链路文档和创作入口回归测试。
|
||||||
|
- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-11 拼图与拼消消运行态剩余阻断层继续局部收口
|
||||||
|
|
||||||
|
- 背景:账号弹窗、拼图 runtime、抓大鹅结算、跳一跳结算和拼图 onboarding 收口后,允许范围内仍剩拼图“正在准备下一关”阻断层与拼消消 runtime 的等待 / 结算层各自手写 overlay;它们结构相近,但又都带着玩法本地语义。
|
||||||
|
- 决策:平台入口里的拼图“正在准备下一关”只在 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 下新增 `PuzzleRuntimeBlockingOverlay.tsx` 做本地薄壳,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板上推到 `common/`。拼消消 runtime 则在 `src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,统一 `!activeRun`、`level_cleared`、`finished`、`level_failed` 三类局部 overlay 的结构和动作出口。拖拽 ghost、swap flight、补牌 / 消除动画和全屏 runtime 容器继续视为玩法专属视觉层,不算旧 modal 债务。
|
||||||
|
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、相关测试与 PlatformUiKit 收口文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
- 关联文档:`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
|
||||||
|
|
||||||
|
## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
|
||||||
|
|
||||||
|
- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。
|
||||||
|
- 决策:`boardBackgroundPrompt` 成为中央场地底图的优先 prompt 来源,只有该字段为空时才回退读取 `themePrompt`;用户上传底图时只执行平台资产持久化和换签,不用主题词重写上传资产。复合图 atlas prompt 只描述“可被服务端按等大 1x1 方格切分”,禁止模型在图案上绘制切分线、边框、网格线或裁切参考线。
|
||||||
|
- 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。
|
||||||
|
- 验证方式:`npm run spacetime:generate`、`npm run check:encoding`、`npm run check:server-rs-ddd`、`cargo test -p module-puzzle-clear`、`cargo test -p spacetime-client puzzle_clear -- --nocapture`、`npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`。
|
||||||
|
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-06 统一创作页表头按契约 title 原样显示
|
||||||
|
|
||||||
|
- 背景:统一创作页长期使用固定表头 `想做个什么玩法?`,导致跳一跳等玩法希望按自身语义展示标题时只能改前端或默认契约。
|
||||||
|
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但读取和保存时都按契约内容原样显示和持久化,不再用入口 `title` 自动覆盖。默认 spec 可以给出玩法中文名;旧库中已经持久化为 `想做个什么玩法?` 的契约也保持原样,若需要改表头应在后台契约结构卡片中点击修改并编辑 `title` 字段。
|
||||||
|
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
|
||||||
|
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款
|
||||||
|
|
||||||
|
- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿;拼图首图后台任务还使用 api-server 进程内 HashSet 互斥,多实例下不能防重复。
|
||||||
|
- 决策:暂不实现 token 限流。所有资产生成预扣费改为 fail-closed,预扣费失败直接返回错误;支持 retry 的计费 ledger id 统一包含 HTTP `request_id`,前端静默刷新重试复用同一个 `x-request-id`。生成失败后的退款先同步调用 SpacetimeDB,失败则写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。拼图首图后台生成互斥改为 SpacetimeDB `puzzle_background_compile_task` 表,使用 `task_id + request_id` 作为 claim id,释放时校验 claim id,避免旧任务误删新租约。
|
||||||
|
- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、拼图首图后台生成、`spacetime-module` 拼图 task 表、`spacetime-client` bindings/facade、前端 API request id 复用和后端架构文档。
|
||||||
|
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`node scripts/check-server-rs-ddd-boundaries.mjs`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation`、`npm run test -- src/services/apiClient.test.ts`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ npm run dev
|
|||||||
|
|
||||||
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
|
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
|
||||||
|
|
||||||
|
本地 `npm run dev`、`npm run dev:spacetime` 和 `npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。
|
||||||
|
|
||||||
该命令会启动:
|
该命令会启动:
|
||||||
|
|
||||||
- SpacetimeDB standalone
|
- SpacetimeDB standalone
|
||||||
@@ -95,18 +97,39 @@ npm run dev:web
|
|||||||
npm run dev:admin-web
|
npm run dev:admin-web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本地 SSH 服务器管理面板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server-manager:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias,支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart <unit>` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。
|
||||||
|
面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。
|
||||||
|
|
||||||
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||||
|
|
||||||
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
||||||
|
|
||||||
生产 `Genarrative-Stdb-Module-Publish` 的备份默认使用 `DATABASE_BACKUP_MODE=async`:流水线在 publish 前先生成本地冷备份,随后继续 publish,并把同一份发布前备份交给后台 Node 进程上传 OSS,避免低带宽 OSS 上传长时间占住部署窗口。需要强制在 publish 前等待打包和上传并让失败阻断发布时,手动选择 `DATABASE_BACKUP_MODE=sync`;已有其他备份窗口且明确接受风险时才选择 `skip`。
|
生产 `Genarrative-Stdb-Module-Publish` 的备份默认使用 `DATABASE_BACKUP_MODE=async`:流水线在 publish 前先生成本地冷备份,随后继续 publish,并把同一份发布前备份交给后台 Node 进程上传 OSS,避免低带宽 OSS 上传长时间占住部署窗口。需要强制在 publish 前等待打包和上传并让失败阻断发布时,手动选择 `DATABASE_BACKUP_MODE=sync`;已有其他备份窗口且明确接受风险时才选择 `skip`。
|
||||||
|
|
||||||
|
生产 API / Web / Stdb 发布流水线不在目标机器 checkout Git。对应 Build 流水线必须把发布产物、校验文件、`release-manifest.json` 和部署 / 发布脚本一起归档;Deploy / Publish 流水线只通过 `copyArtifacts` 复制上游构建归档并执行随产物归档的脚本,避免目标机器 Git 访问和产物 commit 与部署脚本 commit 漂移。
|
||||||
|
|
||||||
查看本地 Rust/SpacetimeDB 日志:
|
查看本地 Rust/SpacetimeDB 日志:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev:spacetime:logs
|
npm run dev:spacetime:logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本机隔离验证外部生成 worker 队列、API-only 更新和 worker 动态扩缩容时,优先使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令生成 `deploy/container/worker-smoke/` 下的 gitignored env 与端口 state,启动独立 compose project 和独立 SpacetimeDB,用 unsupported job 验证 worker claim / fail 回写;排查时用 `api-update` 确认 API 重建不触碰 worker,用 `scale <n>` 调整 worker 数量。
|
||||||
|
`external_generation_job` 是 private table,worker-smoke 通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 查询队列表。
|
||||||
|
worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 依赖官方大镜像下载。若容器内 Cargo 下载依赖不稳定,追加 `--local-binary`,让容器内 Cargo 复用本机 Cargo 缓存构建当前 `api-server` 二进制,并把产物放进 Debian bookworm smoke runtime;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;隔离端口或库数据需要重建时追加 `--force`。
|
||||||
|
|
||||||
后台管理前端:
|
后台管理前端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 踩坑与排障记录
|
# 踩坑与排障记录
|
||||||
|
|
||||||
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
|
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
|
||||||
|
|
||||||
@@ -15,6 +15,78 @@
|
|||||||
- 关联:相关文件、文档、提交或 Issue
|
- 关联:相关文件、文档、提交或 Issue
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
|
||||||
|
|
||||||
|
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
|
||||||
|
- 原因:资产操作扣费和退款都用稳定 ledger id 做幂等;这能保护 lease 过期后的崩溃重领不重复扣费,但不适合“已明确失败且已退款”的自动业务重试。
|
||||||
|
- 处理:拼图 `puzzle_compile_draft` 首期设置 `max_attempts=1`,业务失败直接 failed,只保留 running lease 过期后的崩溃重领。后续若要恢复自动 retry,必须先引入 attempt-aware billing 或可配对撤销的账本接口。
|
||||||
|
- 验证:检查 `external_generation_job.max_attempts`、worker 失败回写和钱包 ledger;失败后草稿进入 failed,重试应由用户重新触发新任务,而不是旧 job 自动 pending。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||||
|
|
||||||
|
## 外部生成队列不再由 HTTP 进程兜底执行
|
||||||
|
|
||||||
|
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
|
||||||
|
- 原因:HTTP 角色只入队,不再直接调用外部 provider;如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 或 `all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
|
||||||
|
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller;首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active,并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例,手动扩缩容只作为兜底;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job,也不能验证 worker 扩缩容。
|
||||||
|
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id` 与 `lease_expires_at` 会更新,完成后 session 进入 ready 或 failed;inline 模式下不应产生新的 `external_generation_job`。
|
||||||
|
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
||||||
|
|
||||||
|
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
||||||
|
- 原因:队列状态栅栏只保护 `external_generation_job` 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 `job_id / worker_id / lease_token`,并在同一个 SpacetimeDB transaction 内校验 job 仍为 `running`、lease 未过期、job kind、owner 和 source entity 匹配。
|
||||||
|
- 处理:拼图首图 worker 的前置 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 已接入 `external_generation_job` lease guard;api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 `external_generation_job 当前不是 running 状态` 或 `external_generation_job 不存在` 时也按 stale guard 处理。inline 模式只允许 `job_id / worker_id / lease_token` 三项同时为空,半空 guard 仍拒绝。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
|
||||||
|
- 验证:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||||
|
|
||||||
|
## 外部生成 worker 核心业务写回失败不能完成 job
|
||||||
|
|
||||||
|
- 现象:worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 `external_generation_job` 标成 `completed`,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
|
||||||
|
- 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
|
||||||
|
- 处理:worker 路径的 `save_puzzle_generated_images` / `save_puzzle_ui_background` 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile,写回成功后才允许把队列 job 标为 failed;失败态未写回时保留租约,等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 `systemctl enable --now genarrative-external-generation-worker@1.service`。
|
||||||
|
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||||
|
|
||||||
|
## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复
|
||||||
|
|
||||||
|
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。
|
||||||
|
- 原因:`genarrative-api.service` 配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时 API 会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只传 `--stop-service spacetimedb.service` 而漏掉 `--restart-service-after genarrative-api.service`,备份脚本只会恢复数据库,不会再拉起 API。
|
||||||
|
- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。
|
||||||
|
- 验证:`systemctl cat genarrative-database-backup.service` 应包含该参数;`systemctl is-active spacetimedb.service genarrative-api.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功。
|
||||||
|
- 关联:`deploy/systemd/genarrative-database-backup.service`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## SpacetimeDB 45 秒超时要看 api-server 记录的阶段
|
||||||
|
|
||||||
|
- 现象:release 上 Nginx 能立刻连到 `api-server`,但 `/api/runtime/*/gallery`、`/api/creation-entry/config` 等请求在约 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 后返回 `502` / `504`。
|
||||||
|
- 原因:旧日志只能看到 HTTP 总耗时和最终状态,无法区分卡在连接池、SDK 建连、等待 `on_connect`、订阅 read model、等待 procedure / reducer 回调还是本地订阅 cache 读取。
|
||||||
|
- 处理:`spacetime-client` 内置阶段化健康检查和失败日志;`/readyz` 用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS` 短窗口检查 SpacetimeDB 连接租约,业务失败日志包含 `operation_kind`、`operation_name`、`spacetime_stage`、`elapsed_ms`。
|
||||||
|
- 验证:`/readyz` 失败时看 `details.spacetime.stage`;业务请求超时时查 `journalctl -u genarrative-api.service` 中同一时间窗口的 `SpacetimeDB client operation failed`,优先按 `pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result`、`read_cache` 分阶段处理。
|
||||||
|
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/api-server/src/health.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 新建草稿扣费不能和入口卡泥点配置分离
|
||||||
|
|
||||||
|
- 现象:后台修改创作入口的 `mudPointCost` 后,入口卡和前置余额提示可能显示新数值,但用户真实钱包流水仍按代码常量扣除。
|
||||||
|
- 原因:早期约定把 `creationTypes[].unifiedCreationSpec.mudPointCost` 只当展示字段,拼图、抓大鹅和汪汪声浪初始生成各自保留了 `2`、`10`、三次单图 `1` 的硬编码扣费路径。
|
||||||
|
- 处理:新建草稿初始生成成本必须统一从 `GET /api/creation-entry/config` 的 `unifiedCreationSpec.mudPointCost` 解析;前端预校验、拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成同源。汪汪声浪结果页单图重新生成仍按单图资产操作成本,不套初始草稿总成本。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "mud points"`、`npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml resolves_mud_point_cost initial_generation_slot_cost_splits_creation_entry_total_cost -- --nocapture`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/match3d/draft.rs`、`server-rs/crates/api-server/src/bark_battle.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## generated 图片重复下载不要改成服务端本地磁盘缓存
|
||||||
|
|
||||||
|
- 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。
|
||||||
|
- 原因:前端输入如果是 `https://*.oss-*.aliyuncs.com/generated-*`,会被当普通绝对 URL 直连,绕过 `/api/assets/read-url` 和 signed URL 本地缓存;旧 OSS 对象如果缺少 `Cache-Control`,浏览器只能依赖 `ETag` / `Last-Modified` 做 304 协商缓存,不会长期强缓存。
|
||||||
|
- 处理:完整 OSS generated URL 先归一成 `/generated-*` legacy public path,再走 `/api/assets/read-url` 换签;`refreshKey` 是 signed URL 缓存版本号,同一路径、同一版本且未临近过期时必须复用,不要每次渲染都强制重新换签。新上传 generated 私有对象由 `platform-oss` 在 `PostObject` form fields / policy 和服务端 `PutObject` 请求头中写入 `Cache-Control: public, max-age=31536000, immutable`。不要把 api-server 变成图片静态代理,也不要把 OSS 内容 fallback 到服务器磁盘。
|
||||||
|
- 验证:前端测试应看到完整 OSS generated URL 调用 `/api/assets/read-url?legacyPublicPath=...`,且相同 `refreshKey` 不重复换签;`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml` 应覆盖 `Cache-Control` policy、form field、PutObject headers 和 V4 `AdditionalHeaders`;线上旧对象可用 `curl -I` 观察是否只有 `ETag` / `Last-Modified` 或已经补齐 `Cache-Control`。
|
||||||
|
- 关联:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/platform-oss/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 小程序 H5 导航不能清掉宿主 query
|
||||||
|
|
||||||
|
- 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。
|
||||||
|
- 原因:小程序 `web-view` 入口通过 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramEnv` 标记宿主环境,但 H5 内部 `pushAppHistoryPath(...)` 阶段导航会默认清空 query;首点时微信 JS bridge 也可能尚未就绪,导致 `isWechatMiniProgramWebViewRuntime()` 和充值平台判断读不到小程序上下文。
|
||||||
|
- 处理:路由层统一把 `clientType`、`clientRuntime`、`miniProgramEnv` 当作 app runtime context,在普通路径归一、显式 query 路由和同一创作流跳转时都跨导航保留;小程序环境识别同时用 `MicroMessenger + miniProgram` User-Agent 兜底首点 bridge 未就绪场景;创作恢复参数仍只在同玩法创作流内保留,离开创作流时继续清理。
|
||||||
|
- 验证:`npm exec vitest run src/routing/appPageRoutes.test.ts src/components/auth/AuthGate.test.tsx src/services/authService.test.ts src/services/payment/paymentPlatform.test.ts`。
|
||||||
|
- 关联:`src/routing/appPageRoutes.ts`、`src/services/authService.ts`、`src/services/payment/paymentPlatform.ts`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||||
|
|
||||||
## 平台异步错误必须带来源弹窗,不要只显示裸错误
|
## 平台异步错误必须带来源弹窗,不要只显示裸错误
|
||||||
|
|
||||||
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
|
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
|
||||||
@@ -23,6 +95,30 @@
|
|||||||
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
|
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 自定义世界旧公开作品不要用 published_at 判断是否存在
|
||||||
|
|
||||||
|
- 现象:RPG / 自定义世界作品详情能打开,但点赞时报 `custom_world 已发布作品不存在,无法点赞`,错误来源是 `作品详情 CW-*` 或其它自定义世界历史公开号。
|
||||||
|
- 原因:部分历史 `custom_world_profile` 已是 `publication_status=Published`,但 `published_at` 为空;统一公开详情会用 `updated_at` 兜底展示,旧点赞 / 游玩 / Remix 判断却额外要求 `published_at.is_some()`。
|
||||||
|
- 处理:公开互动存在性统一按 `Published + deleted_at=None + visible=true` 判断;`custom_world_gallery_entry` 同步和公开展示时间在 `published_at` 缺失时回退 `updated_at`。
|
||||||
|
- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。
|
||||||
|
|
||||||
|
## 拼图公开推荐不要只按 Published 判断
|
||||||
|
|
||||||
|
- 现象:后台把拼图作品隐藏后,作品不在公开列表里显示,但玩家通关其它拼图后的推荐下一作品仍可能出现这条隐藏作品。
|
||||||
|
- 原因:拼图隐藏只把 `puzzle_work_profile.visible` 置为 `false`,不会把 `publication_status` 从 `Published` 改走;通关推荐候选曾只通过 `by_puzzle_work_publication_status().filter(Published)` 取数,漏掉可见性判断。
|
||||||
|
- 处理:拼图公开消费路径统一使用 `Published + visible=true`,范围包括 `puzzle_gallery_view`、`puzzle_gallery_card_view`、兼容 gallery/detail procedure、公开点赞 / Remix、正式公开 runtime 启动和通关后的 `recommended_next_works` 候选。
|
||||||
|
- 验证:`cargo test -p spacetime-module hidden_published_puzzle_work_is_not_public_visible_candidate --manifest-path server-rs/Cargo.toml`,并在需要时用后台隐藏一个已发布拼图后重试通关推荐。
|
||||||
|
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 推荐页 WF 点赞不要落到 RPG / custom-world
|
||||||
|
|
||||||
|
- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。
|
||||||
|
- 原因:推荐页点赞统一走 `likePublicWork`,但敲木鱼尚未接入点赞后端;缺少 `wooden-fish` 分支时会落入默认 RPG / custom-world 点赞路径,把敲木鱼的 owner/profile 传给 custom-world reducer。
|
||||||
|
- 处理:所有公开作品互动必须先按 `packages/shared/src/contracts/playTypes.ts` 中的全局 `sourceType` 分流;暂未接入点赞的玩法直接报“该作品类型暂不支持点赞”,禁止显示开放兜底文案,也禁止用默认 RPG / custom-world 分支兜底。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation wooden fish like does not call RPG gallery like"`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。
|
||||||
|
|
||||||
## 暗色创作进度卡不要被 platform-remap-surface 改成深色文字
|
## 暗色创作进度卡不要被 platform-remap-surface 改成深色文字
|
||||||
|
|
||||||
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。
|
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。
|
||||||
@@ -31,21 +127,21 @@
|
|||||||
- 验证:`CreationAgentWorkspace` 测试应断言进度标题、百分比和提示文本带专属 class;`src/index.test.ts` 应断言这些 class 在 remap surface 内有白色覆盖规则;移动端截图中暗色卡片文字应保持可读。
|
- 验证:`CreationAgentWorkspace` 测试应断言进度标题、百分比和提示文本带专属 class;`src/index.test.ts` 应断言这些 class 在 remap surface 内有白色覆盖规则;移动端截图中暗色卡片文字应保持可读。
|
||||||
- 关联:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/components/creation-agent/CreationAgentWorkspace.test.tsx`、`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/components/creation-agent/CreationAgentWorkspace.test.tsx`、`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## VectorEngine 图片生成 SendRequest 超时要按传输失败排查
|
## VectorEngine 图片生成 request_send 传输错误要按可重试网络抖动排查
|
||||||
|
|
||||||
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。
|
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)`、`[35] SSL connect error (Recv failure: Connection reset by peer)`、`[56] Failure when receiving data from the peer (... unexpected eof while reading ...)`;也可能看到 `failureStage=upstream_status`、`statusCode=502`、错误体是 Nginx HTML `502 Bad Gateway`。前端只知道图片生成失败。
|
||||||
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
|
- 原因:`request_send` 表示请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体;`upstream_status=502/5xx/429/408` 表示拿到了上游错误响应但仍属于可重试的过载 / 网关抖动。`timeout=true` 来自超时判定,`connect=true` 会同时覆盖 DNS / connect 失败以及 libcurl 35 SSL 握手、libcurl 56 收包提前 EOF、connection reset 这类临时传输错误。
|
||||||
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 `request_send` 的 `timeout` / `connect` 错误最多重试 3 次,multipart `/v1/images/edits` 每次重试都必须重建 form;看到 `VectorEngine 图片请求发送失败,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
|
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout/connect=true` 或 `upstream_status + statusCode=408/429/5xx` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 request_send 的 timeout / connect / SSL connect reset / recv error / unexpected eof / send error,以及 upstream_status 的 408 / 429 / 5xx 最多发送 5 次,multipart `/v1/images/edits` 每次重试都会重新构造 form;看到 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `429 moderation_blocked` 或明确审核错误,按审核失败另行处理,不要归到网络抖动。
|
||||||
- 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot`、`asset_kind`、`elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。
|
- 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot`、`asset_kind`、`elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。
|
||||||
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
|
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_send_retry_policy -- --nocapture`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
|
||||||
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## “我的”页每日任务卡不要硬编码进度
|
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
|
||||||
|
|
||||||
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
|
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
|
||||||
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。
|
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。
|
||||||
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。
|
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。
|
||||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`,领取后显示已完成。
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`、领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心。
|
||||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||||
|
|
||||||
## “我的”页不要恢复旧的填邀请码次级按钮
|
## “我的”页不要恢复旧的填邀请码次级按钮
|
||||||
@@ -88,6 +184,14 @@
|
|||||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`,并确认恢复生成中草稿后 `getPuzzleAgentSession` 不会因为进度刷新继续连发。
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`,并确认恢复生成中草稿后 `getPuzzleAgentSession` 不会因为进度刷新继续连发。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/usePlatformCreationAgentFlowController.ts`、`src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/usePlatformCreationAgentFlowController.ts`、`src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`。
|
||||||
|
|
||||||
|
## 小游戏恢复生成页不要只用请求 busy 判定是否生成中
|
||||||
|
|
||||||
|
- 现象:敲木鱼作品架里的生成中草稿点击进入生成页后,页面会显示“重新生成草稿”按钮,而不是继续显示素材生成中的等待态。
|
||||||
|
- 原因:平台壳恢复 `generationStatus=generating` 草稿时会把 `isBusy` 置回 false,只保留 `MiniGameDraftGenerationState` 作为生成事实;生成页如果只把请求 busy 传给 `isGenerating`,共用生成页会误判为空闲态并展示重试按钮。
|
||||||
|
- 处理:小游戏生成页的 `isGenerating` 必须由 `isBusy || isMiniGameDraftGenerating(generationState)` 推导;跳一跳、拼消消、敲木鱼等从作品架恢复的生成页都要使用同一口径。
|
||||||
|
- 验证:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts` 应覆盖 `busy=false` 但敲木鱼 generation state 仍在生成中时继续隐藏重试入口。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/unified-creation/UnifiedGenerationPage.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 拼图试玩恢复 query 必须先切到运行态路径再写
|
## 拼图试玩恢复 query 必须先切到运行态路径再写
|
||||||
|
|
||||||
- 现象:拼图试玩或正式运行态打开后,刷新会停在“正在进入拼图关卡”,或地址栏只有 `runtimeProfileId`,缺少草稿 `runtimeSessionId`。
|
- 现象:拼图试玩或正式运行态打开后,刷新会停在“正在进入拼图关卡”,或地址栏只有 `runtimeProfileId`,缺少草稿 `runtimeSessionId`。
|
||||||
@@ -96,6 +200,30 @@
|
|||||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"puzzle draft generation auto starts trial and runtime back opens draft result\"`,确认 `window.location.pathname === '/runtime/puzzle'` 且 `window.location.search` 同时包含 `runtimeProfileId` 和 `runtimeSessionId`。
|
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"puzzle draft generation auto starts trial and runtime back opens draft result\"`,确认 `window.location.pathname === '/runtime/puzzle'` 且 `window.location.search` 同时包含 `runtimeProfileId` 和 `runtimeSessionId`。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/puzzleRuntimeUrlState.ts`、`src/routing/appPageRoutes.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/puzzleRuntimeUrlState.ts`、`src/routing/appPageRoutes.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 拼消消草稿试玩不能只测 swap 回调
|
||||||
|
|
||||||
|
- 现象:拼消消结果页和 runtime shell 的单测都能通过,但真实页面里卡片只是交换,完全不会消除,顶部准备区还会因为已知的卡背占位路径显示坏图。
|
||||||
|
- 原因:草稿试玩走的是前端本地 runtime,早期测试只覆盖了 `onSwapCards` 回调和局部状态,没有验证完整的消除、重力补牌、关卡完成和资源兜底链路;同时顶部卡背对 `puzzle-clear-card-back.webp` 这类已知缺失资源没有前置回退。
|
||||||
|
- 处理:草稿试玩的回归测试必须覆盖“交换 -> 完整图案消除 -> 补牌 -> 关卡完成”闭环,并在组件测试里验证真实点击/拖拽序列;顶部准备区卡背遇到已知占位路径时直接回退到 `puzzle.webp` 这类可用参考图,不等图片加载失败后再兜底。
|
||||||
|
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 通过,浏览器 smoke 页实测可完成一次消除并弹出“本关完成”。
|
||||||
|
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
|
## 拼消消消除过渡不能隐藏已有卡片的最终下沉格
|
||||||
|
|
||||||
|
- 现象:消除补牌过程中偶尔看起来下方有空位,但同列上方卡片没有落下来。
|
||||||
|
- 原因:后端和本地 runtime 的重力补牌已经把已有卡片压到底;真正的问题在前端过渡层。消除动画曾按旧消除坐标隐藏棋盘格,掉落动画也曾隐藏所有 drop 目标格。当某个旧卡下沉到刚被消除的格子时,最终 snapshot 里的真实卡片会被隐藏,视觉上像补牌没有落下。
|
||||||
|
- 处理:消除 / 掉落覆盖层只负责动画表现,不再隐藏已有场上卡片的最终格;只有从顶部准备区新补入、前一帧棋盘不存在的卡片,才允许临时隐藏底层目标格来配合下落动画。
|
||||||
|
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx -t "已有卡片因重力下沉时目标格不被过渡状态隐藏成空位"`,并保留领域侧 `cargo test -p module-puzzle-clear refill --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`server-rs/crates/module-puzzle-clear/src/application.rs`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。
|
||||||
|
|
||||||
|
## 拼消消完整消除反馈不要让补牌抢帧
|
||||||
|
|
||||||
|
- 现象:玩家正确拼完整组后,卡片几乎瞬间消失,顶部补牌马上出现或下落,导致“拼对了”的确认反馈很弱。
|
||||||
|
- 原因:前端一收到新 snapshot 就同时播放消除和掉落叠层,旧消除动画时长较短;新补入卡牌的下落延迟接近 0ms,视觉上会抢在消除反馈之前开始。
|
||||||
|
- 处理:局部正确拼合但未消除时只给锁定组做一次高光;完整消除时让旧卡片在消除叠层中短暂放大展示再淡出;新补入卡牌的下落延迟到淡出尾段,并继续只隐藏新补入目标格,不隐藏已有场上卡片下沉后的最终格。
|
||||||
|
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`,浏览器里确认局部拼合会闪、完整消除会放大淡出、补牌在淡出后段才开始掉落。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
## 首页推荐分流参数不能条件性调用 hook
|
## 首页推荐分流参数不能条件性调用 hook
|
||||||
|
|
||||||
- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。
|
- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。
|
||||||
@@ -112,6 +240,30 @@
|
|||||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
|
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 内嵌泥点确认弹窗必须自带平台主题作用域
|
||||||
|
|
||||||
|
- 现象:拼图 / 抓大鹅统一创作页点击生成后,“确认消耗泥点”弹窗正文和按钮存在,但弹窗面板背景透明,只剩遮罩和文字。
|
||||||
|
- 原因:`PlatformMudPointConfirmDialog` 作为二级确认常以 `portal={false}` 内嵌到工作台局部 DOM,局部节点不一定继承 `.platform-theme`;`platform-modal-shell` 依赖 `--platform-modal-fill` 等主题变量,变量缺失时面板底色解析为空。
|
||||||
|
- 处理:共享泥点确认弹窗默认在 overlay 上带 `platform-theme platform-theme--<theme>`、`platform-modal-backdrop` 和实色遮罩,在 panel 上带 `platform-modal-shell platform-remap-surface`;单按钮状态弹窗也要有默认 light 主题,避免未来独立调用复现。
|
||||||
|
- 验证:浏览器触发 `/creation/puzzle` 与 `/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
|
||||||
|
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx`、`src/components/common/PlatformStatusDialog.tsx`、`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`。
|
||||||
|
|
||||||
|
## 拼图结果页关卡图不要裁切,嵌套图片预览要高于详情弹窗
|
||||||
|
|
||||||
|
- 现象:拼图结果页“拼图关卡”列表里的关卡图底部被裁掉;进入关卡详情后点击画面图,看起来没有打开全屏预览。
|
||||||
|
- 原因:关卡列表复用 `PlatformMediaFrame aspect="standard"` 默认 `object-cover`,方图或竖向生成图会在 4:3 框内被裁切;关卡详情弹窗自身层级高于 `CreativeImageInputPanel` 默认图片预览层级,预览实际打开但被压在详情弹窗后面。
|
||||||
|
- 处理:结果页关卡缩略图显式传 `imageClassName="h-full w-full object-contain"` 保留完整画面;`CreativeImageInputPanel` 提供 `mainImagePreviewZIndexClassName`,嵌套在高层级弹窗内时由调用方传更高层级。
|
||||||
|
- 验证:聚焦测试断言关卡缩略图使用 `object-contain` 且没有 `object-cover`,并断言关卡详情内主图预览 overlay 层级高于详情弹窗;浏览器里检查列表完整显示图片,详情内点击画面图能打开可见预览。
|
||||||
|
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
|
||||||
|
## 图片大图预览不要复用白底工具弹窗
|
||||||
|
|
||||||
|
- 现象:点击图像输入面板里的参考图或主图预览后,页面只出现白底非全屏弹窗,背后原页面透出,不能缩放或拖拽查看细节。
|
||||||
|
- 原因:图片查看和工具弹窗共用了 `UnifiedModal` 白底壳层;该壳层适合编辑 / 选择工具,不适合沉浸式看图,也没有图片边界拖拽状态。
|
||||||
|
- 处理:纯图片预览统一走 `PlatformImagePreviewModal`,全屏黑底展示,初始 contain 保证完整图片可见,缩放夹在 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免把图片拖到露出背景。
|
||||||
|
- 验证:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx` 应覆盖黑底全屏、缩放上限、拖拽边界和关闭按钮。
|
||||||
|
- 关联:`src/components/common/PlatformImagePreviewModal.tsx`、`src/components/common/CreativeImageInputPanel.tsx`。
|
||||||
|
|
||||||
## 玩法入口分类字段缺失要前端兜底
|
## 玩法入口分类字段缺失要前端兜底
|
||||||
|
|
||||||
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
||||||
@@ -127,6 +279,14 @@
|
|||||||
- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。
|
- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。
|
||||||
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`server-rs/crates/module-runtime/src/application.rs`、`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`。
|
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`server-rs/crates/module-runtime/src/application.rs`、`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`。
|
||||||
|
|
||||||
|
## 创作入口 banner 默认图片路径必须真实存在
|
||||||
|
|
||||||
|
- 现象:创作页顶部 banner 返回旧结构化 `eventBanner` 时,前端 `<img>` 请求 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`,但 `public/` 下没有该文件,导致 banner 背景图加载失败。
|
||||||
|
- 原因:旧库 `event_banners_json=None` 时,读取层把旧单条结构化 banner 当成 `eventBanners` 优先数组下发;同时旧结构化默认 `coverImageSrc` 指向已经不存在的品牌素材路径。
|
||||||
|
- 处理:`module-runtime` 在 `event_banners_json` 缺失或不可解析时回到默认公告数组;默认 HTML 公告和旧结构化默认 `coverImageSrc` 都引用 `public/` 下真实存在的 `/creation-type-references/puzzle.webp`。
|
||||||
|
- 验证:`cargo test -p module-runtime creation_entry_event_banners_none_returns_default_announcements --manifest-path server-rs/Cargo.toml`;重启本地 `api-server` 后 `GET /api/creation-entry/config` 的 `eventBanners[0]` 不再指向缺失的 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`。
|
||||||
|
- 关联:`server-rs/crates/module-runtime/src/application.rs`、`server-rs/crates/module-runtime/src/domain.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 移动端草稿卡不要长按选中文字
|
## 移动端草稿卡不要长按选中文字
|
||||||
|
|
||||||
- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。
|
- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。
|
||||||
@@ -147,7 +307,7 @@
|
|||||||
- 现象:创作 Tab 两列玩法卡上图能看到,但标题、描述或预计消耗泥点在白底信息区里看不见,或只剩泥点小图标。
|
- 现象:创作 Tab 两列玩法卡上图能看到,但标题、描述或预计消耗泥点在白底信息区里看不见,或只剩泥点小图标。
|
||||||
- 原因:旧 `platform-creation-reference-card` 是给暗图蒙版卡用的全局样式,会把卡片及全部子元素强制成白色文字;参考图要求的是“上图 + 下方白底信息区”,继续复用旧类会让白底上的文字消失。
|
- 原因:旧 `platform-creation-reference-card` 是给暗图蒙版卡用的全局样式,会把卡片及全部子元素强制成白色文字;参考图要求的是“上图 + 下方白底信息区”,继续复用旧类会让白底上的文字消失。
|
||||||
- 处理:创作 Tab 首屏模板卡使用独立 `creation-template-card`、`creation-template-card__body`、`creation-template-card__title`、`creation-template-card__subtitle` 和 `creation-template-card__cost` 结构,不挂 `platform-creation-reference-card`;旧弹层如果仍是暗图蒙版卡,可以继续保留旧类。
|
- 处理:创作 Tab 首屏模板卡使用独立 `creation-template-card`、`creation-template-card__body`、`creation-template-card__title`、`creation-template-card__subtitle` 和 `creation-template-card__cost` 结构,不挂 `platform-creation-reference-card`;旧弹层如果仍是暗图蒙版卡,可以继续保留旧类。
|
||||||
- 验证:浏览器创作 Tab 中每张卡都应显示标题、描述和“预计消耗 10-20 泥点”;`npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。
|
- 验证:浏览器创作 Tab 中每张开放态卡都应显示标题、描述和后台契约 `mudPointCost` 数量经前端格式化后的泥点消耗文案;旧契约缺字段时兜底显示 `10泥点数`;`npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。
|
||||||
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/index.css`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`。
|
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/index.css`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`。
|
||||||
|
|
||||||
## 创作首屏开放态卡片不要再显示左上状态标签
|
## 创作首屏开放态卡片不要再显示左上状态标签
|
||||||
@@ -274,7 +434,7 @@
|
|||||||
|
|
||||||
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
|
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
|
||||||
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
|
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
|
||||||
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;必要时执行 `spacetime version install <version> && spacetime version use <version>`,然后重启 `npm run dev:spacetime`。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;遇到版本不匹配时先直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
||||||
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
|
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
|
||||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/Cargo.toml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/Cargo.toml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
@@ -377,6 +537,14 @@
|
|||||||
- 验证:未登录推荐页可以直接进入跳一跳运行态,且 `work_play_start` 事件仍会落库或出现在 outbox 中,metadata 含匿名标记。
|
- 验证:未登录推荐页可以直接进入跳一跳运行态,且 `work_play_start` 事件仍会落库或出现在 outbox 中,metadata 含匿名标记。
|
||||||
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
|
||||||
|
## 跳一跳直接打开空 runtime 路由不能停在加载态
|
||||||
|
|
||||||
|
- 现象:直接访问 `/runtime/jump-hop` 时页面看起来一直停在“正在载入游戏 / 正在加载内容”,DOM 内部只有空的跳一跳运行态,没有平台、地块或 run 数据。
|
||||||
|
- 原因:`appPageRoutes` 会把该路径解析为 `jump-hop-runtime`,但裸路径没有 `work=JH-*` 公开作品码,也没有从详情页启动后写入的 `jumpHopRun`,平台壳仍挂载 `JumpHopRuntimeShell`。
|
||||||
|
- 处理:平台壳在 `jump-hop-runtime` 且缺少 run 时先看 `work` 参数;有 `JH-*` 则通过公开 gallery detail 回读 profile 并启动 published run,没有则回到平台首页。全局作品码恢复 effect 在跳一跳 runtime 阶段要跳过,避免和运行态恢复互相抢路由。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop runtime route"`;浏览器 smoke 分别打开 `/`、`/runtime/jump-hop` 和 `/runtime/jump-hop?work=JH-*`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/routing/appPageRoutes.ts`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。
|
||||||
|
|
||||||
## release tracking outbox 权限错误先查 env 缺失
|
## release tracking outbox 权限错误先查 env 缺失
|
||||||
|
|
||||||
- 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。
|
- 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。
|
||||||
@@ -385,6 +553,14 @@
|
|||||||
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
||||||
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## release otelcol 217/USER 和备份 timer inactive 分开处理
|
||||||
|
|
||||||
|
- 现象:release 巡检中 `otelcol-contrib.service` 持续 `activating (auto-restart)`,日志出现 `status=217/USER` / `Failed to determine user credentials`;同时 `genarrative-database-backup.timer` 显示 `enabled` 但 `inactive/dead`,`NEXT` / `Trigger` 为空。
|
||||||
|
- 原因:otelcol 的 systemd unit 使用 `User=otelcol` / `Group=otelcol`,但目标机缺少该系统用户和 `/etc/otelcol/genarrative-debug.yaml`;备份 timer 在 missed window 后未处于 active waiting 状态,直接重启 Persistent timer 可能在白天立刻补跑冷备份并停止 SpacetimeDB。
|
||||||
|
- 处理:先创建系统用户 / 组 `otelcol`,补齐 `/var/lib/otelcol`、`/etc/otelcol/genarrative-debug.yaml` 和 `/var/log/genarrative`,再重启 `otelcol-contrib.service`;修 timer 时先 `touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer`,再 `systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,避免当前窗口立即补跑冷备份。
|
||||||
|
- 验证:`otelcol-contrib.service` 为 `active (running)` 且监听 `127.0.0.1:4317/4318`;`systemctl list-timers genarrative-database-backup.timer --all` 显示下一次触发约为次日 `03:20`;`/healthz`、`/readyz`、`/v1/ping` 仍通过。
|
||||||
|
- 关联:`scripts/jenkins-server-provision.sh`、`deploy/systemd/otelcol-contrib.service`、`deploy/otelcol/genarrative-debug.yaml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 外部 API 失败没法追溯先查 external_api_call_failure
|
## 外部 API 失败没法追溯先查 external_api_call_failure
|
||||||
|
|
||||||
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
||||||
@@ -968,8 +1144,8 @@
|
|||||||
## 拼图生成完成后图片只显示破图或 alt 文案
|
## 拼图生成完成后图片只显示破图或 alt 文案
|
||||||
|
|
||||||
- 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。
|
- 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。
|
||||||
- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能用于重新请求 `/api/assets/read-url`,不能给 OSS V4 签名 URL 追加 `_v`;OSS 会把 query 纳入签名,额外参数会让签名失效,历史素材常因未传 `refreshKey` 而表现正常。
|
- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能作为 signed URL 缓存版本号,不能给 OSS V4 签名 URL 追加 `_v`;OSS 会把 query 纳入签名,额外参数会让签名失效。
|
||||||
- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage` 或 `useResolvedAssetReadUrl`;`isGeneratedLegacyPath(...)` 必须同时识别 `/generated-*` 和 `generated-*`;`refreshKey` 只绕过前端签名缓存并重新换签,不修改已返回的 OSS 签名 URL;禁止恢复 `/generated-puzzle-assets` 直读代理。
|
- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage` 或 `useResolvedAssetReadUrl`;generated 私有资源识别必须同时覆盖 `/generated-*`、`generated-*` 和 `https://*.oss-*.aliyuncs.com/generated-*`;`refreshKey` 变化时重新换签,同一路径同一 `refreshKey` 且签名未临近过期时复用已返回的 OSS 签名 URL;禁止恢复 `/generated-puzzle-assets` 直读代理。
|
||||||
- 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。
|
- 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。
|
||||||
- 关联:`src/services/assetReadUrlService.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`。
|
- 关联:`src/services/assetReadUrlService.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`。
|
||||||
|
|
||||||
@@ -1010,6 +1186,14 @@
|
|||||||
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
||||||
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
||||||
|
|
||||||
|
|
||||||
|
## 本地短信 smoke 先确认 SMS provider
|
||||||
|
|
||||||
|
- 现象:浏览器里短信验证码发送成功,但提交 `123456` 仍然报验证码错误,或者短信登录后又回到未登录态。
|
||||||
|
- 原因:当前运行中的 `api-server` 如果读取到 `.env.local` 里的 `SMS_AUTH_PROVIDER=aliyun`,就会走真实短信 provider 口径;这时 mock 验证码 `123456` 不会被接受。之前本地调试时常见的误判是把 `.env.local` 改成 mock 了,但没有重启 `npm run dev`,或者旧的 `scripts/dev.mjs` 进程还在沿用旧环境。
|
||||||
|
- 处理:本地只做 UI / 账号链路 smoke 时,把 `.env.local` 显式设为 `SMS_AUTH_PROVIDER=mock` 且配置 `SMS_AUTH_MOCK_VERIFY_CODE=123456`,然后重启 `npm run dev` 或 `npm run dev:api-server`。要做真实短信联调时,再切回 `SMS_AUTH_PROVIDER=aliyun` 并重启。
|
||||||
|
- 验证:`POST /api/auth/phone/send-code` 应返回 `providerRequestId=mock-request-id`;`POST /api/auth/phone/login` 用 `123456` 应返回 `200` 且 `user.loginMethod=phone`。浏览器侧短信登录成功后,会先进入邀请码弹窗或我的页面,不应再提示“验证码错误”。
|
||||||
|
- 关联:`scripts/dev-utils.mjs`、`scripts/dev-utils.test.ts`、`scripts/dev.mjs`、`server-rs/crates/api-server/src/config.rs`。
|
||||||
## 手机验证码登录成功后又瞬间回到未登录
|
## 手机验证码登录成功后又瞬间回到未登录
|
||||||
|
|
||||||
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
|
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
|
||||||
@@ -1023,6 +1207,8 @@
|
|||||||
- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
|
- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
|
||||||
- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
|
- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
|
||||||
- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
|
- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
|
||||||
|
- 追加处理:`/api/auth/refresh` 只有明确返回 `401` / `403` 时才代表登录态权威失效,可以清本地 access token 并触发全局 auth 变化;服务器重启、Nginx 502/503/504、浏览器 `Failed to fetch` 或 refresh 响应契约异常都属于暂时不可用,不能把已有本地 token 清掉,否则重启窗口会把所有打开页面踢成未登录。
|
||||||
|
- 契约:`/api/auth/refresh` 成功响应按共享契约 `RefreshSessionResponse { token }` 解析;测试 mock 不要额外塞 `{ ok: true, token }` 遮住真实恢复路径。
|
||||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。
|
- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。
|
||||||
- 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
- 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||||
|
|
||||||
@@ -1035,6 +1221,7 @@
|
|||||||
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
||||||
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
||||||
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
|
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
|
||||||
|
- 追加处理:未登录推荐页启动任一公开正式玩法时,`/api/runtime/*` 局内路由必须使用 `RuntimePrincipal`,前端通过 `PlatformEntryFlowShellImpl` 的统一 request options helper 给 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作透传 runtime guest token;公开 runtime detail 读取如跳一跳、敲木鱼必须显式 `skipAuth/skipRefresh`,匿名推荐流不能补读受保护创作详情,否则会在真正开局前打出 `/api/auth/refresh 401`。
|
||||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。
|
- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。
|
||||||
- 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
- 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
||||||
|
|
||||||
@@ -1165,8 +1352,8 @@
|
|||||||
|
|
||||||
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
||||||
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
||||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 由 `scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper,自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||||
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||||
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||||
|
|
||||||
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
||||||
@@ -1190,8 +1377,8 @@
|
|||||||
- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为服务器初始化专用口径,不允许公网 Git fallback,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。
|
- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为服务器初始化专用口径,不允许公网 Git fallback,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。
|
||||||
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15`、`curl 56 GnuTLS recv error (-9)`、`early EOF`、`invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。
|
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15`、`curl 56 GnuTLS recv error (-9)`、`early EOF`、`invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。
|
||||||
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件也会拉取所有分支。
|
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件也会拉取所有分支。
|
||||||
- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。
|
- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,指定 commit 时也先保持 `depth=1` 校验,浅历史无法证明归属时才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS` 逐步加深,最后才展开完整历史。发布流水线不得为了缩短 checkout 时间清空上游构建传入的 `COMMIT_HASH`。
|
||||||
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs` 带 `+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}`,`CloneOption` 带 `honorRefspec: true`;运行 `bash -n scripts/jenkins-checkout-source.sh`。
|
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs` 带 `+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}`,`CloneOption` 带 `honorRefspec: true`;扫描发布流水线确认传给 `scripts/jenkins-checkout-source.sh` 的 `COMMIT_HASH` 未被硬编码为空;运行 `bash -n scripts/jenkins-checkout-source.sh`。
|
||||||
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||||
|
|
||||||
## Jenkins 可选参数在 set -u 下不能裸读
|
## Jenkins 可选参数在 set -u 下不能裸读
|
||||||
@@ -1218,6 +1405,14 @@
|
|||||||
- 验证:Jenkins 日志中 `Provision Target` 下的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都应运行在目标 dev / release agent;日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。
|
- 验证:Jenkins 日志中 `Provision Target` 下的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都应运行在目标 dev / release agent;日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。
|
||||||
- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## Server-Provision 不要无条件下载工具包
|
||||||
|
|
||||||
|
- 现象:目标 dev / release 机器已经安装正确版本的 SpacetimeDB 或 `otelcol-contrib`,但 `Prepare Provision Tools` 仍每次下载 release tarball,网络慢或 GitHub 不稳时会把服务器初始化卡在准备阶段。
|
||||||
|
- 原因:工具准备阶段如果只按“生成交付包”理解,会忽略它已经运行在目标部署 agent 上这一事实;此时目标机本地的 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current` 就是可信状态源。
|
||||||
|
- 处理:`scripts/prepare-server-provision-tools.sh` 必须先检查目标机状态:`otelcol-contrib --version` 命中 `OTELCOL_VERSION` 时复制现有二进制;`spacetimedb-cli --version` 命中 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 推导出的版本且 standalone 同时存在时,复制 `${SPACETIME_ROOT}/bin` 并生成 wrapper。只有缺失、不可执行或版本不匹配时,才查 `PROVISION_DOWNLOADS_DIR` 或下载源。
|
||||||
|
- 验证:运行 `bash scripts/check-server-provision-tools.sh`;Jenkins 日志应先出现“检查目标机 ...”,已有版本命中时出现“复用目标机已有 ...”,且不出现“下载 ...”。
|
||||||
|
- 关联:`scripts/prepare-server-provision-tools.sh`、`jenkins/Jenkinsfile.production-server-provision`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 个人任务 scope 不得扩成 work/site/module
|
## 个人任务 scope 不得扩成 work/site/module
|
||||||
|
|
||||||
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
|
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
|
||||||
@@ -1484,7 +1679,7 @@
|
|||||||
|
|
||||||
## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层
|
## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层
|
||||||
|
|
||||||
- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜、下一关按钮或相似作品卡被截断。
|
- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜或下一关按钮被截断。
|
||||||
- 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page`、`platform-recommend-swipe-card__visual` 和 `platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。
|
- 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page`、`platform-recommend-swipe-card__visual` 和 `platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。
|
||||||
- 处理:`PuzzleRuntimeShell` 在 `embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。
|
- 处理:`PuzzleRuntimeShell` 在 `embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。
|
||||||
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。
|
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。
|
||||||
@@ -1593,10 +1788,18 @@
|
|||||||
|
|
||||||
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。
|
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。
|
||||||
- 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
|
- 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
|
||||||
- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
|
- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;失败页点击重新生成必须优先复用当前 `sessionId` 执行编译 action,不得因存在表单缓存 payload 就调用 create-session。拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
|
||||||
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。
|
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 生成失败重试不要走新建草稿
|
||||||
|
|
||||||
|
- 现象:拼图或抓大鹅生成失败后,在失败页点击“重新生成”,作品架里多出一份新的草稿,原失败草稿仍留在列表里。
|
||||||
|
- 原因:重试 handler 曾优先读取缓存的表单 payload 并调用 create-session 路径;失败草稿按 session 留在作品架是正确行为,于是重试动作额外创建了第二份草稿。
|
||||||
|
- 处理:只要当前失败页还能恢复到原 `sessionId`,重试就走该 session 的 compile action;只有没有可恢复 session 时,才允许用表单 payload 重新创建草稿。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed .* draft retry reuses current session"`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 汪汪声浪草稿试玩不要写正式 run
|
## 汪汪声浪草稿试玩不要写正式 run
|
||||||
|
|
||||||
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
|
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
|
||||||
@@ -1644,14 +1847,53 @@
|
|||||||
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
|
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
|
||||||
- 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 跳一跳地块图集不要套通用系列素材 n 行模型
|
## 跳一跳地块图集固定走 18 个 UV 大单元
|
||||||
|
|
||||||
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。
|
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。
|
||||||
- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。
|
- 原因:旧模板先后尝试过通用系列素材 helper、`2x3` 六格固定 tileType 和 `5x5` 单贴图池,但当前跳一跳已经重设计为“主题 -> 一张 `1024x1536` 图集 -> 18 个 `3列*6行` UV 大单元 -> 每格 `4列*3行` 六面贴图 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。
|
||||||
- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。
|
- 处理:跳一跳地块固定只生成一张 `1024x1536` 主题 UV 展开图集,后端先切出 18 个大单元,再从每格固定 UV 网切出 top/front/right/back/left/bottom 六张 `256x256` 不透明 PNG,并对 108 张面贴图各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`5x5` 单贴图、`start / normal / target / finish / bonus / accent` 六格口径。
|
||||||
- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。
|
- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 18 个独立 `JumpHopTileAsset` 且每个新资产包含 `faceAssets` 六面贴图,运行态无限路径从地块池随机取材;旧资产没有 `faceAssets` 时仍能用 `imageSrc` 单贴图 fallback。
|
||||||
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 跳一跳宝可梦主题地块图集 safety rejection 只做专项改写
|
||||||
|
|
||||||
|
- 现象:跳一跳草稿使用“宝可梦 / Pokemon / 皮卡丘 / 精灵球”等主题时,背景底图和返回按钮可能已生成成功,但地块图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地块图集生成失败"`、`status=429`、`code="invalid_prompt"`。
|
||||||
|
- 原因:18 个立方体主题物体 UV 展开图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。
|
||||||
|
- 处理:仅在跳一跳图片生成 prompt 文本命中宝可梦相关词时做生成侧替换,把 `宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon` 改为“原创幻想萌宠冒险道具”,把 `精灵球` 改为“彩色冒险能量球”,把 `皮卡丘 / Pikachu` 改为“黄色闪电萌宠符号”;不要把所有主题都加全局 IP 禁止约束,用户草稿标题和主题展示也不改。
|
||||||
|
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖宝可梦词专项替换;真实联调时同一草稿重试后,地块图集请求的 prompt 不再包含宝可梦相关词。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 跳一跳地块切片不要按 tileType 复用资产槽位
|
||||||
|
|
||||||
|
- 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。
|
||||||
|
- 原因:`tileType` 只是路径平台的玩法类型标签,18 个 atlas 大单元里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path,同类型切片会写入同一个 `/generated-jump-hop-assets/<profile>/<slot>/image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。
|
||||||
|
- 处理:后端切图后必须按 atlas 单元格写入 `tile-01` 到 `tile-18` 的唯一 tile slot,并把六面贴图写入 `tile-XX-top/front/right/back/left/bottom` 唯一 face slot;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。
|
||||||
|
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_eighteen_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键,并覆盖新 UV 资产会解析六张面贴图。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`。
|
||||||
|
|
||||||
|
## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
|
||||||
|
|
||||||
|
- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
|
||||||
|
- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
|
||||||
|
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
|
||||||
|
- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
|
||||||
|
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。
|
||||||
|
|
||||||
|
## 跳一跳长按蓄力不能再消费拖拽方向
|
||||||
|
|
||||||
|
- 现象:跳一跳改成长按蓄力后,如果前端或后端仍消费 `dragVectorX/dragVectorY`,玩家手指轻微移动就会改变跳跃方向,和“始终朝下一块中心跳”的体验不一致。
|
||||||
|
- 原因:历史弹弓拖拽版本把屏幕拖拽方向作为正式裁决输入,契约字段仍为兼容旧客户端保留,容易被误认为仍是当前玩法规则。
|
||||||
|
- 处理:前端运行态只用长按时长提交 `dragDistance` 兼容字段,不再发送方向字段;落点预测按当前地块中心到下一块地块中心的方向投影。后端 `module-jump-hop` 即使收到旧客户端 `dragVectorX/dragVectorY` 也必须忽略,只按当前地块到下一块地块中心的单位向量裁决。
|
||||||
|
- 验证:前端回归测试覆盖手指移动不改变提交方向、预测落点忽略旧方向字段;后端领域测试覆盖旧客户端传错误方向时仍按下一块中心命中。
|
||||||
|
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/module-jump-hop/src/application.rs`。
|
||||||
|
|
||||||
|
## 跳一跳创作入口旧文案先查 SpacetimeDB 配置
|
||||||
|
|
||||||
|
- 现象:`JumpHopWorkspace` 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。
|
||||||
|
- 原因:创作入口卡片事实源是 SpacetimeDB `creation_entry_type_config` 和 `/api/creation-entry/config`,前端只做展示派生;如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 `api-server` 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 `get_creation_entry_config` 里不够。
|
||||||
|
- 处理:同步更新 `module-runtime` 默认入口种子,并在 `spacetime-module/src/runtime/creation_entry_config.rs` 加只命中旧系统默认值的迁移;同时在 `spacetime-client` 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。
|
||||||
|
- 验证:本地 `GET /api/creation-entry/config` 的 `jump-hop` 项应返回新 subtitle 和新 imageSrc;若仍旧,检查本地 SpacetimeDB 是否已发布当前 `spacetime-module`,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。
|
||||||
|
|
||||||
## image2 dry-run 带参考图时不要直接打印 data URL
|
## image2 dry-run 带参考图时不要直接打印 data URL
|
||||||
|
|
||||||
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
||||||
@@ -1730,6 +1972,26 @@
|
|||||||
- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。
|
- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。
|
||||||
- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。
|
- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。
|
||||||
|
|
||||||
|
## 腾讯云 release 上 VectorEngine `SendRequest` 超时先查出口链路与重试
|
||||||
|
|
||||||
|
- 现象:release 机器调用 VectorEngine `gpt-image-2` 的 `/v1/images/generations` 或 `/v1/images/edits` 偶发 `client error (SendRequest) -> connection error -> Connection timed out (os error 110)`,应用层表现为 504;本地通常正常。
|
||||||
|
- 原因:本地 DNS 可能走代理 / 加速出口,而腾讯云 release 直接解析到 VectorEngine 真实边缘节点。实测同一张约 2.37MB PNG、同一 edits 请求,`curl` 5/5 成功,但 `reqwest/hyper` 会间歇性超时;固定 `40.160.33.47` 也只能改善,不能根治。
|
||||||
|
- 处理:不要优先关闭 multipart,也不要直接把 `SendRequest` 解释成上游业务拒绝。VectorEngine 图片 `generations` / `edits` 上游 POST 单独使用 `libcurl`;参考图下载和响应图片 URL 下载仍用 `reqwest`。send 阶段 timeout / connect error 在 `platform-image` 内最多重试 5 次,使用指数退避和短抖动;日志字段 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total`、`request_params` 是定位依据。
|
||||||
|
|
||||||
|
### api-server libcurl / OpenSSL 3.2 runtime
|
||||||
|
|
||||||
|
- 症状:release 部署新 `api-server` 后服务反复 `exit-code`,`LD_TRACE_LOADED_OBJECTS=1 /opt/genarrative/current/api-server` 或 `ldd` 报 `/lib/x86_64-linux-gnu/libssl.so.3: version 'OPENSSL_3.2.0' not found`。
|
||||||
|
- 根因:`platform-image` 使用 `libcurl` 后,Linux release 构建产物可能直接要求 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认 OpenSSL 仍是 `3.0.13`,不能满足该符号版本。
|
||||||
|
- 处理:`Genarrative-Server-Provision` 独立安装 OpenSSL `3.2.0` 到 `/opt/genarrative/openssl-3.2.0`,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 给 api-server 使用,避免替换系统 OpenSSL。
|
||||||
|
|
||||||
|
### VectorEngine edits multipart image part
|
||||||
|
|
||||||
|
- 症状:拼图参考图链路请求 `/v1/images/edits` 返回 `500 image is required`,但应用日志里 `reference_image_count=1`、`reference_image_bytes_total>0`,`request_params.referenceImages[0]` 也有 `field=image`、文件名、MIME 和 bytes。
|
||||||
|
- 根因:Rust `curl::easy::Form` 中 `contents(...).filename(...)` 不等价于文件上传 part;VectorEngine 转码层会认为没有收到图片。release 上用 curl CLI `-F image=@file` 可成功,证明字段名和上游接口本身没变。
|
||||||
|
- 处理:multipart 参考图必须用 `Form::buffer(file_name, bytes)` 并设置 `content_type(...)`,让 libcurl 生成真正的 `name="image"; filename="..."` 文件 part。
|
||||||
|
- 验证:release 上先看 `journalctl -u genarrative-api.service` 中 `VectorEngine 图片请求发送失败,准备重试` 与最终 `HTTP 返回`;若仍失败,再用同一图片分别跑 curl 与最小 reqwest 探针对照。
|
||||||
|
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
## 个人中心不再保留直达“存档”按钮入口
|
## 个人中心不再保留直达“存档”按钮入口
|
||||||
|
|
||||||
- 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。
|
- 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。
|
||||||
@@ -1738,6 +2000,22 @@
|
|||||||
- 验证:`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"`。
|
- 验证:`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`。
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 旧创作入口先确认是不是旧 worktree 在响应
|
||||||
|
|
||||||
|
- 现象:浏览器里明明还看到跳一跳旧入口,比如 `俯视角跳跃闯关` 和 `puzzle.webp`,但当前 worktree 里已经改成了 `主题驱动平台跳跃` 和 `jump-hop.webp`。
|
||||||
|
- 原因:本机常同时存在两个开发栈,旧 worktree 可能还在占用 `3000/8082/3101/3102`,而当前 worktree 可能跑在另一组端口。只看页面文案就下结论,容易把旧进程误认成当前改动没生效。
|
||||||
|
- 处理:先用 `Get-NetTCPConnection` / `Get-CimInstance Win32_Process` 确认端口对应的可执行文件和命令行,再分别请求 `/api/creation-entry/config` 比对旧端口与当前 worktree 端口。必要时以当前 worktree 的实际端口为准重新打开页面。
|
||||||
|
- 验证:旧端口返回旧跳一跳入口,当前 worktree 端口返回新跳一跳入口;两边的 `api-server` / `vite-cli` 命令行应指向不同仓库路径。
|
||||||
|
- 关联:`scripts/dev.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 3001 无法访问先查旧 worktree 占端口和 SpacetimeDB 版本
|
||||||
|
|
||||||
|
- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。
|
||||||
|
- 原因:旧 worktree 的 `api-server`、`spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。
|
||||||
|
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list`,确认本机 CLI/standalone 与 `server-rs/Cargo.toml` 锁定版本一致;不一致时先直接升级 / 切换到锁定版本,再重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`。
|
||||||
|
- 验证:`http://127.0.0.1:3001/`、`http://127.0.0.1:8083/healthz`、`http://127.0.0.1:3103/v1/ping` 都返回 200,且进程命令行指向当前 worktree 路径而不是别的仓库。
|
||||||
|
- 关联:`scripts/dev.mjs`、`.hermes/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 微信历史孤儿作品不要让新注册账号顶替
|
## 微信历史孤儿作品不要让新注册账号顶替
|
||||||
|
|
||||||
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
|
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
|
||||||
@@ -1753,3 +2031,242 @@
|
|||||||
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
|
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
|
||||||
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
|
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
|
||||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
|
||||||
|
## Windows junction worktree 下 Vitest 定向路径失败先切真实路径
|
||||||
|
|
||||||
|
- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 工作区运行 `npm run test -- src/...` 时,Vitest 可能报 `Failed to load url C:/Users/... (resolved id: F:/DevWorktrees/...)`,同一测试文件明明存在却被判定找不到。
|
||||||
|
- 原因:Vite / Vitest 在 Windows 下会把测试入口 realpath 到真实 worktree 路径;如果命令从 junction 路径传入相对文件参数,入口路径和 resolved id 可能跨盘符不一致。
|
||||||
|
- 处理:前端定向测试优先从 `Get-Item <worktree> | Format-List Target` 显示的真实路径运行,例如 `F:\DevWorktrees\codex\worktrees\f584\Genarrative`;不要把这类文件加载失败误判成组件或路由断言失败。
|
||||||
|
- 验证:同一命令从真实路径执行应正常收集并运行测试,例如 `npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
- 关联:`src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx`、`src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/routing/appPageRoutes.test.ts`。
|
||||||
|
|
||||||
|
## 拼消消草稿试玩要和正式 runtime 分流
|
||||||
|
|
||||||
|
- 现象:拼消消结果页点击“试玩”后如果仍然调用 `/api/runtime/puzzle-clear/runs`,草稿试玩会被正式 run 规则和统计约束卡住,公开作品又可能和草稿恢复串台。
|
||||||
|
- 原因:拼消消既有草稿生成 / 结果页 / 发布闭环,也有正式公开 runtime;如果把结果页试玩和公开运行态复用同一个后端 startRun 入口,`work detail` 读取路径和统计口径都会混在一起。
|
||||||
|
- 处理:结果页试玩改走前端本地 `runtimeMode=draft` snapshot,只用于草稿试玩和关卡切换,不写正式 run;公开详情和推荐流进入正式 runtime 时才走后端 `/api/runtime/puzzle-clear/*`。客户端读取作品详情时也要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`。
|
||||||
|
- 验证:点击拼消消结果页的试玩按钮,不应再请求 `/api/runtime/puzzle-clear/runs`;公开详情入口仍应能读取后端运行态详情。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/puzzle-clear/puzzleClearClient.ts`、`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`。
|
||||||
|
|
||||||
|
## 拼消消 runtime 必须继承拼图模板的原生交互基线
|
||||||
|
|
||||||
|
- 现象:拼消消卡片在浏览器里会出现原生图片拖拽 / 下载手柄,或窗口拉伸后棋盘和卡片被拉成矩形。
|
||||||
|
- 原因:拼消消 runtime 早期只继承了“交换 / 消除”的业务逻辑,没有完整继承拼图模板在基础交互上的防护:`touch-none`、`select-none`、`aspect-square`、`draggable={false}`、`onDragStart(event.preventDefault())`、`-webkit-user-drag: none`。
|
||||||
|
- 处理:棋盘容器必须保持正方形约束,卡片按钮和内层 `<img>` 都要显式禁用浏览器原生拖拽,样式层也要补 `user-select: none` 与 `-webkit-user-drag: none`,不能只靠业务指针逻辑。
|
||||||
|
- 验证:浏览器中检查棋盘 `getBoundingClientRect().width === height`,卡片图片 `draggable="false"` 且 `-webkit-user-drag` 为 `none`;真实拖拽只应进入交换逻辑,不应触发原生图片拖拽。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
|
## 拼消消拖拽浮层要挂到页面级 portal
|
||||||
|
|
||||||
|
- 现象:拼消消拖拽时图片看起来没有贴在鼠标或手指上,尤其是平台壳层本身带有 transform 时更明显。
|
||||||
|
- 原因:拖拽 ghost 用了 `position: fixed`,但如果还挂在会被 transform 的局部容器里,浏览器会把 fixed 当成相对该祖先定位;`clientX/clientY` 读到的是视口坐标,两个坐标系一混就会出现肉眼可见的偏移。
|
||||||
|
- 处理:拖拽浮层必须通过 portal 挂到 `document.body` 这一层,再继续使用 `clientX/clientY - pointerOffset` 计算 left/top;不要把 ghost 留在平台壳或任何会参与 transform 的容器里。
|
||||||
|
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应断言拖拽浮层父节点是 `document.body`,且 left/top 与按下点偏移一致。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
|
## 拼消消要继承拼图模板的动作语言,不只是规则
|
||||||
|
|
||||||
|
- 现象:拼消消如果只实现“交换后裁决”,但没有开局翻牌、按下留空位、被替换卡快速飞回、以及局部拼接块整体拖动,玩家会直觉上觉得比原拼图更笨重。
|
||||||
|
- 原因:早期实现容易把“规则独立”误读成“动作语言也要重写”,结果只保留了交换逻辑,没有沿用拼图模板里已经验证过的拖拽反馈、空位让位和合并块连续感。
|
||||||
|
- 处理:拼消消运行态要继承拼图模板的基础手感:只在开局保留入场翻牌,拖起时源位立即呈空,放下时被替换卡要有明确飞向空位的位移感,连通块要作为整体拖动和整体呈现。
|
||||||
|
- 验证:浏览器拖拽时能看到跟手 ghost、源位空槽、落点飞入和整组拼接层;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应覆盖这些行为。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/index.css`。
|
||||||
|
|
||||||
|
## 拼消消空格位必须允许落位,不能当成不可交互死格
|
||||||
|
|
||||||
|
- 现象:运行到某一关后,棋盘里出现空格位,用户能看见空洞但拖不进去,也点不动。
|
||||||
|
- 原因:空格位被前端交互或后端裁决误当成“无效目标”,只保留了交换逻辑,没有把“源卡落入空位、源位清空”当成合法移动。
|
||||||
|
- 处理:空格位必须保留 button 交互态和落点命中逻辑;前端拖拽 / 点击落到空格时直接提交移动,后端和本地 runtime 都要把源卡移动到目标格并清空源格,不再走失败交换。
|
||||||
|
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
|
||||||
|
|
||||||
|
## 拼消消空位落卡后必须立即补位,不能把空洞留成真空格
|
||||||
|
|
||||||
|
- 现象:卡牌成功落进空格后,源位仍然留空,玩家会误以为那个格子坏掉了。
|
||||||
|
- 原因:移动逻辑只处理了“落到空位”,没有在未消除时同步走一遍重力补位,所以源列会短暂或永久留下空洞。
|
||||||
|
- 处理:只要移动后棋盘存在空位,就立即走补位和可解性修复;这样源位会从顶部准备区补卡,不会留下不可交互空洞。
|
||||||
|
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`。
|
||||||
|
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
|
||||||
|
|
||||||
|
## 拼消消素材错位先查 sheet 质量门禁
|
||||||
|
|
||||||
|
- 现象:一张卡牌切片里同时出现两个或多个错位图案,或空白格、相邻编号区域里混入其他图案碎片。
|
||||||
|
- 原因:provider 生成的 `1024x1536 / 4x6` 工作表可能违反视觉契约;旧流程只校验布局元数据和切片数量,无法发现图像内容已经主体缺失或污染空白格。边界贴边检测容易把正常铺满主体误判成跨格污染,不能作为高可靠硬门禁。
|
||||||
|
- 处理:先强化 atlas prompt,要求每个 `256x256` 单元独立查看时只能包含一个主体或同一主体单一局部;服务端在 sheet 切片前做像素级质量门禁,硬拦截非空格前景占比过低和空白格污染,严重多边非同组边界贴边只记录 warning 供排查,不直接让创作失败。硬门禁失败的 sheet 最多尝试 4 次,仍失败则拒绝持久化脏 atlas。
|
||||||
|
- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格表示同一视觉家族,不是随机独立小图,要求共享同一场景锚点、主色和道具语言。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁只在单格内部强色差直线贯穿大部分高度或宽度,且两侧都像低纹理人工平铺色块时,按“单格内部疑似拼接线”硬失败并重试 sheet,避免把窗框、桌沿、地平线等自然场景强边缘误杀。
|
||||||
|
- 追加处理:sheet 生成时如果 VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时,例如 nginx HTML `502 Bad Gateway`,不要立刻把草稿置为 failed,应消耗同一 sheet 的下一次 attempt;仍失败再回写失败状态。
|
||||||
|
- 追加处理:`sheet-03` 原本唯一空白格容易被模型画入主题主体,导致第 6 行第 4 列反复报“空白格有主体”并消耗多次 image2 请求。该格改为 `FILL` 补位格,允许生成主题小图但服务端切片、atlas 合成和运行态全部丢弃;前端拼消消 action 等待窗口同步提高到 40 分钟,避免上游单图慢返回时用户侧 20 分钟超时。
|
||||||
|
- 验证:`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/puzzle_clear.rs`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。
|
||||||
|
|
||||||
|
## 拼消消锁定组覆盖层必须锚定在棋盘本身
|
||||||
|
|
||||||
|
- 现象:消除或补牌过程中,局部完成的组图偶尔会看起来从格子里“飘出去”,并且大小会随着窗口和外层面板变化而异常拉伸。
|
||||||
|
- 原因:锁定组视觉层用了 `absolute inset-0`,但棋盘容器本身不是 `position: relative`,于是覆盖层实际锚到了更外层的运行态面板,`gridColumn` / `gridRow` 只能在错误坐标系里排版。
|
||||||
|
- 处理:棋盘容器必须显式 `relative`,让锁定组覆盖层、拖拽鬼影和格子坐标都在同一正方形棋盘坐标系内排版;不要把这类覆盖层锚到外层 `section` 或整页容器。
|
||||||
|
- 验证:浏览器里棋盘 `getBoundingClientRect()` 和锁定组覆盖层应共享同一块正方形区域,窗口缩放后组图不应再出现越界或被拉伸的现象;`PuzzleClearRuntimeShell.test.tsx` 需要断言棋盘 class 包含 `relative`。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
|
## 拼消消中央场地底图必须挂在棋盘内部
|
||||||
|
|
||||||
|
- 现象:创作阶段选择了中央场地底图,但运行态消除卡片后只看到浅色格子或空点,看不到底图。
|
||||||
|
- 原因:底图被渲染成整页氛围背景,并被页面渐变、棋盘面板和格子 `bg-white/78` 遮住;棋盘内部没有静态底图层,空格仍保留不透明卡片底色。
|
||||||
|
- 处理:`boardBackgroundAsset.imageSrc` 必须作为 `puzzle-clear-board` 内部的 `absolute inset-0` 静态底图渲染;空格、消除空位和拖拽源位必须透明或近透明,不能继续使用实体卡片白底。
|
||||||
|
- 验证:`PuzzleClearRuntimeShell.test.tsx` 断言 `puzzle-clear-board-background` 在棋盘内,`/board-bg.png` 只出现一次,空格 class 包含 `bg-transparent` 且不包含 `bg-white/78`。
|
||||||
|
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 创作入口突然消失先查前后端是否串到不同 worktree
|
||||||
|
|
||||||
|
- 现象:`http://127.0.0.1:3000/` 可访问,但创作 Tab 里新增玩法入口消失;例如 `puzzle-clear` 已在代码默认种子中存在,浏览器仍看不到“拼消消”。
|
||||||
|
- 原因:Vite 可能来自当前 worktree,但代理目标的 `api-server` 仍是另一个 worktree 的旧进程,或者 `api-server` 连到旧 SpacetimeDB 模块;此时 `/api/creation-entry/config` 会返回旧入口配置。
|
||||||
|
- 处理:先用 `Get-NetTCPConnection -State Listen -LocalPort 3000,8083,3103` 结合 `Get-CimInstance Win32_Process` 确认端口进程路径;停止串线的旧 `api-server`,再用当前 worktree 的 `npm run dev:spacetime -- --spacetime-port <port> --database <database>` 和 `npm run dev:api-server -- --api-port <port> --spacetime-port <port> --database <database>` 拉起同一套服务。
|
||||||
|
- 验证:`GET /api/creation-entry/config` 应包含目标入口,且监听端口的命令行都指向同一个 worktree;浏览器创作 Tab 对应分类应显示入口卡。
|
||||||
|
- 关联:`scripts/dev.mjs`、`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## Windows junction 工作区下 dev.mjs 直接执行入口要用 realpath 判断
|
||||||
|
|
||||||
|
- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 路径里运行 `npm run dev:web`,进程会秒退,`3000` 不监听,但同一脚本从真实 worktree 路径能正常启动。
|
||||||
|
- 原因:`scripts/dev.mjs` 的入口判断只比对 `process.argv[1]` 和 `import.meta.url` 的字面路径;junction 路径和 realpath 路径不一致时会误判成“不是直接执行”,于是主流程根本不进入。
|
||||||
|
- 处理:入口判断改成基于 `realpathSync(...)` 的 `isDirectModuleExecution(...)`,让 junction 路径和真实 worktree 路径指向同一个模块;同时补回归测试覆盖该场景。
|
||||||
|
- 验证:`npm run test -- scripts/dev.test.ts scripts/dev-stack-port-utils.test.ts` 通过后,`npm run dev:web -- --web-port 3000 --api-port 8083 --no-interactive` 应能稳定把 `0.0.0.0:3000` 监听起来。
|
||||||
|
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`。
|
||||||
|
|
||||||
|
## Vitest 定向测试在 Windows junction 工作区要切真实路径
|
||||||
|
|
||||||
|
- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 路径里跑 `npm run test -- src/...` 时,Vitest 会报 `Failed to load url ... (resolved id: F:/DevWorktrees/...)`,看起来像文件不存在。
|
||||||
|
- 原因:Vite / Vitest 会把入口 realpath 到真实 worktree 路径;如果命令从 junction 路径传入相对文件参数,入口路径和 resolved id 可能跨盘符不一致。
|
||||||
|
- 处理:前端定向测试优先从真实路径 `F:\DevWorktrees\codex\worktrees\f584\Genarrative` 运行,不要把这类文件加载失败误判成组件或路由断言失败。
|
||||||
|
- 验证:同一命令从真实路径执行应正常收集并运行测试。
|
||||||
|
- 关联:`src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/routing/appPageRoutes.test.ts`。
|
||||||
|
- 现象:新增或扩展 `*-generating` 页面后,生成卡只渲染首帧,`已耗时` / `预计等待` 停在进入页那一刻不动。
|
||||||
|
- 原因:平台壳层的共享 `miniGameGenerationProgressNowMs` 时钟没有把新生成阶段纳入 tick 条件,或者该阶段的 `buildMiniGameDraftGenerationProgress(..., nowMs)` 没有接入同一时钟。
|
||||||
|
- 处理:任何共享生成页都要通过平台壳层统一的时钟判断和 `nowMs` 传递刷新,新增生成阶段时要同时补 `selectionStage` 判定、`useEffect` 依赖和进度调用点。
|
||||||
|
- 验证:浏览器里进入对应生成页后,`已耗时` / `预计等待` 应持续变化,不应停在首帧。
|
||||||
|
|
||||||
|
## 拼消消要用真实可消除判断,不要把“已相邻”当成可解
|
||||||
|
|
||||||
|
- 现象:拼消消开局或补牌后会直接出现已完成的图案组,或者 `1x2` 被当成半锁定局部留在场上。
|
||||||
|
- 原因:早期把可解性写成“场上已经有同组相邻卡”或“只要有一对相邻同组卡就算可解”,这会把已完成盘面误当成合法盘面;同时半锁定规则没有排除 `1x2`。
|
||||||
|
- 处理:开局和补牌后的重排必须先排除现成消除,再用真实交换 / 落位模拟判断是否会产生新消除;`1x2` 永远不进入半锁定组,半锁定只允许 `1x3`、`2x2`、`2x3`。
|
||||||
|
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 与 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml -- --nocapture` 通过后,开局盘面不应直接出现 completed group。
|
||||||
|
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
|
||||||
|
## 推荐页作品 key 漏玩法会导致运行内容和标题作者错位
|
||||||
|
|
||||||
|
- 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。
|
||||||
|
- 原因:平台壳层用 `getPlatformPublicGalleryEntryKey(...)` 写入 `activeRecommendEntryKey`,而 `RpgEntryHomeView` 内部的 `buildPublicGalleryCardKey(...)` 漏掉新玩法 `sourceType` 分支,导致当前 key 查不到条目后回退到推荐列表第一条。
|
||||||
|
- 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 和 `edutainment:<templateId>`;新增玩法公开推荐流时先补这个共享 helper。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend meta matches active"` 应覆盖跳一跳和敲木鱼的当前运行内容、标题和作者一致。
|
||||||
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 跳一跳飞行动画不要直接用最新 run 重绘地块窗口
|
||||||
|
|
||||||
|
- 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。
|
||||||
|
- 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。
|
||||||
|
- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块 DOM 层和 DOM 角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
|
||||||
|
- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,DOM 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。
|
||||||
|
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`server-rs/crates/module-jump-hop/src/application.rs`。
|
||||||
|
|
||||||
|
## 跳一跳相机推进不要让地块图片回退到原型方块
|
||||||
|
|
||||||
|
- 现象:角色落到下一块后,相机推进时旧地块图片突然消失,或新预览地块先露出浅色原型方块,随后真实 image2 切片才出现。
|
||||||
|
- 原因:旧地块进入 exiting 状态时如果 React key 从 `platformId` 变成 `platformId-exiting`,图片组件会重新挂载并丢失已加载状态;同时 `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` 尚未触发时显示 fallback 原型地块。Three.js 平台层接入后,如果隐藏预加载只让浏览器缓存 `<img>`,但没有把未来 `platformId` 的纹理 URL 写入 `platformTextureUrlsByRenderKey`,相机推进时新预览地块会短暂缺 Three 贴图;若旧 blob 贴图在空 URL 回调时先被 revoke,再继续保留在 state 中,也会留下一个看似 ready、实际已失效的贴图地址。
|
||||||
|
- 处理:exiting 地块继续使用稳定 `platformId` key,让旧图片组件在推进期复用;有真实 `resolvedUrl` 且未错误时直接保留真实 `<img>`,只在无 URL 或加载失败时显示 fallback;当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存,并同步按未来 `platformId` 发布 Three 纹理 URL。Three 平台层在当前 render items 全部有贴图 URL 后继续承接包含 exiting 地块在内的 3D 渲染;退出地块只随相机推进自然离屏,不播放独立飞走动画,避免退出期露出被放大的平面贴图或重复飞多次;贴图 URL 替换必须等新 URL 到达后再释放旧 parent-owned blob,空 URL 回调不得清空或 revoke 仍在活跃 / 预加载 key 上的旧贴图。
|
||||||
|
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL 不露出 `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`。
|
||||||
|
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
|
## 跳一跳 Three.js 平台层不能左右镜像 DOM 坐标
|
||||||
|
|
||||||
|
- 现象:视觉上下一块地块在角色右侧,但蓄力引导和角色飞行动画朝左侧;后端回包后地块窗口又闪现摆回正确位置,像是先按反方向飞、再由快照刷新纠正。
|
||||||
|
- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和 DOM 角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。
|
||||||
|
- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。DOM 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。
|
||||||
|
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖 `JUMP_HOP_THREE_CAMERA_UP_Y=1`,并断言 Three 投影与 DOM 屏幕坐标同向。
|
||||||
|
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。
|
||||||
|
|
||||||
|
## 跳一跳立方体贴图不要走透明主体切片
|
||||||
|
|
||||||
|
- 现象:水果等主题生成成功后,运行态地块看起来像薄的纯水果 PNG、果切贴纸、透明 cutout;或者反过来六个面都是同一张平铺果皮 / 果肉材质,无法组合成方块苹果 / 方块香蕉这类完整主题对象表达。
|
||||||
|
- 原因:跳一跳地板已经改为 Three.js 标准 `1x1x1` 等比极小倒角立方体复用几何体,运行态视角固定为近距相机和 45° 下压视角;image2 应生成 `1024x1536` 的 18 个 cube object UV unwrap,每个大单元内的 top/front/right/back/left/bottom 六面要共同包装同一个主题物体。只强调 full-bleed 容易让水果主题退化成果皮、果肉、叶脉等表面纹理;如果仍把一张图贴给六个面,模型也不需要理解正反和跨面连续特征。旧切图链路若把洋红 key 转 alpha、裁边、只保留最大 alpha 连通主体并补透明安全边,会把整格贴图重新抠成苹果 / 香蕉 / 果切等居中主体,贴到立方体上后四角和侧面都变透明。
|
||||||
|
- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端按 3x6 大单元和 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再调用透明化、最大 alpha 连通主体保留或透明补边。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色,裁切后若仍有极少残留则转成不透明材质底色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。
|
||||||
|
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳 UV unwrap prompt、18 个大单元、108 张不透明面贴图、绿色 / 白色材质不被透明化、洋红 key 残留不作为透明洞;前端 `JumpHopRuntimeShell` 测试覆盖新 UV 资产会解析六张面贴图,旧单贴图资产仍可 fallback。
|
||||||
|
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。
|
||||||
|
|
||||||
|
## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码
|
||||||
|
|
||||||
|
- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。
|
||||||
|
- 原因:Windows PowerShell 管道到 Node stdin 时可能按本机非 UTF-8 编码传输脚本文本,JS 源码里的中文字符串在进入 Node 前已经损坏;Rust 后端真实请求不会走这条编码路径。
|
||||||
|
- 处理:含中文提示词的 live 验证优先写成 UTF-8 `.mjs` 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 `request.json` 是否保留真实中文,再判断生图质量。不要基于 `????` prompt 生成的图片调整项目提示词。
|
||||||
|
- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
|
||||||
|
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md`、`server-rs/crates/api-server/src/jump_hop.rs`。
|
||||||
|
|
||||||
|
## 自动试玩退出不要回到生成页
|
||||||
|
|
||||||
|
- 现象:拼图草稿生成完成后自动进入试玩,用户从试玩退出或使用系统返回时落回生成进度页,页面还暴露“重新生成”按钮。
|
||||||
|
- 原因:自动试玩前如果没有先把 `/creation/puzzle/result` 写成 `/runtime/puzzle` 的浏览器历史前一站,系统返回会命中旧的生成页历史项;仅靠运行态内部 `returnStage='puzzle-result'` 只能覆盖运行态按钮返回,不能覆盖浏览器 / WebView 系统返回。
|
||||||
|
- 处理:所有“生成完成后自动进入草稿试玩”的分支在 `openPuzzleRuntimeStage(...)` 前都必须调用结果页历史写入 helper,把 `/creation/puzzle/result` 与当前 `sessionId/profileId/workId` 写入历史;运行态按钮返回到 `puzzle-result` 时也同步写回创作恢复 query。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 推荐页 ready 不能只等主图或首次 DOM 图片
|
||||||
|
|
||||||
|
- 现象:移动端推荐页卡面遮罩在作品主图加载后就渐隐,但游戏内 UI 图集、背景、道具图或换签中的 generated 图片还没有准备好,用户会看到运行态半成品或资源闪入。
|
||||||
|
- 原因:推荐页 ready probe 如果只扫描首次挂载时已有的 `<img>`,就会漏掉 React effect、`/api/assets/read-url` 换签、spritesheet 解析或后续 state 更新才新增的资源。
|
||||||
|
- 处理:推荐页 runtime 遮罩必须持续观察运行态 DOM 内新增图片、内联 `background-image` 和 `data-runtime-resource-pending` 隐藏标记;各玩法对换签中、解析中的资源源头要暴露 pending 标记,失败后释放标记并交给玩法兜底,避免遮罩永久卡住。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend cover waits for async runtime resources beyond the main image|mobile recommend cover waits until runtime images are ready"`。
|
||||||
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/common/RuntimeResourcePendingMarker.tsx`、`src/components/ResolvedAssetImage.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 拼图文字直创的 compile 回包不等于生成完成
|
||||||
|
|
||||||
|
- 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。
|
||||||
|
- 原因:统一创作表单路径把 `compile_puzzle_draft` 的同步回包无条件当成 ready;但后端在 AI 重绘路径会先返回 `stage=image_refining`、`progressPercent=88` 的会话,只表示首关草稿已编译且后台首图 / UI 资产任务已启动,还没有正式封面或候选图。
|
||||||
|
- 处理:前端必须继续用 `isPuzzleCompileActionReady(...)` 判断回包 session;没有 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时保持生成中,不弹完成、不把作品架 pending 标 ready、不自动试玩。生成页轮询合并 session 进度时,未进入编译态或进度无变化就返回原 state,避免轮询制造重复 render。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle text-only form stays generating|puzzle draft generation auto starts trial|running puzzle draft opens generation progress"`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## CreativeImageInputPanel 主图点击默认预览
|
||||||
|
|
||||||
|
- 现象:复用 `CreativeImageInputPanel` 的结果页 / 编辑页已有主图时,用户点击图片却触发上传,无法直接查看大图;不同玩法若各自手写上传按钮会让主图、历史图、AI 重绘和参考图行为再次分叉。
|
||||||
|
- 原因:旧主图卡整卡是上传 label,缺少主图预览模式和上传 / 历史入口的显式控制参数。
|
||||||
|
- 处理:通用面板已有主图时默认点击主图打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方用 `canUploadMainImage` 和 `canUseImageHistory` 分别控制上传与历史按钮,不要复制面板或用样式遮挡按钮。
|
||||||
|
- 验证:`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||||
|
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 统一创作页短表单软键盘打开不要露出黑底
|
||||||
|
|
||||||
|
- 现象:小程序 / H5 移动端点击拼图或敲木鱼创作输入框后,输入框和键盘之间出现一大片黑色区域;H5 还会明显弹一下。跳一跳因为按钮区用 `mt-auto` 撑开页面,看起来没有同样问题。
|
||||||
|
- 原因:旧移动键盘处理会用 `--platform-keyboard-focus-offset` 把 `.platform-viewport-shell` 整体上移;但 H5 浏览器和小程序 `web-view` 已会自行处理输入框可见性,二次整体上移会造成页面弹跳并露出 `body` 或原生 `page` 的黑色宿主底色。统一创作短表单若内容区按短内容收缩,也会放大这个黑底暴露。
|
||||||
|
- 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]` 和 `overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;移动端键盘打开时只记录 `data-mobile-keyboard-open`、隐藏底部 dock、设置键盘 inset 和浅色 `--platform-keyboard-exposed-fill`,不要再对 `.platform-viewport-shell` 做全局 `transform`;小程序 `pages/web-view` 的 `page` 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。
|
||||||
|
- 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。
|
||||||
|
- 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`src/index.css`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 小程序订阅消息授权不要依赖 web-view bindmessage
|
||||||
|
|
||||||
|
- 现象:拼图点击生成后,H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。
|
||||||
|
- 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。
|
||||||
|
- 处理:不要在原生页 `onLoad` 自动触发 `wx.requestSubscribeMessage`,真机会闪页返回且不弹授权框。H5 在 `compile_puzzle_draft` 前应先进入生成进度态并立即发起生成 action,再通过微信 JS SDK `miniProgram.navigateTo` 非阻塞跳转到小程序原生订阅页尝试请求授权;用户接受、拒绝或返回都不能阻塞生成。原生页不要改写上一页 `webViewUrl`,否则 web-view 可能重新加载首页并丢失进度页状态。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。
|
||||||
|
- 验证:`npm run test -- src/services/wechatMiniProgramSubscribe.test.ts miniprogram/pages/subscribe-message/index.test.js`。
|
||||||
|
- 关联:`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`miniprogram/pages/subscribe-message/index.shared.js`、`miniprogram/pages/web-view/index.js`。
|
||||||
|
|
||||||
|
## 微信订阅消息 time 字段不能用内部时间戳
|
||||||
|
|
||||||
|
- 现象:dev 服务器拼图资产生成终态后已经调用订阅消息发送,但日志出现 `微信订阅消息发送失败:argument invalid! data.time4.value invalid`,用户收不到生成结果通知。
|
||||||
|
- 原因:微信模板 `time` 字段不接受内部微秒时间戳、秒级时间戳或带 `Z` / 时区后缀的字符串;发送 `1713686401.234567Z` 或类似 `2026-06-08 08:09:18Z` 会被微信拒绝。
|
||||||
|
- 处理:`api-server` 构造生成结果订阅消息时,`time4` 固定格式化为北京时间 `YYYY-MM-DD HH:mm`;不要复用 `shared_kernel::format_timestamp_micros`。
|
||||||
|
- 验证:`cargo test --manifest-path server-rs\Cargo.toml -p api-server generation_result_template -- --nocapture`;dev 日志中不应再出现 `data.time4.value invalid`。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/wechat_subscribe_message.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 待解决:跳一跳生成超时后可能后台继续成功
|
||||||
|
|
||||||
|
- 风险程度:高。
|
||||||
|
- 现象:跳一跳生成页可能在 `98% 写入正式草稿` 后报“请求超时,请稍后重试”,但后端仍在继续生成,稍后才把同一 session 写成 `DraftCompiled=100`。2026-06-08 排查 `jump-hop-session-6db8fa7af57c4fa2a71e6430cc808412` 时,背景底图 image2 成功但耗时约 `18分25秒`,返回按钮约 `2分44秒`,地板图集约 `1分46秒`,总耗时超过前端 20 分钟等待窗口,最终在前端超时后约 3 分钟写草稿成功。
|
||||||
|
- 原因:跳一跳创作链路仍把背景、返回按钮、地板图集、切片和 OSS 写入串在一次 HTTP 请求里;VectorEngine image2 单步 timeout/connect 失败会在后端重试,单步耗时可能超过前端总等待窗口。中间资产和真实阶段没有落库,session 在完成前仍显示 `Collecting`、`progress_percent=0`,前端只能按时间显示假进度;超时后重试同一 session 时,后端还可能因为 session 没有中间素材而重新从背景开始生成。
|
||||||
|
- 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。
|
||||||
|
- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=<id>` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。
|
||||||
|
- 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋
|
||||||
|
|
||||||
|
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
|
||||||
|
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
|
||||||
|
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
|
||||||
|
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
|
||||||
|
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Genarrative 项目共享概览
|
# Genarrative 项目共享概览
|
||||||
|
|
||||||
更新时间:`2026-05-29`
|
更新时间:`2026-06-12`
|
||||||
|
|
||||||
## 一句话定位
|
## 一句话定位
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
|
|||||||
|
|
||||||
- RPG / 自定义世界创作与运行时。
|
- RPG / 自定义世界创作与运行时。
|
||||||
- 拼图玩法创作、草稿、发布、运行态和排行榜。
|
- 拼图玩法创作、草稿、发布、运行态和排行榜。
|
||||||
|
- 拼消消玩法创作、素材图集生成、结果页、发布、统一作品详情、正式运行态和基础统计。
|
||||||
- 敲木鱼玩法创作、草稿、发布、运行态、公开详情和分享码。
|
- 敲木鱼玩法创作、草稿、发布、运行态、公开详情和分享码。
|
||||||
- 抓大鹅 Match3D 创作、2D 多视角素材生成、发布和运行态。
|
- 抓大鹅 Match3D 创作、2D 多视角素材生成、发布和运行态。
|
||||||
- 大鱼吃小鱼、方洞挑战、视觉小说、汪汪声浪和儿童向寓教于乐玩法。
|
- 大鱼吃小鱼、方洞挑战、视觉小说、汪汪声浪和儿童向寓教于乐玩法。
|
||||||
@@ -33,7 +34,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
|
|||||||
server-rs + Axum + SpacetimeDB
|
server-rs + Axum + SpacetimeDB
|
||||||
```
|
```
|
||||||
|
|
||||||
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.3.0` 对齐。
|
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.5.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
|
||||||
|
|
||||||
职责边界:
|
职责边界:
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
|
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
|
||||||
4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。
|
4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。
|
||||||
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。
|
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。
|
||||||
6. 在提交信息中区分代码变更与文档/记忆变更。
|
6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。
|
||||||
|
|
||||||
## 文档阅读顺序
|
## 文档阅读顺序
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture
|
|||||||
- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。
|
- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。
|
||||||
- UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。
|
- UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。
|
||||||
- 不要在gitignore中添加.env.local文件。
|
- 不要在gitignore中添加.env.local文件。
|
||||||
|
- 提交代码时,提交标题必须使用中文;标题后必须逐行写明本次提交修改了什么,每条变更单独一行。
|
||||||
- 严格遵循简洁的代码风格
|
- 严格遵循简洁的代码风格
|
||||||
- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。
|
- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。
|
||||||
- 禁止将功能说明描述类的文本默认写入UI界面中。
|
- 禁止将功能说明描述类的文本默认写入UI界面中。
|
||||||
|
|||||||
26
CONTEXT.md
26
CONTEXT.md
@@ -18,6 +18,32 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成
|
|||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
|
### Puzzle Clear
|
||||||
|
|
||||||
|
**拼消消**:
|
||||||
|
基于拼图交换 / 拖拽手感的新玩法模板,玩家移动 1x1 卡牌碎片,把同一复合图案组拼成完整矩形后消除,并由顶部对应纵列补牌继续游玩。
|
||||||
|
_Avoid_: 拼图整图过关、三消槽位玩法、前端本地裁决
|
||||||
|
|
||||||
|
**复合图案组**:
|
||||||
|
拼消消中可被消除的一幅小图,由 `1x2`、`1x3`、`2x2` 或 `2x3` 的 1x1 卡牌碎片组成;只有组内碎片按正确相对位置拼成完整矩形后才消除。
|
||||||
|
_Avoid_: 单张卡牌、整关大图、任意相邻同色块
|
||||||
|
|
||||||
|
**1x1 卡牌碎片**:
|
||||||
|
复合图案组被服务端切成的最小可移动单位,带有所属组、形状、组内坐标和图片资产。
|
||||||
|
_Avoid_: 前端临时裁图、无所属图案的普通方块
|
||||||
|
|
||||||
|
**半锁定拼接组**:
|
||||||
|
非 2 格复合图案组中已经局部完成的拼接状态,可作为整体拖动;玩家用外部单格撞入组内某格时只交换该格,其余部分保留并退回半完成状态。
|
||||||
|
_Avoid_: 永久锁死、补牌打散、完整消除
|
||||||
|
|
||||||
|
**顶部卡牌准备区**:
|
||||||
|
拼消消棋盘上方按纵列排列的背面卡牌队列;某列产生空位时,准备区对应列的卡牌从顶部下落补齐。
|
||||||
|
_Avoid_: 全局随机发牌槽、底部三消槽
|
||||||
|
|
||||||
|
**防死局发牌**:
|
||||||
|
拼消消开局和每次补牌后由后端保证至少存在一步可拼接;补牌时至少有一张新掉落卡能与场上剩余某张卡对应。
|
||||||
|
_Avoid_: 前端提示代替可解性、完全随机补牌
|
||||||
|
|
||||||
### Wooden Fish
|
### Wooden Fish
|
||||||
|
|
||||||
**敲木鱼**:
|
**敲木鱼**:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
AdminUpsertProfileRechargeProductRequest,
|
AdminUpsertProfileRechargeProductRequest,
|
||||||
AdminUpsertProfileRedeemCodeRequest,
|
AdminUpsertProfileRedeemCodeRequest,
|
||||||
AdminUpsertProfileTaskConfigRequest,
|
AdminUpsertProfileTaskConfigRequest,
|
||||||
|
AdminUpsertPublicWorkInteractionConfigRequest,
|
||||||
AdminWorkVisibilityListResponse,
|
AdminWorkVisibilityListResponse,
|
||||||
ApiErrorEnvelope,
|
ApiErrorEnvelope,
|
||||||
ApiMeta,
|
ApiMeta,
|
||||||
@@ -129,16 +130,16 @@ export async function request<T>(
|
|||||||
export function loginAdmin(username: string, password: string) {
|
export function loginAdmin(username: string, password: string) {
|
||||||
return request<AdminLoginResponse>('/admin/api/login', {
|
return request<AdminLoginResponse>('/admin/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {username, password},
|
body: { username, password },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminMe(token: string) {
|
export function getAdminMe(token: string) {
|
||||||
return request<AdminMeResponse>('/admin/api/me', {token});
|
return request<AdminMeResponse>('/admin/api/me', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminOverview(token: string) {
|
export function getAdminOverview(token: string) {
|
||||||
return request<AdminOverviewResponse>('/admin/api/overview', {token});
|
return request<AdminOverviewResponse>('/admin/api/overview', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminDatabaseTables(token: string) {
|
export function getAdminDatabaseTables(token: string) {
|
||||||
@@ -154,7 +155,7 @@ export function getAdminDatabaseTableRows(
|
|||||||
) {
|
) {
|
||||||
return request<AdminDatabaseTableRowsResponse>(
|
return request<AdminDatabaseTableRowsResponse>(
|
||||||
`/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`,
|
`/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`,
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +173,14 @@ export function listAdminTrackingEvents(
|
|||||||
) {
|
) {
|
||||||
return request<AdminTrackingEventListResponse>(
|
return request<AdminTrackingEventListResponse>(
|
||||||
`/admin/api/tracking/events${buildQueryString(query)}`,
|
`/admin/api/tracking/events${buildQueryString(query)}`,
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function getAdminCreationEntryConfig(token: string) {
|
export function getAdminCreationEntryConfig(token: string) {
|
||||||
return request<AdminCreationEntryConfigResponse>(
|
return request<AdminCreationEntryConfigResponse>(
|
||||||
'/admin/api/creation-entry/config',
|
'/admin/api/creation-entry/config',
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +213,25 @@ export function upsertAdminCreationEntryBanners(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存公开作品详情页点赞 / 改造能力配置。 */
|
||||||
|
export function upsertAdminPublicWorkInteractions(
|
||||||
|
token: string,
|
||||||
|
payload: AdminUpsertPublicWorkInteractionConfigRequest,
|
||||||
|
) {
|
||||||
|
return request<AdminCreationEntryConfigResponse>(
|
||||||
|
'/admin/api/creation-entry/config/interactions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listAdminWorkVisibility(token: string) {
|
export function listAdminWorkVisibility(token: string) {
|
||||||
return request<AdminWorkVisibilityListResponse>(
|
return request<AdminWorkVisibilityListResponse>(
|
||||||
'/admin/api/works/visibility',
|
'/admin/api/works/visibility',
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +252,7 @@ export function updateAdminWorkVisibility(
|
|||||||
export function listProfileRedeemCodes(token: string) {
|
export function listProfileRedeemCodes(token: string) {
|
||||||
return request<ProfileRedeemCodeAdminListResponse>(
|
return request<ProfileRedeemCodeAdminListResponse>(
|
||||||
'/admin/api/profile/redeem-codes',
|
'/admin/api/profile/redeem-codes',
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +273,7 @@ export function upsertProfileRedeemCode(
|
|||||||
export function listProfileInviteCodes(token: string) {
|
export function listProfileInviteCodes(token: string) {
|
||||||
return request<ProfileInviteCodeAdminListResponse>(
|
return request<ProfileInviteCodeAdminListResponse>(
|
||||||
'/admin/api/profile/invite-codes',
|
'/admin/api/profile/invite-codes',
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +308,7 @@ export function disableProfileRedeemCode(
|
|||||||
export function listProfileTaskConfigs(token: string) {
|
export function listProfileTaskConfigs(token: string) {
|
||||||
return request<ProfileTaskConfigAdminListResponse>(
|
return request<ProfileTaskConfigAdminListResponse>(
|
||||||
'/admin/api/profile/tasks',
|
'/admin/api/profile/tasks',
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +340,7 @@ export function disableProfileTaskConfig(
|
|||||||
export function listProfileRechargeProducts(token: string) {
|
export function listProfileRechargeProducts(token: string) {
|
||||||
return request<ProfileRechargeProductConfigAdminListResponse>(
|
return request<ProfileRechargeProductConfigAdminListResponse>(
|
||||||
'/admin/api/profile/recharge-products',
|
'/admin/api/profile/recharge-products',
|
||||||
{token},
|
{ token },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,13 +429,13 @@ function buildAdminApiError(
|
|||||||
) {
|
) {
|
||||||
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
|
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
|
||||||
const errorPayload = envelope?.error;
|
const errorPayload = envelope?.error;
|
||||||
const details = isRecord(errorPayload?.details)
|
const details = isRecord(errorPayload?.details) ? errorPayload.details : null;
|
||||||
? errorPayload.details
|
|
||||||
: null;
|
|
||||||
const detailsMessage =
|
const detailsMessage =
|
||||||
typeof details?.message === 'string' ? details.message.trim() : '';
|
typeof details?.message === 'string' ? details.message.trim() : '';
|
||||||
const payloadMessage =
|
const payloadMessage =
|
||||||
typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : '';
|
typeof errorPayload?.message === 'string'
|
||||||
|
? errorPayload.message.trim()
|
||||||
|
: '';
|
||||||
const topLevelMessage =
|
const topLevelMessage =
|
||||||
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
|
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -107,12 +107,7 @@ export interface AdminDebugHeaderInput {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminDebugHttpMethod =
|
export type AdminDebugHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
| 'GET'
|
|
||||||
| 'POST'
|
|
||||||
| 'PUT'
|
|
||||||
| 'PATCH'
|
|
||||||
| 'DELETE';
|
|
||||||
|
|
||||||
export interface AdminDebugHttpRequest {
|
export interface AdminDebugHttpRequest {
|
||||||
method: AdminDebugHttpMethod;
|
method: AdminDebugHttpMethod;
|
||||||
@@ -143,11 +138,11 @@ export interface AdminTrackingEventListQuery {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||||
export interface AdminCreationEntryConfigResponse {
|
export interface AdminCreationEntryConfigResponse {
|
||||||
entries: AdminCreationEntryTypeConfigPayload[];
|
entries: AdminCreationEntryTypeConfigPayload[];
|
||||||
eventBanners: AdminCreationEntryEventBannerPayload[];
|
eventBanners: AdminCreationEntryEventBannerPayload[];
|
||||||
|
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
|
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
|
||||||
@@ -201,10 +196,25 @@ export interface AdminUpsertCreationEntryEventBannersRequest {
|
|||||||
eventBannersJson: string;
|
eventBannersJson: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台公开作品详情页互动能力配置项。 */
|
||||||
|
export interface PublicWorkInteractionConfigPayload {
|
||||||
|
sourceType: string;
|
||||||
|
likeEnabled: boolean;
|
||||||
|
remixEnabled: boolean;
|
||||||
|
likeDisabledMessage: string;
|
||||||
|
remixDisabledMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台保存公开作品点赞 / 改造能力配置请求体。 */
|
||||||
|
export interface AdminUpsertPublicWorkInteractionConfigRequest {
|
||||||
|
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 后台统一创作工作台契约表单的传输结构。 */
|
/** 后台统一创作工作台契约表单的传输结构。 */
|
||||||
export interface UnifiedCreationSpecPayload {
|
export interface UnifiedCreationSpecPayload {
|
||||||
playId: string;
|
playId: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
mudPointCost: number;
|
||||||
workspaceStage: string;
|
workspaceStage: string;
|
||||||
generationStage: string;
|
generationStage: string;
|
||||||
resultStage: string;
|
resultStage: string;
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import {beforeEach, expect, test, vi} from 'vitest';
|
import { beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAdminCreationEntryConfig,
|
getAdminCreationEntryConfig,
|
||||||
upsertAdminCreationEntryBanners,
|
upsertAdminCreationEntryBanners,
|
||||||
upsertAdminCreationEntryConfig,
|
upsertAdminCreationEntryConfig,
|
||||||
|
upsertAdminPublicWorkInteractions,
|
||||||
} from '../api/adminApiClient';
|
} from '../api/adminApiClient';
|
||||||
import type {
|
import type {
|
||||||
AdminCreationEntryConfigResponse,
|
AdminCreationEntryConfigResponse,
|
||||||
UnifiedCreationSpecPayload,
|
UnifiedCreationSpecPayload,
|
||||||
} from '../api/adminApiTypes';
|
} from '../api/adminApiTypes';
|
||||||
import {AdminCreationEntrySwitchPage} from './AdminCreationEntrySwitchPage';
|
import { AdminCreationEntrySwitchPage } from './AdminCreationEntrySwitchPage';
|
||||||
|
|
||||||
vi.mock('../api/adminApiClient', () => ({
|
vi.mock('../api/adminApiClient', () => ({
|
||||||
formatAdminApiError: vi.fn((error: unknown) =>
|
formatAdminApiError: vi.fn((error: unknown) =>
|
||||||
@@ -23,11 +30,13 @@ vi.mock('../api/adminApiClient', () => ({
|
|||||||
isAdminApiError: vi.fn(() => false),
|
isAdminApiError: vi.fn(() => false),
|
||||||
upsertAdminCreationEntryBanners: vi.fn(),
|
upsertAdminCreationEntryBanners: vi.fn(),
|
||||||
upsertAdminCreationEntryConfig: vi.fn(),
|
upsertAdminCreationEntryConfig: vi.fn(),
|
||||||
|
upsertAdminPublicWorkInteractions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const puzzleSpec: UnifiedCreationSpecPayload = {
|
const puzzleSpec: UnifiedCreationSpecPayload = {
|
||||||
playId: 'puzzle',
|
playId: 'puzzle',
|
||||||
title: '想做个什么玩法?',
|
title: '拼图',
|
||||||
|
mudPointCost: 10,
|
||||||
workspaceStage: 'puzzle-agent-workspace',
|
workspaceStage: 'puzzle-agent-workspace',
|
||||||
generationStage: 'puzzle-generating',
|
generationStage: 'puzzle-generating',
|
||||||
resultStage: 'puzzle-result',
|
resultStage: 'puzzle-result',
|
||||||
@@ -54,6 +63,15 @@ const configResponse: AdminCreationEntryConfigResponse = {
|
|||||||
htmlCode: '<section>后台公告</section>',
|
htmlCode: '<section>后台公告</section>',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
publicWorkInteractions: [
|
||||||
|
{
|
||||||
|
sourceType: 'puzzle',
|
||||||
|
likeEnabled: true,
|
||||||
|
remixEnabled: true,
|
||||||
|
likeDisabledMessage: '拼图点赞暂不可用。',
|
||||||
|
remixDisabledMessage: '拼图作品改造暂不可用。',
|
||||||
|
},
|
||||||
|
],
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
id: 'puzzle',
|
id: 'puzzle',
|
||||||
@@ -78,28 +96,58 @@ beforeEach(() => {
|
|||||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||||
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
|
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
|
||||||
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||||
|
vi.mocked(upsertAdminPublicWorkInteractions).mockResolvedValue(
|
||||||
|
configResponse,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('创作入口后台展示并保存统一创作契约', async () => {
|
test('创作入口后台展示并保存统一创作契约', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const {container} = render(
|
const { container } = render(
|
||||||
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
|
<AdminCreationEntrySwitchPage
|
||||||
|
token="admin-token"
|
||||||
|
onUnauthorized={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText('pictureDescription');
|
await screen.findByText('pictureDescription');
|
||||||
expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull();
|
expect(
|
||||||
|
container.querySelector('.admin-subsection .admin-info-list'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
|
||||||
|
).toContain('拼图');
|
||||||
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
|
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
|
||||||
expect(container.querySelector('.admin-muted')).toBeNull();
|
expect(container.querySelector('.admin-muted')).toBeNull();
|
||||||
|
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
|
||||||
|
expect(screen.queryByText('puzzle-generating')).toBeNull();
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', {name: '保存入库'}));
|
await user.click(screen.getByRole('button', { name: '修改契约' }));
|
||||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
const dialog = screen.getByRole('dialog', { name: '统一创作契约' });
|
||||||
|
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
|
||||||
|
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
|
||||||
|
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
|
||||||
|
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
|
||||||
|
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
|
||||||
|
target: { value: '12' },
|
||||||
|
});
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '应用修改' }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog', { name: '统一创作契约' })).toBeNull();
|
||||||
|
expect(screen.getByText('12泥点数')).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存入库' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith(
|
expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith(
|
||||||
'admin-token',
|
'admin-token',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'puzzle',
|
id: 'puzzle',
|
||||||
unifiedCreationSpec: puzzleSpec,
|
unifiedCreationSpec: {
|
||||||
|
...puzzleSpec,
|
||||||
|
mudPointCost: 12,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -107,6 +155,18 @@ test('创作入口后台展示并保存统一创作契约', async () => {
|
|||||||
|
|
||||||
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
|
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||||
|
...configResponse,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
...configResponse.entries[0]!,
|
||||||
|
unifiedCreationSpec: {
|
||||||
|
...puzzleSpec,
|
||||||
|
playId: 'match3d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
render(
|
render(
|
||||||
<AdminCreationEntrySwitchPage
|
<AdminCreationEntrySwitchPage
|
||||||
token="admin-token"
|
token="admin-token"
|
||||||
@@ -114,18 +174,12 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async ()
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const textarea = await screen.findByLabelText('契约 JSON');
|
await screen.findByText('pictureDescription');
|
||||||
fireEvent.change(textarea, {
|
await user.click(screen.getByRole('button', { name: '保存入库' }));
|
||||||
target: {
|
|
||||||
value: JSON.stringify({
|
|
||||||
...puzzleSpec,
|
|
||||||
playId: 'match3d',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await user.click(screen.getByRole('button', {name: '保存入库'}));
|
|
||||||
|
|
||||||
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByText('统一创作契约 playId 必须与入口 ID 一致'),
|
||||||
|
).toBeTruthy();
|
||||||
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
|
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,23 +193,25 @@ test('创作入口后台用表单保存公告配置', async () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2);
|
expect(
|
||||||
|
await screen.findAllByRole('heading', { name: '创作入口公告' }),
|
||||||
|
).toHaveLength(2);
|
||||||
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
|
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
|
||||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||||
target: {value: '周末创作赛'},
|
target: { value: '周末创作赛' },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||||
target: {value: '<section>新的入口公告</section>'},
|
target: { value: '<section>新的入口公告</section>' },
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole('button', {name: '新增公告'}));
|
await user.click(screen.getByRole('button', { name: '新增公告' }));
|
||||||
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
|
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
|
||||||
target: {value: '第二条公告'},
|
target: { value: '第二条公告' },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
|
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
|
||||||
target: {value: '<section>轮播第二条</section>'},
|
target: { value: '<section>轮播第二条</section>' },
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
await user.click(screen.getByRole('button', { name: '保存公告' }));
|
||||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
|
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
|
||||||
@@ -179,6 +235,42 @@ test('创作入口后台用表单保存公告配置', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('创作入口后台用表单保存作品互动配置', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AdminCreationEntrySwitchPage
|
||||||
|
token="admin-token"
|
||||||
|
onUnauthorized={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText('作品互动');
|
||||||
|
const likeToggle = screen.getAllByRole('checkbox')[0]!;
|
||||||
|
await user.click(likeToggle);
|
||||||
|
fireEvent.change(screen.getByLabelText('拼图 / puzzle 点赞关闭提示'), {
|
||||||
|
target: { value: '拼图点赞维护中。' },
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存作品互动' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(upsertAdminPublicWorkInteractions).toHaveBeenCalledWith(
|
||||||
|
'admin-token',
|
||||||
|
{
|
||||||
|
publicWorkInteractions: [
|
||||||
|
{
|
||||||
|
sourceType: 'puzzle',
|
||||||
|
likeEnabled: false,
|
||||||
|
remixEnabled: true,
|
||||||
|
likeDisabledMessage: '拼图点赞维护中。',
|
||||||
|
remixDisabledMessage: '拼图作品改造暂不可用。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
|
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
|
||||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||||
...configResponse,
|
...configResponse,
|
||||||
@@ -224,12 +316,12 @@ test('创作入口后台拒绝空公告表单', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||||
target: {value: ''},
|
target: { value: '' },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||||
target: {value: ''},
|
target: { value: '' },
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
await user.click(screen.getByRole('button', { name: '保存公告' }));
|
||||||
|
|
||||||
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
|
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
|
||||||
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
|
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ interface AdminWorkVisibilityPageProps {
|
|||||||
|
|
||||||
const sourceLabels: Record<string, string> = {
|
const sourceLabels: Record<string, string> = {
|
||||||
puzzle: '拼图',
|
puzzle: '拼图',
|
||||||
|
'puzzle-clear': '拼消消',
|
||||||
'custom-world': '自定义世界',
|
'custom-world': '自定义世界',
|
||||||
'jump-hop': '跳一跳',
|
'jump-hop': '跳一跳',
|
||||||
'wooden-fish': '敲木鱼',
|
'wooden-fish': '敲木鱼',
|
||||||
|
|||||||
@@ -791,6 +791,67 @@ button:disabled {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-contract-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #eaded2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdf9;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-list,
|
||||||
|
.admin-contract-field-editor-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-list {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-card,
|
||||||
|
.admin-contract-field-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid #eaded2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff8f1;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-card strong,
|
||||||
|
.admin-contract-field-card span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-card strong {
|
||||||
|
color: #3d1f10;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-card span {
|
||||||
|
color: #8f7868;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-dialog {
|
||||||
|
width: min(100%, 860px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-field-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(120px, 0.6fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contract-required-toggle {
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-status {
|
.admin-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
@@ -948,7 +1009,8 @@ button:disabled {
|
|||||||
.admin-two-column-wide,
|
.admin-two-column-wide,
|
||||||
.admin-form-row,
|
.admin-form-row,
|
||||||
.admin-filter-grid,
|
.admin-filter-grid,
|
||||||
.admin-table-query-grid {
|
.admin-table-query-grid,
|
||||||
|
.admin-contract-field-editor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ Docker Compose
|
|||||||
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
||||||
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
||||||
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
||||||
|
├─ external-generation-worker,独立 worker 进程,消费 external_generation_job 队列
|
||||||
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
||||||
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
||||||
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
||||||
|
容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。
|
||||||
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||||
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
|||||||
|
|
||||||
## 构建工具链
|
## 构建工具链
|
||||||
|
|
||||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.3.0` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
||||||
|
|
||||||
## 启动与验证
|
## 启动与验证
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
|
|||||||
```bash
|
```bash
|
||||||
npm run container:logs -- nginx
|
npm run container:logs -- nginx
|
||||||
npm run container:logs -- api-server
|
npm run container:logs -- api-server
|
||||||
|
npm run container:logs -- external-generation-worker
|
||||||
npm run container:logs -- otelcol
|
npm run container:logs -- otelcol
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -85,6 +88,73 @@ npm run container:config -- --print
|
|||||||
|
|
||||||
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
||||||
|
|
||||||
|
动态扩缩容外部生成 worker 时,只调整 `external-generation-worker` service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:up -- --scale external-generation-worker=3 external-generation-worker
|
||||||
|
npm run container:up -- --scale external-generation-worker=1 external-generation-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
动态扩缩容验证必须保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`;`inline` 模式下生成请求由 `api-server` 同步执行,不会被这些 worker 实例消费。
|
||||||
|
|
||||||
|
### 外部生成 Worker 隔离 Smoke
|
||||||
|
|
||||||
|
如果只想在本机隔离验证 worker 模式,不复用 `deploy/container/api-server.env`,使用专用脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本会生成 gitignored 的 `deploy/container/worker-smoke/api-server.env` 与端口 state,使用独立 compose project、独立 SpacetimeDB 数据卷和独立 host 端口,完成 `build -> up-spacetime -> publish -> up -> enqueue -> api-update -> enqueue`。测试 job 使用 `worker_smoke_unsupported` 类型,不访问真实 VectorEngine、LLM 或 OSS;预期结果是 worker 领取队列任务后按“不支持的任务类型”执行失败分支,从而验证队列 claim、lease、失败回写路径和 API / worker 进程隔离。`external_generation_job` 是 private table,脚本通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 绕过权限。`smoke` 默认只启动 `api-server` 与 `external-generation-worker`,避免无关前端 / Nginx 镜像构建;需要同时验证 Nginx 时可分步执行 `up --with-nginx`。
|
||||||
|
|
||||||
|
分步排查时可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- init --force
|
||||||
|
npm run container:worker-smoke -- build
|
||||||
|
npm run container:worker-smoke -- up-spacetime
|
||||||
|
npm run container:worker-smoke -- publish
|
||||||
|
npm run container:worker-smoke -- up
|
||||||
|
npm run container:worker-smoke -- enqueue before-update
|
||||||
|
npm run container:worker-smoke -- api-update
|
||||||
|
npm run container:worker-smoke -- enqueue after-update
|
||||||
|
npm run container:worker-smoke -- status
|
||||||
|
```
|
||||||
|
|
||||||
|
如果隔离端口或库数据需要重置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- smoke --force
|
||||||
|
```
|
||||||
|
|
||||||
|
`container:worker-smoke` 默认会把本机 `spacetime` 2.4.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测仍默认使用 `clockworklabs/spacetime:v2.4.1`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- smoke --local-binary
|
||||||
|
```
|
||||||
|
|
||||||
|
`api-update` 只会 `--force-recreate api-server`,并校验 `external-generation-worker` 容器 ID 不变;如要同时重建 API 镜像,使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- api-update --build
|
||||||
|
```
|
||||||
|
|
||||||
|
验证 worker 动态扩缩容:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- scale 3
|
||||||
|
npm run container:worker-smoke -- ps
|
||||||
|
npm run container:worker-smoke -- enqueue scaled-workers
|
||||||
|
npm run container:worker-smoke -- scale 1
|
||||||
|
```
|
||||||
|
|
||||||
|
查看或清理隔离环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:worker-smoke -- logs external-generation-worker
|
||||||
|
npm run container:worker-smoke -- down -v
|
||||||
|
```
|
||||||
|
|
||||||
停止:
|
停止:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ FROM rust:1.93-bookworm AS rust-builder
|
|||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY server-rs ./server-rs
|
COPY server-rs ./server-rs
|
||||||
|
COPY public ./public
|
||||||
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
|
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
|
||||||
cp server-rs/target/release/api-server /tmp/api-server
|
cp server-rs/target/release/api-server /tmp/api-server
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ GENARRATIVE_API_PORT=8082
|
|||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||||
GENARRATIVE_API_WORKER_THREADS=4
|
GENARRATIVE_API_WORKER_THREADS=4
|
||||||
|
# 容器 smoke 可临时设 all;压测或预发按 api / external-generation-worker 拆进程。
|
||||||
|
GENARRATIVE_PROCESS_ROLE=api
|
||||||
|
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: genarrative-container-loadtest
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
spacetimedb:
|
spacetimedb:
|
||||||
image: clockworklabs/spacetime:v2.3.0
|
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.4.1}
|
||||||
user: root
|
user: root
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
cpus: "2.0"
|
cpus: "2.0"
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
env_file:
|
env_file:
|
||||||
- ./api-server.env
|
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
|
||||||
environment:
|
environment:
|
||||||
GENARRATIVE_API_HOST: 0.0.0.0
|
GENARRATIVE_API_HOST: 0.0.0.0
|
||||||
GENARRATIVE_API_PORT: 8082
|
GENARRATIVE_API_PORT: 8082
|
||||||
@@ -69,6 +69,32 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
|
external-generation-worker:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/container/api-server.Dockerfile
|
||||||
|
target: api-runtime
|
||||||
|
cpus: "2.0"
|
||||||
|
mem_limit: 1g
|
||||||
|
env_file:
|
||||||
|
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
|
||||||
|
environment:
|
||||||
|
GENARRATIVE_PROCESS_ROLE: external-generation-worker
|
||||||
|
GENARRATIVE_TRACKING_OUTBOX_DIR: /var/lib/genarrative/tracking-outbox-worker
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318
|
||||||
|
OTEL_SERVICE_NAME: genarrative-external-generation-worker
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 4096
|
||||||
|
hard: 4096
|
||||||
|
depends_on:
|
||||||
|
spacetimedb:
|
||||||
|
condition: service_healthy
|
||||||
|
otelcol:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
|
|||||||
8
deploy/env/api-server.env.example
vendored
8
deploy/env/api-server.env.example
vendored
@@ -7,6 +7,14 @@ GENARRATIVE_API_PORT=8082
|
|||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||||
GENARRATIVE_API_WORKER_THREADS=4
|
GENARRATIVE_API_WORKER_THREADS=4
|
||||||
|
# api 只监听 HTTP;外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。
|
||||||
|
GENARRATIVE_PROCESS_ROLE=api
|
||||||
|
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||||
|
|||||||
13
deploy/env/external-generation-controller.env.example
vendored
Normal file
13
deploy/env/external-generation-controller.env.example
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 复制到 /etc/genarrative/external-generation-controller.env 后按机器容量调整。
|
||||||
|
# controller 只管理 systemd worker 实例;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
||||||
|
# systemd unit 会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-controller。
|
||||||
|
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS=1
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS=8
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER=2
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS=10000
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS=6
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE=genarrative-external-generation-worker@{}.service
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN=false
|
||||||
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
|
OTEL_SERVICE_NAME=genarrative-external-generation-controller
|
||||||
11
deploy/env/external-generation-worker.env.example
vendored
Normal file
11
deploy/env/external-generation-worker.env.example
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 复制到 /etc/genarrative/external-generation-worker.env 后按机器容量调整。
|
||||||
|
# 该文件只覆盖 worker 专属参数;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
||||||
|
# systemd 模板会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-worker
|
||||||
|
# 和 GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i,避免多实例 ID 冲突。
|
||||||
|
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||||
|
# 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||||
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
|
OTEL_SERVICE_NAME=genarrative-external-generation-worker
|
||||||
@@ -10,6 +10,7 @@ User=genarrative
|
|||||||
Group=genarrative
|
Group=genarrative
|
||||||
WorkingDirectory=/opt/genarrative/current
|
WorkingDirectory=/opt/genarrative/current
|
||||||
EnvironmentFile=/etc/genarrative/api-server.env
|
EnvironmentFile=/etc/genarrative/api-server.env
|
||||||
|
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
||||||
ExecStart=/opt/genarrative/current/api-server
|
ExecStart=/opt/genarrative/current/api-server
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ User=root
|
|||||||
Group=root
|
Group=root
|
||||||
WorkingDirectory=/opt/genarrative/current
|
WorkingDirectory=/opt/genarrative/current
|
||||||
EnvironmentFile=/etc/genarrative/api-server.env
|
EnvironmentFile=/etc/genarrative/api-server.env
|
||||||
ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service
|
ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service
|
||||||
|
|
||||||
# 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。
|
# 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ReadWritePaths=/stdb /var/lib/genarrative
|
ReadWritePaths=/stdb /var/lib/genarrative
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Genarrative External Generation Worker Controller
|
||||||
|
After=network-online.target spacetimedb.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=spacetimedb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/genarrative/current
|
||||||
|
EnvironmentFile=/etc/genarrative/api-server.env
|
||||||
|
EnvironmentFile=-/etc/genarrative/external-generation-controller.env
|
||||||
|
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
||||||
|
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-controller GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/controller OTEL_SERVICE_NAME=genarrative-external-generation-controller /opt/genarrative/current/api-server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGINT
|
||||||
|
TimeoutStopSec=120
|
||||||
|
LimitNOFILE=65535
|
||||||
|
TasksMax=512
|
||||||
|
|
||||||
|
# controller 需要调用 systemctl 管理 worker@N 实例,因此不降为 genarrative 用户。
|
||||||
|
# 它只复用 api-server 发布包和 SpacetimeDB 配置,不直接执行外部生成任务。
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Genarrative External Generation Worker %i
|
||||||
|
After=network-online.target spacetimedb.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=spacetimedb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=genarrative
|
||||||
|
Group=genarrative
|
||||||
|
WorkingDirectory=/opt/genarrative/current
|
||||||
|
EnvironmentFile=/etc/genarrative/api-server.env
|
||||||
|
EnvironmentFile=-/etc/genarrative/external-generation-worker.env
|
||||||
|
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
||||||
|
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-worker GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/%H-%i OTEL_SERVICE_NAME=genarrative-external-generation-worker /opt/genarrative/current/api-server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGINT
|
||||||
|
TimeoutStopSec=7200
|
||||||
|
LimitNOFILE=65535
|
||||||
|
TasksMax=2048
|
||||||
|
|
||||||
|
# worker 复用 api-server 发布目录;外部生成审计与临时运行态只写服务端私有目录。
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
20
deploy/systemd/genarrative-health-patrol.service
Normal file
20
deploy/systemd/genarrative-health-patrol.service
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Genarrative Production Health Patrol
|
||||||
|
After=network-online.target genarrative-api.service spacetimedb.service nginx.service
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=/opt/genarrative/current/scripts/ops/production-health-patrol.mjs
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
WorkingDirectory=/opt/genarrative/current
|
||||||
|
EnvironmentFile=-/etc/genarrative/health-patrol.env
|
||||||
|
ExecStart=/usr/bin/node /opt/genarrative/current/scripts/ops/production-health-patrol.mjs --status-file /var/lib/genarrative/health-patrol/status.json
|
||||||
|
TimeoutStartSec=30
|
||||||
|
|
||||||
|
# 巡检只读 systemd、HTTP 和 journal;只允许写入自己的最近一次状态文件。
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths=/var/lib/genarrative/health-patrol
|
||||||
13
deploy/systemd/genarrative-health-patrol.timer
Normal file
13
deploy/systemd/genarrative-health-patrol.timer
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Genarrative Production Health Patrol
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnCalendar=*-*-* *:0/5:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=30
|
||||||
|
AccuracySec=30s
|
||||||
|
Unit=genarrative-health-patrol.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
||||||
|
|
||||||
|
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.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)。
|
生产部署切换到 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)。
|
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||||
@@ -83,6 +85,8 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
|
|||||||
|
|
||||||
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
|
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
|
||||||
|
|
||||||
|
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台图片全屏预览收口到 `src/components/common/PlatformImagePreviewModal.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、全屏黑底图片查看、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
|
||||||
|
|
||||||
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
|
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
|
||||||
|
|
||||||
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||||
|
|||||||
77
docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md
Normal file
77
docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 拼消消玩法模板 PRD
|
||||||
|
|
||||||
|
日期:`2026-05-30`
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
新增玩法模板 **拼消消**,工程域与 `playId` 均为 `puzzle-clear`,公开作品码前缀为 `PC-`。拼消消以拼图的交换 / 拖拽手感为原型,但运行态规则独立:玩家移动 1x1 卡牌碎片,把同一复合图案组拼成完整矩形后消除;消除产生空位后,由顶部对应纵列的卡牌准备区下落补位。
|
||||||
|
|
||||||
|
首版必须完成公开闭环:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创作入口 -> 轻表单工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime -> 基础统计 / 作品架 / 广场
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创作工具平台接入声明
|
||||||
|
|
||||||
|
- 工作台模式:表单 / 图片输入创作工作台。
|
||||||
|
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。
|
||||||
|
- 单图资产槽位:
|
||||||
|
- `board-background` / `ui-background` / `中央场地底图` / `boardBackgroundPrompt` 优先、空值时回退 `themePrompt`,并支持用户上传图 / 写回 `draft.boardBackgroundAsset`、`draft.boardBackgroundPrompt`、`work.boardBackgroundAsset` 与 `work.boardBackgroundPrompt` / 允许历史图 / 允许 AI 重绘。
|
||||||
|
- 中央场地底图的字段名沿用平台表面口径,实际作用是玩家逐步消除清空中央棋盘后慢慢看到的主题目标图;AI 生成尺寸必须与中央棋盘一致,使用 1:1 正方形画面。prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,带来探索、揭开全貌和追求目标完成的感受;不得继续要求“画面干净”或“适合作为卡牌棋盘底图”。
|
||||||
|
- 系列素材槽位:
|
||||||
|
- `batchId=puzzle-clear-pattern-atlas-v1`。
|
||||||
|
- `sheetSpec`:4 张素材工作表,每张 `1024x1536` 竖版,后台按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`;服务端再把切片合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数为 `35`,形状配比 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`,总计 `95` 个 1x1 卡牌切片。
|
||||||
|
- `slotSpecs`:每个复合图案组一个 `patternGroup`,服务端预排 `groupId`、`shape`、atlas 坐标和 1x1 切片坐标。
|
||||||
|
- 切图规则:生图 prompt 只要求复合图案组能按 4x6 素材工作表均等切成 1x1 方形小份,不允许模型在图上绘制切分线、边框、网格线或裁切参考线;服务端按 sheet 布局直接裁出 1x1 卡牌碎片,校验每个编号占格数与领域图案组面积一致,再合成最终 atlas,写入 `patternGroups[]` 与 `cardAssets[]`。
|
||||||
|
- 透明化规则:首版保留完整方形卡面,不强制透明化;若 provider 输出带边框、切分线、网格、裁切参考线或文字,生成任务失败并回写审计。
|
||||||
|
- 失败回写:生成页写回 `generationStatus=failed` 与失败阶段;结果页保留重试入口。
|
||||||
|
- 局部重生成:v1 允许整批 4 张素材工作表重试,不做单组局部重生。
|
||||||
|
- API 命名空间:`/api/creation/puzzle-clear/...` 与 `/api/runtime/puzzle-clear/...`。
|
||||||
|
- 业务真相:草稿、发布、runtime snapshot、胜负、补牌、防死局、统计均由后端裁决;前端只做动画和交互表现。
|
||||||
|
- 创作工具模式例外:无。
|
||||||
|
- 验证命令:`npm run check:encoding`、`npm run typecheck`、`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`;涉及 SpacetimeDB schema 后运行 `npm run spacetime:generate`、`npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`。
|
||||||
|
|
||||||
|
## 工作台字段
|
||||||
|
|
||||||
|
| 字段 | 契约字段 | 默认值 | 校验 | 落库 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 作品标题 | `workTitle` | 空 | 必填,1-30 字 | session draft / work profile |
|
||||||
|
| 简介 | `workDescription` | 空 | 0-120 字 | session draft / work profile |
|
||||||
|
| 主题词 | `themePrompt` | 空 | 必填,1-80 字 | 生成 prompt 与草稿 |
|
||||||
|
| 场地底图主题词 | `boardBackgroundPrompt` | 空 | 0-80 字;为空时底图生成回退 `themePrompt` | session draft / work profile / 主题目标图生成 prompt |
|
||||||
|
| 中央场地底图 | `boardBackgroundAsset` | 空 | 上传或 AI 生成至少一种 | 单图资产槽位 |
|
||||||
|
| AI 生成底图 | `generateBoardBackground` | `true` | boolean | 生成编排参数 |
|
||||||
|
|
||||||
|
规则参数不开放创作者编辑:棋盘尺寸、倒计时、消除次数、形状解锁、防死局发牌和半锁定规则固定。
|
||||||
|
|
||||||
|
## 运行规则
|
||||||
|
|
||||||
|
| 关卡 | 棋盘 | 目标消除 | 倒计时 | 解锁形状 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 6x6 | 35 | 10 分钟 | 1x2、1x3、2x2、2x3 |
|
||||||
|
|
||||||
|
- 开局每个小格子从背面翻向正面。
|
||||||
|
- 可消除图由横向或纵向复合图案组组成,最小消除单位为两张图拼接。
|
||||||
|
- 完成一个复合图案组后,该组所有 1x1 卡牌碎片消除。
|
||||||
|
- 消除后空位按列由顶部卡牌准备区下落补齐。
|
||||||
|
- 每次补牌至少保证掉落卡中有一张可以与场上剩余某张卡拼接,防止死局。
|
||||||
|
- 非 2 格消除时,若场上已有局部完成的半锁定拼接组,补牌不得破坏它。
|
||||||
|
- 半锁定拼接组可整体拖动;玩家用外部单格撞入组内某格时,只交换该格,组其余部分保留,组状态退回半完成。
|
||||||
|
- 超时只判当前关失败,可重试当前关;完成 35 次目标并清空当前棋盘后整局完成。
|
||||||
|
|
||||||
|
## 结果页
|
||||||
|
|
||||||
|
结果页展示:素材 atlas、中央场地底图、发布状态、试玩入口和失败重试。结果页不写功能说明类文案,不开放规则编辑器,不新增排行榜配置。
|
||||||
|
|
||||||
|
## 统计
|
||||||
|
|
||||||
|
首版只记录正式 `published` run:
|
||||||
|
|
||||||
|
- 开局。
|
||||||
|
- 全局完成。
|
||||||
|
- 当前关失败。
|
||||||
|
- 耗时。
|
||||||
|
- 消除统计。
|
||||||
|
|
||||||
|
草稿试玩不写正式统计,不进入排行榜;v1 不做排行榜。
|
||||||
@@ -205,10 +205,11 @@ WF-*
|
|||||||
|
|
||||||
1. 若 payload 已包含上传/录音音频资产,`compile-draft` 跳过音效生成,直接持久化该资产;
|
1. 若 payload 已包含上传/录音音频资产,`compile-draft` 跳过音效生成,直接持久化该资产;
|
||||||
2. 若 payload 已上传或录制音频,则直接写回 `hitSoundAsset`;
|
2. 若 payload 已上传或录制音频,则直接写回 `hitSoundAsset`;
|
||||||
3. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`;
|
3. 麦克风录制音频在保存前由前端自动裁掉开头连续静音段;上传音频不做裁剪,裁剪失败时保留原始录音继续保存;
|
||||||
4. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长;
|
4. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`;
|
||||||
5. 通用创作音频接口当前对 `wooden_fish` 的 `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
|
5. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长;
|
||||||
6. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
|
6. 通用创作音频接口当前对 `wooden_fish` 的 `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
|
||||||
|
7. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
|
||||||
|
|
||||||
### 6.3 封面
|
### 6.3 封面
|
||||||
|
|
||||||
@@ -371,7 +372,7 @@ finish
|
|||||||
|
|
||||||
音频播放:
|
音频播放:
|
||||||
|
|
||||||
1. 前端使用小复音池;
|
1. 前端使用 10 路小复音池;
|
||||||
2. 设置最小播放间隔,避免极端连点导致浏览器抖动;
|
2. 设置最小播放间隔,避免极端连点导致浏览器抖动;
|
||||||
3. 点击计数不能因为音频节流而丢失;
|
3. 点击计数不能因为音频节流而丢失;
|
||||||
4. 签名 URL 未就绪时先静音表现,不请求裸 generated 私有路径。
|
4. 签名 URL 未就绪时先静音表现,不请求裸 generated 私有路径。
|
||||||
|
|||||||
@@ -2,491 +2,198 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
新增一个可创作、可试玩、可发布的玩法模板:
|
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
|
||||||
|
|
||||||
```text
|
首版目标:
|
||||||
跳一跳
|
|
||||||
```
|
|
||||||
|
|
||||||
本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。
|
1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生;
|
||||||
|
2. image2 只生成一张 `1024x1536` 地板 UV 展开图集,后端切成 18 组、共 108 张面贴图 PNG;
|
||||||
首版要求:
|
3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG;
|
||||||
|
4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块;
|
||||||
1. 初始草稿生成时,角色形象单独调用一次生图;
|
5. 操作方式为长按屏幕蓄力并按拖拽方向起跳,松手后角色按前端提交的后端方向向量弹出;
|
||||||
2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集;
|
6. 只要落点未命中下一个地块,本局立即失败并冻结计时;
|
||||||
3. 运行态不接真实 3D 网格,不生成 GLB / glTF;
|
7. 成绩记录成功跳跃次数和游戏时长;
|
||||||
4. 作品可以直接进入试玩和发布。
|
8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
||||||
|
|
||||||
## 2. 模板定位
|
## 2. 模板定位
|
||||||
|
|
||||||
模板 ID:
|
- 模板 ID:`jump-hop`
|
||||||
|
- 展示名:`跳一跳`
|
||||||
|
- 工程域:`jump-hop`
|
||||||
|
- 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp`
|
||||||
|
- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + DOM 角色 + DOM HUD`
|
||||||
|
- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16`
|
||||||
|
- 素材策略:18 个立方体主题物体 UV 展开包装 + Three.js 复用标准 1x1x1 等比立方体几何 + 陶泥儿 logo 透明角色
|
||||||
|
- 渲染分层:Three.js 平台层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体必须正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,也不得用不同 x/y/z scale 压成扁盒子;运行态视角采用约 `1.3x` 近距相机和 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,后续两块向上展开且保持紧凑的纵向 / 横向间距;Three.js 平台层与 DOM 角色层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转;DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色必须由 DOM 透明 PNG 层渲染并保持在 Three.js 平台层之上
|
||||||
|
|
||||||
|
本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。
|
||||||
|
|
||||||
|
## 3. 创作工具平台接入声明
|
||||||
|
|
||||||
|
- 工作台模式:表单输入创作工作台
|
||||||
|
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
||||||
|
- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色
|
||||||
|
- 系列素材槽位:
|
||||||
|
- `batchId = jump-hop-tile-atlas`
|
||||||
|
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
|
||||||
|
- `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId`
|
||||||
|
- 切图规则:先按原图宽高均分为 3 列 6 行,从上到下、从左到右得到 18 个大单元;每个大单元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;每个面输出 `256x256` 不透明 PNG
|
||||||
|
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位,后端不做透明化抠图,只把裁切后残留的洋红 key 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理
|
||||||
|
- 失败回写:生成失败时 session 保持 failed,可从生成页重试
|
||||||
|
- 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
|
||||||
|
- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`
|
||||||
|
- 业务真相:后端裁决落点、失败、成功跳跃次数、冻结时长和排行榜
|
||||||
|
- 创作工具模式例外:无
|
||||||
|
- 验证命令:`npm run check:encoding`、`npm run typecheck`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml`
|
||||||
|
|
||||||
|
## 4. 创作输入
|
||||||
|
|
||||||
|
主题是唯一必填项。工作台不展示角色提示词、地块提示词、风格卡、难度卡、终点氛围或规则说明。
|
||||||
|
|
||||||
|
提交后系统自动派生:
|
||||||
|
|
||||||
|
1. 作品标题:主题为空白修剪后的短标题,默认前缀不外露;
|
||||||
|
2. 作品简介:基于主题生成一句短简介;
|
||||||
|
3. 标签:`跳一跳`、`休闲` 和主题关键词;
|
||||||
|
4. 地板贴图提示词:围绕主题生成 18 个风格一致的立方体主题物体 UV 展开包装,每个包装由 top/front/right/back/left/bottom 六面组成,供 Three.js 标准 1x1x1 等比极小倒角立方体地板复用;实际 image2 prompt 使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlas”措辞,要求六面共同表达同一个完整方块化主题物体,例如水果主题要生成可一眼辨认的方块苹果、方块香蕉、方块橙子、方块西瓜等,而不是单纯生成平铺材质、抽象纹理、平台、跳台、地块成品、单张图重复六面或游戏界面资源;
|
||||||
|
5. 初始平台流参数:固定 v1 标准参数,不让创作者手工调规则。
|
||||||
|
|
||||||
|
## 5. 地板贴图图集
|
||||||
|
|
||||||
|
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图;不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
|
||||||
|
|
||||||
|
图集要求:
|
||||||
|
|
||||||
|
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`;
|
||||||
|
2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标;
|
||||||
|
3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成;
|
||||||
|
4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理;
|
||||||
|
5. 大单元之间、UV 空位、六面之间和画布外圈为纯洋红 `#FF00FF`,方便后端安全切图;
|
||||||
|
6. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、可见网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底;
|
||||||
|
7. 贴图不能跨格、贴边串色或进入相邻格;每个面贴图应尽量铺满自己的 UV 面,纯洋红只作为安全缝、UV 空位和外圈 key 色。
|
||||||
|
|
||||||
|
大单元切片顺序固定为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
jump-hop
|
tile-01 tile-02 tile-03
|
||||||
|
tile-04 tile-05 tile-06
|
||||||
|
tile-07 tile-08 tile-09
|
||||||
|
tile-10 tile-11 tile-12
|
||||||
|
tile-13 tile-14 tile-15
|
||||||
|
tile-16 tile-17 tile-18
|
||||||
```
|
```
|
||||||
|
|
||||||
用户展示名:
|
每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面,旧作品没有 `faceAssets` 时运行态仍可把单张旧贴图应用到立方体所有面。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
|
||||||
|
|
||||||
|
## 6. 运行态规则
|
||||||
|
|
||||||
|
### 6.1 平台流
|
||||||
|
|
||||||
|
运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见:
|
||||||
|
|
||||||
|
1. 当前地块;
|
||||||
|
2. 目标地块;
|
||||||
|
3. 下一预览地块。
|
||||||
|
|
||||||
|
服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。
|
||||||
|
|
||||||
|
### 6.2 操作
|
||||||
|
|
||||||
|
1. 用户按住当前地块或画面开始蓄力;
|
||||||
|
2. 长按时长形成蓄力值,达到 `maxChargeMs` 后封顶;
|
||||||
|
3. 松手后角色按本次输入方向弹出;
|
||||||
|
4. 蓄力值决定跳跃距离,拖拽方向决定跳跃方向;
|
||||||
|
5. 前端必须同时提交 `dragDistance` 与换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端以这两个方向字段裁决真实落点;旧客户端缺失方向或方向非法时,后端才 fallback 到当前地块中心指向下一块地块中心。
|
||||||
|
|
||||||
|
手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 是正式方向输入契约,不能在前端提交或后端裁决中丢弃。
|
||||||
|
|
||||||
|
松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前地块沿下一块地块中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,再进入约 `1440ms` 的相机推进过渡,避免角色先飞过很远再瞬间拉回地块。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
|
||||||
|
|
||||||
|
### 6.3 判定
|
||||||
|
|
||||||
|
1. 目标永远是当前地块后的下一个地块;
|
||||||
|
2. 真实落点沿前端提交的 `dragVectorX/dragVectorY` 归一化方向计算;仅当方向缺失、非有限数或长度过小时,才沿当前地块中心到下一块地块中心方向兼容计算;
|
||||||
|
3. 落点进入下一个地块可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%;
|
||||||
|
4. 落点未进入下一个地块可见顶面 footprint,则失败;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定;
|
||||||
|
5. 失败后状态改为 `failed`,计时冻结;
|
||||||
|
6. v1 没有通关状态、combo、perfect 或生命数。
|
||||||
|
|
||||||
|
### 6.4 计分与时间
|
||||||
|
|
||||||
|
- 成功跳跃次数:每成功落到下一个地块后 `+1`;
|
||||||
|
- 游戏时长:`startedAtMs` 到 `finishedAtMs`,失败时冻结;
|
||||||
|
- 运行中时长由前端根据服务端 `startedAtMs` 展示;
|
||||||
|
- 失败后只展示冻结时长。
|
||||||
|
|
||||||
|
## 7. 排行榜
|
||||||
|
|
||||||
|
排行榜按作品维度生成。每位玩家只保留 1 条最佳记录。
|
||||||
|
|
||||||
|
排序规则固定为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
跳一跳
|
successfulJumpCount desc -> durationMs asc -> updatedAt asc
|
||||||
```
|
```
|
||||||
|
|
||||||
体验关键词:
|
展示字段:
|
||||||
|
|
||||||
1. 俯视角;
|
1. rank;
|
||||||
2. 等距感地块;
|
2. displayName;
|
||||||
3. 单局闯关;
|
3. successfulJumpCount;
|
||||||
4. 长按蓄力,松手起跳;
|
4. durationMs;
|
||||||
5. 轻量休闲。
|
5. updatedAt。
|
||||||
|
|
||||||
首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是:
|
排行榜 UI 禁止展示 `user_id` / `playerId` 这类内部身份键。后端可以继续用 `playerId` 做作品维度最佳成绩去重和 `viewerBest` 匹配,但 HTTP 响应必须补齐 `displayName`;已登录用户读取账号 `displayName`,匿名游客展示为“游客玩家”,账号失效或无法解析时展示为“失效玩家”。
|
||||||
|
|
||||||
1. 大面积留白或浅色渐变背景;
|
草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。
|
||||||
2. 角色站在单个地块上;
|
|
||||||
3. 地块有明显顶面、侧面和投影;
|
## 8. 结果页
|
||||||
4. 整体是俯视角 / 等距视角,而不是横版平台跳跃;
|
|
||||||
5. UI 克制,只保留必要控制,不堆说明文案。
|
结果页展示:
|
||||||
|
|
||||||
## 3. 与拼图模板的复用边界
|
1. 陶泥儿 logo 透明角色预览;
|
||||||
|
2. 18 个地块资源池预览;
|
||||||
可以复用:
|
3. 首屏 3 块平台预览;
|
||||||
|
4. 试玩;
|
||||||
1. 创作入口和模板分流;
|
5. 发布;
|
||||||
2. 生成过程页;
|
6. 返回编辑;
|
||||||
3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路;
|
7. 重生成地块。
|
||||||
4. 作品架展示和草稿恢复口径;
|
|
||||||
5. 平台统一的发布与公开展示流程。
|
结果页不再展示角色图片生成槽位,也不提供独立角色重生成。
|
||||||
|
|
||||||
不复用:
|
## 9. 契约要点
|
||||||
|
|
||||||
1. 拼图关卡切片逻辑;
|
公开语义保留:
|
||||||
2. 拼图拖拽拼块逻辑;
|
|
||||||
3. 拼图 UI 背景和多关卡编辑结构;
|
1. `themeText`;
|
||||||
4. 任何方格拼合语义。
|
2. `tileAtlasAsset`;
|
||||||
|
3. `tileAssets[]`;
|
||||||
## 4. 工程接入范围
|
4. `defaultCharacter`;
|
||||||
|
5. `path.platforms[]` 作为服务端路径缓冲;
|
||||||
首版需要做到完整玩法闭环,不只做入口占位。
|
6. `currentPlatformIndex`;
|
||||||
|
7. `successfulJumpCount`;
|
||||||
新增前端阶段:
|
8. `startedAtMs` / `finishedAtMs` / `durationMs`;
|
||||||
|
9. `leaderboard`。
|
||||||
```text
|
|
||||||
jump-hop-workspace
|
旧语义处理:
|
||||||
jump-hop-generating
|
|
||||||
jump-hop-result
|
1. `characterAsset` 仅作为角色描述兼容字段,不再表示生成图片;前端固定使用陶泥儿 logo 透明 PNG;
|
||||||
jump-hop-runtime
|
2. `score` 兼容映射为成功跳跃次数;
|
||||||
jump-hop-gallery-detail
|
3. `combo` 固定为 0,不作为公开玩法语义;
|
||||||
```
|
4. `cleared` 状态不再由 v1 产生;
|
||||||
|
5. 旧 finite path 只作为服务端路径缓冲兼容形态。
|
||||||
新增前端组件建议:
|
|
||||||
|
## 10. 验收
|
||||||
1. `src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx`;
|
|
||||||
2. `src/components/jump-hop-result/JumpHopResultView.tsx`;
|
1. 创作页只显示主题输入;
|
||||||
3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`;
|
2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图;
|
||||||
4. `src/services/jump-hop/jumpHopClient.ts`。
|
3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG;
|
||||||
|
4. 结果页不依赖旧角色图片槽;
|
||||||
新增共享契约建议:
|
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
|
||||||
|
6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向;
|
||||||
1. `packages/shared/src/contracts/jumpHop.ts`;
|
7. 未落到下一个地块立即失败;
|
||||||
2. `server-rs/crates/shared-contracts/src/jump_hop.rs`。
|
8. 成功跳跃次数累加,失败后计时冻结;
|
||||||
|
9. 排行榜按成功跳跃次数优先排序;
|
||||||
新增后端模块建议:
|
10. 作品可保存、发布、分享并从公开入口启动。
|
||||||
|
11. 运行态 Three.js 地板必须优先把 `tileAssets[].faceAssets` 六面贴图按 right/left/top/bottom/front/back 材质顺序贴到标准 `1x1x1` 等比立方体上;旧作品没有 `faceAssets` 时才使用 `tileAssets[].imageSrc` 单贴图 fallback。六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL,任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`。立方体正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,不得把 x/y/z 缩放成扁盒子;相机保持近距 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,可见三块地板之间的屏幕间距必须偏紧凑;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或 DOM 角色层。
|
||||||
1. `server-rs/crates/module-jump-hop`:纯领域规则,包含路径生成、蓄力换算、落点判定、通关 / 失败状态机;
|
12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再看到地块窗口前移;成功落地显示必须保留真实落点偏移。
|
||||||
2. `server-rs/crates/api-server/src/jump_hop.rs` 和 `src/jump_hop/` 子模块:HTTP handler、生成编排、资产保存和 DTO 映射;
|
|
||||||
3. `server-rs/crates/spacetime-module/src/jump_hop.rs`:session、work profile、runtime run、公开 view 和 reducer / procedure;
|
|
||||||
4. `server-rs/crates/spacetime-client/src/jump_hop.rs`:api-server 访问 SpacetimeDB 的 facade;
|
|
||||||
5. `server-rs/crates/api-server/src/modules/jump_hop.rs`:路由挂载。
|
|
||||||
|
|
||||||
入口配置事实源必须走 SpacetimeDB `creation_entry_type_config` 默认种子和后台配置接口,不新增前端硬编码入口配置。
|
|
||||||
|
|
||||||
## 5. 创作输入
|
|
||||||
|
|
||||||
创作者需要填写以下内容:
|
|
||||||
|
|
||||||
1. 作品主题描述,必填;
|
|
||||||
2. 角色形象描述,必填;
|
|
||||||
3. 地块风格卡,必选;
|
|
||||||
4. 难度,必选;
|
|
||||||
5. 可选的终点氛围或节奏偏好。
|
|
||||||
|
|
||||||
推荐的最小输入形态是:
|
|
||||||
|
|
||||||
1. 一句话主题;
|
|
||||||
2. 角色一句话描述;
|
|
||||||
3. 风格卡;
|
|
||||||
4. 难度卡。
|
|
||||||
|
|
||||||
不在首版开放手工拖拽平台编辑器。平台路径、地块间距和终点位置由系统自动生成,创作者只负责风格与难度选择。
|
|
||||||
|
|
||||||
### 5.1 地块风格卡
|
|
||||||
|
|
||||||
建议提供以下风格:
|
|
||||||
|
|
||||||
1. 极简积木;
|
|
||||||
2. 纸模玩具;
|
|
||||||
3. 霓虹玻璃;
|
|
||||||
4. 森林石块;
|
|
||||||
5. 未来金属;
|
|
||||||
6. 自定义。
|
|
||||||
|
|
||||||
### 5.2 难度
|
|
||||||
|
|
||||||
建议提供以下离散档位:
|
|
||||||
|
|
||||||
1. 轻松;
|
|
||||||
2. 标准;
|
|
||||||
3. 进阶;
|
|
||||||
4. 挑战。
|
|
||||||
|
|
||||||
难度主要影响:
|
|
||||||
|
|
||||||
1. 平台路径长度;
|
|
||||||
2. 平台间距;
|
|
||||||
3. 可落点容差;
|
|
||||||
4. 完美落点窗口;
|
|
||||||
5. 终点前的节奏变化。
|
|
||||||
|
|
||||||
## 6. 生成规则
|
|
||||||
|
|
||||||
本模板必须把生图责任拆成两条独立链路:
|
|
||||||
|
|
||||||
### 6.1 角色形象只生一次
|
|
||||||
|
|
||||||
角色形象必须只调用一次生图,输出一张可直接进入运行态的主角色图。
|
|
||||||
|
|
||||||
角色图要求:
|
|
||||||
|
|
||||||
1. 单人主角;
|
|
||||||
2. 全身可见;
|
|
||||||
3. 透明背景;
|
|
||||||
4. 角色站姿或轻微前倾姿态;
|
|
||||||
5. 镜头和透视必须匹配俯视角场景;
|
|
||||||
6. 不要求多视角,不要求多帧动画图集。
|
|
||||||
|
|
||||||
角色图生成后作为作品级锚点资产使用,结果页、封面合成、试玩和发布都复用同一张图。后续如果只修改标题、标签、难度或路径,不应默认重新生角色。只有用户在结果页明确点击“重生成角色”时,才允许再调用一次角色生图。
|
|
||||||
|
|
||||||
### 6.2 地块只生一次图集
|
|
||||||
|
|
||||||
地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。
|
|
||||||
|
|
||||||
地块图集要求:
|
|
||||||
|
|
||||||
1. 统一使用等距 / 俯视角;
|
|
||||||
2. 必须表现出顶面、侧面和投影;
|
|
||||||
3. 必须与角色图保持同一光向;
|
|
||||||
4. 必须有清晰的立体层次,但仍然是 2D 图片;
|
|
||||||
5. 六格必须按固定顺序包含以下地块类型:
|
|
||||||
- 起点地块;
|
|
||||||
- 普通地块;
|
|
||||||
- 目标地块;
|
|
||||||
- 终点地块;
|
|
||||||
- 奖励地块;
|
|
||||||
- 视觉强调地块。
|
|
||||||
|
|
||||||
固定格位为:
|
|
||||||
|
|
||||||
| 格位 | tileType | 语义 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 第 1 行第 1 列 | `start` | 起点地块 |
|
|
||||||
| 第 1 行第 2 列 | `normal` | 普通地块 |
|
|
||||||
| 第 1 行第 3 列 | `target` | 目标地块 |
|
|
||||||
| 第 2 行第 1 列 | `finish` | 终点地块 |
|
|
||||||
| 第 2 行第 2 列 | `bonus` | 奖励地块 |
|
|
||||||
| 第 2 行第 3 列 | `accent` | 视觉强调地块 |
|
|
||||||
|
|
||||||
图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。
|
|
||||||
|
|
||||||
### 6.3 不新增第三次生成
|
|
||||||
|
|
||||||
首版不把封面、分享海报、路径预览再拆成第三次图像生成。封面和分享图必须由角色图 + 地块图集在本地或后端轻量合成,不额外增加新的角色生图次数。
|
|
||||||
|
|
||||||
### 6.4 路径元数据
|
|
||||||
|
|
||||||
除图片资产外,系统还必须生成跳跃路径元数据:
|
|
||||||
|
|
||||||
1. 平台序列;
|
|
||||||
2. 平台中心点;
|
|
||||||
3. 平台宽度;
|
|
||||||
4. 平台间距;
|
|
||||||
5. 终点索引;
|
|
||||||
6. 评分和容差参数。
|
|
||||||
|
|
||||||
路径由领域规则自动生成,创作者不直接编辑坐标。路径元数据不依赖 LLM 或图片生成。
|
|
||||||
|
|
||||||
### 6.5 推荐的难度区间
|
|
||||||
|
|
||||||
| 难度 | 平台数量 | 平台间距 | 节奏 |
|
|
||||||
| --- | ---: | --- | --- |
|
|
||||||
| 轻松 | 12 - 14 | 短 | 宽容 |
|
|
||||||
| 标准 | 16 - 18 | 中 | 稳定 |
|
|
||||||
| 进阶 | 20 - 24 | 中长 | 紧凑 |
|
|
||||||
| 挑战 | 26 - 32 | 长 | 高压 |
|
|
||||||
|
|
||||||
平台宽度和容差由系统按难度自动缩放,不要求创作者手工填写。
|
|
||||||
|
|
||||||
## 7. 契约草案
|
|
||||||
|
|
||||||
### 7.1 草稿结构
|
|
||||||
|
|
||||||
`JumpHopDraft` 至少包含:
|
|
||||||
|
|
||||||
1. `templateId = "jump-hop"`;
|
|
||||||
2. `templateName = "跳一跳"`;
|
|
||||||
3. `profileId`;
|
|
||||||
4. `workTitle`;
|
|
||||||
5. `workDescription`;
|
|
||||||
6. `themeTags`;
|
|
||||||
7. `difficulty`;
|
|
||||||
8. `stylePreset`;
|
|
||||||
9. `characterPrompt`;
|
|
||||||
10. `tilePrompt`;
|
|
||||||
11. `characterAsset`;
|
|
||||||
12. `tileAtlasAsset`;
|
|
||||||
13. `tileAssets[]`;
|
|
||||||
14. `path`;
|
|
||||||
15. `coverComposite`;
|
|
||||||
16. `generationStatus`。
|
|
||||||
|
|
||||||
### 7.2 资产结构
|
|
||||||
|
|
||||||
`JumpHopCharacterAsset` 至少包含:
|
|
||||||
|
|
||||||
1. `assetId`;
|
|
||||||
2. `imageSrc`;
|
|
||||||
3. `imageObjectKey`;
|
|
||||||
4. `assetObjectId`;
|
|
||||||
5. `generationProvider`;
|
|
||||||
6. `prompt`;
|
|
||||||
7. `width`;
|
|
||||||
8. `height`。
|
|
||||||
|
|
||||||
`JumpHopTileAsset` 至少包含:
|
|
||||||
|
|
||||||
1. `tileType`;
|
|
||||||
2. `imageSrc`;
|
|
||||||
3. `imageObjectKey`;
|
|
||||||
4. `assetObjectId`;
|
|
||||||
5. `sourceAtlasCell`;
|
|
||||||
6. `visualWidth`;
|
|
||||||
7. `visualHeight`;
|
|
||||||
8. `topSurfaceRadius`;
|
|
||||||
9. `landingRadius`。
|
|
||||||
|
|
||||||
`tileType` 首版限定:
|
|
||||||
|
|
||||||
```text
|
|
||||||
start | normal | target | finish | bonus | accent
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 路径结构
|
|
||||||
|
|
||||||
`JumpHopPath` 至少包含:
|
|
||||||
|
|
||||||
1. `seed`;
|
|
||||||
2. `difficulty`;
|
|
||||||
3. `platforms[]`;
|
|
||||||
4. `finishIndex`;
|
|
||||||
5. `cameraPreset`;
|
|
||||||
6. `scoring`。
|
|
||||||
|
|
||||||
`JumpHopPlatform` 至少包含:
|
|
||||||
|
|
||||||
1. `platformId`;
|
|
||||||
2. `tileType`;
|
|
||||||
3. `x`;
|
|
||||||
4. `y`;
|
|
||||||
5. `width`;
|
|
||||||
6. `height`;
|
|
||||||
7. `landingRadius`;
|
|
||||||
8. `perfectRadius`;
|
|
||||||
9. `scoreValue`。
|
|
||||||
|
|
||||||
### 7.4 运行态快照
|
|
||||||
|
|
||||||
`JumpHopRunSnapshot` 至少包含:
|
|
||||||
|
|
||||||
1. `runId`;
|
|
||||||
2. `profileId`;
|
|
||||||
3. `status = playing | failed | cleared`;
|
|
||||||
4. `currentPlatformIndex`;
|
|
||||||
5. `score`;
|
|
||||||
6. `combo`;
|
|
||||||
7. `lastJump`;
|
|
||||||
8. `startedAtMs`;
|
|
||||||
9. `finishedAtMs`。
|
|
||||||
|
|
||||||
`lastJump` 至少包含:
|
|
||||||
|
|
||||||
1. `chargeMs`;
|
|
||||||
2. `jumpDistance`;
|
|
||||||
3. `targetPlatformIndex`;
|
|
||||||
4. `landedX`;
|
|
||||||
5. `landedY`;
|
|
||||||
6. `result = miss | hit | perfect | finish`。
|
|
||||||
|
|
||||||
## 8. API 草案
|
|
||||||
|
|
||||||
HTTP 路由建议:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/creation/jump-hop/sessions
|
|
||||||
GET /api/creation/jump-hop/sessions/{sessionId}
|
|
||||||
POST /api/creation/jump-hop/sessions/{sessionId}/actions
|
|
||||||
POST /api/creation/jump-hop/works/{profileId}/publish
|
|
||||||
GET /api/runtime/jump-hop/works/{profileId}
|
|
||||||
POST /api/runtime/jump-hop/runs
|
|
||||||
POST /api/runtime/jump-hop/runs/{runId}/jump
|
|
||||||
POST /api/runtime/jump-hop/runs/{runId}/restart
|
|
||||||
GET /api/runtime/jump-hop/gallery
|
|
||||||
GET /api/runtime/jump-hop/gallery/{publicWorkCode}
|
|
||||||
```
|
|
||||||
|
|
||||||
动作类型建议:
|
|
||||||
|
|
||||||
```text
|
|
||||||
compile-draft
|
|
||||||
regenerate-character
|
|
||||||
regenerate-tiles
|
|
||||||
update-work-meta
|
|
||||||
update-difficulty
|
|
||||||
```
|
|
||||||
|
|
||||||
`compile-draft` 是长耗时动作。前端进入生成页后必须持久化 `generationStatus=generating`,刷新后能从作品架恢复生成页。失败前需要复读 session;如果后端已经完成草稿并写回资产,前端按成功收尾。
|
|
||||||
|
|
||||||
## 9. SpacetimeDB 表和 view
|
|
||||||
|
|
||||||
建议新增表:
|
|
||||||
|
|
||||||
1. `jump_hop_agent_session`;
|
|
||||||
2. `jump_hop_work_profile`;
|
|
||||||
3. `jump_hop_runtime_run`;
|
|
||||||
4. `jump_hop_event`;
|
|
||||||
5. `jump_hop_leaderboard_entry`,首版可暂不对外展示;
|
|
||||||
6. `jump_hop_gallery_view`;
|
|
||||||
7. `jump_hop_gallery_card_view`。
|
|
||||||
|
|
||||||
表结构新增字段必须按 SpacetimeDB 迁移规则放在结构体末尾并设置明确默认值。新增或调整表、reducer、procedure、view 后必须同步 `migration.rs`、表目录、生成 bindings,并执行 `npm run check:spacetime-schema`。
|
|
||||||
|
|
||||||
公开列表主路径应优先订阅 `jump_hop_gallery_card_view` 后在 `api-server` 本地 cache 构造列表响应,不要让每个 HTTP 请求都调用 SpacetimeDB procedure 组装全量列表。
|
|
||||||
|
|
||||||
## 10. 结果页能力
|
|
||||||
|
|
||||||
结果页必须展示:
|
|
||||||
|
|
||||||
1. 作品标题;
|
|
||||||
2. 作品简介;
|
|
||||||
3. 角色形象;
|
|
||||||
4. 地块图集;
|
|
||||||
5. 路径预览;
|
|
||||||
6. 标签;
|
|
||||||
7. 试玩;
|
|
||||||
8. 发布;
|
|
||||||
9. 返回编辑。
|
|
||||||
|
|
||||||
结果页还必须支持:
|
|
||||||
|
|
||||||
1. 单独重生成角色;
|
|
||||||
2. 单独重生成地块图集;
|
|
||||||
3. 单独修改标题和简介;
|
|
||||||
4. 单独调整标签和难度。
|
|
||||||
|
|
||||||
结果页不应强制再走一次封面生图。封面只做合成,不新增图像生成调用。
|
|
||||||
|
|
||||||
## 11. 运行态规则
|
|
||||||
|
|
||||||
运行态采用 2D 表现,但画面视觉上必须保留参考图那种俯视角 / 等距感。
|
|
||||||
|
|
||||||
### 11.1 核心玩法
|
|
||||||
|
|
||||||
1. 玩家长按蓄力;
|
|
||||||
2. 松手后角色按蓄力长度起跳;
|
|
||||||
3. 跳跃距离决定是否落到下一个地块;
|
|
||||||
4. 落在目标区域内判定成功;
|
|
||||||
5. 落在地块外或越界判定失败;
|
|
||||||
6. 到达终点地块判定通关。
|
|
||||||
|
|
||||||
### 11.2 判定规则
|
|
||||||
|
|
||||||
1. 只做一个当前局面的起跳判定;
|
|
||||||
2. 不做复杂连招动作树;
|
|
||||||
3. 不新增生命数、体力、回合数;
|
|
||||||
4. 不新增计时赛作为首版核心规则;
|
|
||||||
5. 不把前端动画结果当成最终真相,通关与失败必须能回写运行态状态。
|
|
||||||
|
|
||||||
### 11.3 角色动画
|
|
||||||
|
|
||||||
角色不需要多帧生图,运行态只通过位移、缩放、轻微旋转和投影变化表达:
|
|
||||||
|
|
||||||
1. 蓄力时轻微压缩;
|
|
||||||
2. 起跳时向上抬升;
|
|
||||||
3. 空中保持可读轮廓;
|
|
||||||
4. 落地时轻微弹性回弹;
|
|
||||||
5. 失败时从地块边缘跌落。
|
|
||||||
|
|
||||||
### 11.4 摄像机与构图
|
|
||||||
|
|
||||||
1. 相机以当前角色和下一地块为中心;
|
|
||||||
2. 至少保证下一个落点一直可见;
|
|
||||||
3. 画面要留出顶部和底部的 UI 安全区;
|
|
||||||
4. 不要把地块做得太满,保留参考图那种疏朗感。
|
|
||||||
|
|
||||||
### 11.5 UI
|
|
||||||
|
|
||||||
运行态 UI 只保留必要元素:
|
|
||||||
|
|
||||||
1. 分数;
|
|
||||||
2. 暂停;
|
|
||||||
3. 重新开始;
|
|
||||||
4. 分享;
|
|
||||||
5. 结算按钮。
|
|
||||||
|
|
||||||
不默认展示大段规则说明。首进如果需要引导,只能用一次轻量提示,不允许常驻一屏的说明文案。
|
|
||||||
|
|
||||||
## 12. 视觉规范
|
|
||||||
|
|
||||||
本模板的视觉目标是“像 3D,但仍是 2D 图片”。
|
|
||||||
|
|
||||||
必须遵守:
|
|
||||||
|
|
||||||
1. 平台有明确厚度;
|
|
||||||
2. 侧面可见分层或材质变化;
|
|
||||||
3. 投影统一且方向一致;
|
|
||||||
4. 背景干净,颜色克制;
|
|
||||||
5. 角色尺寸在小屏上依然可读;
|
|
||||||
6. 地块不能出现过多文字、按钮或装饰信息;
|
|
||||||
7. 不能把运行态做成重 UI 面板。
|
|
||||||
|
|
||||||
建议的背景策略:
|
|
||||||
|
|
||||||
1. 以静态浅色渐变或纯色背景为主;
|
|
||||||
2. 不把背景也做成每次都生成的重资产;
|
|
||||||
3. 让地块和角色成为画面的第一视觉焦点。
|
|
||||||
|
|
||||||
## 13. 发布后体验
|
|
||||||
|
|
||||||
发布后的作品必须支持:
|
|
||||||
|
|
||||||
1. 进入作品架和公开展示;
|
|
||||||
2. 分享;
|
|
||||||
3. 试玩;
|
|
||||||
4. 重新进入结果页编辑。
|
|
||||||
|
|
||||||
发布后的卡片封面应优先由角色图和地块图合成,不要求单独再生成封面图。
|
|
||||||
|
|
||||||
首版不新增排行榜、回放和对局对抗。后续如要扩展排行,可另起版本,不要塞进首版模板范围。
|
|
||||||
|
|
||||||
## 14. 验收
|
|
||||||
|
|
||||||
1. 创作入口能看到 `跳一跳` 模板;
|
|
||||||
2. 创作者可以填写主题、角色描述、风格和难度;
|
|
||||||
3. 提交后只生成一次角色图和一次地块图集;
|
|
||||||
4. 结果页能看到角色图、地块图集和路径预览;
|
|
||||||
5. 结果页可单独重生成角色或地块;
|
|
||||||
6. 试玩进入跳一跳运行态;
|
|
||||||
7. 长按蓄力、松手起跳、落点判定、失败和通关都可用;
|
|
||||||
8. 作品可以保存、发布和分享;
|
|
||||||
9. 前端不直接读取或暴露生图密钥;
|
|
||||||
10. 发布后的封面不依赖第三次额外生图。
|
|
||||||
11. `npm run check:spacetime-schema` 在 schema 变更后通过;
|
|
||||||
12. `npm run check:encoding` 通过。
|
|
||||||
|
|||||||
531
docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
Normal file
531
docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
# PlatformUiKit 弹窗与状态组件收口计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
前端已经有 `UnifiedModal` 统一遮罩、无障碍属性、Escape 关闭和移动端底部贴边布局,但业务页面仍反复手写提示弹窗、确认弹窗和 footer 按钮。平台入口的泥点提示、作品删除确认、发布失败提示等都在页面实现内拼接同类 `UnifiedModal` 属性和按钮样式,导致后续调整主题、按钮状态或移动端布局时需要改多个页面。
|
||||||
|
|
||||||
|
## 收口目标
|
||||||
|
|
||||||
|
- `src/components/common/UnifiedModal.tsx` 继续作为底层模态窗口 Module,负责遮罩、panel、header、body、footer 与关闭路径。
|
||||||
|
- 新增 `src/components/common/UnifiedConfirmDialog.tsx` 作为提示 / 确认弹窗 Module,统一承载标题、说明、正文、主按钮、副按钮、危险动作、处理中禁用和主题样式。
|
||||||
|
- 新增 `src/components/common/useCopyFeedback.ts` 作为复制反馈 Module,统一承载剪贴板写入、`idle / copied / failed` 状态、定时复位和卸载清理。
|
||||||
|
- 新增 `src/components/common/CopyFeedbackButton.tsx` 作为复制反馈按钮 Module,统一承载默认复制 / 成功图标、反馈文案、`aria-label` / `title`、纯图标按钮模式和胶囊 action 外观入口。
|
||||||
|
- 新增 `src/components/common/CopyCodeButton.tsx` 作为代码复制按钮 Module,统一承载作品号 / 用户号等短代码 chip 的三态文案、默认可访问名称、标题和胶囊 action 外观透传。
|
||||||
|
- 新增 `src/components/common/CopyFeedbackMessage.tsx` 作为复制反馈提示 Module,统一承载成功 / 失败 toast 或行内状态提示。
|
||||||
|
- 新增 `src/components/common/PlatformStatusMessage.tsx` 作为平台状态提示 Module,统一承载错误、成功、信息和警告提示条的基础边框、底色、文字颜色和默认间距。
|
||||||
|
- 新增 `src/components/common/PlatformEmptyState.tsx` 作为平台空态 / 轻量加载态 Module,统一承载作品架、公开广场和素材选择弹窗的空面板外观。
|
||||||
|
- 新增 `src/components/common/PlatformAssetPickerCard.tsx` 作为平台历史素材选择 Module,统一承载历史图片 / 历史素材的缩略图卡片、读取态、错误态、空态和响应式网格外观。
|
||||||
|
- 新增 `src/components/common/PlatformActionButton.tsx` 作为平台动作按钮 Module,统一承载平台按钮、个人中心主动作按钮和暗色编辑 / 运行面板普通动作按钮的样式族、尺寸、圆角、宽度和禁用态 class。
|
||||||
|
- 新增 `src/components/common/PlatformIconButton.tsx` 作为平台图标动作按钮 Module,统一承载普通 icon button / 图标上传 label / 白底短标签浮动图标按钮的可访问名称、默认 `type="button"`、title 和基础外观。
|
||||||
|
- 新增 `src/components/common/PlatformIconBadge.tsx` 作为平台非交互图标徽章 Module,统一承载弹窗标题、列表项和小卡片里的中性图标槽。
|
||||||
|
- 新增 `src/components/common/PlatformUploadTile.tsx` 作为平台虚线入口 Module,统一承载图片 / 附件上传方块和紧凑虚线动作入口的图标、主副文案、button / label 语义和禁用态。
|
||||||
|
- 新增 `src/components/common/PlatformUploadPreviewCard.tsx` 作为平台上传预览 Module,统一承载上传后的缩略图壳、预览图片、右上角移除按钮和禁用态。
|
||||||
|
- 新增 `src/components/common/PlatformPillSwitch.tsx` 作为平台胶囊开关 Module,统一承载图片面板中类似 AI 重绘的 label + switch 语义、轨道、圆点、禁用态和白底浮层 chrome。
|
||||||
|
- 新增 `src/components/common/PlatformToggleRow.tsx` 作为平台整行开关 Module,统一承载设置面板和结果页配置里的白底 label + checkbox / 状态行。
|
||||||
|
- 新增 `src/components/common/PlatformTextField.tsx` 作为平台输入字段 Module,统一承载白底 / 暗色 input、textarea 和下拉框共用 chrome,认证表单也只保留受控值、原生属性和业务校验。
|
||||||
|
- 新增 `src/components/common/PlatformFieldLabel.tsx` 作为平台字段标签 Module,统一承载结果页、编辑弹窗和创作工作台中的普通字段名、分区标题、表单字段标题、胶囊字段标题和强调胶囊字段标题。
|
||||||
|
- 新增 `src/components/common/PlatformSegmentedTabs.tsx` 作为平台分段选择 Module,统一承载白底结果页 Tab、编辑弹窗二选一和轻量配置 Tab 的容器、按钮、选中态、禁用态、列数、尺寸和截断标签。
|
||||||
|
- 新增 `src/components/common/PlatformStatGrid.tsx` 作为平台统计小卡 Module,统一承载结果页里的数值 / 标签摘要、轻量状态 chip、响应式列数、密度、surface 和 label/value 顺序。
|
||||||
|
- 新增 `src/components/common/PlatformPillBadge.tsx` 作为平台胶囊状态标签 Module,统一承载结果页、作品卡和配置摘要里的单个状态 / 标签 chip。
|
||||||
|
- 新增 `src/components/common/PlatformProgressBar.tsx` 作为平台进度条 Module,统一承载 `progressbar` 语义、`platform-progress-track` 壳、填充宽度、最小可见宽度、未知进度语义、条内覆盖层和局部主题色。
|
||||||
|
- 新增 `src/components/common/PlatformInfoBlock.tsx` 作为平台只读信息块 Module,统一承载弹窗和详情页中的短标签、白底内容壳、单行 / 多行正文排版。
|
||||||
|
- 新增 `src/components/common/PlatformReportDialog.tsx` 作为平台可复制报告弹窗 Module,统一承载来源 / 状态 / 错误这类字段块展示、报告拼装、复制反馈按钮和标准 footer。
|
||||||
|
- 新增 `src/components/common/PlatformSubpanel.tsx` 作为平台白底子面板 Module,统一承载结果页、创作工作台和普通白底面板内的小型列表卡片里的 `platform-subpanel` / flat 外壳、标题行、右侧动作区、圆角、响应式内边距和交互态。
|
||||||
|
- 新增 `src/components/common/PlatformMediaFrame.tsx` 作为平台媒体预览框 Module,统一承载图片源、fallback 图、fallback 文案、固定比例、surface 和可选 overlay。
|
||||||
|
- 新增 `src/components/common/PlatformMediaTileGrid.tsx` 作为平台媒体缩略格网格 Module,统一承载结果页里同尺寸素材 tile 的列数、间距、白底容器、圆角、边框、图片和 fallback 格。
|
||||||
|
- 新增 `src/components/common/PlatformTagEditor.tsx` 作为平台标签编辑 Module,统一承载结果页里的标签 chip、删除、新增输入、Enter / Escape 键盘行为、空态、可选 AI 生成动作和错误提示。
|
||||||
|
- 新增 `src/components/common/PlatformModalCloseButton.tsx` 作为平台弹窗关闭按钮 Module,统一承载个人中心弹窗和平台浮层关闭按钮的尺寸、圆形视觉、默认图标和可访问名称。
|
||||||
|
- 新增 `src/components/common/squareImageCropModel.ts` 作为正方形图片裁剪数学 Module,统一承载居中初始裁剪、尺寸边界和坐标 clamp,头像裁剪和拼图参考图裁剪不再从弹窗组件文件导入 helper。
|
||||||
|
- 平台页面遇到“知道了”“确认 / 取消”“危险确认”这三类弹窗时,优先使用 `UnifiedConfirmDialog`,不再在业务 JSX 中手写 `UnifiedModal` footer。
|
||||||
|
- 带复制反馈的弹窗和详情页优先组合使用 `useCopyFeedback`、`CopyFeedbackButton` 与 `CopyFeedbackMessage`,不再重复写 `useState + setTimeout + clearTimeout` 的复制状态机,也不在业务 JSX 中手写 copied / failed 文案分支。
|
||||||
|
- 白底平台弹窗、详情页、结果页、个人页、认证入口、统一创作工作台和通用创作输入区中的普通错误 / 成功 / 信息 / 警告 / 中性提示条优先使用 `PlatformStatusMessage`,不再在业务 JSX 中重复拼 `border-rose-* / bg-rose-* / text-rose-*`、`border-emerald-* / bg-emerald-* / text-emerald-*`、`platform-banner--danger / success / info / warning / neutral` 或个人页 token 色值 class。
|
||||||
|
- 平台公开列表、作品架、分类列表、素材选择弹窗、RPG 暗色编辑器和 RPG 运行态弹窗 / 面板中的“正在读取 / 暂无内容 / 当前筛选下没有内容 / 还没有配置”等无操作空态优先使用 `PlatformEmptyState`,业务页只传展示内容和必要的 `surface` / `size`,不再重复写 `platform-surface--soft`、虚线空态面板或暗色编辑器 dashed 空态 class。
|
||||||
|
- 平台弹窗、个人中心弹窗、认证入口、公共确认弹窗 footer、统一创作工作台、创作面板和 RPG 暗色弹窗 / 运行面板中的普通主动作 / 次动作按钮优先使用 `PlatformActionButton`,业务页只传 `surface`、`tone`、`size`、`shape`、`fullWidth` 和动作回调,不再重复拼 `platform-button` / `platform-primary-button`、暗色按钮边框 / 底色、圆角、px/py、字号和禁用态 class。
|
||||||
|
- 普通图标动作按钮、图标上传 label 和白底短标签浮动图标按钮优先使用 `PlatformIconButton`,业务页只传 `label`、`icon`、可选 `children`、可选 `title`、`asChild="label"` 和局部尺寸 class,不再重复手写 `platform-icon-button`、浮动白底按钮、`type="button"` 与 aria。平台浮层、个人中心弹窗和资料面板中只承担“关闭当前弹窗”的圆形图标按钮优先使用 `PlatformModalCloseButton`,业务页只传 `label`、`onClick` 和必要的 `variant` / `icon`,不再重复手写 `platform-modal-close`、绝对定位白底关闭按钮或关闭按钮 aria。
|
||||||
|
- 弹窗标题、列表项和小卡片里的非交互中性图标槽优先使用 `PlatformIconBadge`,业务页只传 icon、尺寸和形状,不再重复拼 `grid h-* w-* place-items-center bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]`。
|
||||||
|
- 平台表单和结果页中的方形上传入口、紧凑虚线新增入口优先使用 `PlatformUploadTile`,业务页只传 `label`、`hint`、可选 `icon`、`size`、`showLabel`、`disabled`、`asChild="label"` 或点击回调,不再重复手写虚线边框、图标、提示文案和 hover / 禁用态 class。上传后的方形图片预览优先使用 `PlatformUploadPreviewCard`,业务页只保留文件读取、预览数组和删除回调,不再重复手写缩略图壳、`object-cover` 图片和右上角移除按钮。
|
||||||
|
- 特殊内容弹窗仍可直接使用 `UnifiedModal`,但只有在正文需要复杂网格、媒体预览、渠道按钮或运行态专属布局时才保留自定义 footer。
|
||||||
|
- `UnifiedModal` 补充:平台入口公开编号搜索结果弹层使用 `size="sm"`、`closeLabel="关闭搜索结果"` 和 `closeOnBackdrop={false}`;壳层只保留搜索状态机、命中 / 未命中分支和关闭时清空结果状态,不再手写 overlay、header 和平台 close button 布局。
|
||||||
|
|
||||||
|
## 当前接口
|
||||||
|
|
||||||
|
- `open`:是否展示弹窗。
|
||||||
|
- `title` / `description` / `children`:标题、说明和正文。
|
||||||
|
- `onClose`:关闭弹窗,取消按钮、遮罩和关闭图标共用。
|
||||||
|
- `confirmLabel` / `onConfirm` / `confirmTone` / `confirmDisabled` / `confirmClassName`:主操作按钮;`confirmClassName` 只用于整行按钮、局部主题等外观适配,不让业务页重新手写 footer。
|
||||||
|
- `cancelLabel` / `showCancel` / `cancelDisabled`:副操作按钮。
|
||||||
|
- `busy` / `busyConfirmLabel`:执行中禁用关闭路径,并替换主按钮文案。
|
||||||
|
- `portal`:默认挂到 `document.body`;已有弹窗栈内的二级确认使用 `portal={false}`,避免脱离当前局部遮罩和层级。
|
||||||
|
- `variant`:默认 `platform`;RPG 编辑器内需要像素风确认时使用 `pixel`,不再为简单确认另写专用壳层和按钮。
|
||||||
|
- `overlayClassName` / `panelClassName` / `zIndexClassName`:保留主题和层级 Adapter,不把主题选择写死在组件内。
|
||||||
|
- `useCopyFeedback().copyText(value)`:调用统一剪贴板写入并更新反馈状态。
|
||||||
|
- `useCopyFeedback().copyState`:调用方按 `idle / copied / failed` 渲染文案或图标。
|
||||||
|
- `useCopyFeedback().resetCopyState()`:业务上下文切换时主动清空旧反馈。
|
||||||
|
- `CopyFeedbackButton`:接收 `state`、`idleLabel`、`copiedLabel`、`failedLabel`、三态图标、`showIcon`、`showLabel`、`labelClassName`、`accessibleLabel`、`actionSurface`、`actionTone`、`actionSize`、`actionFullWidth`、`actionAppearance="pill"`、`actionPillTone` 和 `actionPillSize`;文本按钮、chip 按钮和运行态纯图标分享按钮都应走同一 Module。需要平台主按钮外观时通过 `actionSurface="platform"` 或 `actionSurface="profile"` 复用 `PlatformActionButton` 样式,不在业务 JSX 中传整串 `platform-button` class;需要可点击胶囊复制 / 分享 chip 时用 `actionAppearance="pill"` 复用 `PlatformPillBadge` chrome,不在业务 JSX 中传 `platform-pill`。
|
||||||
|
- `CopyCodeButton`:接收 `state`、`code`、`codeLabel`、`copiedSuffix`、`failedSuffix`、`codeClassName`、`suffixClassName`、`actionAppearance="pill"`、`actionPillTone`、`actionPillSize` 和复制按钮透传属性;作品号、用户号等短代码 chip 优先用它,不在业务 JSX 中重复写 `{code} + 已复制 / 复制失败` fragments,也不直接传 `platform-pill` class。
|
||||||
|
- `CopyCodeButton` 补充:作品详情页作品号复制按钮使用 `actionAppearance="pill" actionPillTone="neutralSolid" actionPillSize="sm"`;详情页只保留顶部外边距和复制回调,不再把代码 chip 基础 chrome 写在 `platform-work-detail__code`。
|
||||||
|
- `CopyFeedbackMessage`:接收 `state`、`copiedLabel` 和 `failedLabel`;toast 或行内状态只展示成功 / 失败时使用,不在业务页手写三态分支。若场景需要按成功 / 失败切换状态条色值,可在业务壳层继续使用 `useCopyFeedback` 状态机,并组合 `PlatformStatusMessage` 渲染对应 tone。
|
||||||
|
- `PlatformStatusMessage`:接收 `tone="error" | "success" | "info" | "warning" | "neutral"`、`surface="light" | "tinted" | "platform" | "profile" | "editorDark"`、`size="xs" | "sm" | "md"`、`remapSurface`、`children` 和 `className`;根节点固定带 `platform-status-message` 稳定类名,业务测试可断言公共状态条接入。局部可覆盖圆角、外边距和网格布局,但状态色值、基础内边距和字号由 Module 统一控制。结果页、发布检查、素材生成面板和 creation-agent composer 错误条等需要复用旧 `platform-banner--danger / success / info / warning / neutral` token 外观时使用 `surface="platform"`;需要在局部 platform token 作用域内重映射 CSS 变量的提示条传 `remapSurface`,不在业务 JSX 手写 `platform-remap-surface platform-banner`。个人中心弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区需要 profile token 外观时使用 `surface="profile"`,RPG 暗色编辑 / 运行面板内的普通状态提示使用 `surface="editorDark"`;背包故事档案 QA、NPC 叙事提示、角色聊天错误提示、营地编组战斗中提示、自定义选择弹窗错误 / 生成中提示等暗色状态条已迁移。旧 `platform-profile-error` / `platform-profile-success`、暗色手写 `border-*-300/15 bg-*-500/10 text-*-50/90` 和 `platform-banner--danger / success / info / warning / neutral` 不再作为业务 JSX 接口。
|
||||||
|
- `PlatformStatusMessage` 补充:大鱼吃小鱼结果页发布校验阻断项使用 `tone="warning" surface="platform" size="xs"`;结果页只保留阻断项裁剪和文案,不再手写 amber 文本列表。
|
||||||
|
- `PlatformStatusMessage` 补充:个人中心邀请弹窗里的邀请奖励说明使用 `tone="warning" surface="profile" size="md"`;弹窗只保留奖励文案和两行排版,不再手写 amber 提示块。
|
||||||
|
- `PlatformStatusMessage` 补充:拼图首访 onboarding 的输入错误和登录保存错误使用 `surface="editorDark"`;onboarding 只保留错误文案和条件渲染,不再手写暗色红色错误条。
|
||||||
|
- `PlatformStatusMessage` 补充:平台作品详情页分享复制反馈使用 `surface="platform"` 并按 `shareState` 映射 `success / error`;详情页只保留复制状态机和文案,不再为失败态复用成功 toast chrome。
|
||||||
|
- `PlatformStatusMessage` 补充:creative-agent 首页错误提示使用 `tone="error" surface="platform" size="md"`;首页只保留宽度对齐布局 class 和错误文案,不再手写 danger panel chrome。
|
||||||
|
- `PlatformStatusMessage` 补充:平台入口公开编号搜索未命中结果使用 `tone="neutral" surface="platform" size="md"`;壳层只保留搜索错误文案,不再手写普通文本提示块。
|
||||||
|
- `PlatformRuntimeStatusToast`:接收 `tone="error" | "success" | "info" | "warning" | "neutral"`、`surface="light" | "dark" | "solid"`、`size="xs" | "sm" | "md"`、`shape="pill" | "rounded"`、`children` 和 `className`;根节点固定带 `platform-runtime-status-toast` 稳定类名,默认按 `tone` 写入 `role="alert/status"` 与 `aria-live`。它只承接运行态 HUD 中短错误、成功和反馈 chip 的圆角、字号、阴影、色值和可访问语义,具体浮层位置、玩法资产按钮、计分牌、蓄力提示、强品牌 primary 按钮仍由玩法 runtime 控制。跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态的短错误 / 成功 / 投放反馈已先迁移;后续同类短 toast 不再手写 `rounded-full bg-white/* text-*`、暗色 `border-rose/emerald bg-*/text-*` 或单玩法 `*-runtime-error-chip`。
|
||||||
|
- `PlatformDarkOptionCard`:接收 `selected`、`tone="emerald" | "sky" | "rose" | "amber"`、`radius="sm" | "md" | "lg"`、`padding="sm" | "md" | "lg"`、`children`、`className` 和原生 button props;根节点固定带 `platform-dark-option-card` 稳定类名,统一承接 RPG 暗色弹窗 / 面板中的 selected / idle / hover / disabled 可选项卡按钮外观。NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格、营地编组替换位按钮和角色聊天建议按钮已先迁移;业务页只保留选中判断、点击回调和内容布局,不再重复手写 `rounded-* border px-3 py-*`、`border-*-400/* bg-*-500/10` 或 `border-white/* bg-black/20 hover:border-white/15`。
|
||||||
|
- `PlatformEmptyState`:接收 `surface="soft" | "dashed" | "subpanel" | "editorDark"`、`size="compact" | "panel" | "inline"`、`tone="base" | "soft"`、`children` 和 `className`;根节点固定带 `platform-empty-state` 稳定类名,业务测试可断言公共空态接入。`soft + compact` 用于公开广场、排行和作品架内的轻量空态,`soft + panel` 用于创作中心作品架整块空态,`dashed + panel` 用于素材选择、历史资源等弹窗的大面积空态或读取态,`subpanel + inline` 用于视觉小说 runtime、个人中心充值 / 任务等白底子面板内的无操作空态,`editorDark + compact/inline` 用于 RPG 大编辑器、实体详情弹窗、营地编组、角色聊天和运行态设置弹窗等暗色面板里的纯展示空态 / 禁用提示。组件只承接外观,不内置业务文案。
|
||||||
|
- `PlatformEmptyState` 补充:个人中心存档弹窗和玩过弹窗里的简单“暂无存档 / 暂无玩过”也使用 `surface="subpanel" size="inline"`;玩过弹窗可通过 `tone="base"` 和局部 `text-left` 保留原有白底列表语境,不在业务 JSX 重复写 `rounded-xl bg-zinc-50 px-4 py-* text-sm`。
|
||||||
|
- `PlatformEmptyState` 补充:个人中心钱包账单弹窗里的“暂无账单记录”使用 `surface="subpanel" size="inline"`;业务组件只保留外边距和纵向留白,不再手写白底空态边框、字号和居中文案。
|
||||||
|
- `PlatformEmptyState` 补充:个人中心邀请弹窗里的“已填写邀请码 / 暂无成功邀请”使用 `surface="subpanel"`;业务组件保留面板分支和邀请状态机,不再为无操作提示手写白底空态。
|
||||||
|
- `PlatformEmptyState` 补充:creation-agent 聊天区里的“暂无消息”使用 `surface="subpanel" size="compact"`;工作台保留消息列表滚动容器和文案,不再手写居中空态字号、颜色和高度 class。
|
||||||
|
- `PlatformEmptyState` 补充:大鱼吃小鱼结果页缺少可编辑草稿时使用 `surface="subpanel" size="compact"`;结果页只保留草稿分支和文案,不再为白底无操作提示手写 `PlatformSubpanel` 空面板。
|
||||||
|
- `PlatformEmptyState` 补充:creative-agent 首页抽屉“暂无创作记录”使用 `surface="subpanel" size="inline"`;抽屉只保留分组和历史条目分支,不再手写白底 bordered empty chrome。
|
||||||
|
- `PlatformEmptyState` 补充:creative-agent 工作台消息区“发一句想法,或加一张参考图。”使用 `surface="subpanel" size="compact"`;工作台只保留消息分支和文案,不再为白底空消息面板手写 `PlatformSubpanel` 外壳。
|
||||||
|
- `PlatformEmptyState` 补充:creative-agent 过程面板空态“等待新的创作输入”使用 `surface="subpanel" size="compact"`;过程面板只保留空态分支和文案,非空时继续复用 `PlatformSubpanel` + `PlatformPillBadge` 承接过程列表。
|
||||||
|
- `PlatformTextField`:接收 `variant="input" | "textarea"`、`surface="platform" | "editorDark"`、`size="xs" | "sm" | "md" | "lg"`、`density="default" | "compact" | "roomy"`、`tone="warm" | "rose" | "emerald" | "sky"`、`className` 和原生 input / textarea props;统一承接平台白底与 RPG 暗色弹窗里的圆角输入框、文本域、禁用态、密度、字号 / 行高和焦点色,暗色 surface 根节点固定带 `platform-text-field--editor-dark` 稳定类名。`PlatformSelectField` 复用同一套输入 chrome 承接下拉框。业务页继续持有 `value`、`onChange`、`aria-label`、`rows`、`placeholder`、`option` 等语义,不再重复拼 `rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3`、`rounded-[0.85rem] bg-white/90 px-3 py-2`、`bg-white/90 px-4 py-3`、暗色 `border-white/10 bg-black/30 px-4 py-3` 或 `focus:border-* focus:ring-*`。抓大鹅结果页作品信息、封面描述、素材名称和批量物品名称,方洞结果页主信息表单和形状 / 洞口选项字段,拼图 / 敲木鱼结果页作品信息字段,视觉小说结果页的音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段,以及视觉小说 / 抓大鹅 / 汪汪声浪 / 宝贝识物 / 拼消消 / 跳一跳创作工作台普通输入字段已先迁移;自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述和角色聊天草稿等暗色字段使用 `surface="editorDark"`。通用创作图片输入面板的提示词文本域也使用该 Module,只通过局部 class 保留高度和底部浮动上传按钮避让。认证图形验证码答案、短信 / 密码登录、重置密码、绑定手机号、邀请码和账号安全表单字段,以及个人中心兑换码 / 邀请码输入使用 `surface="platform"`,业务层只保留认证 / 兑换流程、受控值、原生属性和校验提示。
|
||||||
|
- `PlatformTextField` 补充:个人中心昵称弹窗输入框使用 `surface="editorDark" size="lg" density="roomy"`,业务组件保留外层原生 `label` / sr-only “新昵称”、`autoFocus`、`maxLength`、Enter 提交和保存状态;局部 class 只保留暗色弹窗里的 `bg-white/10`、文字色和焦点边框,不再手写 input chrome。
|
||||||
|
- `PlatformTextField` 补充:`PlatformTagEditor` 内部新增标签输入框使用 `density="compact" size="xs"` 复用同一输入 chrome;标签编辑器只保留新增输入状态、解析、Enter / Escape 行为和按钮组合,不再手写输入框边框、白底、字号、焦点色或禁用态。
|
||||||
|
- `PlatformTextField` 补充:creation-agent composer 文本域使用 `variant="textarea" size="md" density="compact"`;工作台只保留受控值、禁用条件、Enter / Shift+Enter 行为和局部布局 class,不再手写 textarea chrome。
|
||||||
|
- `PlatformTextField` 补充:拼图首访 onboarding 提示词文本域使用 `variant="textarea" surface="editorDark" density="roomy" size="lg"`;onboarding 保留受控输入、生成 / 已生成禁用和沉浸式壳层,不再手写 textarea 基础 chrome。
|
||||||
|
- `PlatformTextField` 补充:平台反馈页问题描述和联系电话字段使用 `surface="platform"`;反馈页保留外层原生 `label`、受控值、长度限制和透明嵌入式局部 class,不再手写 textarea / input 基础语义和重复 chrome。
|
||||||
|
- `PlatformFieldLabel`:接收 `variant="field" | "section" | "form" | "inlineForm" | "pill" | "accentPill"`、`children` 和 `className`;`field` 用于视觉小说等结果页的普通字段名,`section` 用于平台白底面板内小标题,`form` 用于创作工作台、通用创作输入面板和认证表单普通字段标题,`inlineForm` 用于模板确认弹层这类行内字段标题,`pill` / `accentPill` 用于汪汪声浪等工作台里的胶囊字段标题。业务页只传字段文案和必要的局部 class,不再重复写 `text-xs font-bold text-[var(--platform-text-soft)]`、`text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]`、`mb-2 block text-sm font-black`、`text-sm font-bold text-[var(--platform-text-base)]`、普通胶囊或 rose 强调胶囊 class。视觉小说结果页、抓大鹅结果页作品 / 封面 / 素材字段标题、方洞结果页主信息 / 形状 / 洞口 / 历史图片字段标题、拼图结果页关卡详情 / 发布弹窗字段标题、拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt、RPG 发布弹窗发布检查 / 封面设置、汪汪声浪轻配置编辑器、宝贝识物工作台、通用创作图片输入面板主图 / 提示词标题,以及认证表单中的手机号 / 验证码 / 密码 / 邀请码标题已先迁移。认证和提示词字段继续保留外层原生 `label` 关联,不把可访问命名交给装饰性标题组件。
|
||||||
|
- `PlatformFieldLabel` 补充:个人中心玩过弹窗内的“可继续 / 玩过”分区标题使用 `variant="section"`;业务组件只传分区文案和 `mb-2 block` 局部布局,不再手写 `text-xs font-black text-zinc-500`。
|
||||||
|
- `PlatformFieldLabel` 补充:个人中心邀请弹窗里的“邀请码 / 成功邀请”小标题使用 `variant="section"`;业务组件只保留必要的居中或深色文本局部 class,不再手写同类小标题字体。
|
||||||
|
- `PlatformFieldLabel` 补充:平台反馈页问题描述和联系电话标题使用 `variant="form"`,并保留外层原生 `label htmlFor` 负责可访问名称;反馈页不再手写字段标题字体和颜色。
|
||||||
|
- `PlatformFieldLabel` 补充:creative-agent 模板确认弹层里的“关卡数”行内标题使用 `variant="inlineForm"`,并继续保留外层原生 `label` 与 `PlatformTextField aria-label="计划关卡数"` 的可访问命名;弹层不再手写紧凑行内字段标题字体。
|
||||||
|
- `PlatformSegmentedTabs`:接收 `items`、`activeId`、`onChange`、`columns="one" | "two" | "three" | "four" | "threeToSix"`、`gap="sm" | "md"`、`radius="md" | "lg" | "xl"`、`size="sm" | "md" | "compact" | "choice" | "tab"`、`surface="default" | "soft" | "transparent"`、`tone="neutral" | "warm" | "rose" | "underline"`、`frame="panel" | "bare"`、`semantics="segment" | "tabs"`、`ariaLabel`、`truncateLabels`、`disabled`、`className` 和 `itemClassName`;普通分段统一写入 `aria-pressed`,Tab 语义统一写入 `role="tablist"` / `role="tab"` / `aria-selected`,并承载禁用阻断、白底选中态、空闲 hover、焦点 ring、响应式列数、裸分段外壳、下划线选中态和 label 截断。拼图结果页、抓大鹅结果页、抓大鹅素材配置、抓大鹅创作 / 结果页难度选择、视觉小说结果页、creative-agent 模板确认弹窗和认证入口短信 / 密码登录切换已先迁移。后续白底结果页 Tab、弹窗分段选择、四选一配置项或认证 / 设置类下划线 Tab 只传选项、当前值和变更回调,不再重复 `grid + border + bg-white/62 + button aria-pressed` 或本地 `role="tab"` 下划线按钮。
|
||||||
|
- `PlatformStatGrid`:接收 `items`、`columns="two" | "three" | "four" | "twoToFour"`、`density="compact" | "default"`、`order="valueFirst" | "labelFirst"`、`surface="soft" | "plain"`、`textAlign="left" | "center"`、`className` 和 `itemClassName`;统一承载平台结果页里的统计小卡、状态 chip、白底摘要卡、label/value 排版和响应式列数。拼消消结果页素材摘要、方洞结果页封面状态 chip、抓大鹅结果页难度摘要、creative-agent 模板确认摘要和自定义世界实体目录世界页档案规模已先迁移,业务页只传统计项数组和少量布局参数,不再重复写 `grid + rounded + bg-white/* + text-xl/text-xs`。
|
||||||
|
- `PlatformPillBadge`:接收 `tone="muted" | "neutral" | "neutralSolid" | "lightOverlay" | "success" | "warning" | "danger" | "cool" | "profile" | "profileAccent" | "darkSoft" | "darkNeutral" | "darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`、`size="xxs" | "xs" | "sm"`、可选 `icon`、`children`、`className` 和原生 span props;统一承接单个状态 / 标签 chip 的圆角、边框、底色、字号和图标间距,并通过 `platformPillBadgeModel.ts` 的 `getPlatformPillBadgeClassName` 给复制类交互按钮复用同一视觉 chrome。`xxs` 用于实体目录卡片等密集元信息 chip,`muted` 用于平台白底柔和选择态和地图节点当前状态,`lightOverlay` 用于主动作按钮内部的泥点消耗等浅色叠层小胶囊,`danger` 用于删除 / 选中危险态,`profile` / `profileAccent` 用于个人中心玫瑰色信息 / 分类 chip,`dark*` 用于 RPG 暗色弹窗和角色详情里的纯展示 chip。宝贝识物结果页发布状态、主题标签与占位资源 overlay,宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 顶部阶段 / 过程计数 / 条目 meta chip、通用音频输入面板限制标签、自定义世界实体目录批量选择 / 生成中 / 开局 CG / 可扮演角色元信息 badge、RPG 首页公开作品卡 / 搜索结果 / 充值商品 / 移动端创建入口 / 桌面发现区 chip、RPG 世界详情静态元信息 chip、RPG 角色身份 / 等级 / 技能出手方式 / 技能详情与状态标签 / 背景故事解锁状态 / 好感等级 / 角色资产工作室动作状态 / 角色编辑技能动作状态 / 角色资源应用状态 / 场景角色选择状态 / 地标当前连接状态 / 地图节点方向标签 / 地图场景切换方向标签 / 营地编组状态数值 / 作品封面来源状态 / 开局物品标签、NPC 交易数量 / 赠礼好感和背包工坊材料需求等暗色展示 chip、抓大鹅批量新增 / 批量重生成物品名称预览 chip、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、拼图图库详情页题材标签、自定义世界作品卡二级 badge、生成失败 chip,以及个人中心泥点账单余额、玩过总时长和玩过作品类型 chip 已先迁移。后续作品卡状态、结果页标签、个人中心轻量信息、按钮内消耗标签和轻量配置 chip 优先使用该 Module;多项数值 / 标签摘要仍使用 `PlatformStatGrid`,可交互标签编辑仍使用 `PlatformTagEditor`,可点击复制 / 分享 chip 使用 `CopyCodeButton` / `CopyFeedbackButton actionAppearance="pill"`。
|
||||||
|
- `PlatformPillBadge` 补充:大鱼吃小鱼结果页 hero 顶部的玩法摘要 chip 使用 `tone="lightOverlay"` 并保留局部 `bg-white/10` 覆盖;hero 只保留 `coreFun / ecologyTheme / levelCount` 文案,不再手写三段 `rounded-full bg-white/10 px-3 py-1` 静态标签。
|
||||||
|
- `PlatformPillBadge` 补充:RPG 实体编辑器基本设定里的拆分标签也使用 `tone="darkSoft"`;这类标签只表达解析后的静态词条,不把可编辑标签输入、删除按钮或点击选择态塞进静态 badge。
|
||||||
|
- `PlatformPillBadge` 补充:`tone="neutralSolid"` 承接无强调、无业务状态色的实心中性胶囊;`PlatformToggleRow mode="status"` 的开启 / 关闭状态已改用该 tone。后续只读开关状态或类似轻量状态值优先复用它,不在业务 JSX 中重复拼 `rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black`。
|
||||||
|
- `PlatformPillBadge` 补充:平台作品详情页的主题标签使用 `tone="neutralSolid" size="sm"`;详情页只保留标签数组映射,不再手写 `platform-work-detail__chip` 的边框、底色、圆角、字号和内边距。
|
||||||
|
- `PlatformProgressBar`:接收 `value`、可选 `minVisibleValue`、`size="xs" | "sm" | "md" | "lg"`、`ariaLabel`、`labelledBy`、`indeterminate`、`className`、`fillClassName`、`fillStyle`、`trackStyle` 和 `children`;内部 clamp 到 0-100,并统一写入 `role="progressbar"`、`aria-valuemin/max/now`、`platform-progress-track`、填充宽度和最小可见宽度。`children` 仅用于条内倒计时、加载图标等覆盖层;没有准确百分比的脉冲占位条使用 `indeterminate`,避免暴露假的 `aria-valuenow`。creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移;后续普通平台进度条只传业务进度值、标签关联、局部主题色和必要覆盖内容,不再重复手写 aria、track/fill div 和 `Math.max(...)` 宽度计算。
|
||||||
|
- `PlatformSubpanel`:接收 `as="section" | "div" | "article" | "aside" | "button"`、`title`、`titleVariant="section" | "strong"`、`actions`、`interactive`、`padding="tight" | "row" | "xs" | "sm" | "md" | "lg" | "none"`、`radius="xs" | "sm" | "md" | "lg" | "xl"`、`surface="platform" | "flat" | "soft" | "dark" | "darkSky" | "darkEmerald" | "darkAmber" | "darkRose" | "danger"`、`className`、`headerClassName`、`titleClassName`、`actionsClassName`、`bodyClassName` 和 `children`;静态 element 透传 `aria-*`、`data-*` 等原生属性,`as="button"` 时透传普通 button 属性并默认 `type="button"`。Module 统一承接平台结果页 / 工作台 / 个人中心子面板外壳、`PlatformFieldLabel variant="section"` 标题、强标题、右侧动作区、内容容器和普通白底列表卡片的 hover / focus / disabled 交互态。`surface="platform"` 复用 `platform-subpanel` token;`surface="soft" + padding="tight"` 用于标签编辑新增输入行等白底柔和紧凑行,`surface="soft" + padding="row"` 用于上传预览横向已选素材条等白底柔和横向行;`surface="danger"` 用于整卡危险选中态;`radius="xl" + padding="lg"` 用于方洞等更大圆角的标准结果页面板;`surface="platform" + radius="xl" + padding="none"` 用于只需要公共边框 / 背景 / 大圆角且内部自带固定比例内容的静态封面壳,`surface="platform" + radius="xl" + padding="sm"` 可用局部 `sm:p-5` 保留物品详情类响应式内容面板;`surface="flat" + radius="sm" + padding="sm"` 用于素材 / 音频 / 排行榜 / 选项编辑 / 局部进度状态等小型白底卡片,`surface="flat" + radius="sm" + padding="none"` 仅用于只包已有图片、图集、角色或路径预览且不需要 fallback / overlay 的白底壳;需要图片源、fallback、固定比例或 overlay 时优先使用 `PlatformMediaFrame`。需要整卡点击或缩略图点击时组合 `as="button" interactive`。拼图结果页作品名称 / 描述 / 标签编辑 / 智能修订条 / 关卡卡片、拼图图库详情页封面轮播壳 / 题材标签 / 关卡摘要、敲木鱼结果页主预览面板 / 作品标题 / 简介 / 主题标签 / 飘字 / 音效、敲木鱼工作台功德词条面板、跳一跳结果页预览 / 操作面板 / 排行榜 / 轻量媒体壳、拼消消创作工作台左侧表单面板、拼消消结果页预览 / 统计 / 操作面板 / 轻量媒体壳、方洞结果页封面 / 主信息 / 形状 / 洞口标准面板、方洞形状 / 洞口选项卡与缩略图按钮、RPG 结果页开发资产诊断摘要 / 条目 / 空态、RPG 个人中心未登录提示、通用音频输入面板、视觉小说创作工作台画风选择面板、视觉小说结果页素材 / 音频小面板、视觉小说结果页作品 / 开场 / 运行配置 / 世界观标准编辑面板、视觉小说 runtime 历史条目 / 存档列表、抓大鹅创作工作台难度小面板、抓大鹅结果页作品 / 难度 / 统计 / UI素材预览标准面板 / 当前难度摘要小卡 / 物品详情五视角面板 / 物品图集分组卡 / 批量素材生成进度卡、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、平台反馈页问题描述 / 上传凭证 / 联系方式区块、自定义世界实体目录世界基调 / 角色维度 / 基本设定条目 / 场景幕级缩略图 / 目录卡片媒体壳 / 目录卡片整卡壳、创作中心作品架加载骨架卡,以及 creative-agent 工作台目录 / 目标就绪 / 空消息 / 过程 / 关卡计划 / 关卡计划小卡 / 模板确认理由面板已先迁移;`PlatformTagEditor` 内部新增输入行使用 `surface="soft" padding="tight"`,`PlatformUploadPreviewCard layout="inline"` 内部横向已选素材条使用 `surface="soft" padding="row"`。后续同类白底面板、白底轻量媒体壳或白底交互列表卡片只传标题、动作、内容、可访问属性和点击回调,不再重复写 `platform-subpanel rounded-[1.25rem] p-4`、`rounded-[1.35rem] p-4 sm:p-5`、`platform-subpanel rounded-[1.5rem] p-4 sm:p-5`、`rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]`、`rounded-[1rem] border ... bg-white/72 p-3`、`rounded-[1rem] border ... bg-white/68 p-2`、`rounded-[1rem] border ... bg-white/68 px-3 py-2`、`rounded-[1.1rem] border ... bg-white/58 p-3`、`rounded-[1rem] border ... bg-white/80`、`hover:bg-white disabled:cursor-not-allowed disabled:opacity-55`、标题行 flex 和 `text-xs font-bold tracking-[0.18em]`。
|
||||||
|
- `PlatformSubpanel` 补充:个人中心玩过弹窗里的已玩作品按钮卡使用 `as="button" surface="flat" radius="sm" padding="md" interactive`,业务组件只保留作品标题 / 副标题 / 类型胶囊 / 作品号 / 最近游玩 / 时长内容和粉色 hover 边框,不再手写白底按钮卡 chrome。
|
||||||
|
- `PlatformSubpanel` 补充:平台入口壳纯 Suspense fallback、作品详情读取 / 错误提示和 Agent 工作区恢复提示使用 `radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳,业务层只保留居中布局、提示文案和局部内边距;生成结果恢复面板使用 `radius="xl" padding="none"` 保留恢复动作与固定内容间距。玩法 runtime overlay 仍保留专用层级语义,后续单独评估。
|
||||||
|
- `PlatformSubpanel` 补充:平台入口公开编号搜索命中用户卡使用 `surface="flat" radius="sm" padding="md"` 和 `titleVariant="strong"`;壳层只保留用户摘要字段和关闭分支,不再手写白底 bordered 搜索结果卡。
|
||||||
|
- `PlatformSubpanel` 补充:RPG runtime 主阶段路由里的平台首页、角色选择和冒险面板懒加载提示使用 `radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳;路由器只保留 Suspense 分流和提示文案,运行态 HUD / overlay 继续保留专用层级语义。
|
||||||
|
- `PlatformSubpanel` 补充:个人中心钱包账单行使用 `as="div" surface="flat" radius="xs" padding="none"`,业务组件只保留来源、时间、收入 / 支出色值、余额右对齐和局部 `px-3 py-3 shadow-sm`;后续同类白底数据行优先从该组合扩展。
|
||||||
|
- `PlatformSubpanel` 补充:个人中心邀请弹窗里的社区二维码卡、邀请码展示卡、成功邀请容器和邀请用户行使用 `surface="flat" | "soft"` 的白底子面板;复制按钮、奖励说明卡和弹窗状态机不并入本轮。
|
||||||
|
- `PlatformSubpanel` 补充:个人中心任务中心里的任务条目使用 `radius="sm" padding="md"` 承接原 `platform-subpanel` 外壳;业务组件只保留任务标题、进度、奖励、状态和领取按钮逻辑。
|
||||||
|
- `PlatformSubpanel` 补充:个人中心充值弹窗里的微信 Native 支付二维码确认面板使用 `radius="sm" padding="md"`;业务组件保留二维码生成、扫码提示和确认支付按钮,不再手写 `platform-subpanel` 外壳。
|
||||||
|
- `PlatformSubpanel` 补充:个人中心充值弹窗里的商品整卡按钮使用 `as="button" interactive radius="sm" padding="none"`;业务组件只保留商品标题、金额、角标、购买状态和下单回调,不再手写 `platform-subpanel` 按钮壳、hover、focus 或 disabled chrome。
|
||||||
|
- `PlatformSubpanel` 补充:发布分享弹窗里的渠道 tile 按钮使用 `as="button" surface="flat" radius="sm" padding="tight" interactive`;弹窗只保留渠道枚举、品牌图标和复制分享文本回调,不再手写白底 tile 的圆角、边框、底色、hover 或 focus chrome。
|
||||||
|
- `PlatformSubpanel` 补充:平台入口创作类型弹层里的玩法卡片使用 `as="button" surface="platform" radius="xl" padding="none"`;卡片只保留玩法图片、锁定态、标题、副标题和选择分流,外层按钮语义、标准圆角和已开放卡 hover / focus chrome 由公共子面板承接。
|
||||||
|
- `PlatformSubpanel` 补充:creation-agent 工作台聊天区外壳使用 `radius="xl" padding="none"`;工作台只保留消息列表、引用图预览、错误提示和输入区语义,不再手写聊天面板外层圆角、边框和底色。
|
||||||
|
- `PlatformSubpanel` 补充:当前 Interface 额外支持 `padding="xs"`、`radius="xs"` 和 `surface="dark"`,用于 RPG 暗色编辑器 / 运行态里的非交互小信息卡。任务目标、区域、进度、描述、角色维度、角色形象状态、自定义选择弹窗当前角色、地图场景切换当前 / 前往摘要、营地编组分区、同行者卡、营地气氛小卡、角色聊天状态和聊天总结这类只展示信息的小卡走该组合;暗色 HUD、动作按钮、可点击卡片和强玩法品牌面板仍保留业务布局。
|
||||||
|
- `PlatformSubpanel` 补充:当前 Interface 额外支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于 RPG 暗色编辑器 / 运行态中带业务色强调的结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板,以及 `CharacterInfoShared.MultiplierContributionList` 状态标签外壳已迁移,后续同类 sky / emerald / amber / rose 暗色信息壳不再手写 `border-*-400/18 bg-*-500/8`。
|
||||||
|
- `PlatformSubpanel` 补充:RPG 大编辑器里的标题型暗色信息块通过本地 `EditorInfoPanel` 适配到 `surface="dark" radius="md" padding="md"`;场景幕角色槽位的当前角色 / 可选角色面板、幕背景预览面板和预设背景面板已迁移。业务 JSX 只保留标题、内容和局部 grid,不再重复拼 `rounded-2xl border border-white/8 bg-black/20 px-4 py-4`。
|
||||||
|
- `PlatformSubpanel` 补充:RPG 队伍面板和实体详情弹窗中的构筑标签效果详情统一由 `CharacterInfoShared.BuildContributionDetailPanel` 承接,标签概览、属性加成明细和无属性明细提示都组合 `surface="dark"` 的公共子面板;业务弹窗只传选中标签行和属性 rows,不再重复手写同一段标签效果 JSX 或 `rounded-2xl border border-white/8 bg-black/20 p-4` / `rounded-xl border border-white/8 bg-black/25 px-4 py-3` 暗色面板 chrome。
|
||||||
|
- `PlatformSubpanel` 补充:实体详情弹窗的技能预览 fallback、伤害 / 法力 / 冷却 / 距离数值卡、技能说明和附带状态标签区使用 `surface="dark" radius="xs"`;实体详情只保留技能数值、文案和状态标签数据,不再重复手写 `rounded-xl border border-white/8 bg-black/20 px-* py-*` 暗色小卡。
|
||||||
|
- `PlatformSubpanel` 补充:宝贝识物工作台玩法预览卡使用 `surface="soft" radius="md" padding="md"`,只通过局部 `className` 保留玩法渐变和装饰层;工作台不再直接手写该类静态白底柔和卡片的边框、圆角和内边距。
|
||||||
|
- `PlatformSubpanel` 补充:creation-agent 无 session / 加载提示块使用 `radius="sm" padding="lg"` 承接普通居中提示面板;工作台只传提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4`。
|
||||||
|
- `PlatformSubpanel` 补充:拼图结果页空草稿提示块使用 `radius="sm" padding="lg"` 承接普通居中提示面板;结果页只传提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4`。
|
||||||
|
- `PlatformMediaFrame`:接收 `src`、`fallbackSrc`、`alt`、`fallbackLabel`、`fallbackContent`、`aspect="auto" | "square" | "standard" | "landscape" | "wide" | "portrait" | "video"`、`surface="warm" | "editorDark" | "plain" | "soft" | "bright" | "none" | "bare"`、`loading`、`refreshKey`、`imageClassName`、`imageProps`、`className`、`fallbackShellClassName`、`fallbackClassName`、`previewOverlay`、`overlayInteractive`、`children` 和原生 `div` 的 `aria-*` / `data-*` 等属性;内部使用 `ResolvedAssetImage`,统一承接图片换签、fallback 图、无图 fallback 文案 / 自定义占位内容、fallback 外壳局部着色、固定比例、圆角、surface 背景和绝对定位 overlay。`standard` 用于 4:3 关卡 / 封面预览,`wide` 用于 9:5 宽图候选预览,`portrait` 用于 9:16 竖版场地底图 / 海报类资产,`soft` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/68` 的白底柔和预览,`bright` 用于素材缩略图等需要 `border border-[var(--platform-subpanel-border)] bg-white/82` 的亮白预览槽,`none` 用于嵌在已有按钮 / 卡片交互壳里的纯图片与 fallback 内容,不抢外层边框、背景和选中态,`bare` 用于外层卡片已经提供边框和圆角的内嵌媒体框。自定义世界实体目录场景图片框、RPG 实体编辑器本地 `ImagePreview`、拼图结果页关卡列表正式图框、拼图发布弹窗封面关卡预览、拼消消结果页场地底图 / 素材图集 / 卡片预览网格、方洞结果页图片查看弹窗预览、方洞结果页封面 / 背景点击预览、方洞结果页形状 / 洞口贴图缩略图、宝贝识物结果页素材卡图片框、视觉小说结果页封面 / 资产字段图片预览、敲木鱼结果页主 9:16 背景 + 敲击物叠层预览、跳一跳结果页地块图集整图预览、大鱼吃小鱼关卡主图缩略图、大鱼吃小鱼素材工坊候选预览、大鱼吃小鱼场地背景竖版预览、creative-agent 模板确认预览,以及抓大鹅结果页物品素材列表缩略图、详情大图、视角缩略图和 UI 素材背景 / spritesheet 主图已先迁移;拼图关卡列表正式图、拼消消场地底图 / 素材图集这类外层白底媒体壳、宝贝识物素材卡顶部媒体槽、视觉小说资产字段、creative-agent 模板目录卡、跳一跳地块图集整图、大鱼关卡主图 / 工坊候选 / 场地背景主题槽、抓大鹅 UI 素材页白底预览壳,以及方洞封面 / 背景点击预览、方洞形状 / 洞口贴图缩略图这类外层按钮已承接渐变、边框、选中或 hover 交互壳的场景,内层统一使用 `surface="none"`;拼图发布弹窗封面关卡和 creative-agent 模板确认预览这类由媒体框自身承接白底柔和槽的场景使用 `surface="soft"`。后续只是“图片 / fallback / 比例 / overlay”的预览框优先使用该 Module;历史素材选择继续使用 `PlatformAssetPickerCard`,上传后预览继续使用 `PlatformUploadPreviewCard`,整块白底面板继续使用 `PlatformSubpanel`。
|
||||||
|
- `PlatformMediaFrame` 补充:组件根节点固定带 `platform-media-frame` 稳定类名,业务测试可断言公共媒体框接入,不再依赖局部 Tailwind 色值作为组件归属判断。
|
||||||
|
- `PlatformMediaFrame` 补充:拼图图库详情页封面轮播的内层正方形图片 / 暂无封面 fallback / 轮播 overlay 已迁移到 `PlatformMediaFrame aspect="square" surface="none"`;外层 `PlatformSubpanel radius="xl" padding="none"` 继续承接面板边框、圆角和裁切。
|
||||||
|
- `PlatformMediaFrame` 补充:RPG 角色形象参考图缩略框和营地编组同行者头像框使用 `surface="editorDark"` 与固定尺寸 class 复用媒体框;这类只展示图片源 / fallback / 圆角边框的缩略框不再在业务 JSX 中手写 `img + overflow-hidden + border`。
|
||||||
|
- `PlatformMediaFrame` 补充:需要运行时计算比例、裁剪或拖拽测量的媒体区域使用 `aspect="auto"`、`ref` 和 `imageProps`,由业务层只传动态 `style`、`draggable` 等图片属性和 overlay 操作层;RPG 作品封面上传裁剪操作区 / 结果预览、角色素材工作室形象预览 / 动作静态预览、场景幕背景预设、技能编辑 fallback 预览、技能列表缩略图和角色编辑顶部形象预览已迁移。后续“图片 + 动态比例 / 不可拖拽 / overlay 操作层”的场景优先扩展 `PlatformMediaFrame`,不在业务 JSX 中重新手写 `ResolvedAssetImage`、固定图片壳和 fallback 文案。
|
||||||
|
- `PlatformMediaTileGrid`:接收 `items`、`columns="five" | "six"`、`gap="xs" | "sm"`、`aspect="auto" | "square"`、`surface="none" | "soft"`、`tileSurface="white" | "slate" | "bare"`、默认 `fallbackLabel`、默认图片 / fallback class 和局部 class;每个 item 接收稳定 `id`、`src`、`alt`、`refreshKey`、`fallbackLabel`、`fallbackContent`、`testId` 与局部 class。tile 的边框 / 底色 / 阴影统一由 `tileSurface` 承接,内部 `PlatformMediaFrame` 使用 `surface="none"`,避免重复叠加公共媒体框底色。跳一跳结果页地块池、跳一跳无图集 fallback 地块池、拼消消结果页卡片预览网格和抓大鹅物品 spritesheet 解析预览分组已先迁移。后续结果页只是展示一组同尺寸正方形素材 tile 时优先使用该 Module;单张大图预览继续用 `PlatformMediaFrame`,历史素材选择继续用 `PlatformAssetPickerGrid`,上传预览继续用 `PlatformUploadPreviewCard`。
|
||||||
|
- `PlatformTagEditor`:接收 `title`、`tags`、`disabled`、`maxTags`、`error`、`addLabel`、`generateLabel`、`inputLabel`、`inputPlaceholder`、`emptyLabel`、`parseInput`、`onChange`、可选 `onGenerate` / `generateIcon`、`radius`、`padding` 和 `tone="amber" | "warm"`;内部持有新增输入态,统一处理标签去重、添加、删除、Enter 提交、Escape 取消、空态、可选 AI 生成按钮和错误提示。拼图结果页作品标签、敲木鱼结果页主题标签和抓大鹅结果页作品标签已先迁移。后续结果页只保留业务标签规范化函数和写回回调,不再重复手写 tag chip、删除按钮、输入框、添加 / 取消按钮和 AI 生成按钮。
|
||||||
|
- `PlatformTagEditor` 补充:新增输入行外壳继续由 `PlatformSubpanel surface="soft" padding="tight"` 承接,输入框由 `PlatformTextField` 承接;标签编辑 Module 内部也遵守公共输入 / 子面板分工,不再把白底 input chrome 写成本地 class。
|
||||||
|
- `PlatformAssetPickerCard`:接收 `imageSrc`、`imageAlt`、可选 `assetTitle` / `subtitle`、`surface="platform" | "editorDark"`、`selectLabel`、`selected`、`disabled`、`onClick`、`aria-label`、`cardRadiusClassName`、`imageShellClassName`、`imageClassName` 和 `bodyClassName`;图片读取统一走 `ResolvedAssetImage`,按钮禁用态、选中态、边框、hover、缩略图外壳和可选卡片内选择按钮由 Module 统一控制,`assetTitle` 专指卡片内展示标题,不占用原生 button `title` 属性。`PlatformAssetPickerGrid`:接收素材数组、读取 / 错误 / 空态、`getKey`、`getImageSrc`、`getImageAlt`、`getTitle`、`getSubtitle`、`getAriaLabel`、`isSelected`、`cardClassName` 和 `onSelect`;默认组合 `PlatformStatusMessage`、`PlatformEmptyState` 与 `PlatformAssetPickerCard`,业务页只保留素材字段映射、文案、选中判断和选择回调,不再重复手写缩略图卡片、选中 ring、虚线读取 / 空态和网格 JSX。白底平台弹窗使用默认 `platform` surface;RPG 大编辑器等暗色弹窗使用 `editorDark`,并通过 `imageShellClassName` 保留场景横图比例。视觉小说等同一弹窗里混有上传 / AI 生成错误时,可继续由外层错误条承接动作错误,只把历史素材读取 / 空态 / 网格交给 `PlatformAssetPickerGrid`。
|
||||||
|
- `PlatformActionButton`:接收 `tone="primary" | "secondary" | "ghost" | "danger" | "success" | "warning" | "accent" | "accentSoft"`、`surface="platform" | "profile" | "editorDark"`、`size="xxs" | "xs" | "sm" | "md" | "lg"`、`shape="default" | "pill"`、`align="center" | "start"`、`fullWidth`、`children` 和原生 button props;`surface="platform"` 复用 `platform-button` 样式族,`surface="profile"` 的主按钮复用个人中心 `platform-primary-button`,`surface="editorDark"` 统一承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,`tone="accent"` 承接琥珀实心 CTA,`tone="accentSoft"` 承接带局部 accent 变量的柔和强调按钮,根节点固定带 `platform-action-button--accent` / `platform-action-button--accent-soft` 稳定类名。认证表单的 48px 高按钮使用 `size="lg"`,暗色微型刷新 / 工具动作使用 `size="xxs" shape="pill"`,需要文件上传等 label 语义时使用 `asChild="label"` 复用同一套按钮外观,不把上传控件改成普通 button。推荐回复、列表内动作等需要左对齐时使用 `align="start"`,不要在业务 JSX 中重复写 `justify-start text-left`;创作中心错误重试、反馈页 header 返回和暗色次要动作等普通 ghost 动作同样走 `tone="ghost"` 与 `shape="pill"`,不在业务 JSX 中直接拼按钮 class。复制按钮仍使用 `CopyFeedbackButton`,可选项按钮卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。
|
||||||
|
- `PlatformActionButton` 补充:反馈页内的“查看反馈与投诉记录”这类页面内次级文本动作使用 `tone="ghost" shape="pill" size="xs"`;业务组件只保留点击反馈,不再手写居中、字号、内边距和冷色文本按钮 class。
|
||||||
|
- `PlatformActionButton` 补充:作品详情底部“作品改造 / 作品编辑”和“启动”使用 `surface="platform" shape="pill" size="lg" fullWidth`,保留 `platform-work-detail__remix / start` 局部 class 控制 sticky 底部栏位置、比例和品牌背景。
|
||||||
|
- `PlatformActionButton` 补充:作品详情点赞按钮使用 `tone="accentSoft"` 并通过局部 `--platform-action-accent` 变量复用柔和强调 chrome;详情页只保留纵向排布、尺寸和可访问名称,不再手写点赞按钮边框、底色、文字和阴影。
|
||||||
|
- `PlatformActionButton` 补充:创作中心作品卡积分激励的“领取积分 / 领取中”按钮使用 `tone="secondary" size="xxs"`;作品卡保留 `creation-work-card-incentive__button` 局部 class 控制三列布局、移动端跨列、紧凑高度和玻璃底,不再手写原生按钮 chrome。
|
||||||
|
- `PlatformActionButton` 补充:拼图首访 onboarding 生成 / 登录 CTA 使用 `surface="editorDark" tone="accent" size="lg" fullWidth`,跳过按钮使用 `surface="editorDark" tone="ghost" shape="pill"` 并只保留右上角定位 class;首访页不再手写按钮基础 chrome。
|
||||||
|
- `PlatformIconButton`:接收 `label`、`icon`、可选 `children`、可选 `variant="platformIcon" | "surfaceFloating" | "darkMini"`、`title`、`className`、`asChild="label"` 和原生 button / label props;默认 `platformIcon` 用于平台弹窗 header、搜索结果弹窗、工具栏、结果页选项删除等普通图标动作按钮,也用于保持 file input 原生语义的图标上传 label;`surfaceFloating` 用于通用创作图片面板里覆盖在图片或输入区上的白底圆形图标动作,短文案入口通过 `children` 渲染可见短标签但仍由 `label` 提供可访问名称;`darkMini` 用于上传预览卡右上角等覆盖在缩略图上的暗色小型图标动作。creation-agent composer 中的上传文档 / 上传参考图入口使用默认 `platformIcon`,只保留动态 label、title、busy 和 picker 回调;作品详情顶部返回 / 分享与封面轮播上一张 / 下一张入口也使用默认 `platformIcon`,并通过局部 class 保留详情页专属位置和尺寸。发送按钮、点赞按钮、带复制三态或强品牌动作继续保留专用布局。关闭语义复杂或属于个人中心 / 浮层关闭按钮时仍优先使用 `PlatformModalCloseButton`,带复制三态时使用 `CopyFeedbackButton`。同一面板内存在主图上传和提示词参考图上传时,两个 file input 必须使用不同可访问名称,避免业务测试或读屏用户只能看到多个同名“上传参考图”入口。
|
||||||
|
- `PlatformIconBadge`:接收 `icon`、可选 `label`、`size="xs" | "sm" | "base" | "md" | "lg" | "xl" | "xxl"`、`shape="circle" | "rounded" | "xl"`、`tone="neutral" | "soft" | "softBright" | "hero" | "heroMuted" | "darkAmber" | "success" | "danger"` 和 `className`;统一承接非交互图标槽的中性 / 柔和 / hero / 暗色琥珀 / 成功 / 危险底色、文字色、尺寸、圆角和 `aria-hidden` / `aria-label`。根节点固定带 `platform-icon-badge` 稳定类名,业务测试可断言共享图标槽接入。视觉小说 runtime 面板标题、存档列表项,creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标,大鱼吃小鱼发布失败弹窗图标槽,通用创作图片面板空主图上传占位图标槽,拼图结果页智能修订条图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移。后续同类图标槽不再重复手写 `grid h-* w-*`、`inline-flex h-* w-* items-center justify-center`、`rounded-full`、`rounded-[0.85rem]`、`rounded-2xl`、neutral token class、白底柔和小圆槽、暗色琥珀图标槽或危险提示红色圆槽。
|
||||||
|
- `PlatformIconBadge` 补充:宝贝识物工作台玩法预览卡内礼物图标槽使用 `size="xl" shape="rounded" tone="softBright"`,业务页只保留玩法色和投影覆盖,不再手写 `grid h-14 w-14 place-items-center rounded-* bg-white/*`。
|
||||||
|
- `PlatformIconBadge` 补充:个人中心充值结果弹窗和支付确认遮罩里的 56px 圆形图标槽使用 `size="xl"` 并通过局部 `bg-white/10`、状态文字色 class 覆盖;弹窗只保留支付结果文案、支付状态图标和确认动作,不再手写 `flex h-14 w-14 items-center justify-center rounded-full bg-white/10` 图标容器。
|
||||||
|
- `PlatformUploadTile`:接收 `label`、可选 `hint`、`icon`、`size="square" | "compact" | "panel"`、`surface="platform" | "editorDark"`、`showLabel`、`disabled`、`className`、`asChild="label"` 和原生 button / label props;默认渲染 `type="button"` 的平台虚线上传方块,`compact + showLabel={false}` 用于工作台里的纯图标虚线新增入口,`panel` 用于整行上传说明入口,`editorDark` 用于 RPG 大编辑器等暗色弹窗。label 模式保留 file input 原生关联语义,禁用时写入 `aria-disabled` 并阻断 label 默认点击。反馈页上传凭证、敲木鱼工作台新增功德词条入口、RPG 大编辑器参考图入口、角色素材工作室参考图入口和封面上传入口已迁移,后续图片 / 附件上传方块或紧凑虚线新增入口只保留业务选择文件 / 新增动作,不再重复写虚线入口 chrome。
|
||||||
|
- `PlatformUploadPreviewCard`:接收 `imageSrc`、`imageAlt`、`removeLabel`、可选 `layout="square" | "inline"`、`surface="platform" | "editorDark"`、`caption`、`previewLabel`、`onPreview`、`onRemove`、`disabled`、`resolveAsset`、`imageRefreshKey`、`className`、`imageClassName`、`imageShellClassName`、`captionClassName`、`previewButtonProps`、`removeIcon` 和 `removeButtonProps`;默认 `square` 渲染平台缩略图壳、`object-cover` 预览图、可选标题行和可选移除按钮,square 右上移除按钮复用 `PlatformIconButton variant="darkMini"`,`inline + platform` 通过 `PlatformSubpanel surface="soft" padding="row"` 渲染白底横向已选素材条,`inline + editorDark` 通过 `PlatformSubpanel surface="dark" padding="row"` 渲染暗色编辑器横向参考图条。需要点击预览的参考图传 `previewLabel/onPreview`,需要 generated / OSS 资产换签的缩略图传 `resolveAsset`,需要展示文件名 / 素材名的参考图传 `caption`,不要在业务 JSX 中额外包一层缩略图标题栏或横向参考图条。反馈页上传凭证预览、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer、creation-agent 已选参考图条、拼图结果页关卡引用图横条和 RPG 大编辑器参考图预览条已迁移,后续上传预览只保留素材数据、预览回调和删除回调,不在业务 JSX 中重复写预览卡 chrome。
|
||||||
|
- `PlatformPillSwitch`:接收 `label`、`checked`、`disabled`、`className` 和原生 input props;内部固定 `role="switch"`、`type="checkbox"` 和 `sr-only` 输入,视觉层统一白底胶囊、开关轨道、圆点位置、hover / 禁用态。通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已迁移,后续同类胶囊开关只传受控 checked / onChange,不再手写 switch 轨道和圆点。
|
||||||
|
- `PlatformToggleRow`:接收 `label`、`checked`、`onChange`、`disabled`、`mode="checkbox" | "status"`、`icon`、`onLabel`、`offLabel`、`onClick`、`surface="soft" | "plain"`、`className` 和 `labelClassName`;`checkbox` 模式用于结果页运行配置和角色可见性,`status` 模式用于 runtime 设置面板的只读开关状态,可选 `onClick` 时自身渲染为 button。视觉小说结果页运行配置 / 玩家可见开关、视觉小说 runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移,业务页不再重复手写 `flex min-h-12 ... bg-white/74 px-3`、checkbox class 或“开启 / 关闭”状态 pill。
|
||||||
|
- `PlatformInfoBlock`:接收可选 `label`、`children`、`multiline`、`className`、`labelClassName` 和 `valueClassName`;统一承载平台弹窗 / 详情页中的短标签、无标签只读正文、白底圆角边框、内容换行、单行加粗排版和横向只读信息行的标签 / 值局部排版。错误弹窗与生成完成弹窗的来源、错误、状态块、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已先迁移,后续同类只读信息展示只传 label、内容和必要局部排版 class,纯正文块可省略 label,不在业务 JSX 中重复写 `rounded-[1rem] border ... bg-white/72 px-3 py-2`、`rounded-[1.25rem] border ... bg-white/72 p-4` 或 `rounded-[0.85rem] bg-white/74 px-* py-*`。
|
||||||
|
- `PlatformInfoBlock` 补充:当前 Interface 支持 `variant="compactRow"` 承接预览卡里的密集横向 label / value 行,标签、值、圆角、白底和响应式内边距由公共组件控制;汪汪声浪预览卡四个信息行已去掉本地 `PREVIEW_INFO_*` class 常量。
|
||||||
|
- `PlatformModalCloseButton`:接收 `label`、`variant="profile" | "profileCompact" | "floating" | "floatingPlain" | "platformIcon" | "pixel" | "editorDark"`、`icon` 和原生 button props;`profile` 复用个人中心 `platform-modal-close` 圆形按钮,`profileCompact` 复用个人中心小弹窗 `platform-profile-icon-button` 关闭按钮,`floating` 复用平台浮层右上角白底关闭按钮,`floatingPlain` 复用个人中心邀请 / 社区浮层的透明右上角关闭按钮,`platformIcon` 复用平台弹窗头部 `platform-icon-button` 关闭入口,`pixel` 复用 `UnifiedModal variant="pixel"` 的像素风圆形关闭入口,`editorDark` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口并固定带 `platform-modal-close-button--editor-dark` 稳定类名。认证入口、邀请码弹窗等平台弹窗头部关闭按钮使用 `variant="platformIcon"`,像素风 `UnifiedModal` 使用 `variant="pixel"`,自定义选择弹窗使用 `variant="editorDark"`,业务页可以追加局部 class,但不重新声明基础尺寸、圆角、默认图标和 `aria-label`。
|
||||||
|
- `squareImageCropModel`:导出 `SquareImageCropRect`、`buildCenteredSquareImageCropRect(imageSize)` 和 `clampSquareImageCropRect(imageSize, crop)`;可复用裁剪数学留在 model,`SquareImageCropModal` 只承接弹窗 UI、拖拽交互和提交动作。
|
||||||
|
|
||||||
|
## 迁移顺序
|
||||||
|
|
||||||
|
1. 先迁移平台入口壳中的泥点提示和作品删除确认,验证普通提示与危险确认两个分支。
|
||||||
|
2. 迁移 `PlatformErrorDialog`、`PlatformTaskCompletionDialog`、`PublishShareModal` 的复制反馈到 `useCopyFeedback` 与 `CopyFeedbackButton`,验证成功、失败和上下文切换复位。
|
||||||
|
3. 迁移公开作品详情、RPG 作品详情、拼图广场详情、大鱼 runtime 分享和账号个人资料区中的作品号 / 用户号复制与分享复制状态;短代码 chip 使用 `CopyCodeButton`,分享按钮继续按场景使用 `CopyFeedbackButton` 或 `CopyFeedbackMessage`,避免页面继续散落 `copyState / shareState + setTimeout` 或三态按钮 JSX。
|
||||||
|
4. 再迁移结果页、工作台和账号区域中只有单个确认按钮或确认 / 取消按钮的简单弹窗;拼图结果页关卡画面生成、抓大鹅结果页物品素材生成 / 重新生成的“确认消耗泥点”已使用 `UnifiedConfirmDialog` 的内嵌渲染模式,拼图 / 抓大鹅创作工作台的初始泥点确认已使用默认 portal 模式,大鱼吃小鱼结果页发布失败提示已通过 `confirmClassName` 保持整行确认按钮外观。
|
||||||
|
5. 自定义世界实体目录的删除确认、批量删除确认和“至少保留一个可扮演角色”提示统一使用 `UnifiedConfirmDialog`,不再调用浏览器原生 `window.confirm` / `window.alert`。
|
||||||
|
6. RPG 结果页整页重新生成确认由页面层使用 `UnifiedConfirmDialog` 承接,`useRpgCreationResultActions` 只保留执行命令和忙碌态保护,不再在 hook 内调用浏览器原生确认框。
|
||||||
|
7. RPG 详情页删除确认由平台壳的共享作品删除弹窗承接;`useRpgEntryLibraryDetail` 只保留已确认后的删除命令、刷新和阶段回退,不再直接调用浏览器原生确认框。
|
||||||
|
8. RPG 角色素材工作室的形象 / 动作泥点消耗确认使用 `UnifiedConfirmDialog portal={false}` 内嵌在工作室弹窗栈内;点击生成只打开确认,确认后再执行生成工作流。
|
||||||
|
9. RPG 场景编辑器中的多幕数量、连接关系、主角色、幕预览和角色槽位阻断提示统一使用基于 `UnifiedConfirmDialog` 的编辑器提示弹窗,不再调用浏览器原生 `window.alert`。
|
||||||
|
10. RPG 可扮演角色 / 场景角色的背景章节删除阻断提示由角色编辑器壳层承接,背景章节编辑控件只上报 `onNotice`,不直接调用原生弹窗。
|
||||||
|
11. RPG 编辑器关闭未保存草稿时使用 `UnifiedConfirmDialog` 统一承接“确认关闭 / 继续编辑”,不再维护单独的关闭确认按钮样式。
|
||||||
|
12. RPG 场景背景和作品封面生成结果未保存时,退出确认也使用 `UnifiedConfirmDialog`;像素风场景生成弹窗通过 `variant="pixel"` 适配视觉。
|
||||||
|
13. 公开作品详情或运行态深链失效时,由平台入口壳展示 `UnifiedConfirmDialog` 的“作品不可用”提示;用户确认后再回到首页,错误处理分支不再调用浏览器原生 `window.alert`。
|
||||||
|
14. 带复杂内容的专用 Module 可以保留自己的布局,但复制反馈仍应复用 `useCopyFeedback`;如果有可点击复制按钮,优先复用 `CopyFeedbackButton`;如果只展示复制结果提示,优先复用 `CopyFeedbackMessage`。
|
||||||
|
15. 白底平台弹窗、详情页、结果页、目录页、个人页、认证入口、统一创作工作台和通用创作输入区的基础错误 / 成功 / 信息 / 警告 / 中性状态提示逐步迁移到 `PlatformStatusMessage`;RPG 结果页、拼图结果页、抓大鹅结果页、跳一跳结果页、敲木鱼结果页、拼消消结果页、宝贝识物结果页、方洞结果页、汪汪声浪结果页、视觉小说结果页、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creative-agent 工作台、creation-agent operation banner、自定义世界实体目录、拼消消 runtime 白底错误条和平台作品详情分享复制反馈已使用 `surface="platform"` 承接发布检查、错误提示、进度提示、素材生成提示、资源未就绪提示、主线目标提示和复制反馈;个人中心、认证入口、统一创作工作台和创作输入区需要 profile token 外观时使用 `surface="profile"`;RPG 暗色编辑 / 运行面板和拼图首访 onboarding 里的普通错误 / 成功 / 信息 / 警告 / 中性提示使用 `surface="editorDark"`,背包故事档案 QA 提示、NPC 交易 / 赠礼 / 招募叙事提示和角色聊天错误提示已先迁移。运行态里的短错误 / 成功 / 命中反馈 chip 使用 `PlatformRuntimeStatusToast`,位置和玩法强品牌 HUD 仍留在 runtime 壳层;深色半透明游戏内提示和强品牌样式可以暂保留专用布局,避免状态条组件过早承接游戏视觉。
|
||||||
|
16. 正方形图片裁剪的初始居中、边界 clamp 和裁剪矩形类型统一从 `squareImageCropModel` 导入,避免头像裁剪、拼图参考图裁剪等业务页面依赖弹窗组件文件里的 helper。
|
||||||
|
17. 个人中心的账户充值、泥点账单、每日任务、兑换码、扫码、存档、玩过作品、邀请 / 社区、昵称修改、头像裁剪,以及平台筛选、创作图片预览、认证入口、邀请码弹窗、公开编号搜索结果弹窗、方洞结果页图片素材弹窗、视觉小说结果页资产 / 音频 / 编辑器弹窗、视觉小说 runtime 普通面板、creative-agent 模板确认弹窗、像素风 UnifiedModal 和自定义选择弹窗等圆形关闭按钮迁移到 `PlatformModalCloseButton`;后续新增弹窗关闭按钮先判断是否属于 `profile`、`profileCompact`、`floating`、`floatingPlain`、`platformIcon`、`pixel` 或 `editorDark` 七类,确有品牌化或运行态 HUD 语义时才保留专用按钮。
|
||||||
|
17.1. 平台弹窗 header 和普通工具栏里的 `platform-icon-button` 迁移到 `PlatformIconButton`;历史图片选择弹窗、RPG 发布检查弹窗、creative-agent 侧边栏关闭 / 外观 / 设置入口、通用输入 Composer 上传 / 发送 / 移除参考图、creation-agent composer 上传文档 / 上传参考图、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、敲木鱼创作工作台功德词条删除入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口、RPG 首页搜索结果清空入口、方洞结果页形状 / 洞口选项删除入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移。结果页内的普通平台弹窗关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`;图标上传控件使用 `PlatformIconButton asChild="label"` 保留 label + file input 语义,不改成普通按钮;`PlatformIconButton` 的 label 模式会自动写入隐藏文本,保证内嵌 file input 仍能继承可访问名称。通用创作图片面板中覆盖在图片上的更换主图、移除主图、历史入口短标签按钮和提示词参考图上传入口,抓大鹅封面编辑中覆盖在封面图上的移除入口,以及敲木鱼创作工作台功德词条删除入口使用 `PlatformIconButton variant="surfaceFloating"`,不再手写白底圆形 / 短标签浮动按钮 chrome。运行态 HUD、带复制状态或需要专用交互禁用语义的图标按钮,先保留专用布局,等对应场景验证时再迁移。
|
||||||
|
17.2. 非交互图标徽章迁移到 `PlatformIconBadge`;视觉小说 runtime 面板标题、存档列表项,creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标,大鱼吃小鱼发布失败弹窗图标槽,通用创作图片面板空主图上传占位图标槽,拼图结果页智能修订条图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移。后续同类图标槽只表达 icon、尺寸、形状和 neutral / soft / softBright / hero / heroMuted / darkAmber / success / danger 调性,不再重复中性、白底柔和、hero 叠层、暗色琥珀、成功或危险底色、文字色、居中和 shrink class。
|
||||||
|
17.3. RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口迁移到 `PlatformModalCloseButton variant="platformIcon"`;暗色编辑器仍保留原 `platform-icon-button` 视觉 token,但业务 JSX 不再手写 `button`、`aria-label` 和默认关闭图标。
|
||||||
|
18. RPG 首页、公开广场、排行、作品架、个人中心充值 / 任务弹窗、视觉小说 runtime 普通白底面板、历史素材选择弹窗、视觉小说上传资产弹窗本地上传占位、自定义世界实体目录搜索无结果、大鱼吃小鱼结果页缺草稿提示、RPG 大编辑器纯展示暗色列表、背景故事空档案和 RPG 运行态设置保存禁用提示中的无操作空态 / 轻量读取态迁移到 `PlatformEmptyState`;后续空态如果包含 CTA、插画、复杂列表恢复动作或玩法 HUD,再保留专用布局。
|
||||||
|
18.1. 历史图片 / 历史素材 / 可引用素材选择迁移到 `PlatformAssetPickerCard` 与 `PlatformAssetPickerGrid`;拼图历史图片弹窗、方洞历史生成、视觉小说历史素材选择器、RPG 大编辑器历史素材弹窗和抓大鹅封面编辑可引用素材网格已先迁移。后续素材选择只传素材数组、`imageSrc`、主副文案、可访问名称、surface、选中判断和选择回调,不再在业务页重复缩略图、边框、选中 ring、禁用态、`ResolvedAssetImage` 壳层、虚线读取 / 空态和网格 JSX。
|
||||||
|
18.2. 平台白底圆角输入框和文本域迁移到 `PlatformTextField surface="platform"`;RPG 暗色弹窗 / 运行面板里的普通输入框、文本域和下拉框迁移到 `PlatformTextField surface="editorDark"` / `PlatformSelectField surface="editorDark"`;抓大鹅结果页作品名称 / 描述、封面描述、素材名称、批量新增 / 批量重生成物品名称,方洞结果页游戏名称、标签、简介、题材主题、反差规则、背景提示、形状数量、形状 / 洞口名称、形状目标洞口和图片提示词,拼图结果页作品名称 / 描述、关卡名称和智能修订输入,敲木鱼结果页作品标题 / 简介,视觉小说结果页的音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段,以及视觉小说 / 抓大鹅 / 汪汪声浪 / 宝贝识物 / 拼消消 / 跳一跳创作工作台普通输入字段、敲木鱼创作工作台功德词条输入、creative-agent 模板确认调整弹层关卡数输入已先迁移。通用输入 Composer、通用创作图片输入面板的提示词文本域、自定义世界实体目录搜索框、认证验证码答案输入、短信 / 密码登录、重置密码、绑定手机号、邀请码、账号安全表单字段、个人中心兑换码 / 邀请码输入、自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述、角色聊天草稿、拼图首访 onboarding 提示词文本域和平台反馈页问题描述 / 联系电话也使用 `PlatformTextField` / `PlatformSelectField`;浮动胶囊 Composer 可继续由 `.creative-agent-composer--floating textarea` 覆盖尺寸和背景,图片输入面板可通过局部 class 保留高度与浮动上传按钮避让,实体目录搜索框可通过局部 class 保留紧凑圆角和底色,验证码答案输入和认证表单字段可通过局部 class 保留表单高度、横向验证码按钮布局和原生 `label` 关联,个人中心兑换码 / 邀请码输入通过局部 class 保留大写和居中,暗色聊天草稿和首访提示词文本域可通过局部 class 保留沉浸式底色 / 高度,反馈页字段可通过局部 class 保留透明嵌入式视觉,不在业务 JSX 中手写 textarea / input / select chrome。默认密度用于结果页主表单,`density="compact"` 用于选项卡片、工具条、认证提示内或反馈页联系电话的紧凑字段,`density="roomy"` 用于宽内边距文本域、关卡详情字段、首访提示词文本域或反馈页问题描述;默认 `tone="warm"`,玩法需要保留调性焦点色时使用 `tone="rose"`、`tone="emerald"` 或 `tone="sky"`,不要在业务 JSX 中重复写 `focus:border-* focus:ring-*`。后续结果页、工作台、目录工具条、认证提示、认证表单、个人中心轻量表单、反馈表单、首访页或 RPG 暗色弹窗内的普通文本输入 / 下拉框只传受控值、事件、可访问名称、占位符、选项和局部布局 class,不再重复基础边框、背景、内边距、字号、禁用态和焦点色。
|
||||||
|
18.2.1. 个人中心昵称弹窗输入框迁移到 `PlatformTextField surface="editorDark"`;昵称状态机、校验、保存和弹窗壳层不随输入框 chrome 收口改动。
|
||||||
|
18.3. 平台字段标签迁移到 `PlatformFieldLabel`;视觉小说结果页、抓大鹅结果页作品 / 封面 / 素材字段标题、方洞结果页主信息 / 形状 / 洞口 / 历史图片字段标题、拼图结果页关卡详情 / 发布弹窗字段标题、拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt、RPG 发布弹窗发布检查 / 封面设置、汪汪声浪轻配置编辑器、宝贝识物工作台、通用创作图片输入面板主图 / 提示词标题,以及认证登录 / 绑定 / 邀请码 / 账号安全表单标题、平台反馈页问题描述 / 联系电话标题已先迁移。后续结果页、编辑弹窗、工作台、通用创作输入面板、反馈表单或认证表单中只表达字段名称的小标题,优先选择 `field` / `section` / `form` / `pill` / `accentPill`,不要在业务 JSX 中重复拼字段标题 class;认证表单、反馈表单和提示词字段保留外层原生 `label`,带品牌化插画、运行态 HUD 或复杂步骤标题时可暂保留专用标题。
|
||||||
|
18.3.1. 个人中心存档 / 玩过弹窗里的简单空态、分区标题和已玩作品白底按钮卡分别迁移到 `PlatformEmptyState`、`PlatformFieldLabel` 与 `PlatformSubpanel`;`SaveArchiveCard` 带图片遮罩和加载视觉,仍保留专用实现,后续需要单独视觉验收后再决定是否收口。
|
||||||
|
18.3.2. 平台入口壳中的纯 Suspense fallback、作品详情读取 / 错误提示、Agent 工作区恢复提示、RPG runtime 主阶段懒加载提示和 `CreationResultRecoveryPanel` 外壳迁移到 `PlatformSubpanel`;加载 / 错误提示使用 `radius="sm" padding="none"`,带恢复动作的结果恢复面板使用 `radius="xl" padding="none"`,玩法 runtime overlay 后续单独评估。
|
||||||
|
18.3.3. 个人中心钱包账单弹窗里的空态和账单行分别迁移到 `PlatformEmptyState` 与 `PlatformSubpanel`;账单展示只保留收支内容、余额和时间,不在业务 JSX 重复白底列表行 chrome。
|
||||||
|
18.3.4. 个人中心邀请弹窗内部的二维码卡、邀请码卡、成功邀请列表、邀请用户行、小标题和简单空态分别迁移到 `PlatformSubpanel`、`PlatformFieldLabel` 与 `PlatformEmptyState`;外层弹窗、query 自动打开、复制邀请、提交邀请码和社区面板信息架构不随本轮改变。
|
||||||
|
18.3.5. 个人中心任务中心任务条目迁移到 `PlatformSubpanel`;任务选择、领取、奖励和完成态仍由任务 ViewModel / 业务流程控制。
|
||||||
|
18.3.6. 个人中心充值弹窗 Native 支付二维码确认面板迁移到 `PlatformSubpanel`;支付渠道选择、二维码生成和确认支付流程不随 UI chrome 收口改动。
|
||||||
|
18.3.7. 个人中心充值弹窗商品整卡按钮迁移到 `PlatformSubpanel as="button" interactive`;支付渠道选择、商品展示、提交中态和购买回调不随按钮卡 chrome 收口改动。
|
||||||
|
18.4. 平台白底分段 Tab / 二选一 / 四选一配置项迁移到 `PlatformSegmentedTabs`;拼图结果页、抓大鹅结果页、抓大鹅素材配置、抓大鹅创作 / 结果页难度选择、视觉小说结果页和 creative-agent 模板确认弹窗已先迁移。后续同类控件只传选项、当前 id、变更回调、列数、尺寸、调性和外壳形态,不再在业务 JSX 中重复容器边框、`bg-white/62`、选中态和 `aria-pressed`。
|
||||||
|
18.4.1. `PlatformSegmentedTabs` 支持 `semantics="tabs"`、`tone="underline"`、`size="tab"` 和 `columns="one"`,用于承接认证入口短信 / 密码登录切换这类真实 Tab 语义;业务页不再维护本地 `LoginTabButton`、`role="tab"`、`aria-selected` 和下划线选中态。
|
||||||
|
18.5. 平台只读信息块迁移到 `PlatformInfoBlock`;错误弹窗和生成完成弹窗的来源、错误和状态展示、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已先迁移。后续弹窗、详情页或预览卡里只是展示短标签 + 只读正文,或无标签纯只读正文时,优先使用该 Module;横向信息行通过 `labelClassName` / `valueClassName` 保留标签和值排版,不在业务 JSX 中重复白底信息块 chrome。
|
||||||
|
18.5.1. 平台来源 / 状态 / 错误这类可复制报告弹窗迁移到 `PlatformReportDialog`;`PlatformErrorDialog` 和 `PlatformTaskCompletionDialog` 已先迁移,业务弹窗只保留标题、字段语义和黑名单过滤,不再重复维护 `UnifiedModal`、`CopyFeedbackButton`、`useCopyFeedback`、报告拼装和 `PlatformInfoBlock` footer 组合。后续同类“字段展示 + 复制整段报告”弹窗优先复用该 Module。
|
||||||
|
18.6. 平台统计小卡和轻量状态 chip 迁移到 `PlatformStatGrid`;拼消消结果页素材摘要、方洞结果页封面状态 chip、抓大鹅结果页难度摘要、creative-agent 模板确认摘要和自定义世界实体目录世界页档案规模已先迁移。后续结果页里只表达数值 / 标签摘要时,优先传 `items`、列数、密度、surface 和 label/value 顺序,不再在业务 JSX 中重复手写统计卡 chrome。
|
||||||
|
18.6.1. 平台普通进度条迁移到 `PlatformProgressBar`;creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移。creation-agent operation banner 的状态外壳也迁移到 `PlatformStatusMessage surface="platform" remapSurface`,避免业务 JSX 继续组合 `platform-remap-surface platform-banner` 和 `platform-banner--*`。后续生成进度、素材进度或实体目录进度只保留进度值、显示文案、主题色、必要覆盖层和业务状态,不再重复写 `role="progressbar"`、`platform-progress-track`、fill 宽度和最小可见宽度计算;未知进度用 `indeterminate`。生成页环形总进度继续保留 `GenerationProgressHero` 专用 SVG。
|
||||||
|
18.6.2. 平台单个胶囊状态 / 标签 chip 迁移到 `PlatformPillBadge`;宝贝识物结果页发布状态、主题标签与占位资源 overlay,宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 顶部阶段 / 过程计数 / 条目 meta chip、通用音频输入面板限制标签、自定义世界实体目录批量选择 / 生成中 / 开局 CG / 可扮演角色元信息 badge、RPG 首页公开作品卡 / 搜索结果 / 充值商品 / 移动端创建入口 / 桌面发现区 chip、RPG 世界详情静态元信息 chip、平台作品详情主题标签、RPG 角色身份 / 等级 / 技能出手方式 / 技能详情与状态标签 / 背景故事解锁状态 / 好感等级 / 角色资产工作室动作状态 / 角色编辑技能动作状态 / 角色资源应用状态 / 场景角色选择状态 / 地标当前连接状态 / 地图节点当前状态 / 地图节点方向标签 / 地图场景切换方向标签 / 作品封面来源状态 / 开局物品标签、NPC 交易数量 / 赠礼好感和背包工坊材料需求等暗色展示 chip、抓大鹅批量新增 / 批量重生成物品名称预览 chip、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、RPG 开发资产诊断数量 / 加载状态 badge、RPG 发布弹窗封面来源 badge、账号弹窗主题状态 / 会话数量 / 设备状态 badge、汪汪声浪生成页和通用生成页右上状态 badge、创作类型弹层锁定 badge、通用创作图片面板提交按钮内泥点消耗标签,以及个人中心泥点账单余额、玩过总时长和玩过作品类型 chip 已先迁移。后续只表达一个状态、标签、分类 chip 或按钮内消耗小胶囊时使用该 Module,不在业务 JSX 中重复拼 `rounded-full border bg-* text-* px-* py-*`;个人中心玫瑰色 chip 使用 `tone="profile"` / `tone="profileAccent"`,RPG 暗色展示 chip 使用 `dark*` tone,密集目录元信息用 `size="xxs"`,平台白底柔和状态使用 `tone="muted"`,实心中性详情标签使用 `tone="neutralSolid"`,按钮内浅色叠层使用 `tone="lightOverlay"`,多项统计摘要继续使用 `PlatformStatGrid`。可点击复制 / 分享胶囊 chip 继续由 `CopyCodeButton` / `CopyFeedbackButton` 管复制状态,并通过 `actionAppearance="pill"` 复用 `PlatformPillBadge` chrome。
|
||||||
|
18.6.2.1. 抓大鹅创作工作台提交按钮内的泥点消耗标签使用 `PlatformPillBadge tone="lightOverlay" size="xs"`;工作台只保留泥点数值和提交状态,不再手写 `rounded-full bg-white/24 px-2 py-0.5`。
|
||||||
|
18.6.3. 平台媒体悬浮短标签迁移到 `PlatformOverlayBadge`,复合控件内部的紧凑槽位编号迁移到 `PlatformSlotBadge`;RPG 场景幕预览左上幕标签和每幕角色槽位的“主 / 2 / 3”标记已先迁移。后续覆盖在图片、素材预览或舞台画面上的非交互短标签只传文案、位置和局部 class,绝对定位、白底半透明、边框、阴影与字距由 `PlatformOverlayBadge` 承接;角色槽、步骤槽等复合按钮里的小圆形序号只传文案和 active / inactive 语义,由 `PlatformSlotBadge` 承接。普通状态 / 分类仍使用 `PlatformPillBadge`,外层按钮、人物舞台布局和运行态 HUD 不迁入这两个小 Module。
|
||||||
|
18.6.3.1. `PlatformOverlayBadge` 支持 `tone="muted"`、`size="compact"` 和 `offset="tight"`,用于素材缩略图右上角“占位图”等更紧凑的非交互浮层;宝贝识物结果页素材卡占位图标记已迁移到该组合。后续这类贴在媒体框上的短标签优先使用 overlay badge,不再把 `PlatformPillBadge` 绝对定位到图片内。
|
||||||
|
18.6.3.2. `PlatformSlotBadge` 支持 `tone="soft"` 和 `size="md"`,用于 creative-agent 阶段时间线这类白底柔和步骤圆点;时间线外层阶段卡、进行中 / 已完成 / 未开始语义配色仍保留在业务 Module,公共徽标只承接圆点尺寸、白底、边框和图标居中。
|
||||||
|
18.6.4. 物品格、奖励格等缩略图右下角的数量角标迁移到 `PlatformQuantityBadge`;背包物品格和 RPG 冒险面板 / 覆盖层的奖励物品数量已先迁移。后续同类右下角数量只传数量值,绝对定位、黑底半透明、圆角、边框和字号由该 Module 承接;可交互物品按钮、选中态、稀有度边框、图标来源和详情弹窗仍留在业务 Module。
|
||||||
|
18.6.5. RPG 冒险面板和覆盖层里的任务目标状态、任务日志状态、当前幕、剩余交谈等纯展示暗色 chip 复用 `PlatformPillBadge` 的 `dark*` tone;任务 presentation / 日志状态只返回语义 tone,不再携带完整 `border / bg / text` class。运行态行动按钮、任务面板打开按钮和需要 hover / click 语义的胶囊仍保留专用按钮布局。
|
||||||
|
18.6.6. RPG 角色面板里的标签数、适配倍数、性别和装备稀有度等纯展示暗色 chip 复用 `PlatformPillBadge darkNeutral / darkEmerald / darkAmber`;构筑适配倍数只保留 multiplier 计算,不再手写 emerald 胶囊 chrome。后续带复杂数值拆解的统计 / 加成类展示能力再单独收口。
|
||||||
|
18.6.7. RPG 首页作品卡里的发布状态、元信息、主标签,以及存档卡右上恢复 / 最近游玩时间等暗色静态 chip 复用 `PlatformPillBadge dark*`;作品卡 / 存档卡只保留可点击卡片、删除动作、进入 / 继续创作箭头和业务文案。
|
||||||
|
18.6.8. 自定义世界实体目录里的基础设定词条标签复用 `PlatformPillBadge darkSoft`;目录页只保留词条解析和空值展示逻辑,不再手写白字暗底 tag chrome。
|
||||||
|
18.7. 平台白底子面板迁移到 `PlatformSubpanel`;拼图结果页作品信息 / 智能修订条 / 关卡卡片、敲木鱼结果页主预览面板 / 元信息、敲木鱼工作台功德词条、拼图图片生成模式选择器菜单外壳、跳一跳结果页预览 / 操作面板 / 排行榜 / 轻量媒体壳、拼消消创作工作台左侧表单面板、拼消消结果页预览 / 统计 / 操作面板 / 轻量媒体壳、方洞结果页封面 / 主信息 / 形状 / 洞口标准面板、方洞形状 / 洞口选项卡与缩略图按钮、RPG 结果页开发资产诊断摘要 / 条目 / 空态、RPG 发布弹窗封面预览壳、通用音频输入面板、视觉小说创作工作台画风选择面板、视觉小说结果页素材 / 音频小面板、视觉小说结果页作品 / 开场 / 运行配置 / 世界观标准编辑面板、视觉小说结果页角色 / 场景 / 阶段列表项与空态、视觉小说 runtime 历史条目 / 存档列表、抓大鹅创作工作台难度小面板、抓大鹅结果页作品 / 难度 / 统计 / UI素材预览标准面板 / 物品图集分组卡 / 批量素材生成进度卡、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、平台反馈页问题描述 / 上传凭证 / 联系方式区块、自定义世界实体目录世界基调 / 角色维度 / 基本设定条目 / 场景幕级缩略图 / 目录卡片媒体壳 / 目录卡片整卡壳,以及 creative-agent 工作台标准白底面板 / 关卡计划小卡和通用输入 Composer 普通 panel 外壳已先迁移。后续仅表达“白底子面板 + 标题 / 右侧动作 + 内容”“不需要 fallback / overlay 的白底轻量媒体壳”或“白底整卡点击列表项”的片段优先使用该 Module;标准面板使用 `surface="platform"`,选中 / 删除预备等危险整卡态使用 `surface="danger"`,大圆角标准面板使用 `radius="xl" padding="lg"`,小型白底卡片或小型浮层菜单使用 `surface="flat"`,不要在业务 JSX 里继续覆盖 flat 的圆角和底色,轻量媒体壳使用 `surface="flat" padding="none"`,整卡或缩略图点击使用 `as="button" interactive`;暗色运行态 HUD、通用输入 Composer 浮动胶囊或强玩法品牌面板可继续保留专用布局。
|
||||||
|
18.7.1. 账号设置入口卡、主题选择卡、当前主题状态、账号绑定卡、密码 / 安全 / 设备 / 操作记录区块,以及设备 / 操作记录内的白底列表行已迁移到 `PlatformSubpanel`;账号弹窗只保留绑定、换绑、撤销会话和日志展示语义,不再直接拼 `platform-subpanel rounded-2xl` 或内层白底列表边框。
|
||||||
|
18.7.2. RPG 世界详情页的世界信息统计卡、关键角色 / 关键场景预览卡和操作区标题已迁移到 `PlatformSubpanel` 与 `PlatformFieldLabel variant="section"`;详情页只保留作品展示、启动、编辑、发布、下架和删除动作语义,不再直接拼小型 `platform-subpanel` 卡片或本地 section 标题 class。
|
||||||
|
18.7.3. 大鱼吃小鱼结果页的关卡卡片、场地背景卡、发布校验卡、空草稿提示和素材工坊 PROMPT 信息块已迁移到 `PlatformSubpanel`;结果页只保留大鱼玩法的青色主题按钮、预览背景、素材生成动作和发布校验语义,不再直接拼 `rounded-[1.45rem] border ... bg-[var(--platform-subpanel-fill)] p-4` 或 `rounded-[1.25rem] border ... bg-white/72 p-4`。
|
||||||
|
18.7.4. 平台媒体预览框迁移到 `PlatformMediaFrame`;自定义世界实体目录场景图片框、RPG 实体编辑器 `ImagePreview`、拼图结果页关卡列表正式图框、拼图发布弹窗封面关卡预览、拼消消结果页场地底图 / 素材图集 / 卡片预览网格、方洞结果页图片查看弹窗预览、方洞结果页封面 / 背景点击预览、方洞结果页形状 / 洞口贴图缩略图、宝贝识物结果页素材卡图片框、视觉小说结果页封面 / 资产字段图片预览、敲木鱼结果页主 9:16 背景 + 敲击物叠层预览、跳一跳结果页地块图集整图预览、大鱼吃小鱼关卡主图缩略图、大鱼吃小鱼素材工坊候选预览、大鱼吃小鱼场地背景竖版预览、creative-agent 模板确认预览、拼图图库详情页封面轮播媒体框、认证验证码图片,以及抓大鹅结果页物品素材列表缩略图、详情大图、视角缩略图、UI 素材背景 / UI spritesheet / 物品 spritesheet 主图预览已先迁移。拼图发布弹窗封面关卡预览、creative-agent 模板确认预览和认证验证码图片使用 `surface="soft"` 承接白底柔和边框,业务 JSX 只保留局部圆角、高度或 fallback 渐变差异;拼图关卡列表正式图、拼消消场地底图 / 素材图集、宝贝识物素材卡顶部媒体槽、视觉小说资产字段、creative-agent 模板目录卡、跳一跳地块图集整图、大鱼关卡主图 / 工坊候选 / 场地背景主题槽、拼图图库详情页封面轮播媒体框、方洞封面 / 背景点击预览、方洞形状 / 洞口贴图缩略图和抓大鹅 UI 素材页白底预览壳 / 详情视角缩略图嵌在保留媒体壳或交互态的外层壳内,使用 `surface="none"` 只承接图片 / fallback;抓大鹅素材列表缩略图和详情大图使用 `surface="bright"` 承接亮白素材槽,并通过容器属性透传保留测试 id / aria。后续需要图片源、fallback 图、fallback 文案 / 自定义占位内容、fallback 外壳局部着色、固定比例或 overlay 的预览框只传素材地址、可访问名称、比例、surface、刷新 key 和覆盖层,不再在业务 JSX 中重复拼图片框渐变、无图占位、`aspect-*`、基础边框 / 底色和绝对定位 overlay。
|
||||||
|
18.7.5. 汪汪声浪结果页草稿编译小卡迁移到 `PlatformSubpanel surface="flat"`,跳一跳结果页排行榜行卡迁移到 `PlatformSubpanel surface="flat"`,排行榜无成绩空态迁移到 `PlatformEmptyState surface="subpanel"`;结果页只保留玩法文案、排行字段和错误 / 空态文案,不再手写白底小卡圆角、边框、底色和 padding。
|
||||||
|
18.7.6. creative-agent 模板目录卡迁移到 `PlatformSubpanel as="button" interactive surface="flat"`,卡内 16:9 预览迁移到 `PlatformMediaFrame aspect="landscape" surface="none"`;工作台只保留模板选择、标题、摘要、预览渐变局部样式和泥点范围,不再手写白底按钮卡、16:9 图片框或图标 fallback 容器。
|
||||||
|
18.7.7. `PlatformSubpanel` 支持 `surface="dark"`、`radius="xs"` 和 `padding="xs"`,用于承接暗色编辑 / 运行面板中的小型信息卡;RPG 冒险面板 / 覆盖层任务目标、区域、进度、任务摘要卡、奖励条、描述卡、任务更新提示、任务日志条目、冒险统计总览和统计卡、任务完成领奖提示、奖励缓存、战斗结束、战利品面板和奖励物品详情描述 / 效果 / 标签,自定义世界实体目录角色维度小卡,自定义选择弹窗当前角色信息块,RPG 大编辑器场景幕背景信息、预设背景和场景连接关系面板,角色面板个人线阶段 / 背景 / 性格块 / 装备行,好感状态卡的等级摘要 / 进度面板,背景故事公开印象 / 已解锁章节 / 锁定章节面板,角色详情装备 / 背包 / 旅程 / 背景 / 性格小卡,通用角色技能卡,实体详情主分区壳和最近回响里的后果 / 编年 / 载体 / 场景残留卡,背包文书 / 故事档案 / 工坊分区与非交互条目卡,NPC 交易数量 / 库存 / 详情 / 总价 / 交易详情装备与使用属性,以及角色聊天状态 / 总结等静态信息卡已先迁移。RPG 大编辑器本地 `EditorInfoPanel` / `SectionPanel` 和实体详情本地 `Section` 只保留标题、右侧动作、subtitle 或内容插槽,暗色面板 chrome 继续由 `PlatformSubpanel surface="dark"` 承接;可扮演角色背景故事、关系、技能、物品和世界基础设定等编辑分区不再手写外层暗色面板。后续暗色小信息卡只保留标题、图标、值和必要动作,不再重复手写 `rounded-xl border border-white/10 bg-black/* px-* py-*`。
|
||||||
|
18.7.7.1. `PlatformSubpanel` 支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于承接暗色编辑 / 运行面板中的强调态结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板和共享构筑状态标签外壳已先迁移。后续同类只读或半结构化提示只传 surface、radius、padding 和业务内容,不再手写 `border-*-400/18 bg-*-500/8` 暗色 tint 面板。
|
||||||
|
18.7.8. 拼图图库详情页封面轮播壳迁移到 `PlatformSubpanel radius="xl" padding="none"`,内层图片 / fallback / 轮播 overlay 迁移到 `PlatformMediaFrame surface="none"`;详情页只保留图片 slide 数据、轮播按钮和 fallback 文案,不再手写 `rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]` 静态封面面板或直接依赖底层 `ResolvedAssetImage`。
|
||||||
|
18.7.9. 抓大鹅结果页物品详情五视角面板迁移到 `PlatformSubpanel radius="xl" padding="sm"` 并通过局部 `sm:p-5` 保留桌面间距;详情页只保留视角预览、缩略图切换和素材名称字段,不再手写 `platform-subpanel min-h-0 rounded-[1.5rem] p-3 sm:p-5`。
|
||||||
|
18.7.10. RPG 暗色弹窗里的可选项按钮卡迁移到 `PlatformDarkOptionCard`;NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格、营地编组替换位按钮和角色聊天建议按钮已先迁移。业务页只传 selected、tone、点击回调和内容布局,不再重复写选中 / 未选中暗色卡片边框、底色、hover 和 disabled chrome;像素风 footer 按钮、强品牌动作按钮和含复杂禁用语义的动作按钮继续保留专用布局。
|
||||||
|
18.7.11. 发布分享弹窗渠道 tile 按钮迁移到 `PlatformSubpanel as="button" interactive surface="flat"`;复制反馈状态、渠道枚举和品牌图标继续留在分享弹窗内。
|
||||||
|
18.7.12. 平台入口创作类型弹层玩法卡片迁移到 `PlatformSubpanel as="button" surface="platform" radius="xl" padding="none"`;玩法图片蒙版、锁定 badge、标题副标题和分流回调继续由弹层组件持有。
|
||||||
|
18.7.13. creation-agent 工作台聊天区外壳迁移到 `PlatformSubpanel radius="xl" padding="none"`;消息列表、上传预览、错误提示和输入区继续由工作台组件持有。
|
||||||
|
18.7.14. 绑定手机号页左侧的“当前登录身份”提示块迁移到 `PlatformSubpanel as="div" radius="sm" padding="md"`;认证页只保留品牌说明、当前用户显示名和绑定流程,不再手写 `platform-subpanel` 信息块外壳。
|
||||||
|
18.8. 平台标签编辑器迁移到 `PlatformTagEditor`;拼图、敲木鱼和抓大鹅结果页标签编辑已先迁移。后续标签编辑只把 parse / normalize 和保存语义留在业务页,新增输入状态、删除 chip、空态、AI 生成按钮和错误提示统一由 Module 承接。
|
||||||
|
19. 个人中心充值、任务、兑换、邀请、支付结果等弹窗里的普通主动作按钮迁移到 `PlatformActionButton surface="profile"`;RPG 首页作品卡删除小动作、RPG 作品详情、RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪 / 视觉小说 / 大鱼吃小鱼结果页、自定义世界实体目录小动作、生成结果恢复面板、通用生成页重试 / 中断动作、法律信息弹窗 footer、公共确认弹窗 footer、统一创作工作台、统一创作页壳层、拼图创作工作台、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creation-agent 推荐回复、creative-agent 工作台、creative-agent 模板确认弹窗、创作中心错误重试、创作中心作品卡积分激励领取按钮、反馈页 header 返回、通用创作输入面板、认证表单、敲木鱼 fallback 返回、跳一跳结算、拼消消 runtime header / 结算弹窗和视觉小说 runtime 普通白底面板里的普通主动作 / 次动作 / 危险动作迁移到 `PlatformActionButton surface="platform"`;RPG 暗色弹窗 / 运行面板中的角色自定义 footer、生成 footer、地图切换确认、营地编组普通动作、角色聊天刷新动作、角色素材工作室本地 `ActionButton`,以及 RPG 大编辑器暗色面板内的保存 / 角色槽动作都迁移到 `PlatformActionButton surface="editorDark"`。若业务侧仍需要 `stopPropagation`、局部 tone 映射或内容排版差异,可以保留局部 `ActionButton` 包装层,但包装层本体应委托共享按钮,而不是继续直接渲染原生 `<button>`。统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,提交 / 生成 / 发布 / 保存按钮使用默认主动作,素材槽小按钮、作品卡角落小动作、拼图图片生成模式选择器触发器和白底面板行内动作使用 `size="xs"` 与 `shape="pill"`,积分激励领取这类密集卡片小动作使用 `size="xxs"` 并由局部卡片 class 保留响应式布局,暗色微型刷新动作使用 `size="xxs" shape="pill"`,左对齐回复 / 列表动作使用 `align="start"`,认证表单提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 保持 48px 高度,文件上传 label 使用 `asChild="label"` 保持上传语义;复制邀请、错误复制、完成复制和分享复制继续使用 `CopyFeedbackButton` 管状态,并通过 `actionSurface` 复用动作按钮外观。大鱼吃小鱼结果页资产工坊 footer、关卡主图 / 动作入口和场地背景生成这类白底平台动作也使用 `shape="pill" size="xs"`,深色 hero 返回 / 测试 / 发布按钮保留玩法品牌布局。后续带复制三态的按钮不改用普通 ActionButton,避免复制状态分支回流业务页;暗色可选项卡继续使用 `PlatformDarkOptionCard`,像素风发送按钮和强品牌动作继续保留专用布局。
|
||||||
|
19.1. `CopyFeedbackButton` 支持 `actionShape`,用于在复用共享复制状态机时直接对齐 `PlatformActionButton` 的圆角外观;拼图广场详情 hero 的“分享作品”已使用 `actionSurface="editorDark" actionShape="pill"`,不再手写复制按钮 rounded / border / bg class。
|
||||||
|
19.2. 拼图广场详情 hero 的返回、上一张 / 下一张关卡图入口迁移到 `PlatformIconButton variant="darkMini"`,修改作品和进入第 1 关迁移到 `PlatformActionButton`,分享动作继续使用 `CopyFeedbackButton` 但复用共享动作按钮 chrome;详情页只保留轮播、复制和跳转语义,不再手写 hero 区按钮壳。
|
||||||
|
19.3. 个人中心充值商品卡里的“购买 / 处理中”胶囊暂保留局部 `span`,不直接套用 `PlatformActionButton`,避免在 `PlatformSubpanel as="button"` 内再嵌套交互按钮;待出现第二个同形态的非交互 action chip 后,再决定是否沉淀独立的共享展示基元。
|
||||||
|
19.3.1. RPG 首页创作 / 草稿顶栏的钱包快捷入口迁移到同文件适配器 `TopbarWalletShortcutButton`,内部复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"` 与 `PlatformIconBadge`;移动端和桌面端继续保留 `.platform-mobile-create-wallet-chip`、`.platform-desktop-create-wallet-chip` 和 `.platform-desktop-search` 兼容 class,承接移动端余额截断、桌面顶栏胶囊底色以及既有测试锚点。入口点击仍统一走 `openRechargeOrRewardCodeModal`,不把充值 / 兑换码平台分流逻辑改散到两个顶栏分支里。
|
||||||
|
19.3.2. 个人中心昵称修改、账户充值、每日任务和兑换码四类标准头部弹窗迁移到 `UnifiedModal`;`UnifiedModal` 新增 `closeVariant`、`closeOnEscape`、`titleClassName` 和 `descriptionClassName`,用于在收口弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、居中浮层布局和标题层级。上述弹窗统一通过 `closeOnBackdrop={false}`、`closeOnEscape={false}` 保持原有交互语义,不把 backdrop / Escape 关闭行为悄悄带进个人中心;邀请、玩过作品等结构更复杂的二级弹层继续按同一壳层策略逐步迁移。
|
||||||
|
19.3.3. 个人中心支付结果提示和支付确认遮罩迁移到 `UnifiedModal` 的 headerless 模式;`UnifiedModal` 新增 `showHeader`,用于在保留 `role="dialog"`、可访问名称、遮罩和 z-index 语义的同时,允许业务页自己排版 icon badge、轻量标题和正文。支付结果提示与确认遮罩统一使用 `showHeader={false}`、`showCloseButton={false}`、`closeOnBackdrop={false}`、`closeOnEscape={false}`,继续保持阻断式确认语义;业务页只保留图标、文案和按钮,不再手写 backdrop、dialog aria 和面板壳层。
|
||||||
|
19.3.4. 个人中心移动端顶栏的“扫码”“打开设置”入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-header__icon-button` 局部 class 控制位置、尺寸和主题色,交互语义与可访问名称统一由共享按钮承接,不再在 `RpgEntryHomeView` 里手写图标按钮的 `type`、`aria-label` 和基础 chrome。
|
||||||
|
19.3.5. 发现页分类筛选弹窗与个人中心扫码面板迁移到 `UnifiedModal`;分类筛选继续复用本地选项栅格和底部动作区样式,但 backdrop、dialog 语义、头部关闭入口和 `closeOnEscape={false}` 统一收口到共享壳层。扫码面板复用 `showHeader={false}` 模式保留深色自定义头部、摄像头 viewport 和状态提示,同时显式保持 `closeOnBackdrop={false}`、`closeOnEscape={false}`,确保不会把扫码中的资源清理语义改散到页面外层。
|
||||||
|
19.3.6. 个人中心泥点账单弹窗迁移到 `UnifiedModal` 的 headerless 模式;共享壳层承接 `dialog` 语义、层级和关闭策略,账单弹窗继续保留自定义渐变面板、浮动关闭按钮、余额 badge、列表 / 空态 / 错误态布局以及 `closeOnBackdrop={false}`、`closeOnEscape={false}` 的原有交互,不再手写 `fixed inset-0` 遮罩壳层。
|
||||||
|
19.3.7. 个人中心“玩过作品”面板迁移到 `UnifiedModal` 的 headerless 模式;共享壳层承接 `dialog` 语义、层级与关闭策略,面板继续保留 `PLAYED` kicker、总时长 badge、浮动关闭按钮、`可继续 / 玩过` 双分区、作品卡与空态布局,以及 `closeOnBackdrop={false}`、`closeOnEscape={false}` 的原有交互。存档入口仍留在同一个“玩过”面板内,不再回退成独立的 `SAVE ARCHIVE` / `ARCHIVE` 壳层。
|
||||||
|
19.3.8. 个人中心邀请相关弹层中的 live 分支迁移到 `UnifiedModal` 的 headerless 模式;玩家社区与填邀请码继续保留浮动关闭按钮、居中标题、二维码卡片、邀请码表单 / 已填写空态和成功 / 失败提示,但 `dialog` 语义、层级与关闭策略统一由共享壳层承接。`community / redeem` 两条真实入口继续显式保持 `closeOnBackdrop={false}`、`closeOnEscape={false}`;历史 `invite` 分支暂不扩张能力面,只随同一壳层复用现状内容。
|
||||||
|
19.3.9. 个人中心昵称旁的铅笔入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-edit-button` 局部 class 控制 1.45rem 紧凑尺寸、边框与浅色底,但按钮语义、默认 `type="button"` 和共享 icon chrome 统一由公共组件承接,不再在 `RpgEntryHomeView` 里手写原生图标按钮。
|
||||||
|
19.3.10. RPG 首页推荐运行态卡片底部的点赞 / 分享 / 改造入口迁移到 `PlatformIconButton`;推荐卡继续保留 `.platform-recommend-work-meta__action*` 局部 class 控制透明圆角按钮尺寸、间距和玩法主题色,同时显式保留 `onPointerDown` / `onClick` 里的 `stopPropagation`,避免图标动作把推荐卡纵向拖拽切换误触发。后续任何耦合 swipe / drag 手势的图标动作都沿用“共享按钮承接语义,本地 class 保留视觉与手势隔离”的策略。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged in recommend runtime preloads adjacent work previews and drag switches like video feed"`。
|
||||||
|
19.3.11. 创作中心公开作品卡右上角的分享快按钮迁移到 `PlatformIconButton`;作品卡继续保留 `.creation-work-card__quick-action-button` 局部 class 承接卡片角落定位和尺寸,并显式保留 `stopPropagation`、关闭 swipe action、清理 `suppressOpenRef` 与分享回调顺序,避免右上角分享入口误触整卡打开或遗留左滑状态。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`。
|
||||||
|
19.3.12. RPG 首页个人中心的统计卡、统计骨架、常用功能入口、设置行与法律信息入口抽离到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;`RpgEntryHomeView` 只继续保留账户数据、图片资源、点击回调和打开弹层的控制器,不再把这一组纯展示原子和个人中心页面编排混在同一个 7k+ 首页文件里。组件级验证新增 `src/components/platform-entry/PlatformProfilePrimitives.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile stats cards are centered without update timestamp|profile page shows legal entries and hides archive shortcuts"`。
|
||||||
|
19.3.13. RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;`RpgEntryHomeView` 仅保留个人中心展示、昵称头像编辑、扫码入口和页面级编排 / 交互,不再直接承接账户动作分流、商业状态派生和面板控制。该收口默认保持现有弹层与充值链路语义不变,避免在职责迁移时顺带扩张行为面。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||||
|
19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||||
|
19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;`PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
|
||||||
|
19.3.15.1. `PlatformProfileModalShell` 继续补齐标准 footer 插槽:壳层现已直接透传 `UnifiedModal.footer` 与 `footerClassName`,`RpgEntryHomeView.tsx` 的昵称修改弹窗不再把双按钮动作区塞在 body 末尾,而是改成标准 profile modal footer。后续个人中心里同类“表单 body + 底部双按钮动作区”弹窗,优先走 `PlatformProfileModalShell + footer`,不要把共享按钮再手写回内容区。
|
||||||
|
19.3.15.2. `PlatformProfileModalShell` 的 footer 接法继续扩展到单 CTA 表单收尾:`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换动作已迁到标准 profile footer,body 仅保留输入与反馈消息;后续个人中心里这种“输入表单 + 底部唯一主动作”弹窗,也优先复用壳层 footer,而不是把按钮继续塞在内容区。验证命令:`npx vitest run src/components/platform-entry/PlatformProfileModalShell.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.16. RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;组件统一复用 `PlatformProfileSecondaryModalShell` 承接居中白底浮层、floatingPlain 关闭按钮和成功 / 失败提示区,`RpgEntryHomeView` 不再内联邀请码规范化、社区二维码卡片和邀请用户头像行。组件级验证新增 `src/components/platform-entry/PlatformProfileReferralModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的邀请链路断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders invite panel with shared profile content|submits redeem panel with the shared form shell|renders community QR panels|profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login"`、`npm run typecheck`。
|
||||||
|
19.3.17. RPG 首页个人中心的账户充值弹层抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;组件承接 Native 二维码生成、点数 / 会员 tab、套餐卡片、空态和错误重试,继续复用 `PlatformProfileModalShell` 与平台白底卡片 token,`RpgEntryHomeView` 不再内联 `useWechatNativeQrCode`、`RechargeProductCard` 和 `ProfileRechargeModal`。组件级验证新增 `src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的充值入口与 Native 二维码断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders point products and forwards buy action|shows empty state when the selected tab has no products|profile recharge modal shows native qr code on desktop web by default|create tab wallet chip opens recharge when recharge entry is enabled"`、`npm run typecheck`。
|
||||||
|
19.3.18. RPG 首页个人中心的泥点账单、每日任务和兑换码三类标准 profile 弹层分别抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;账单继续复用 `PlatformProfileSecondaryModalShell`,任务和兑换码继续复用 `PlatformProfileModalShell`,页面不再内联账单余额 badge、任务领取列表和兑换码输入提交实现。三者均新增组件级测试,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的真实入口断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders ledger entries with shared balance presentation|retries from the shared error state|renders claimable tasks and forwards claim action|keeps incomplete tasks disabled|submits on button click and enter key|disables submit when the code is blank|opens wallet ledger modal from narrative coin card|profile daily task shortcut reflects task progress and claim updates|wallet ledger modal shows empty and error states|opens reward code modal from profile action on mobile|create tab wallet chip opens reward code when recharge entry is hidden"`、`npm run typecheck`。
|
||||||
|
19.3.19. RPG 首页个人中心的支付结果提示、支付确认遮罩与扫码面板继续向共享组件收口:支付结果 / 确认中弹层统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 仅保留支付状态映射、扫码打开关闭和结果写回,不再内联 `RechargePaymentResultModal`、`RechargePaymentConfirmationMask`、`ProfileQrScannerModal`、`BarcodeDetector` 启动逻辑和 profile 弹层壳层参数。组件级验证新增 `src/components/common/PlatformStatusDialog.test.tsx` 与 `src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的支付 / 扫码入口断言。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx`、`npm run test -- src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal jumps to h5 payment on mobile web by default|profile recharge modal posts mini program payment request and reacts to success hash result|profile recharge modal releases submitting state and shows virtual payment failure detail|profile recharge modal eventually shows error text even when hashchange is not dispatched|profile recharge modal resumes virtual payment confirmation when pageshow returns with paid order|profile recharge modal blocks tab navigation while virtual payment confirmation is pending|profile scan action opens camera scanner instead of recharge panel"`、`npm run typecheck`。
|
||||||
|
19.3.20. `PlatformStatusDialog` 继续扩展到 notice 场景:组件新增 header notice 布局、body content、close button、backdrop / Escape 关闭路径以及动作按钮样式透传;`PlatformEntryFlowShellImpl` 里的 `draftGenerationPointNotice` / `workNotFoundRecoveryDialog` 和 `RpgCreationEntityEditorShared.tsx` 里的 `EditorNoticeDialog` 已接入。创作入口泥点不足、作品不可用恢复和 RPG 大编辑器规则阻断提示不再各自维护 `UnifiedConfirmDialog` 壳层,只保留标题、正文、辅助提示和关闭回调。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle form checks mud points before creating a draft|puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|direct missing public work detail shows unified dialog before returning home"`、`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色至少保留一个背景章节时使用统一提示弹窗|场景连接缺少可连接目标时使用统一提示弹窗|场景保存缺少主角色时使用统一提示弹窗"`、`npm run typecheck`。
|
||||||
|
19.3.21. `PlatformStatusDialog` 继续收口规则阻断和搜索未命中提示:`CustomWorldEntityCatalog.tsx` 的 `minimum-playable` 规则阻断从删除确认分支中拆出,改由独立 `PlatformStatusDialog` 承接;`PlatformEntryFlowShellImpl` 的公开编号搜索弹层拆成“命中用户继续走 `UnifiedModal + PlatformSubpanel`”与“未找到结果改走 `PlatformStatusDialog`”两条分支。业务页不再让规则阻断提示和危险删除确认共用同一套 confirm config,也不再在搜索结果 modal 内同时维护用户信息和错误态两套内容布局。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "最后一个可扮演角色不可删除时使用平台状态弹窗"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "searching unmatched public work code shows not-found search result dialog|public code search shows public user summary in shared search result modal and clears it on close"`、`npm run typecheck`。
|
||||||
|
19.3.22. 标准泥点消耗确认弹窗收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;组件固定承接“确认消耗泥点 + 消耗 N 泥点”的同形态标题、正文骨架和确认 / 取消动作,业务页只保留点数、补充说明和确认回调。`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx`、`PuzzleResultView.tsx`、`Match3DResultView.tsx` 以及 `RpgCreationRoleAssetStudioModalImpl.tsx` 已迁移;其中角色形象生成 / 动作草稿生成继续通过自定义 title 和补充说明承接工坊语义,但不再各自拼接 `UnifiedConfirmDialog` 的相同文案和内容结构。后续同类泥点确认优先复用该 Module;像 runtime 道具确认、预计消耗区间确认这类节奏不同的弹层再单独评估是否扩展变体。
|
||||||
|
19.3.23. 平台危险确认弹窗收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;组件固定承接“确认 / 取消 + 危险主动作”的标准骨架,并透传忙碌态、遮罩关闭策略、按钮文案和局部面板样式。`PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认,以及 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认已迁移;业务页继续保留标题、说明文案和确认回调,不再各自拼接 `UnifiedConfirmDialog` 的危险按钮配置。后续删除、覆盖、清空等危险动作优先复用该 Module,再按需要补充更窄的语义 wrapper。
|
||||||
|
19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。
|
||||||
|
19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调,不再各自手写 `PlatformStatusDialog` 的 `action={{ label: '知道了', onClick: onClose }}` 结构。
|
||||||
|
19.3.26. profile 侧重复的 `error / loading / empty / content` 分支统一收口到 `src/components/common/PlatformAsyncStatePanel.tsx`;该 Module 只承接互斥状态切换,不承接需要和内容并存的 success / error banner。`PlatformProfileReferralModal.tsx`、`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileTaskCenterModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入。后续 profile 或白底 panel 侧若只是同形态互斥异步状态,优先传 slot 复用该骨架,不再把 `loading skeleton` / `empty state` / `retry error` 直接写回业务页。
|
||||||
|
19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 和 `RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件先统一 `tablist/tab` 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心内重复出现时,再沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset,避免业务页继续复制 `itemClassName`,也避免把一次性玩法配置项抽成过胖公共组件。
|
||||||
|
19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx` 与 `MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。
|
||||||
|
19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper;不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。
|
||||||
|
19.3.30. `PlatformSegmentedTabs` 继续承接首页 / 结果页里剩余的横向 rail 与二选一切换:`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。`CustomWorldEntityCatalog` 通过 `ReactNode label` 保留“标题 + count”两行内容;`RpgEntryHomeView`、创作入口、作品架和个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤沉淀到 `PlatformSegmentedTabPresets`,业务页只保留 items、activeId 和回调。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,不再把这些切换项当普通 button;一次性玩法配置项继续直接组合 `PlatformSegmentedTabs`。
|
||||||
|
19.3.31. 简单泥点确认流的开关状态机收口到 `src/components/common/useMudPointConfirmController.ts`;该 hook 只承接 `open / requestOpen / close / confirm` 四个动作,`confirm` 固定先关弹窗再执行回调,不持有 `points / title / description / confirmDisabled` 之类业务字段。`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 和 `Match3DResultView.tsx` 的两个批量素材面板已接入;`PuzzleCreationWorkspace` 仍在业务页判断“只有 `aiRedraw` 才弹确认”。`PuzzleResultView.tsx` 与 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类要么节奏不同、要么携带 pending payload 的场景先保留本地状态机,不把 hook 扩成泛型动作路由器。
|
||||||
|
19.3.32. 标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页里重复的白底 portal 弹窗壳层进一步收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 以支持“可见标题随业务对象变化、可访问名称保持固定”的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态专属 overlay 继续保留本地 close 语义,不把 `PlatformToolModalShell` 硬塞进非平台白底工具弹窗场景。
|
||||||
|
19.3.33. `PlatformAsyncStatePanel` 从 profile modal 扩展到作品架:`CustomWorldCreationHub.tsx` 的作品架主体现在也统一通过 `loadingState / emptyState / children` 三个 slot 切换,保留外层 error + 重试提示不并入共享状态骨架。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用 `PlatformAsyncStatePanel`,不要再在业务 JSX 中重复拼 skeleton 和“当前筛选下没有内容”的分支。验证命令:`npx vitest run src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run check:encoding`。
|
||||||
|
19.3.34. `CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续向共享按钮收口:带平台动作外观的复制按钮现在直接组合 `PlatformActionButton`,仅保留 `pill` 分支继续复用 `PlatformPillBadge` 风格。复制反馈按钮不再手动调用 `getPlatformActionButtonClassName` 拼平台按钮基础 chrome;后续同类“复制状态机 + 平台动作按钮”组合也优先走 `CopyFeedbackButton + PlatformActionButton`,不要在业务页或按钮组件里重新混写图标、文案、aria 和 class。验证命令:`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
||||||
|
19.3.35. 详情页头部动作组合收口到 `src/components/common/PlatformDetailTopbar.tsx` 与 `src/components/common/PlatformDetailShareActions.tsx`;前者只承接“返回 / 标题 / 右侧动作槽位”的 topbar 骨架,并允许 `pill` / `icon` 两种返回按钮语义,后者只承接“前置 badge 区块 + 作品号复制 + 分享复制”这一组稳定动作,不吸收详情页自己的标题、摘要、作者、封面轮播或业务 CTA。`RpgEntryWorldDetailView.tsx` 已接入完整的 overlay 版动作组合,统一世界主题 badge、作者、发布时间、作品号和分享入口;`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,并继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续同类详情页若只是复用返回按钮骨架、标题居中布局或作品号 / 分享动作排列,优先直接组合这两个 Module,不要把整页 detail header 抽成巨型配置对象。验证命令:`npx vitest run src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check -- src/components/common/PlatformDetailTopbar.tsx src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.tsx src/components/platform-entry/PlatformWorkDetailView.tsx`。
|
||||||
|
19.3.36. `PlatformToolModalShell` 继续承接 RPG 结果页发布检查弹窗;`RpgCreationResultActionBar.tsx` 只保留发布检查、封面预览、封面设置和发布动作语义,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close、body/footer spacing 和遮罩关闭逻辑。后续结果页 / 工具页里同形态的白底 portal 弹窗优先迁移到 `PlatformToolModalShell`;编辑器大壳、暗色 runtime overlay 和需要专属布局的面板继续保留局部 shell。
|
||||||
|
19.3.37. `PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
|
||||||
|
19.3.38. `PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭,实体编辑器弹窗需要保留编辑 footer,本轮先不混入同一提交,后续逐个迁移并补对应交互测试。
|
||||||
|
19.3.35. 白底 / 暗色面板里的轻量空态和普通 CTA 继续按共享组件收口:`PuzzleResultView.tsx` 的“还没有可编辑的拼图草稿”、`RpgCreationAssetDebugPanel.tsx` 的“没有可诊断项”、`VisualNovelEntityGrid` 的空实体列表、`AccountModal.tsx` 里账号安全分区的“无安全限制 / 无登录设备 / 无操作记录”以及 `LoginScreen.tsx` 的“当前登录入口暂不可用”都改为 `PlatformEmptyState`;`Match3DResultView.tsx` 的引用素材列表直接交给 `PlatformAssetPickerGrid` 自己处理空态。`AdventureEntityModal.tsx` 的私聊按钮、`InventoryPanel.tsx` 的锻造 / 合成按钮、`RpgAdventurePanel.tsx` 底部 `队伍 / 背包 / 换一换 / 退出聊天` 按钮,以及 `RpgAdventurePanelOverlays.tsx` 里的“查看任务 / 保存并退出”都改为 `PlatformActionButton surface="editorDark"`,业务页只贴回局部 sky / emerald / runtime 皮肤。后续白底子面板里的只读空态优先使用 `PlatformEmptyState surface="subpanel"`;暗色编辑 / 运行面板里的普通动作优先使用 `PlatformActionButton surface="editorDark"`,若还需要 stopPropagation、局部字号或图标排版,可保留薄包装层,但不要再回退到原生 `<button>` 基础 chrome。验证命令:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformActionButton.test.tsx`、`npm run check:encoding`。
|
||||||
|
19.3.36. `VisualNovelEntityGrid` 的空态也继续收口到 `PlatformEmptyState surface="subpanel" size="inline"`;角色 / 场景 / 剧情阶段共用这一网格组件后,白底实体列表里的“暂无角色 / 暂无场景 / 暂无剧情阶段”等同构空态不再回退成 `PlatformSubpanel`。同时,`RpgCreationRoleAssetStudioModalImpl.tsx` 与 `RpgCreationEntityEditorShared.tsx` 保留局部 `ActionButton` 语义壳,但按钮本体已统一委托给 `PlatformActionButton surface="editorDark"`,只在包装层补最小的 `stopPropagation`、tone 映射和局部 class 适配。后续类似“暗色编辑器局部包装按钮”优先沿用这种薄包装模式,不再直接手写原生 `<button>` 基础 chrome。验证命令:`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformActionButton.test.tsx`、`npm run check:encoding`。
|
||||||
|
19.3.37. 暗色编辑器里的局部动作按钮继续往共享 `editorDark` button 收口:`CustomWorldNpcVisualEditor.tsx` 的本地 `ActionButton` 和 `SkillEffectPreview.tsx` 的“重新预览”按钮都改为委托 `PlatformActionButton surface="editorDark"`。这类局部包装仍可保留 `stopPropagation`、图标布局、`tone` 映射和少量局部视觉覆写,但按钮本体不再直接使用原生 `<button>` 承接边框 / 底色 / hover / disabled chrome。验证命令:`npm run test -- src/components/common/PlatformActionButton.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.38. `PlatformAsyncStatePanel` 继续从 profile / 作品架扩展到首页公开分区:`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed,以及“我的创作”分区都统一改成 `loadingState / emptyState / children` 三个 slot 切换。页面继续把 `platformError` 保留在状态壳外层,让错误提示可以和内容并存;`recommend runtime`、分类筛选和其它含二级筛选 / 运行态语义的分支暂不并入这次收口。后续首页、作品架或白底列表若只是纯 `loading / empty / content` 互斥状态,优先直接复用 `PlatformAsyncStatePanel`,不要再把空态与读取态分支手写回业务 JSX。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.39. 桌面首页里的轻量可点击扁平行统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;该 Module 只承接 `button + 左侧主内容 + 右侧 affordance` 的结构、默认 `type="button"` 和 `leading / trailing` 插槽,不承接卡片封面、复杂摘要或 runtime 专属交互。`RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行已接入。教培 promo card、分类卡片、世界卡和 runtime 列表项继续保留各自语义,等出现更多同构 desktop flat row 再逐步扩覆盖面。验证命令:`npx vitest run src/components/common/PlatformNavigableListItem.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.40. `PlatformNavigableListItem` 继续从桌面首页扩展到 profile 设置行:`src/components/platform-entry/PlatformProfilePrimitives.tsx` 里的 `ProfileSettingsRow` 现已统一委托共享 `button + leading + trailing` 骨架,保留本地 `platform-profile-settings-row` class 承接行间分隔、icon 胶囊和字号微调。后续 profile / 账户中心里同类“左图标标题 + 右箭头”的轻量导航行,优先直接复用 `PlatformNavigableListItem`,不要再回退成原生 `<button>` 手写布局。验证命令:`npx vitest run src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.41. `PlatformAsyncStatePanel` 继续补齐首页分类分支:`RpgEntryHomeView.tsx` 的移动端“发现 -> 分类”、桌面发现页“分类”以及桌面首页“作品分类”模块都改成共享状态壳承接外层 `loading / empty / content` 切换,分类控制条与排序按钮继续保留在内容 slot 中;筛选后无结果的“当前筛选下没有作品。”也统一改由内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自手写空态分支。后续同类“外层数据可用性 + 内层筛选空态”面板优先沿用这套双层状态壳,不要回退成嵌套 ternary。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.42. `PlatformAsyncStatePanel` 继续从首页扩展到公共素材网格、runtime 面板和账号子区块:`PlatformAssetPickerGrid` 现已统一用共享状态壳承接 `loading / empty / content`,但继续把 `error` banner 留在外层,以保持“错误提示可与内容或加载态并存”的原语义;`VisualNovelSavePanel.tsx` 的存档列表,以及 `AccountModal.tsx` 里的“安全状态 / 当前登录设备 / 账号操作记录”三个子区块也都改成各自使用 `PlatformAsyncStatePanel`。后续白底列表、素材选择器或账号子面板若只是标准互斥异步状态,优先按这三种接法复用共享状态壳,不再把读取态和空态分支手写回组件内部。验证命令:`npx vitest run src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.43. 轻量按钮漏网继续向共享按钮收口:`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留 `label="删除标签 ${tag}"`、透明背景和原 chip 高度,不再手写裸 `<button>`;`RpgEntryCharacterSelectView.tsx` 两处重复的“返回”按钮已统一沉到文件内 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`,保留原有暗色视觉与文案。后续同类“局部 chip 删除按钮”优先先用 `PlatformIconButton` 压缩尺寸和视觉;暗色轻量返回 / 返回上一级 CTA 则优先用 `PlatformActionButton surface="editorDark"` 包一层局部 helper,不再复制原生 `<button>` class。验证命令:`npx vitest run src/components/common/PlatformTagEditor.test.tsx src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.44. 暖色生成页顶部返回入口开始沉淀共享壳:`GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,统一承接 `ArrowLeft + 文案 + 透明背景` 的暖色生成页返回按钮骨架,并底层复用 `PlatformIconButton variant="darkMini"`;`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已接入,继续保留各自 `backLabel`、禁用态和局部暖色文字样式。后续同类生成页、等待页或暖色 hero 顶栏若只是“左箭头 + 返回文案”的轻量返回入口,优先复用这个小组件,不再各自手写 `ArrowLeft`、透明按钮背景和字号间距。验证命令:`npx vitest run src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/components/common/PlatformIconButton.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.45. 白底 / 浅色结果页与工作台顶部的轻量返回入口继续收口到 `src/components/common/PlatformBackActionButton.tsx`;该 Module 固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的 `ArrowLeft + 返回文案` 骨架,并只暴露 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前 `PuzzleResultView.tsx`、`SquareHoleResultView.tsx`、`Match3DResultView.tsx`、`VisualNovelResultView.tsx` 四个结果页已接入 `variant="compact"`,`PuzzleClearResultView.tsx`、`JumpHopResultView.tsx`、`WoodenFishResultView.tsx` 三个结果页已接入 `variant="regular"`,`BabyObjectMatchResultView.tsx` 继续使用紧凑款并保留 `className="px-3"` 贴合原横向留白。暖色生成页顶部返回入口继续走 `GenerationHeaderBackButton`,`BigFishResultView.tsx` 这类 dark hero / 强品牌 special case 继续保留 `PlatformIconButton variant="darkMini"` 路线,不强行并入同一个白底返回按钮基元。后续白底结果页、浅色工作台或普通 platform 顶栏里若只是“左箭头 + 返回”轻量返回入口,优先直接复用 `PlatformBackActionButton`,只在局部补尺寸和少量外边距,不再各页重复手写 ghost button class。
|
||||||
|
19.3.46. `PlatformNavigableListItem` 继续从桌面 flat row 扩展到首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx` 的 `PlatformRankingItem` 和 `PlatformCategoryGameItem` 现在都统一委托共享 `button + leading + body + trailing` 骨架,同时保留原有 `platform-ranking-item__*`、`platform-category-game-item__*` 局部 class 承接封面尺寸、标题摘要、公开 badge、metric 和右侧 `试玩 / 进入` affordance。后续首页、发现页或 profile 侧同类“封面 + 文本主体 + 右侧动作提示”的浅色导航行,优先先尝试复用 `PlatformNavigableListItem` 并把局部视觉挂在业务 class 上,不要为了这类 row 再回退成原生 `<button>` 手写布局;但教培 promo card、runtime 列表项和带复杂手势的卡片仍保留本地语义,不把共享行骨架扩成万能作品卡。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame,分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper,不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx`;`UnifiedModal` 为此只薄补了 `titleId` 与 `closeIcon` 透传,继续由调用方决定 `closeOnBackdrop`、`closeOnEscape`、`portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/`。`SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的图片查看器改为 `src/components/common/PlatformImagePreviewModal.tsx`:参考图预览与主图预览都使用黑底全屏查看器,底层继续委托 `UnifiedModal size="fullscreen"` 承接 dialog / portal / Escape 语义,但 overlay、panel 和 body 必须强制全屏黑底,避免透出原页面或白底工具面板。查看器固定提供缩小、重置、放大和关闭图标按钮,缩放范围夹在 `1x-4x`;图片先按视口完整 contain,放大后拖拽位移按缩放后的图片边界夹取,不能把图片拖到露出背景。移除图片确认继续复用 `src/components/common/UnifiedConfirmDialog.tsx`,不和全屏查看器混同。后续 `common` 级图片大图预览优先复用 `PlatformImagePreviewModal`,若只是裁剪、选择或编辑工具弹窗,再回到 `UnifiedModal` / `PlatformToolModalShell` 的白底工具语义。验证命令:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.51. `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗壳层继续收口到 `src/components/common/PlatformUtilityInfoModal.tsx`;该 Module 只承接平台主题 overlay、白底 panel,以及 body / footer 的基础间距与标准 footer frame,底层继续委托 `UnifiedModal.tsx`,不吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌图标这些业务内容。`PlatformReportDialog.tsx` 继续保留 `PlatformInfoBlock` 字段列表与 joined report copy 行为,`PublishShareModal.tsx` 继续保留分享文案、主复制动作和渠道按钮网格;后续 `common` 级白底工具信息弹窗若只是重复这套“共享 modal 外壳 + 业务正文 / footer 内容”的骨架,优先复用 `PlatformUtilityInfoModal`,只有当正文编排或 footer 交互明显偏离时才回退到直接组合 `UnifiedModal`。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.52. profile 白底 modal 里的摘要头、列表骨架和内容行继续沉到 `src/components/common/PlatformProfileSummaryHeader.tsx`、`src/components/common/PlatformProfileSkeletonList.tsx` 与 `src/components/common/PlatformProfileContentRow.tsx`;这三个 Module 只承接 `kicker + title + badge` 的摘要层次、重复 skeleton 列表行,以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或 modal 状态切换逻辑。`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileReferralModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入;后续 profile 副弹层若只是重复这三类白底内容骨架,优先继续复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx` 和 `JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/`;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
||||||
|
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
||||||
|
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
||||||
|
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`。
|
||||||
|
21. 图片编辑面板中的白底胶囊开关迁移到 `PlatformPillSwitch`;通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已先迁移。后续同类开关只保留受控布尔值和状态变更回调,switch 输入语义、轨道、圆点、白底浮层和禁用态统一由 Module 承接。
|
||||||
|
22. 设置面板、结果页运行配置和工作台白底配置项中的整行开关迁移到 `PlatformToggleRow`;视觉小说结果页、runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移。后续整行配置项只保留字段写回和可选点击动作,不再重复开关行 chrome、checkbox class 或状态 pill。
|
||||||
|
22.1. RPG 创作侧标准 dark header / footer 动作继续向共享按钮收口:`RpgCreationRoleAssetStudioModalImpl.tsx` 的 header“关闭”、`RpgCreationEntityEditorShared.tsx` 的 footer“取消”、`RpgCreationRoleAssetStudioFooter.tsx` 的“保存到当前角色”都改为委托 `PlatformActionButton surface="editorDark"`。局部壳层只继续保留 `stopPropagation`、tone 映射、布局和极少量字号/宽度贴合;标准暗色编辑器里的 close / cancel / save CTA 不再各自手写原生 `<button>` 基础 chrome。
|
||||||
|
22.2. RPG runtime overlay 里的标准 dark CTA 和可点击 dark row 继续向共享原子收口:`RpgAdventurePanelOverlays.tsx` 的 goal panel“知道了”、任务详情里的“领取任务 / 返回交付”、任务完成提示里的“打开任务日志”都改为委托 `PlatformActionButton surface="editorDark"`;设置面板里的“运行统计”入口改为 `PlatformSubpanel as="button" surface="dark"`。像素风 choice button、HUD launcher、奖励物品格和输入 composer 这类 runtime 专属控件继续保留独立语义,不并回普通平台按钮。
|
||||||
|
22.3. NPC dark modal footer 与暗色明细空态继续向共享原子收口:`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"`;`CharacterInfoShared.tsx` 里的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带更强业务语义的控件继续保留局部实现。
|
||||||
|
22.4. 暗色 / 像素 modal 的标准 footer 布局统一收口到 `src/components/common/PlatformDarkModalFooter.tsx`;该 Module 只承接 dark footer 的顶部分隔线、padding 和常见动作区排布,不承接“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx` 的 `SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer、`InventoryItemViews.tsx` 的详情 footer wrapper,以及 `CompanionCampModal.tsx` 的“营地气氛”内容 footer 已接入。sticky 工作台 footer、正文里的单独 CTA 收尾和 runtime HUD 工具条继续保留局部布局;后续 dark / pixel modal 若只是同构 footer chrome,优先直接复用这个 Module,不再重复手写 `border-t border-white/10 + px/py + justify-end gap-*` 组合。验证命令:`npx vitest run src/components/common/PlatformDarkModalFooter.test.tsx src/components/CompanionCampModal.test.tsx src/components/NpcModals.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformTaskCompletionDialog.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/auth/BindPhoneScreen.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx -t "renders generated formal previews with accurate status copy"`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/UnifiedConfirmDialog.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/useCopyFeedback.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CopyFeedbackButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CopyCodeButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PublishShareModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyFeedbackButton.test.tsx src/components/common/CopyCodeButton.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx -t "BuildContributionDetailPanel|技能详情静态标签"`
|
||||||
|
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "CharacterSkillsList|supports dark compact subpanel cards"`
|
||||||
|
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/AdventureEntityModal.test.tsx -t "物品空态|技能详情静态标签"`
|
||||||
|
- `npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "主分区|最近回响|supports dark compact subpanel cards"`
|
||||||
|
- `npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "私聊和队友收束|tinted dark information panels"`
|
||||||
|
- `npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx -t "背包文书|背包工坊|supports dark compact subpanel cards|supports editor dark surface"`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "可扮演角色技能动作状态|supports dark compact subpanel cards"`
|
||||||
|
- `npm run test -- src/components/CharacterDetailModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "角色面板详情静态信息|tinted dark information panels"`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/BackstoryArchive.test.tsx src/components/AffinityStatusCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/MapModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformOverlayBadge.test.tsx src/components/common/PlatformSlotBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformQuantityBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain"`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|supports dark RPG badge tones"`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "adventure statistics panel|supports dark compact subpanel cards"`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformQuantityBadge.test.tsx -t "quest offer accept button|quest reward strip|quest completion notice|battle reward modal|supports dark compact subpanel cards|supports dark RPG badge tones|renders a dark bottom-right quantity badge"`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile played modal summary"`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal trusts per-product first bonus display after points recharge"`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users"`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "confirms virtual payment after returning without hash result|releases submitting state after cancelled wechat pay result"`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CopyFeedbackMessage.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformRuntimeStatusToast.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`
|
||||||
|
- `npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
||||||
|
- `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformProgressBar.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`
|
||||||
|
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色空态复用暗色平台空态"`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "settings save-disabled hint|dark editor dashed empty state"`
|
||||||
|
- `npm run test -- src/components/NpcModals.test.tsx -t "NPC 弹窗空态复用暗色平台空态"`
|
||||||
|
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "NPC 交易静态信息卡|supports dark compact subpanel cards"`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain|quest log empty state reuses dark PlatformEmptyState chrome"`
|
||||||
|
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformActionButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
||||||
|
- `npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/shared/PuzzleImageModelPicker.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx -t "shows publish failures in a dismissible modal"`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/game-canvas/GameCanvasEntityLayer.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillSwitch.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformToggleRow.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/CustomWorldResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformToggleRow.test.tsx src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal"`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut|profile redeem invite"`
|
||||||
|
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformFieldLabel.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile daily task"`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal shows native qr code"`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile played modal|profile page keeps save archives inside played stats panel"`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
|
||||||
|
- `npm run test -- src/components/rpg-runtime-shell/RpgRuntimeStageRouter.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger"`
|
||||||
|
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformTextField.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CharacterChatModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
||||||
|
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/auth/AccountModal.test.tsx src/components/auth/BindPhoneScreen.test.tsx src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "reward code|invite query|profile redeem invite|daily task"`
|
||||||
|
- `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/auth/AuthGate.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformStatGrid.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile played modal summary"`
|
||||||
|
- `npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformProgressBar.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
||||||
|
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
|
||||||
|
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
||||||
|
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
|
||||||
|
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "作品封面上传|tinted dark information panels"`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTagEditor.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
|
||||||
|
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformTextField.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformPillSwitch.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformModalCloseButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/squareImageCropModel.test.ts`
|
||||||
|
- `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`
|
||||||
|
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail"`
|
||||||
|
- `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`
|
||||||
|
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
||||||
|
- `npm run test -- src/components/auth/AuthGate.test.tsx`
|
||||||
|
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/auth/AccountModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/auth/AccountModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
|
||||||
|
- `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx`
|
||||||
|
- `npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx`
|
||||||
|
- `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
|
||||||
|
- `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformActionButton.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "保存修改|保存角色"`
|
||||||
|
- `npm run test -- src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
|
||||||
|
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
||||||
|
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformActionButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`
|
||||||
|
- `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
|
||||||
|
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
|
||||||
|
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`
|
||||||
|
- `rg -n "window\\.confirm|window\\.alert" src/components src/services src/hooks -g '*.tsx' -g '*.ts'`
|
||||||
|
- 涉及 JSX 迁移时同步运行对应页面交互测试。
|
||||||
@@ -194,7 +194,7 @@ cargo test -p api-server app --manifest-path server-rs/Cargo.toml
|
|||||||
|
|
||||||
后续建议继续拆分:
|
后续建议继续拆分:
|
||||||
|
|
||||||
- `match3d`: `draft.rs`、`background_and_cover.rs`、`material_sheet.rs`、`apimart_image.rs`。
|
- `match3d`: `draft.rs`、`background_and_cover.rs`、`material_sheet.rs`。
|
||||||
- `puzzle`: `session_form.rs`、`draft_compile.rs`、`image_provider.rs`、`errors.rs`。
|
- `puzzle`: `session_form.rs`、`draft_compile.rs`、`image_provider.rs`、`errors.rs`。
|
||||||
- `custom_world`: `publish_gate.rs`、`foundation_job.rs`、`foundation_assets.rs`、`errors.rs`。
|
- `custom_world`: `publish_gate.rs`、`foundation_job.rs`、`foundation_assets.rs`、`errors.rs`。
|
||||||
- `square_hole`: `config.rs`、`errors.rs`。
|
- `square_hole`: `config.rs`、`errors.rs`。
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ Handler 主要在 `story.rs`、`combat.rs`、`runtime_inventory.rs`:
|
|||||||
| Square Hole 图片重生成 | OpenAI/VectorEngine GPT image helper | URL 下载或 base64/data URL 解码 | `LegacyAssetPrefix::SquareHoleAssets` | 方洞作品图片槽位相关 kind | profile/work + image slot | 调用方包裹 | 生成成功但入库失败保留 Data URL 回包 |
|
| Square Hole 图片重生成 | OpenAI/VectorEngine GPT image helper | URL 下载或 base64/data URL 解码 | `LegacyAssetPrefix::SquareHoleAssets` | 方洞作品图片槽位相关 kind | profile/work + image slot | 调用方包裹 | 生成成功但入库失败保留 Data URL 回包 |
|
||||||
| Custom World 场景/封面 | VectorEngine GPT image 2 / OpenAI helper | URL 下载或 base64 解码 | `LegacyAssetPrefix::CustomWorldScenes` 等 | scene/cover/opening storyboard | `custom_world_profile` 或 profile/landmark/scene slot | `custom_world_ai.rs` 调用方包裹 | entity/scene 生成存在 LLM fallback;资产持久化失败按当前错误口径返回 |
|
| Custom World 场景/封面 | VectorEngine GPT image 2 / OpenAI helper | URL 下载或 base64 解码 | `LegacyAssetPrefix::CustomWorldScenes` 等 | scene/cover/opening storyboard | `custom_world_profile` 或 profile/landmark/scene slot | `custom_world_ai.rs` 调用方包裹 | entity/scene 生成存在 LLM fallback;资产持久化失败按当前错误口径返回 |
|
||||||
| Puzzle 图片 | GPT image 2 generations/edits | 无参考图 JSON 创建;有参考图 multipart 编辑;base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 fallback 保持原逻辑 |
|
| Puzzle 图片 | GPT image 2 generations/edits | 无参考图 JSON 创建;有参考图 multipart 编辑;base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 fallback 保持原逻辑 |
|
||||||
| Match3D 图片 | APIMart/VectorEngine/OpenAI image helper | 下载、切图、透明化、校准后入库 | `LegacyAssetPrefix::Match3DAssets` | cover/background/item material sheet,音频 kind 另列 | match3d profile/session slot | `match3d.rs` 调用方包裹 | 新草稿不回退 Rodin/GLB;部分连接错误按现有计费跳过规则处理 |
|
| Match3D 图片 | VectorEngine/OpenAI image helper | 下载、切图、透明化、校准后入库 | `LegacyAssetPrefix::Match3DAssets` | cover/background/item material sheet,音频 kind 另列 | match3d profile/session slot | `match3d.rs` 调用方包裹 | 新草稿不回退 Rodin/GLB;部分连接错误按现有计费跳过规则处理 |
|
||||||
| Visual Novel 音频 | VectorEngine Suno/Vidu | 任务提交后按 task publish 下载音频 | 视觉小说/creation audio scope | `visual_novel_music`、`visual_novel_ambient_sound` | `visual_novel_scene` + scene id + `music`/`ambient_sound` | `vector_engine_audio_generation.rs` 调用方包裹 | 上游/下载失败显式错误,不混入图片 Adapter |
|
| Visual Novel 音频 | VectorEngine Suno/Vidu | 任务提交后按 task publish 下载音频 | 视觉小说/creation audio scope | `visual_novel_music`、`visual_novel_ambient_sound` | `visual_novel_scene` + scene id + `music`/`ambient_sound` | `vector_engine_audio_generation.rs` 调用方包裹 | 上游/下载失败显式错误,不混入图片 Adapter |
|
||||||
| 通用音频 | VectorEngine Suno/Vidu | 同上 | creation audio scope | background_music/sound_effect 由调用方目标指定 | creation target entity/slot | 调用方包裹 | 不与 VN 场景语义混用 |
|
| 通用音频 | VectorEngine Suno/Vidu | 同上 | creation audio scope | background_music/sound_effect 由调用方目标指定 | creation target entity/slot | 调用方包裹 | 不与 VN 场景语义混用 |
|
||||||
| 视频 Opening CG | Ark/火山视频 + storyboard | 先生 storyboard,再图生视频,下载 remote video | Custom World 相关 prefix | `custom_world_opening_cg_storyboard`、`custom_world_opening_cg_video` | `custom_world_profile` + opening cg slots | `execute_billable_asset_operation_with_cost` 固定点数 | 配置缺失/超时显式错误,不应静默降级 |
|
| 视频 Opening CG | Ark/火山视频 + storyboard | 先生 storyboard,再图生视频,下载 remote video | Custom World 相关 prefix | `custom_world_opening_cg_storyboard`、`custom_world_opening_cg_video` | `custom_world_profile` + opening cg slots | `execute_billable_asset_operation_with_cost` 固定点数 | 配置缺失/超时显式错误,不应静默降级 |
|
||||||
|
|||||||
204
docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
Normal file
204
docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# 外部生成 Worker 化方案
|
||||||
|
|
||||||
|
更新时间:`2026-06-12`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
||||||
|
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
|
||||||
|
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
||||||
|
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
|
||||||
|
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
||||||
|
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
||||||
|
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
|
||||||
|
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。
|
||||||
|
|
||||||
|
## Module 与 Interface
|
||||||
|
|
||||||
|
新增深一点的 **外部生成任务 Module**,Interface 收敛为:
|
||||||
|
|
||||||
|
- `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。
|
||||||
|
- `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。
|
||||||
|
- `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。
|
||||||
|
- `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。
|
||||||
|
- `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。
|
||||||
|
- `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
|
||||||
|
- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
|
||||||
|
|
||||||
|
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
|
||||||
|
|
||||||
|
## BFF 状态接口
|
||||||
|
|
||||||
|
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
|
||||||
|
|
||||||
|
- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于 `我的` 页签、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
|
||||||
|
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
|
||||||
|
|
||||||
|
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
|
||||||
|
|
||||||
|
## 任务表
|
||||||
|
|
||||||
|
新增私有表 `external_generation_job`:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `job_id` | 主键,`extgen-` 前缀 UUID |
|
||||||
|
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
|
||||||
|
| `job_kind` | 执行类型,当前拼图为 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background` |
|
||||||
|
| `owner_user_id` | 触发用户 |
|
||||||
|
| `source_module` | 玩法或能力名,例如 `puzzle` |
|
||||||
|
| `source_entity_id` | session/profile/work 等作用域 |
|
||||||
|
| `request_label` | 排障标签 |
|
||||||
|
| `request_payload_json` | worker 执行入参 JSON |
|
||||||
|
| `status` | `pending/running/completed/failed/cancelled` |
|
||||||
|
| `attempt` / `max_attempts` | 当前尝试次数与最大尝试次数 |
|
||||||
|
| `last_error_message` | 最近失败原因 |
|
||||||
|
| `worker_id` | 当前 lease owner |
|
||||||
|
| `lease_expires_at` | lease 到期时间 |
|
||||||
|
| `lease_token` | 本次 claim 的 fencing token,用于阻止过期 worker 回写 |
|
||||||
|
| `available_at` | 下次可领取时间 |
|
||||||
|
| `result_payload_json` | 完成摘要 |
|
||||||
|
| `created_at/started_at/completed_at/updated_at` | 审计时间 |
|
||||||
|
|
||||||
|
索引:
|
||||||
|
|
||||||
|
- `by_external_generation_job_status_available(status, available_at)`
|
||||||
|
- `by_external_generation_job_worker_id(worker_id)`
|
||||||
|
- `by_external_generation_job_source(source_module, source_entity_id)`
|
||||||
|
- `by_external_generation_job_owner_user_id(owner_user_id)`
|
||||||
|
|
||||||
|
## 状态机
|
||||||
|
|
||||||
|
```text
|
||||||
|
pending -> running -> completed
|
||||||
|
pending -> running -> pending (可重试失败)
|
||||||
|
pending -> running -> failed (达到最大重试次数)
|
||||||
|
pending/running -> cancelled (预留)
|
||||||
|
```
|
||||||
|
|
||||||
|
`claim` 只领取 `pending` 且 `available_at <= now` 的任务,或 `running` 且 `lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id`、`started_at`、新的 `lease_expires_at` 和 `lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。
|
||||||
|
|
||||||
|
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 在 `queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
|
||||||
|
|
||||||
|
## 执行模式与进程角色
|
||||||
|
|
||||||
|
外部生成执行模式由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制:
|
||||||
|
|
||||||
|
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
|
||||||
|
- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
|
||||||
|
|
||||||
|
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
||||||
|
|
||||||
|
- `api`:只启动 HTTP server。
|
||||||
|
- `external-generation-worker`:只启动外部生成 worker,不监听 HTTP。
|
||||||
|
- `external-generation-controller`:只启动 worker controller,不监听 HTTP,也不直接执行外部生成任务。
|
||||||
|
- `all`:本地开发可同时启动 HTTP 与 worker。
|
||||||
|
|
||||||
|
worker 配置:
|
||||||
|
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`:实例 ID;未配置时用 hostname/pid 派生。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。
|
||||||
|
|
||||||
|
controller 配置:
|
||||||
|
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS`:保底 worker 实例数,生产默认 `1`,controller 不会主动停止 `@1`。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS`:自动扩容上限,生产模板默认 `8`。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER`:每个 worker 实例承担的目标未完成任务数,默认 `2`;目标实例数按 `claimable_pending + running_active + expired_running` 计算后夹在 min/max 之间,避免把已包含过期 running 的 `claimable_count` 重复计入。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS`:controller 轮询队列统计的间隔,默认 `10000`。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS`:连续多少轮无可领取、无运行中、无过期 running 后才允许缩容,默认 `6`;缩容每轮只停止最高编号的一个实例。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE`:systemd worker 模板,默认 `genarrative-external-generation-worker@{}.service`。
|
||||||
|
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN`:只记录决策不执行 systemctl,默认 `false`。
|
||||||
|
|
||||||
|
动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。
|
||||||
|
|
||||||
|
## 已接入的拼图纵切
|
||||||
|
|
||||||
|
### 拼图
|
||||||
|
|
||||||
|
`compile_puzzle_draft`:
|
||||||
|
|
||||||
|
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
|
||||||
|
2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。
|
||||||
|
3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。
|
||||||
|
4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover` 或 `compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。
|
||||||
|
5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。
|
||||||
|
6. 失败后调用 `mark_puzzle_draft_generation_failed`,拼图首期业务失败直接进入 failed;只有失败态写回成功才把队列 job 标为 failed,失败态写回失败则保留租约等待重领。队列仍保留 lease 过期后的崩溃重领,避免 worker 退款后再次成功导致钱包账本漂移。前端通过现有失败草稿/弹窗机制展示来源错误。
|
||||||
|
|
||||||
|
`generate_puzzle_images`:
|
||||||
|
|
||||||
|
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
||||||
|
2. worker 执行原结果页关卡图链路:自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。
|
||||||
|
3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。
|
||||||
|
4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。
|
||||||
|
|
||||||
|
`generate_puzzle_ui_background`:
|
||||||
|
|
||||||
|
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
||||||
|
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
|
||||||
|
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
|
||||||
|
|
||||||
|
### 跳一跳、拼消消和敲木鱼扩展范围
|
||||||
|
|
||||||
|
以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列:
|
||||||
|
|
||||||
|
- 跳一跳 `jump-hop`
|
||||||
|
- `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`。
|
||||||
|
- `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`。
|
||||||
|
- 拼消消 `puzzle-clear`
|
||||||
|
- `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`。
|
||||||
|
- `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`。
|
||||||
|
- 敲木鱼 `wooden-fish`
|
||||||
|
- `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`。
|
||||||
|
- `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`。
|
||||||
|
|
||||||
|
这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
|
||||||
|
|
||||||
|
每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
基础检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run spacetime:generate
|
||||||
|
npm run check:spacetime-schema
|
||||||
|
npm run check:server-rs-ddd
|
||||||
|
cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
定向测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml
|
||||||
|
cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml
|
||||||
|
cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml
|
||||||
|
npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible"
|
||||||
|
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft"
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 smoke:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GENARRATIVE_PROCESS_ROLE=all npm run dev
|
||||||
|
curl -f http://127.0.0.1:<api-port>/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。
|
||||||
|
|
||||||
|
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
|
||||||
|
|
||||||
|
systemd 生产 controller 与手动兜底示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl enable --now genarrative-external-generation-worker@1.service
|
||||||
|
systemctl enable --now genarrative-external-generation-controller.service
|
||||||
|
systemctl start genarrative-external-generation-worker@2.service
|
||||||
|
systemctl stop genarrative-external-generation-worker@2.service
|
||||||
|
systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'
|
||||||
|
```
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
- `GET /admin/api/works/visibility`
|
- `GET /admin/api/works/visibility`
|
||||||
- `POST /admin/api/works/visibility`
|
- `POST /admin/api/works/visibility`
|
||||||
|
|
||||||
后台操作 key 使用统一的 `sourceType + profileId` 组合。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。
|
后台操作 key 使用统一的 `sourceType + profileId` 组合。当前后端统一可见性管理覆盖 `puzzle`、`puzzle-clear`、`custom-world`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`big-fish` 和 `bark-battle`;`edutainment` 当前没有后端统一作品源表,暂不接入该后台能力。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。
|
||||||
|
|
||||||
该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry` 与 `public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。
|
该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry` 与 `public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
- 旧 view 退到底层 source / 兼容职责。
|
- 旧 view 退到底层 source / 兼容职责。
|
||||||
- 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
|
- 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
|
||||||
- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。
|
- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。
|
||||||
|
- RPG / 自定义世界旧数据可能缺少 `published_at`。统一公开详情可以用 `updated_at` 作为展示和排序兜底;点赞、游玩、Remix 等写入路径也必须按 `publication_status=Published + visible=true + 未删除` 判断作品存在,不能额外要求 `published_at` 非空。
|
||||||
- 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view` 的 `publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。
|
- 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view` 的 `publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。
|
||||||
- 旧 `/api/runtime/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
|
- 旧 `/api/runtime/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
|
||||||
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
|
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。
|
||||||
|
|||||||
82
docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md
Normal file
82
docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 本地 SSH 服务器管理面板技术方案
|
||||||
|
|
||||||
|
日期:`2026-06-11`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
release / dev 等服务器的日常巡检已经有 `genarrative-health-patrol.timer`、`/readyz`、`/healthz`、SpacetimeDB `/v1/ping` 和 systemd 状态文件,但开发者本地仍需要在多个 SSH alias 之间切换命令。服务器管理面板用于把这些只读巡检和少量 systemd 服务操作收敛到一个本地桌面入口。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 使用 Rust `egui` / `eframe` 实现本地桌面面板,不接入线上 Web 后台,不暴露公网端口。
|
||||||
|
- 从本机 `~/.ssh/config` 的 `Host` alias 发现服务器;只展示不含通配符的 alias。
|
||||||
|
- 支持多个服务器,左侧服务器侧边栏可收起。
|
||||||
|
- 主面板展示硬件状态、服务状态、HTTP 健康探测和生产健康巡检状态。
|
||||||
|
- 支持对允许的 systemd unit 执行启动、关闭、重启。
|
||||||
|
|
||||||
|
## 命令入口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server-manager:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
等价于:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
面板启动时会自动查找本机中文字体并注入 egui 字体集,优先使用 `Noto Sans CJK SC`,其次使用文泉驿 / Droid fallback。若某台开发机字体路径特殊,可用 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指定;普通 `.ttf` 可省略 `|index`。
|
||||||
|
|
||||||
|
## SSH 约定
|
||||||
|
|
||||||
|
本地 `~/.ssh/config` 中需要存在类似:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host dev
|
||||||
|
HostName 10.2.0.10
|
||||||
|
User genarrative
|
||||||
|
|
||||||
|
Host release
|
||||||
|
HostName genarrative.world
|
||||||
|
User genarrative
|
||||||
|
```
|
||||||
|
|
||||||
|
面板通过 `ssh <alias> sh -s` 执行远端只读巡检脚本。服务操作使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -n systemctl <start|stop|restart> <unit>
|
||||||
|
```
|
||||||
|
|
||||||
|
若 SSH 用户是 root,则直接执行 `systemctl`。非 root 用户需要提前配置只允许目标 unit 的无密码 sudo;否则面板会显示 sudo 权限错误,不会弹出交互密码输入。
|
||||||
|
|
||||||
|
## 健康检查内容
|
||||||
|
|
||||||
|
只读巡检覆盖:
|
||||||
|
|
||||||
|
- 主机名、内核、运行时长、CPU 核数 / 型号、load average。
|
||||||
|
- 内存 / swap 使用情况。
|
||||||
|
- `/`、`/var`、`/opt`、`/stdb`、`/data` 中存在路径的磁盘使用率。
|
||||||
|
- `genarrative-api.service`、`spacetimedb.service`、`nginx.service`、`genarrative-health-patrol.timer`、`genarrative-database-backup.timer` 的 systemd 状态。
|
||||||
|
- `http://127.0.0.1:8082/healthz`、`/readyz`、`http://127.0.0.1:3101/v1/ping` 和代表性公开接口。
|
||||||
|
- `/var/lib/genarrative/health-patrol/status.json` 的最近巡检状态。
|
||||||
|
- 若服务器安装了 `sensors`,附带温度 / 风扇等硬件传感器摘要。
|
||||||
|
|
||||||
|
## 服务操作安全边界
|
||||||
|
|
||||||
|
面板只允许 `start`、`stop`、`restart` 三种动作,并且 unit 名必须匹配安全字符集:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A-Z a-z 0-9 . _ - @ :
|
||||||
|
```
|
||||||
|
|
||||||
|
服务操作会先出现确认弹窗,避免误点。第一版默认列出 Genarrative 生产相关 unit,并提供“其他 unit”输入框;该输入框仍只会执行 `systemctl` 的三种受控动作,不提供任意命令执行入口。
|
||||||
|
|
||||||
|
## 状态判定
|
||||||
|
|
||||||
|
- service / HTTP 探测失败:`CRITICAL`。
|
||||||
|
- 磁盘使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||||
|
- 内存使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||||
|
- 生产健康巡检状态沿用 `OK / WARNING / CRITICAL`。
|
||||||
|
|
||||||
|
面板状态只是本地巡检视图,最终运维事实仍以服务器上的 systemd、journal、Nginx 日志、`production-health-patrol.mjs` 输出和现有部署文档为准。
|
||||||
123
docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md
Normal file
123
docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# 拼消消玩法模板技术方案
|
||||||
|
|
||||||
|
日期:`2026-05-30`
|
||||||
|
|
||||||
|
## 总体边界
|
||||||
|
|
||||||
|
拼消消使用独立工程域 `puzzle-clear`,不复用拼图运行态规则本体。实现按 DDD 分层:
|
||||||
|
|
||||||
|
- `module-puzzle-clear`:纯领域规则,覆盖图案组规划、棋盘、交换、半锁定、消除、补牌、防死局、关卡状态。
|
||||||
|
- `shared-contracts` / `packages/shared`:工作台输入、生成素材、结果页、作品摘要、runtime snapshot 与 action DTO。
|
||||||
|
- `spacetime-module`:session、work profile、runtime run、事件 / 统计、公开 source view。
|
||||||
|
- `spacetime-client`:typed facade 与 row mapper。
|
||||||
|
- `api-server`:Axum 路由、鉴权、入口熔断、生成编排、资产持久化、BFF。
|
||||||
|
- `platform-image` / OSS / asset object:图片生成、切图、上传、换签和失败审计。
|
||||||
|
- 前端:轻表单、生成页、结果页与 runtime 动画,不承接正式业务真相。
|
||||||
|
|
||||||
|
## 资产生成方案
|
||||||
|
|
||||||
|
素材目标从“单张超大 atlas 生图”收敛为 4 张素材工作表,再由服务端合成最终 atlas:
|
||||||
|
|
||||||
|
- image2 调用:4 次,每次生成 1 张 `1024x1536` 竖版素材工作表。
|
||||||
|
- sheet 裁切:每张按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`。
|
||||||
|
- 最终 atlas:服务端把 95 个切片按领域坐标合成 `10x10 / 2560x2560` PNG,空单元保留浅色背景。
|
||||||
|
- 运行态素材:最终写回 `35` 个复合图案组和 `95` 个 1x1 卡牌切片;`sheet-03` 的第 6 行第 4 列为 `FILL` 补位格,只为填满 4x6 工作表,生成后会被服务端丢弃,不进入最终 atlas 或运行态卡牌。
|
||||||
|
|
||||||
|
服务端固定布局如下:
|
||||||
|
|
||||||
|
| 形状 | 数量 | 单组单元数 | 解锁 |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| 1x2 | 23 | 2 | 第 1 关 |
|
||||||
|
| 1x3 | 5 | 3 | 第 1 关 |
|
||||||
|
| 2x2 | 4 | 4 | 第 1 关 |
|
||||||
|
| 2x3 | 3 | 6 | 第 1 关 |
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
主题词 / 场地底图主题词 / 用户底图 -> 4 张 sheet 坐标规划 -> gpt-image-2 生成素材工作表 -> 按 4x6 裁切 1x1 -> 合成最终 atlas -> atlas 与卡牌切片持久化 -> OSS / asset_object / bind -> session draft 回写
|
||||||
|
```
|
||||||
|
|
||||||
|
中央场地底图的 prompt 来源固定为:若用户填写 `boardBackgroundPrompt`,AI 生成底图只读取该字段;若该字段为空,才回退读取 `themePrompt`。用户直接上传底图资产时不再用主题词重写该资产,只执行平台资产持久化与换签。中央场地底图在运行态不是普通棋盘衬底,而是玩家逐渐消除卡牌后露出的主题目标图;生成请求使用与中央棋盘一致的 1:1 正方形尺寸,prompt 必须强调探索、揭开全貌、追求完成目标、精致主题主视觉和强主题表现,不写“画面干净”或“适合作为卡牌棋盘底图”。
|
||||||
|
|
||||||
|
### 素材工作表风险与切片验证
|
||||||
|
|
||||||
|
风险:4x6 工作表 prompt 仍需要告诉 provider 编号布局;如果模型把布局理解成 UI 海报、说明图或卡牌模板,可能画出文字、编号、边框、切分线、贴纸外框或重复主体。若 provider 无法严格按布局输出,切片后可能出现跨格、主体贴边、重复图案、文字或图案错位。
|
||||||
|
|
||||||
|
验证策略:
|
||||||
|
|
||||||
|
- 生图 prompt 明确要求照片式构图 / 绘本式渲染的主题微场景拼图卡,每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面,禁止出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右 / 上下两块不同背景,场景变化只能发生在 256 单元边界上。
|
||||||
|
- 同编号连续格表示同一视觉家族,不是随机独立小图;同组格子要共享同一场景锚点、主色和道具语言,像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族。
|
||||||
|
- 同一张 sheet 内不同编号必须发散成不同视觉概念;以水果为例,应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等微场景,禁止同品种主体换角度、换大小或换姿势后重复出现。
|
||||||
|
- 每个 256x256 小卡切片独立查看时也要有可辨识的背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素,避免“孤立主体 + 纯色背景”导致运行态难区分。
|
||||||
|
- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线、纯色背景、白底商品图、孤立主体、同品种重复和同一物体多角度。
|
||||||
|
- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2`、`1x3`、`2x2`、`2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1,横向 `1x2` 按纵向切线分成两个 1x1,其他形状同理。图案组可以在语义上成组,但不能把一张大图的照片边界或拼贴边界落在单个 1x1 单元内部。
|
||||||
|
- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 正式编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形;`FILL` 补位格不参与校验、切片、atlas 合成和运行态。
|
||||||
|
- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部明显人工拼贴式分割需要硬失败;内部强边缘检测必须同时满足“贯穿大部分高度或宽度”和“两侧近似低纹理平铺色块”,避免把照片式微场景里的窗框、桌沿、地平线等自然结构误杀。非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。
|
||||||
|
- 每张 sheet 生成最多尝试 4 次;除质量门禁失败外,VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时也应消耗下一次 sheet attempt,避免上游 nginx 偶发 502 或单次拼贴式坏图直接把草稿置为 failed。
|
||||||
|
- 前端拼消消创作 action 的请求等待窗口为 40 分钟,用于覆盖 VectorEngine 单张图偶发 10 分钟以上的慢返回;这只是本地验收稳定性兜底,后续若继续优化体验,应把素材生成迁到后台任务 / 轮询进度链路。
|
||||||
|
- sheet 多次生成仍未通过硬质量门禁时,生成任务进入 `failed` 并写入错误原因;不得把明显空白格污染或主体缺失的工作表切成正式卡牌资产。
|
||||||
|
- 首版若当前 provider 无法稳定产出可切 atlas,生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。
|
||||||
|
- 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey` 或 `placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。
|
||||||
|
- 技术回退需要用户确认后才能改成更多 sheet、降低切片规格或改为逐图生成;当前需求固定为 4 张 `1024x1536` sheet 与最终 `2560x2560` atlas。
|
||||||
|
|
||||||
|
## 领域规则
|
||||||
|
|
||||||
|
`module-puzzle-clear` 已固定以下规则:
|
||||||
|
|
||||||
|
- 关卡配置:单关 `6x6/35`,600 秒。
|
||||||
|
- 图案组配比:`1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`。
|
||||||
|
- 开局随机铺满并保证至少一步可解。
|
||||||
|
- 补牌按列重力下落;补牌后仍保证至少一步可解。
|
||||||
|
- 完整图案组消除并清空对应格。
|
||||||
|
- 半锁定拼接组只由玩家主动交换 / 撞入打散,补牌不破坏。
|
||||||
|
- 超时失败只作用于当前单关,可重试;完成 35 次消除目标并清空棋盘后整局完成。
|
||||||
|
|
||||||
|
## API 命名空间
|
||||||
|
|
||||||
|
- `POST /api/creation/puzzle-clear/sessions`
|
||||||
|
- `GET /api/creation/puzzle-clear/sessions/{sessionId}`
|
||||||
|
- `POST /api/creation/puzzle-clear/sessions/{sessionId}/actions`
|
||||||
|
- `GET /api/creation/puzzle-clear/works`
|
||||||
|
- `GET /api/creation/puzzle-clear/works/{profileId}`
|
||||||
|
- `POST /api/creation/puzzle-clear/works/{profileId}/publish`
|
||||||
|
- `GET /api/runtime/puzzle-clear/works/{profileId}`
|
||||||
|
- `POST /api/runtime/puzzle-clear/runs`
|
||||||
|
- `POST /api/runtime/puzzle-clear/runs/{runId}/swap`
|
||||||
|
- `POST /api/runtime/puzzle-clear/runs/{runId}/retry-level`
|
||||||
|
- `POST /api/runtime/puzzle-clear/runs/{runId}/next-level`
|
||||||
|
- `POST /api/runtime/puzzle-clear/runs/{runId}/time-up`
|
||||||
|
|
||||||
|
api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`,不得新增前端硬编码事实源。
|
||||||
|
|
||||||
|
## Runtime 事件与统计载荷
|
||||||
|
|
||||||
|
正式 `published` run 记录开局、全局完成、当前关失败、耗时和消除统计。runtime action 返回的终态事件包括:
|
||||||
|
|
||||||
|
- `run-finished`:第 1 关完成并结束整局,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。
|
||||||
|
- `level-failed`:当前关超时失败,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。
|
||||||
|
|
||||||
|
草稿试玩只消费同一份 snapshot/action 结果做表现,不写正式统计。
|
||||||
|
|
||||||
|
## 前端阶段
|
||||||
|
|
||||||
|
新增阶段:
|
||||||
|
|
||||||
|
- `puzzle-clear-workspace` -> `/creation/puzzle-clear`
|
||||||
|
- `puzzle-clear-generating` -> `/creation/puzzle-clear/generating`
|
||||||
|
- `puzzle-clear-result` -> `/creation/puzzle-clear/result`
|
||||||
|
- `puzzle-clear-runtime` -> `/runtime/puzzle-clear`
|
||||||
|
|
||||||
|
runtime 移动端优先,首屏结构为顶部倒计时 / 单关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出和列补牌延迟下落,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。
|
||||||
|
|
||||||
|
## 验证计划
|
||||||
|
|
||||||
|
- `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`
|
||||||
|
- `cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`
|
||||||
|
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml puzzle_clear_compile_requires_real_atlas_assets_from_api_server`
|
||||||
|
- `npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`
|
||||||
|
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
|
||||||
|
- `npm run test -- src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`
|
||||||
|
- `npm run check:encoding`
|
||||||
|
- `npm run typecheck`
|
||||||
|
- 接入 SpacetimeDB schema 后:`npm run spacetime:generate`、`npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`
|
||||||
81
docs/test-cases/【测试用例】敲木鱼音频延迟上传与本地标准化-2026-06-06.md
Normal file
81
docs/test-cases/【测试用例】敲木鱼音频延迟上传与本地标准化-2026-06-06.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 敲木鱼音频延迟上传与本地标准化测试用例
|
||||||
|
|
||||||
|
## 覆盖目标
|
||||||
|
|
||||||
|
- 选择上传或录音结束后只在浏览器本地处理,不请求 OSS 上传凭证。
|
||||||
|
- 用户点击 `生成` 时才上传处理后的音频 Blob/File,并把确认后的 `WoodenFishAudioAsset` 放入创建 session payload。
|
||||||
|
- 上传和录音统一执行前后声音过小片段裁切、最长 1 秒限制、近似 `-15 LKFS` 响度平衡和峰值保护。
|
||||||
|
- 音频面板明确显示 `最长 1 秒`,并正确处理上传、录音、重置、禁用和错误状态。
|
||||||
|
- OSS 上传 client 只接收 Blob/File,不接受 Data URL,并覆盖上传凭证、OSS POST、资产确认和错误分支。
|
||||||
|
|
||||||
|
## 音频处理 helper
|
||||||
|
|
||||||
|
- 空文件:`size=0`,报 `音频文件为空,请重新选择。`
|
||||||
|
- 非音频 MIME:`text/plain`,报 `请选择音频文件。`
|
||||||
|
- 浏览器没有 `AudioContext`:报 `当前浏览器不支持音频处理。`
|
||||||
|
- `decodeAudioData` 失败:报 `音频解码失败,请重新选择。`
|
||||||
|
- 全静音或声音全低于阈值:报 `音频声音过小,请重新录制或上传。`
|
||||||
|
- 前后静音裁切:低于阈值的头尾帧被裁掉,`startFrame` 和 `frameCount` 正确。
|
||||||
|
- 裁切后刚好 `1000ms`:允许通过。
|
||||||
|
- 裁切后超过 `1000ms`:报 `音频最长 1 秒。`
|
||||||
|
- 上传来源 `uploaded` 与录音来源 `recorded`:返回 pending asset 保留对应 source。
|
||||||
|
- 原文件名有扩展名:输出 `.wav` 文件名;无扩展名补 `.wav`;空白文件名输出 `creative-audio.wav`。
|
||||||
|
- `URL.createObjectURL` 存在:`audioSrc` / `previewUrl` 为 blob URL;不存在时返回空字符串且不阻断处理。
|
||||||
|
- 近似响度平衡:低 RMS 样本被拉向 `-15 LKFS` 目标。
|
||||||
|
- 峰值保护:高峰值样本增益后不超过 `peakCeiling`。
|
||||||
|
- 零能量 section:归一化阶段报声音过小。
|
||||||
|
- WAV 编码:写入 RIFF/WAVE/data header、PCM16 数据长度和采样值。
|
||||||
|
|
||||||
|
## 音频输入面板
|
||||||
|
|
||||||
|
- 传入 `limitLabel` 时显示 `最长 1 秒`;未传入时不显示限制标签。
|
||||||
|
- 无资产时显示默认音效文案。
|
||||||
|
- 有资产且 `audioSrc` 存在时渲染 `<audio controls>`。
|
||||||
|
- 有资产但无 `audioSrc` 时显示 `音效已选择`。
|
||||||
|
- 点击重置调用 `onAssetChange(null)`。
|
||||||
|
- 上传取消选择时不读取音频、不写入资产。
|
||||||
|
- 上传成功后调用 `readFileAsAsset(file, 'uploaded')`,清空错误并写入资产。
|
||||||
|
- 上传失败时展示错误,不写入资产。
|
||||||
|
- 浏览器不支持录音时提示 `当前浏览器不支持录音。`
|
||||||
|
- 麦克风启动失败时透传启动错误。
|
||||||
|
- 录音停止后把 Blob 包成 File,并以 `recorded` 来源读取。
|
||||||
|
- 录音保存失败时展示错误。
|
||||||
|
- disabled 状态不启动录音,文件输入禁用。
|
||||||
|
|
||||||
|
## 木鱼工作台链路
|
||||||
|
|
||||||
|
- 音频面板显示 `最长 1 秒`,并只保留上传和录音入口。
|
||||||
|
- 选择上传音频后只调用本地处理 helper,不调用 `uploadWoodenFishHitSoundAsset`。
|
||||||
|
- 点击 `生成` 且有 pending 音频时,先上传处理后的 WAV,再调用 `woodenFishClient.createSession`。
|
||||||
|
- 上传给 OSS 的文件是处理后的 WAV,文件名和 MIME 为 `.wav` / `audio/wav`。
|
||||||
|
- 提交 payload 使用 OSS confirmed asset,不包含 `data:audio`。
|
||||||
|
- 未选择音频时不上传 OSS,payload 使用默认木鱼音。
|
||||||
|
- 处理阶段报超过 1 秒时展示错误,不写入用户音频;继续生成时走默认木鱼音。
|
||||||
|
- OSS 上传失败时停留工作台,展示错误,不创建 session。
|
||||||
|
- `createSession` 失败时停留工作台,展示错误。
|
||||||
|
- 提交中重复点击 `生成` 不重复上传、不重复创建 session。
|
||||||
|
- 替换本地音频时回收旧 `previewUrl`。
|
||||||
|
|
||||||
|
## 木鱼音频上传 client
|
||||||
|
|
||||||
|
- 空 Blob/File、超过 20MB、非音频 MIME 均在本地拒绝,不创建上传凭证。
|
||||||
|
- File 上传默认使用 `file.name` 和 `file.type`。
|
||||||
|
- Blob 上传支持通过显式文件名扩展推断 `audio/wav` 等音频 MIME。
|
||||||
|
- Blob 缺少 MIME 且扩展未知时拒绝上传。
|
||||||
|
- direct upload ticket 请求包含 `legacyPrefix`、path segments、fileName、contentType、access、maxSizeBytes 和木鱼音频 metadata。
|
||||||
|
- OSS POST 成功后调用 `/api/assets/objects/confirm`。
|
||||||
|
- OSS POST 非 2xx 时提示 `上传敲击音效失败。`,不确认资产对象。
|
||||||
|
- confirm 失败时透传确认错误。
|
||||||
|
- confirm 请求包含 bucket、objectKey、contentType、contentLength、assetKind、accessPolicy 和 entityId。
|
||||||
|
- 成功返回的 `WoodenFishAudioAsset` 包含 assetObjectId、audioObjectKey、audioSrc、source 和 prompt。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- src/components/common/creativeAudioProcessing.test.ts
|
||||||
|
npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx
|
||||||
|
npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx
|
||||||
|
npm run test -- src/services/wooden-fish/woodenFishAssetClient.test.ts
|
||||||
|
npm run typecheck
|
||||||
|
npm run check:encoding
|
||||||
|
```
|
||||||
40
docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md
Normal file
40
docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# SpacetimeDB 连接池租约 Drop 兜底与取消安全
|
||||||
|
|
||||||
|
- 日期:2026-06-11
|
||||||
|
- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。
|
||||||
|
- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs`
|
||||||
|
|
||||||
|
## 故障根因
|
||||||
|
|
||||||
|
修复前的连接池存在两个叠加缺陷:
|
||||||
|
|
||||||
|
1. **租约没有 Drop 兜底**。`PooledConnectionLease` 只能通过显式 `release_connection` 归还。当 HTTP 请求方在等待 StDB 回包期间断开(前端超时、用户刷新、Nginx 截断),axum/hyper 会直接丢弃 handler future,租约被 Drop:permit 因 `OwnedSemaphorePermit` 自动归还,但槽位的 `in_use` 标记永远不会复位。
|
||||||
|
2. **acquire 在槽位泄漏后永久空转**。后续请求拿到 permit 后进入 `loop { 扫描槽位; yield_now }`,找不到空闲槽位就无限自旋,且这段自旋不受 `procedure_timeout` 约束,自旋期间 permit 不归还。
|
||||||
|
|
||||||
|
叠加效果:StDB 一旦变慢(请求占用连接接近 45 秒),客户端取消请求的概率大增,每次取消泄漏一个槽位并连带吞掉一个 permit;泄漏数量达到 `pool_size`(release 为 8)后,所有业务请求与健康检查全部在 `pool_acquire` 阶段 45 秒超时,服务表现为"连不上 StDB",只有重启能恢复。
|
||||||
|
|
||||||
|
## 本地复现
|
||||||
|
|
||||||
|
不需要真实 SpacetimeDB,单元测试即可复现机制(位于 `spacetime-client` tests 模块):
|
||||||
|
|
||||||
|
- 修复前:将一个槽位置为 `in_use=true` 后调用 `acquire_connection_with_timeout(200ms)`,acquire 在 5 秒守护窗口内不返回(永久自旋),测试红。
|
||||||
|
- `dropped_lease_releases_slot_and_permit`:模拟"请求被取消、租约未经 release 直接 Drop",断言槽位与 permit 都被复位归还。
|
||||||
|
- `acquire_times_out_at_pool_acquire_when_pool_is_busy`:池内 permit 全部被占用时,acquire 必须在超时窗口内返回 `PoolAcquire + Timeout`,不允许无限等待。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
1. `PooledConnectionSlot` 改为 `in_use: AtomicBool + connection: Mutex<Option<PooledConnection>>`,槽位占用标记不再依赖异步锁。
|
||||||
|
2. `PooledConnectionLease` 持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop`:无论显式归还还是 future 被取消,统一在 Drop 中复位槽位、按 broken 状态决定连接是否回池,permit 随后自动归还。Drop 体先复位 `in_use` 再释放 permit(字段在 Drop 体之后析构),保证新请求拿到 permit 时必有空闲槽位。
|
||||||
|
3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
|
||||||
|
4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(35 通过,含上述新测试)
|
||||||
|
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz`(2 通过)
|
||||||
|
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
|
||||||
|
|
||||||
|
## 运维提示
|
||||||
|
|
||||||
|
- 此修复解决的是"取消导致的永久泄漏"。StDB 真慢时仍会出现成批 45 秒超时(连接被在途请求合法占用),那是容量/上游问题,应结合 `GENARRATIVE_SPACETIME_POOL_SIZE` 与 StDB 负载排查,不要再怀疑池泄漏。
|
||||||
|
- 健康检查 `/readyz` 在池被在途请求占满时仍可能短暂 503(stage=pool_acquire),恢复后自动转好,无需重启。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# server-rs 与 SpacetimeDB 数据契约
|
# server-rs 与 SpacetimeDB 数据契约
|
||||||
|
|
||||||
更新时间:`2026-05-15`
|
更新时间:`2026-06-12`
|
||||||
|
|
||||||
## 后端主线
|
## 后端主线
|
||||||
|
|
||||||
@@ -16,13 +16,13 @@ server-rs + Axum + SpacetimeDB
|
|||||||
|
|
||||||
`server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。
|
`server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。
|
||||||
|
|
||||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.3.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `2.3.0` 对齐,避免 BSATN / procedure result 反序列化错配。
|
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.5.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。
|
||||||
|
|
||||||
当前主要 crate:
|
当前主要 crate:
|
||||||
|
|
||||||
- HTTP 服务:`api-server`。
|
- HTTP 服务:`api-server`。
|
||||||
- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。
|
- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。
|
||||||
- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-speech`。
|
- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。
|
||||||
- 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。
|
- 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。
|
||||||
- SpacetimeDB:`spacetime-client`、`spacetime-module`。
|
- SpacetimeDB:`spacetime-client`、`spacetime-module`。
|
||||||
- 测试支撑:`tests-support`。
|
- 测试支撑:`tests-support`。
|
||||||
@@ -35,6 +35,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
|
|||||||
4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。
|
4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。
|
||||||
5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。
|
5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。
|
||||||
6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口。
|
6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口。
|
||||||
|
7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载微信协议 client、订阅消息 `stable_token` / `subscribeMessage.send`、微信支付 V3 / 虚拟支付消息推送的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。
|
||||||
|
|
||||||
验证:
|
验证:
|
||||||
|
|
||||||
@@ -53,12 +54,13 @@ npm run check:server-rs-ddd
|
|||||||
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
||||||
|
|
||||||
- 健康检查:`GET /healthz`。
|
- 健康检查:`GET /healthz`。
|
||||||
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
|
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
|
||||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。
|
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||||
- LLM 与语音:`/api/llm/*`、`/api/speech/volcengine/*`。
|
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||||
- 资产:`/api/assets/*`,包括直传票据、STS、对象确认、实体绑定、读签名、读 bytes、历史资产、角色图像/动画和 Hyper3D 代理。
|
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。
|
||||||
- 创作入口配置:`/api/creation-entry/config`,后台 `/admin/api/creation-entry/config` 和 `/admin/api/creation-entry/config/banners`。
|
- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`。
|
||||||
|
- 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。
|
||||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||||
- 拼图:`/api/runtime/puzzle/*`。
|
- 拼图:`/api/runtime/puzzle/*`。
|
||||||
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
||||||
@@ -66,17 +68,30 @@ npm run check:server-rs-ddd
|
|||||||
- 方洞挑战:`/api/creation/square-hole/*`、`/api/runtime/square-hole/*`。
|
- 方洞挑战:`/api/creation/square-hole/*`、`/api/runtime/square-hole/*`。
|
||||||
- 视觉小说:`/api/creation/visual-novel/*`、`/api/runtime/visual-novel/*`。
|
- 视觉小说:`/api/creation/visual-novel/*`、`/api/runtime/visual-novel/*`。
|
||||||
- 大鱼吃小鱼:`/api/runtime/big-fish/*`。
|
- 大鱼吃小鱼:`/api/runtime/big-fish/*`。
|
||||||
|
- 跳一跳:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`。
|
||||||
- 汪汪声浪:`/api/runtime/bark-battle/*`。
|
- 汪汪声浪:`/api/runtime/bark-battle/*`。
|
||||||
- 儿童向创作:`/api/creation/edutainment/*`。
|
- 儿童向创作:`/api/creation/edutainment/*`。
|
||||||
- AI task:`/api/ai/tasks*`。
|
|
||||||
|
|
||||||
需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。
|
需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。涉及创作、生成、作品、公开详情、试玩、正式运行态、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、AI task、角色资产工坊或玩法生成支撑资产的路由,不再直接在 `app.rs` 逐玩法 `.merge(...)`,也不挂到 `modules/platform.rs`;必须先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 的统一玩法流程主干,再由主干注册表分发到各领域 HTTP Adapter 或支撑能力 handler。
|
||||||
|
|
||||||
|
### 创作 / 游玩统一流程主干
|
||||||
|
|
||||||
|
`modules/play_flow.rs` 是后端创作与游玩流程的统一入口。现有外部 URL、DTO、错误 envelope、鉴权方式、入口开关语义和 SpacetimeDB schema 默认不变,但路由组织必须遵循:
|
||||||
|
|
||||||
|
1. `app.rs` 只合并 `modules::play_flow::router(state)`,不直接合并 RPG、拼图、抓大鹅、跳一跳、敲木鱼、拼消消、汪汪声浪、视觉小说或儿童向创作等逐玩法模块。
|
||||||
|
2. `play_flow` 统一注册每个玩法的 `playId`、领域模块 key、创作路由前缀和运行态路由前缀;后续新增玩法或迁移旧玩法时,先补这个注册表,再挂具体领域模块路由。
|
||||||
|
3. 新建创作、首次生成和 Remix 成草稿等会产生新创作的入口开关匹配规则同样归 `play_flow` 管理;`creation_entry_config.rs` 只复用该规则执行 `open=false` 熔断,不再维护第二份路径判断。
|
||||||
|
4. `play_flow` 在进入领域 handler 前先解析并挂载 `PlayFlowRequestContext`,统一标记请求处于 `Creation`、`Runtime`、`CreationEntryConfig`、`CreationSupport`、`RuntimeSupport`、`AiTask`、`PublicReadModel` 或 `RuntimeInventory` 阶段,并记录目标 `playId` / 领域模块 key;领域 handler 可以读取该上下文做后续收口,但不能绕过主干自建平行流程。
|
||||||
|
5. `play_flow` 只做平台共性编排和领域 Adapter 组合,不下沉玩法规则;最后一步的草稿编译、资产生成、发布、运行态 start/action/finish、计分和排行榜仍交给对应 `module-*`、`spacetime-module` procedure 和玩法 HTTP handler 处理。
|
||||||
|
6. 公开作品聚合、作品详情、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理属于跨玩法或玩法支撑流程,也从 `play_flow` 主干挂入;`modules/platform.rs` 只保留通用 LLM / 语音代理,不再承接创作 / 游玩支撑路由。
|
||||||
|
7. 如果某个旧玩法仍使用历史 `/api/runtime/<play>/agent/*` 作为创作命名空间,只保留外部兼容路径;新增实现和文档仍按“统一主干 -> 领域 Adapter”的语义描述,不把历史路径当新架构模板。
|
||||||
|
|
||||||
### 认证态用户与会话摘要下发口径
|
### 认证态用户与会话摘要下发口径
|
||||||
|
|
||||||
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`。
|
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。
|
||||||
- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。
|
- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。
|
||||||
- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。
|
- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。
|
||||||
|
- 多端登录语义以 `refresh_session` 为粒度:同一账号可保留多个 active session,普通登录不会撤销旧设备;`POST /api/auth/logout` 只撤销当前 refresh session,不提升 `token_version`;`POST /api/auth/logout-all`、改密、重置密码才吊销全端 session 并提升 `token_version`。鉴权中间件仍校验 Bearer `sid` 对应的 refresh session 是否 active,单独踢下线或当前设备退出可以让目标设备立即失效而不误伤其它设备。
|
||||||
|
|
||||||
## api-server 模块化演进规则
|
## api-server 模块化演进规则
|
||||||
|
|
||||||
@@ -84,8 +99,8 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
路由模块化规则:
|
路由模块化规则:
|
||||||
|
|
||||||
1. 每个能力 Module 只暴露 `router(state) -> Router<AppState>`,由 `app.rs` 统一 `.merge(...)`。
|
1. 每个能力 Module 只暴露 `router(state) -> Router<AppState>`;平台创作 / 游玩相关 Module 和支撑能力由 `modules/play_flow.rs` 统一 `.merge(...)` 或在支撑 router 内挂载,其它账号、资产基础、后台和平台基础能力再由 `app.rs` 直接合并。
|
||||||
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。
|
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue;不得重新恢复逐玩法 creation/runtime merge 列表。
|
||||||
3. 能力 Module 可在路由内部用 `FromRef<AppState>` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。
|
3. 能力 Module 可在路由内部用 `FromRef<AppState>` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。
|
||||||
4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。
|
4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。
|
||||||
5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。
|
5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。
|
||||||
@@ -98,7 +113,7 @@ npm run check:server-rs-ddd
|
|||||||
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
||||||
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||||
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt 和初始资产就绪校验。
|
||||||
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||||
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||||
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||||
@@ -110,11 +125,14 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
|
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
|
||||||
|
|
||||||
|
公开正式 runtime 的启动与局内同步动作统一接受 `RuntimePrincipal`,包括拼图、拼消消、跳一跳、敲木鱼、抓大鹅 Match3D、方洞挑战、视觉小说、大鱼吃小鱼和汪汪声浪。登录用户仍使用账号 Bearer;未登录推荐页或公开运行态使用 Runtime Guest Token,后端以 `principal.subject()` 作为本局 owner / player subject,并用 `WorkPlayTrackingDraft::runtime_principal(...)` 记录游玩。创作、个人作品、删除、发布、Remix、点赞等账号或所有权动作不得改成 runtime guest 鉴权。
|
||||||
|
|
||||||
抓大鹅 Match3D `api-server` 内部拆分:
|
抓大鹅 Match3D `api-server` 内部拆分:
|
||||||
|
|
||||||
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
||||||
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
||||||
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
||||||
|
- `/api/runtime/match3d/works/{profile_id}/runs`、`/api/runtime/match3d/runs/{run_id}`、`/click`、`/stop`、`/restart` 与 `/time-up` 属于正式运行态局部请求,必须接受 `RuntimePrincipal`;登录用户使用账号 Bearer,推荐页匿名游客使用 runtime guest token,后端以 principal subject 作为本局 owner,不得退回只认普通 Bearer 的路由。
|
||||||
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
||||||
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
||||||
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。
|
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。
|
||||||
@@ -130,7 +148,7 @@ npm run check:server-rs-ddd
|
|||||||
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
||||||
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
||||||
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
|
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
|
||||||
6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。
|
||||||
7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
||||||
8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
||||||
9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
||||||
@@ -167,17 +185,27 @@ npm run check:server-rs-ddd
|
|||||||
7. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`。
|
7. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`。
|
||||||
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
||||||
|
|
||||||
|
## 创作入口泥点扣费契约
|
||||||
|
|
||||||
|
1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。
|
||||||
|
2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。
|
||||||
|
3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。
|
||||||
|
4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。
|
||||||
|
5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。
|
||||||
|
6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。
|
||||||
|
7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。
|
||||||
|
|
||||||
## 外部服务与资产
|
## 外部服务与资产
|
||||||
|
|
||||||
- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。
|
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
|
||||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
|
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||||
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
||||||
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
||||||
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
||||||
- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
|
- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
|
||||||
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
|
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
|
||||||
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。
|
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。
|
||||||
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。
|
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。
|
||||||
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
|
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
|
||||||
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
|
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
|
||||||
|
|
||||||
@@ -205,6 +233,12 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`AiTaskStage`
|
- Rust 结构体:`AiTaskStage`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
|
||||||
|
|
||||||
|
### `external_generation_job`
|
||||||
|
|
||||||
|
- Rust 结构体:`ExternalGenerationJob`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||||
|
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||||
|
|
||||||
### `ai_text_chunk`
|
### `ai_text_chunk`
|
||||||
|
|
||||||
- Rust 结构体:`AiTextChunk`
|
- Rust 结构体:`AiTextChunk`
|
||||||
@@ -245,7 +279,7 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`AuthStoreSnapshot`
|
- Rust 结构体:`AuthStoreSnapshot`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs`
|
||||||
|
|
||||||
认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。
|
认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。
|
||||||
|
|
||||||
`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/<user_id>`、`phone/<phone+user>`、`session/<session_id>`、`session_hash/<hash+session>`、`wechat/<provider_uid+user>`、`union/<union+user>`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
|
`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/<user_id>`、`phone/<phone+user>`、`session/<session_id>`、`session_hash/<hash+session>`、`wechat/<provider_uid+user>`、`union/<union+user>`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
|
||||||
|
|
||||||
@@ -335,15 +369,15 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`CreationEntryConfig`
|
- Rust 结构体:`CreationEntryConfig`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||||
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`。
|
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`、`public_work_interactions_json`。
|
||||||
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。
|
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置,也不把旧结构化 `eventBanner` 升格为前端优先数组。旧库缺少 `public_work_interactions_json` 时写入 `None`,读取层按 `module-runtime` 默认作品互动矩阵补齐 `publicWorkInteractions`,不覆盖后台已保存开关。HTTP 响应同时返回 `eventBanners` 数组、旧 `eventBanner` 单条兼容字段和 `publicWorkInteractions` 互动矩阵;前端优先消费数组与矩阵。后台新公告配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。默认公告背景和旧结构化默认 `coverImageSrc` 必须引用 `public/` 下真实存在的静态资源,当前为 `/creation-type-references/puzzle.webp`。
|
||||||
|
|
||||||
### `creation_entry_type_config`
|
### `creation_entry_type_config`
|
||||||
|
|
||||||
- Rust 结构体:`CreationEntryTypeConfig`
|
- Rust 结构体:`CreationEntryTypeConfig`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||||
- 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`、`unified_creation_spec_json`。
|
- 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`、`unified_creation_spec_json`。
|
||||||
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时只回退首批 `puzzle`、`match3d`、`wooden-fish` 默认 spec。
|
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时按 `shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖。
|
||||||
|
|
||||||
### `custom_world_agent_message`
|
### `custom_world_agent_message`
|
||||||
|
|
||||||
@@ -378,6 +412,7 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`CustomWorldProfile`
|
- Rust 结构体:`CustomWorldProfile`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
|
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
|
||||||
|
- 兼容约束:历史公开 RPG / 自定义世界 profile 可能存在 `publication_status=Published` 但 `published_at=None`。公开详情、点赞、游玩、Remix 和 `custom_world_gallery_entry` 同步都以 `Published + deleted_at=None + visible=true` 判断作品可公开互动;展示和 gallery 同步时间在 `published_at` 缺失时回退 `updated_at`,不得仅因 `published_at` 为空返回“已发布作品不存在”。
|
||||||
|
|
||||||
### `custom_world_session`
|
### `custom_world_session`
|
||||||
|
|
||||||
@@ -409,15 +444,24 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`JumpHopEventRow`
|
- Rust 结构体:`JumpHopEventRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
||||||
|
|
||||||
|
### `jump_hop_leaderboard_entry`
|
||||||
|
|
||||||
|
- Rust 结构体:`JumpHopLeaderboardEntryRow`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
||||||
|
- 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。
|
||||||
|
- 展示契约:`player_id` 只作为后端去重和 `viewerBest` 匹配身份键,不得直接进入 HTTP/UI 展示字段;`/api/runtime/jump-hop/works/{profile_id}/leaderboard` 必须补齐 `displayName`,已登录玩家读取账号显示名,匿名游客展示“游客玩家”,失效账号展示“失效玩家”。
|
||||||
|
|
||||||
### `jump_hop_runtime_run`
|
### `jump_hop_runtime_run`
|
||||||
|
|
||||||
- Rust 结构体:`JumpHopRuntimeRunRow`
|
- Rust 结构体:`JumpHopRuntimeRunRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
||||||
|
- 说明:运行记录持久化 `runtime_mode`,取值为 `draft` / `published`;草稿试玩只允许作品所有者启动,不累计公开游玩次数,也不写入公开排行榜。
|
||||||
|
|
||||||
### `jump_hop_work_profile`
|
### `jump_hop_work_profile`
|
||||||
|
|
||||||
- Rust 结构体:`JumpHopWorkProfileRow`
|
- Rust 结构体:`JumpHopWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
||||||
|
- 说明:作品投影持久化独立 `theme_text`,用于生成主题和公开卡片主题展示;历史行为空时按 `work_title` 兜底。`back_button_asset_json` 保存 image2 单独生成并去绿后的 1:1 左上角返回按钮资产快照;旧迁移数据按 `None` 兼容,运行态缺失该字段时使用同尺寸 CSS 主题按钮兜底。
|
||||||
|
|
||||||
### SpacetimeDB view:`jump_hop_gallery_card_view`
|
### SpacetimeDB view:`jump_hop_gallery_card_view`
|
||||||
|
|
||||||
@@ -606,6 +650,12 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`PuzzleAgentSessionRow`
|
- Rust 结构体:`PuzzleAgentSessionRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
|
||||||
|
### `puzzle_background_compile_task`
|
||||||
|
|
||||||
|
- Rust 结构体:`PuzzleBackgroundCompileTaskRow`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
- 说明:拼图首图后台生成的跨 api-server 实例互斥 claim 表,只保存活动任务租约,不表达最终生成结果;`task_id` 为主键,`claim_id` 用于释放时防止误删新租约,租约超时时间为 30 分钟。
|
||||||
|
|
||||||
### `puzzle_event`
|
### `puzzle_event`
|
||||||
|
|
||||||
- Rust 结构体:`PuzzleEvent`
|
- Rust 结构体:`PuzzleEvent`
|
||||||
@@ -625,20 +675,61 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`PuzzleWorkProfileRow`
|
- Rust 结构体:`PuzzleWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
- 说明:拼图作品 profile 表,保存草稿 / 已发布作品的标题、作者、关卡、封面、发布状态、可见性、基础游玩数、点赞数、改造数和积分激励领取状态。
|
||||||
|
- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime;默认 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。
|
||||||
|
|
||||||
|
### `puzzle_clear_agent_session`
|
||||||
|
|
||||||
|
- Rust 结构体:`PuzzleClearAgentSessionRow`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
|
||||||
|
- 说明:拼消消创作会话表,保存轻表单草稿、生成状态、已发布 profile 关联和更新时间;只由拼消消 procedure 读写。
|
||||||
|
|
||||||
|
### `puzzle_clear_work_profile`
|
||||||
|
|
||||||
|
- Rust 结构体:`PuzzleClearWorkProfileRow`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
|
||||||
|
- 说明:拼消消作品 profile 表,保存中央底图资产、4 张素材工作表切片后合成的最终 atlas、35 个复合图案组、95 个 1x1 卡牌切片、卡背占位图、发布状态、可见性和基础 play count;公开列表 / 详情只通过 read model 消费,不让前端直接订阅源表。
|
||||||
|
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
|
||||||
|
|
||||||
|
### `puzzle_clear_runtime_run`
|
||||||
|
|
||||||
|
- Rust 结构体:`PuzzleClearRuntimeRunRow`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
|
||||||
|
- 说明:拼消消正式 runtime run 表,保存当前关卡、已消除次数、棋盘 snapshot、开始 / 完成时间和 run 状态;正式胜负、重试、完成、超时和交换结果以后端 procedure 裁决为准。
|
||||||
|
|
||||||
|
### `puzzle_clear_event`
|
||||||
|
|
||||||
|
- Rust 结构体:`PuzzleClearEventRow`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
|
||||||
|
- 说明:拼消消基础 runtime 事件表,记录 published run 的开局、关卡完成、全局完成、失败、超时和消除统计来源;首版不做排行榜。
|
||||||
|
|
||||||
|
### SpacetimeDB view:`puzzle_clear_gallery_view`
|
||||||
|
|
||||||
|
- Rust view:`puzzle_clear_gallery_view`
|
||||||
|
- 返回类型:`Vec<PuzzleClearGalleryViewRow>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear.rs`
|
||||||
|
- 说明:拼消消公开详情 source 投影,只暴露 `publication_status = published` 且 `visible = true` 的作品,包含 atlas、底图、图案组和卡牌切片等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
|
||||||
|
|
||||||
|
### SpacetimeDB view:`puzzle_clear_gallery_card_view`
|
||||||
|
|
||||||
|
- Rust view:`puzzle_clear_gallery_card_view`
|
||||||
|
- 返回类型:`Vec<PuzzleClearGalleryCardViewRow>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear.rs`
|
||||||
|
- 说明:拼消消公开列表 source 投影,只暴露平台卡片需要的公开字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle-clear/gallery` 保留玩法专属 HTTP shape。
|
||||||
|
|
||||||
### SpacetimeDB view:`puzzle_gallery_view`
|
### SpacetimeDB view:`puzzle_gallery_view`
|
||||||
|
|
||||||
- Rust view:`puzzle_gallery_view`
|
- Rust view:`puzzle_gallery_view`
|
||||||
- 返回类型:`Vec<PuzzleWorkProfile>`
|
- 返回类型:`Vec<PuzzleWorkProfile>`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
|
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 且 `visible = true` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
|
||||||
|
|
||||||
### SpacetimeDB view:`puzzle_gallery_card_view`
|
### SpacetimeDB view:`puzzle_gallery_card_view`
|
||||||
|
|
||||||
- Rust view:`puzzle_gallery_card_view`
|
- Rust view:`puzzle_gallery_card_view`
|
||||||
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
|
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。
|
- 说明:拼图公开列表 source 投影,只暴露 `publication_status = Published` 且 `visible = true` 的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。
|
||||||
|
|
||||||
### 拼图公开列表 HTTP 窗口缓存
|
### 拼图公开列表 HTTP 窗口缓存
|
||||||
|
|
||||||
@@ -655,6 +746,7 @@ npm run check:server-rs-ddd
|
|||||||
- `SELECT * FROM public_work_detail_entry`
|
- `SELECT * FROM public_work_detail_entry`
|
||||||
- `SELECT * FROM bark_battle_gallery_view`
|
- `SELECT * FROM bark_battle_gallery_view`
|
||||||
- `SELECT * FROM puzzle_gallery_card_view`
|
- `SELECT * FROM puzzle_gallery_card_view`
|
||||||
|
- `SELECT * FROM puzzle_clear_gallery_card_view`
|
||||||
- `SELECT * FROM jump_hop_gallery_card_view`
|
- `SELECT * FROM jump_hop_gallery_card_view`
|
||||||
- `SELECT * FROM wooden_fish_gallery_card_view`
|
- `SELECT * FROM wooden_fish_gallery_card_view`
|
||||||
- `SELECT * FROM custom_world_gallery_entry`
|
- `SELECT * FROM custom_world_gallery_entry`
|
||||||
@@ -671,14 +763,15 @@ npm run check:server-rs-ddd
|
|||||||
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
|
||||||
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
|
||||||
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'`
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'`
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'`
|
||||||
- `SELECT * FROM creation_entry_config`
|
- `SELECT * FROM creation_entry_config`
|
||||||
- `SELECT * FROM creation_entry_type_config`
|
- `SELECT * FROM creation_entry_type_config`
|
||||||
- `SELECT * FROM asset_object`
|
- `SELECT * FROM asset_object`
|
||||||
|
|
||||||
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
|
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
|
||||||
|
|
||||||
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
`GET /api/creation-entry/config`、入口熔断和公开作品互动熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||||
入口配置快照包含 start card、类型弹窗、公告位兼容字段和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
|
入口配置快照包含 start card、类型弹窗、公告位兼容字段、入口类型列表和 `publicWorkInteractions` 作品互动矩阵;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。作品互动矩阵是全局公开作品详情能力配置,不属于单个 `creation_entry_type_config`;后台通过 `/admin/api/creation-entry/config/interactions` 保存,前端据此隐藏或拦截已接入的点赞 / Remix 入口,api-server 同时对已接入后端动作执行 `public_work_interaction_disabled` 熔断。
|
||||||
|
|
||||||
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。
|
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 本地开发验证与生产运维
|
# 本地开发验证与生产运维
|
||||||
|
|
||||||
更新时间:`2026-06-05`
|
更新时间:`2026-06-12`
|
||||||
|
|
||||||
## 标准开发流程
|
## 标准开发流程
|
||||||
|
|
||||||
@@ -51,8 +51,20 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
|
|||||||
|
|
||||||
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
||||||
|
|
||||||
|
本地 `npm run dev` 和 `npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。
|
||||||
|
|
||||||
|
本地排查外部内容生成 worker 队列时,必须显式使用 queue,例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部图片生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`。非外部图片生成动作继续 inline,不进入队列。worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
|
||||||
|
|
||||||
|
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
|
||||||
|
|
||||||
|
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。
|
||||||
|
|
||||||
|
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
|
||||||
|
|
||||||
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||||
|
|
||||||
|
微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图`、`敲木鱼`、`抓大鹅`;`number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。
|
||||||
|
|
||||||
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
|
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
|
||||||
|
|
||||||
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:
|
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:
|
||||||
@@ -65,11 +77,13 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
|
|||||||
|
|
||||||
本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。
|
本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。
|
||||||
|
|
||||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.3.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.5.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
||||||
|
|
||||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
||||||
|
|
||||||
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对同一请求最多发送 3 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。
|
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对同一请求最多发送 5 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504 / 502。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。
|
||||||
|
|
||||||
|
拼图入口直创的 `compile_puzzle_draft` 是长耗时链路:后端会先快速编译草稿并返回 `image_refining` / `generating` 快照,然后在 api-server 后台任务中完成首图、UI 资产、OSS 持久化、作品投影、计费退款和失败态回写。生产排查小程序 `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`、`upstream_status=-`,说明客户端或 WebView 先断开;此时不应再把长 POST 是否返回作为生成成败依据,而应继续按实际 `session_id` 查后台任务日志、VectorEngine provider 日志、`external_api_call_failure` 和后续 GET 轮询结果。同一用户可能先轮询旧的 `puzzle-session-*`,随后 POST 新建实际生成 session;必须用 action POST 的 `request_id` 和 `/api/runtime/puzzle/agent/sessions/<session_id>/actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。
|
||||||
|
|
||||||
查看本地 Rust / SpacetimeDB 日志:
|
查看本地 Rust / SpacetimeDB 日志:
|
||||||
|
|
||||||
@@ -90,6 +104,7 @@ npm run admin-web:typecheck
|
|||||||
```bash
|
```bash
|
||||||
npm run check:encoding
|
npm run check:encoding
|
||||||
npm run check:spacetime-schema
|
npm run check:spacetime-schema
|
||||||
|
npm run check:production-ops
|
||||||
npm run check:server-rs-ddd
|
npm run check:server-rs-ddd
|
||||||
npm run lint:eslint
|
npm run lint:eslint
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
@@ -195,7 +210,7 @@ UI 相关修改要重点验证:
|
|||||||
|
|
||||||
## SpacetimeDB 操作规则
|
## SpacetimeDB 操作规则
|
||||||
|
|
||||||
1. 不在人工命令、本地联调或文档示例中使用 `spacetime --root-dir`。
|
1. 不在人工命令、本地联调或文档示例中使用 `spacetime --root-dir`;CI/CD 脚本内部为隔离运行用户登录态的受控用法例外,但不得写成手工排障命令。
|
||||||
2. 本地开发使用项目脚本维护数据目录;需要清空本地数据时先确认可丢弃,再停止服务并处理本地数据目录。
|
2. 本地开发使用项目脚本维护数据目录;需要清空本地数据时先确认可丢弃,再停止服务并处理本地数据目录。
|
||||||
3. 发布目标必须显式 `--server` / `--server-url`。
|
3. 发布目标必须显式 `--server` / `--server-url`。
|
||||||
4. 身份问题先查 `spacetime login show`、`spacetime server list` 和目标库权限,不通过切回旧 Node / PostgreSQL 绕过。
|
4. 身份问题先查 `spacetime login show`、`spacetime server list` 和目标库权限,不通过切回旧 Node / PostgreSQL 绕过。
|
||||||
@@ -205,13 +220,13 @@ UI 相关修改要重点验证:
|
|||||||
|
|
||||||
### SpacetimeDB 数据目录 OSS 备份
|
### SpacetimeDB 数据目录 OSS 备份
|
||||||
|
|
||||||
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
|
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`);生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service
|
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive <tar.gz>` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
|
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service` 依赖 `spacetimedb.service`,生产定时冷备份还必须传入 `--restart-service-after genarrative-api.service`,确保备份后 API 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉该参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive <tar.gz>` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
|
||||||
|
|
||||||
生产环境变量模板在 `deploy/env/api-server.env.example`:
|
生产环境变量模板在 `deploy/env/api-server.env.example`:
|
||||||
|
|
||||||
@@ -226,7 +241,18 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
|||||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||||
```
|
```
|
||||||
|
|
||||||
`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。
|
`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。如果 timer 显示 `enabled` 但 `inactive/dead` 且 `NEXT` / `Trigger` 为空,先写入当前 stamp 避免 `Persistent=true` 在白天立刻补跑冷备份:`touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer && systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,随后确认下一次触发时间约为次日 `03:20`。
|
||||||
|
|
||||||
|
冷备份后必须做一次只读验收,不要只看 `genarrative-database-backup.service` 是否成功退出:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl is-active spacetimedb.service genarrative-api.service nginx.service
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.1:3101/v1/ping
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.1:8082/healthz
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.1:8082/readyz
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.1/api/creation-entry/config >/dev/null
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.1/api/runtime/puzzle/gallery >/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
## 生产运维
|
## 生产运维
|
||||||
|
|
||||||
@@ -238,40 +264,73 @@ Nginx 负责站点和反向代理
|
|||||||
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 生产健康巡检
|
||||||
|
|
||||||
|
`Genarrative-Server-Provision` 会安装并启用 `genarrative-health-patrol.timer`,默认每 5 分钟运行一次 `genarrative-health-patrol.service`。巡检脚本随 API release 归档到 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs`,只读检查:
|
||||||
|
|
||||||
|
- `genarrative-api.service`、`genarrative-external-generation-controller.service`、`spacetimedb.service`、`nginx.service` 是否 active。
|
||||||
|
- 至少一个 `genarrative-external-generation-worker@*.service` 实例是否 active;如果 controller 存活但 worker 全部退出,巡检直接返回 `CRITICAL`,避免外部生成队列长期无人消费。
|
||||||
|
- API 直连 `/healthz`、`/readyz`。
|
||||||
|
- SpacetimeDB 直连 `/v1/ping`。
|
||||||
|
- 默认直连 API 端口检查 `/api/creation-entry/config`、`/api/runtime/puzzle/gallery`、`/api/runtime/custom-world-gallery`;如需走 Nginx / 公网域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`。
|
||||||
|
- 最近 15 分钟 `genarrative-api.service`、`genarrative-external-generation-controller.service`、`genarrative-external-generation-worker@*.service`、`spacetimedb.service`、`nginx.service` 的 `err..alert` 日志。
|
||||||
|
|
||||||
|
巡检输出总状态 `OK / WARNING / CRITICAL`;只有 `CRITICAL` 默认让 systemd service 失败,`WARNING` 只写日志和状态文件,避免历史日志噪声把 timer 长期打成失败。最近一次结果写入 `/var/lib/genarrative/health-patrol/status.json`。手动执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl start genarrative-health-patrol.service
|
||||||
|
systemctl status genarrative-health-patrol.service --no-pager
|
||||||
|
journalctl -u genarrative-health-patrol.service -n 80 --no-pager
|
||||||
|
cat /var/lib/genarrative/health-patrol/status.json
|
||||||
|
```
|
||||||
|
|
||||||
|
如需接外部告警,可在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_WEBHOOK_URL`;脚本只会在 `WARNING` 或 `CRITICAL` 时向该 webhook 发送 JSON。未配置 webhook 时,告警来源是 systemd 失败状态、journal 和状态文件。
|
||||||
|
|
||||||
`Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `build<Play>PublicWorkCode` 与 `isSame<Play>PublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。
|
`Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `build<Play>PublicWorkCode` 与 `isSame<Play>PublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。
|
||||||
|
|
||||||
`Genarrative-Web-Build` 会把 `build/<version>/web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物,再执行 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。
|
`Genarrative-Web-Build` 会把 `build/<version>/web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。
|
||||||
|
|
||||||
|
`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build/<version>/api-server`、`api-server.sha256`、`release-manifest.json`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/deploy/production-api-deploy.sh`、`scripts/deploy/maintenance-on.sh` 和 `scripts/deploy/maintenance-off.sh`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制部署脚本、备份脚本和巡检脚本,不再在目标机器 checkout Git。如果 API 发布后 current release 中缺少这些脚本,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build/<version>/scripts/database-backup-to-oss.mjs` 与 `build/<version>/scripts/ops/production-health-patrol.mjs`,不要只在部署机工作区手工补文件。
|
||||||
|
|
||||||
|
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh` 和 `scripts/database-backup-to-oss.mjs`。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制这些产物和发布脚本,不再在目标机器 checkout Git;如果 publish 前备份脚本缺失,应先检查 Stdb Build 的归档列表和 Stdb Publish 的复制过滤器。
|
||||||
|
|
||||||
`Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。
|
`Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。
|
||||||
|
|
||||||
生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。
|
生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移。
|
||||||
|
|
||||||
|
dev 服务器上的 Gitea 内网入口固定为 `http://10.2.0.10/GenarrativeAI/Genarrative.git`,用于 release / dev 等内网 agent 直接拉取仓库,避免绕公网 `git.genarrative.world`。该入口由 dev Nginx `/etc/nginx/conf.d/gitea-internal.conf` 暴露,只允许 `10.2.0.0/16` 和本机访问;Gitea 进程自身仍只监听 `127.0.0.1:3000`,公网域名 `https://git.genarrative.world/` 继续走原有 TLS 反代。验证时从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD`,应能直接返回 HEAD。
|
||||||
|
|
||||||
|
`scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建流水线和服务器初始化流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout;只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy` 和 `Genarrative-Stdb-Module-Publish` 仍保留上游构建传入的 `COMMIT_HASH` 作为通知和追溯字段,但不再用它在目标机器重新 checkout 部署脚本。
|
||||||
|
|
||||||
|
|
||||||
`Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。
|
`Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。
|
||||||
|
|
||||||
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
|
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash;Stdb module 的 `CARGO_HOME`、`CARGO_TARGET_DIR` 和 `SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/stdb-module` 下,可用 `GENARRATIVE_STDB_CACHE_ROOT` 覆盖,避免 `WORKSPACE@tmp` 被清理后无改动也触发近似冷构建。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
|
||||||
|
|
||||||
`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。
|
`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。
|
||||||
|
|
||||||
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
||||||
|
|
||||||
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
|
`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` 默认保留 `inline` 开发体验,只有显式配置 `queue` 才会落 `external_generation_job`。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部图片生成动作覆盖拼图、跳一跳、拼消消和敲木鱼,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。
|
||||||
|
|
||||||
|
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机为完成这一步会安装 `build-essential`、`ca-certificates`、`curl`、`perl`、`tar` 等 OpenSSL 运行时自举工具;这只服务于独立 OpenSSL 运行时安装,不代表 provision 重新承担 api-server 构建职责。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
|
||||||
|
|
||||||
50 HTTP req/s 首版压测优化口径:
|
50 HTTP req/s 首版压测优化口径:
|
||||||
|
|
||||||
- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024`、`GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。
|
- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024`、`GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。
|
||||||
- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 与 `/readyz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
|
- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 与 `/readyz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
|
||||||
- `api-server` 正常运行时 `/healthz` 返回进程存活状态,`/readyz` 返回是否仍接收新流量;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。
|
- `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。
|
||||||
|
- SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。
|
||||||
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
||||||
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内准备 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 并生成 `provision-tools/`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.5.0` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
||||||
- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。
|
- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。
|
||||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
|
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。
|
||||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
||||||
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
||||||
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
||||||
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
||||||
|
|
||||||
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额:
|
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证外部生成 worker 动态扩缩容,`inline` 模式不参与该验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run container:init
|
npm run container:init
|
||||||
@@ -284,6 +343,7 @@ npm run container:down
|
|||||||
|
|
||||||
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||||
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||||
|
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并使用 unsupported job 验证 worker claim / fail 回写,不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
|
||||||
|
|
||||||
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
|
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
|
||||||
|
|
||||||
@@ -296,7 +356,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
|||||||
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
|
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
|
||||||
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
||||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
||||||
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
|
||||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||||
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
||||||
@@ -309,8 +369,9 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
|||||||
- `GENARRATIVE_SPACETIME_TOKEN`
|
- `GENARRATIVE_SPACETIME_TOKEN`
|
||||||
- `GENARRATIVE_DATABASE_BACKUP_*`
|
- `GENARRATIVE_DATABASE_BACKUP_*`
|
||||||
- `GENARRATIVE_LLM_*`
|
- `GENARRATIVE_LLM_*`
|
||||||
- `APIMART_*`
|
|
||||||
- `VECTOR_ENGINE_*`
|
- `VECTOR_ENGINE_*`
|
||||||
|
- ~~`APIMART_*`~~(已弃用,LLM 文本调用统一迁移到 VectorEngine)
|
||||||
|
- `APIMART_*`(历史残留,创意 Agent LLM 已迁移到 VectorEngine)
|
||||||
- `HYPER3D_*`
|
- `HYPER3D_*`
|
||||||
- `VOLCENGINE_SPEECH_*`
|
- `VOLCENGINE_SPEECH_*`
|
||||||
- `DASHSCOPE_*`
|
- `DASHSCOPE_*`
|
||||||
@@ -320,6 +381,14 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
|||||||
|
|
||||||
结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。
|
结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。
|
||||||
|
|
||||||
|
创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/test-ve-llm.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本读取仓库根目录 `.env.secrets.local` 中的 `VECTOR_ENGINE_BASE_URL` 和 `VECTOR_ENGINE_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
|
||||||
|
|
||||||
### 手机验证码短信
|
### 手机验证码短信
|
||||||
|
|
||||||
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
||||||
@@ -353,7 +422,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
|
|||||||
- `profile_task_reward_claim`
|
- `profile_task_reward_claim`
|
||||||
- `profile_wallet_ledger`
|
- `profile_wallet_ledger`
|
||||||
|
|
||||||
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
||||||
|
|
||||||
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
|
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
|
||||||
|
|
||||||
@@ -406,7 +475,9 @@ systemctl restart genarrative-api.service
|
|||||||
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
|
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
|
||||||
```
|
```
|
||||||
|
|
||||||
`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会等待启动恢复,超时后继续监听但进入依赖不可用模式,所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表。
|
`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。
|
||||||
|
|
||||||
|
前端登录态恢复只把 `/api/auth/refresh` 的 `401` / `403` 当成权威失效信号;服务器重启窗口里的 `502` / `503` / `504`、浏览器 `Failed to fetch` 或 refresh 响应契约异常都必须保留已有本地 access token,不触发全局 auth 变化。refresh 成功响应以共享契约 `RefreshSessionResponse { token }` 为准,前端不要额外要求业务 `ok` 字段。排查“重启后用户都掉线”时,先区分前端是否被暂时不可用清掉本地 token,再检查 SpacetimeDB 正式认证表是否缺 `user_account` / `refresh_session` 数据。
|
||||||
|
|
||||||
常用检查思路:
|
常用检查思路:
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
|
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
|
||||||
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
|
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
|
||||||
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。
|
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。
|
||||||
- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。
|
- 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。
|
||||||
|
|
||||||
## 关键文件
|
## 关键文件
|
||||||
|
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
|
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||||
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
|
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
|
||||||
- API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs`
|
- API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs`
|
||||||
- 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs`
|
- 后端下单与订单编排:`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/api-server/src/wechat/pay.rs`
|
||||||
|
- 微信支付 / 虚拟支付协议适配:`server-rs/crates/platform-wechat/src/pay.rs`
|
||||||
|
- 微信订阅消息协议适配:`server-rs/crates/platform-wechat/src/subscribe_message.rs`
|
||||||
- WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events`、`POST /api/profile/recharge/orders/{orderId}/wechat/confirm`
|
- WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events`、`POST /api/profile/recharge/orders/{orderId}/wechat/confirm`
|
||||||
- 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs`
|
- 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs`
|
||||||
|
|
||||||
@@ -33,6 +35,9 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey>
|
|||||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选>
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选>
|
||||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token>
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token>
|
||||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey>
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey>
|
||||||
|
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true
|
||||||
|
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID=m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU
|
||||||
|
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE=formal
|
||||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,7 +64,7 @@ npm run check:encoding
|
|||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView,但虚拟支付会继续由后端拦截并提示重新登录。
|
- 旧微信登录快照可能没有 `session_key`;普通进入小程序 WebView 仍允许匿名打开,虚拟支付会由后端拦截并提示用户在小程序内重新登录。H5 内部导航不得清理 `clientType`、`clientRuntime`、`miniProgramEnv`,且首点登录要用小程序 User-Agent 兜底识别,否则登录和支付会误判为普通网页环境。
|
||||||
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。
|
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。
|
||||||
- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。
|
- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。
|
||||||
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
|
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
|
||||||
@@ -69,4 +74,5 @@ npm run check:encoding
|
|||||||
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
||||||
- Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
|
- Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
|
||||||
- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。
|
- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。
|
||||||
|
- 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。
|
||||||
- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。
|
- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# 平台入口与玩法链路
|
# 平台入口与玩法链路
|
||||||
|
|
||||||
更新时间:`2026-06-04`
|
更新时间:`2026-06-10`
|
||||||
|
|
||||||
## 平台创作入口
|
## 平台创作入口
|
||||||
|
|
||||||
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理入口开关,通过 `/admin/api/creation-entry/config/interactions` 管理公开作品点赞 / 改造能力矩阵。前端只在展示层派生可见卡片、入口状态和作品详情互动状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
||||||
|
|
||||||
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
|
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
|
||||||
|
|
||||||
|
旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
|
||||||
|
|
||||||
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。
|
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。
|
||||||
|
|
||||||
@@ -28,17 +30,19 @@
|
|||||||
|
|
||||||
RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。
|
RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。
|
||||||
|
|
||||||
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单、表头和入口卡泥点消耗数量由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,入口卡泥点消耗按 `unifiedCreationSpec.mudPointCost` 由前端格式化为 `X泥点数`,读取和保存时不再用入口名称或前端固定文案自动覆盖;需要改表头或入口卡消耗数量时应在后台契约结构卡片点击修改,并通过弹窗表单编辑 `title` 或 `mudPointCost` 字段,不再要求直接编辑 JSON。`workspaceStage`、`generationStage` 和 `resultStage` 属于内部阶段标识,后台弹窗不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的前端固定阶段映射自动带出。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。统一创作页根容器必须保留平台浅色背景并让内容区占满剩余高度,移动端软键盘打开或视口被小程序宿主压缩时,短表单也不得露出浏览器 / 宿主黑底;H5 根节点在 `data-mobile-keyboard-open=true` 时必须把 `html` / `body` / `#root` 背景切到当前平台浅色底,但不得再用 `.platform-viewport-shell` 全局 `transform` 二次上推页面;小程序 `web-view` 页面原生宿主也必须使用浅色背景,不能沿用全局黑色 page 背景。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||||
|
|
||||||
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;校验成本必须读取同一份 `creationTypes[].unifiedCreationSpec.mudPointCost`,不能回到前端常量。余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
||||||
|
|
||||||
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。
|
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。
|
||||||
|
|
||||||
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
|
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。外部生成队列的用户可见概览统一放在移动端一级 `我的` 页签,生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作;用户离开生成页后仍可在 `我的` 页查看当前账号可见的排队与生成数量。队列概览只作为等待状态补充,草稿 ready / failed 与作品结果仍以后端玩法 session/detail 回读为准。
|
||||||
|
|
||||||
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
|
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
|
||||||
|
|
||||||
创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示 `10-20泥点数` 这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
|
公开作品点赞 / 改造是否开放不跟随入口 `open` 字段,而是读取 `GET /api/creation-entry/config` 的 `publicWorkInteractions`。后台可以按 `sourceType` 分别关闭点赞或改造并维护关闭提示;前端只据此关闭已接入的作品详情动作,尚未接入后端动作的玩法仍按实际能力矩阵返回不可用提示。api-server 对已接入的 RPG、自定义世界兼容路径、大鱼吃小鱼和拼图点赞 / 改造接口做同源熔断,关闭时返回 `public_work_interaction_disabled`,但公开列表、公开详情、已发布作品启动和运行态过程请求不受影响。
|
||||||
|
|
||||||
|
创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示泥点消耗这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
|
||||||
|
|
||||||
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
||||||
|
|
||||||
@@ -54,30 +58,32 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts`
|
|||||||
创作入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
创作入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后端链路也按同一条平台主干组织:所有创作、生成、作品回读、发布、试玩、正式 runtime、公开详情、作品架、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊和玩法生成支撑资产相关 HTTP 路由,先注册到 `server-rs/crates/api-server/src/modules/play_flow.rs`,由主干在进入领域 handler 前统一解析 `PlayFlowRequestContext`,再在最后一步分发给对应领域模块或支撑能力 handler 处理。`app.rs` 不再逐玩法挂载创作 / 运行态路由,`modules/platform.rs` 只保留通用 LLM / 语音代理;新增玩法、补齐旧玩法或迁移旧路径时,必须先补 `play_flow` 的 `playId`、领域模块 key、创作路由前缀、运行态路由前缀和入口开关匹配规则,再补具体 handler。领域规则、胜负裁决、计分、发布状态、资产完整性和排行榜仍留在各自 `module-*` 与 SpacetimeDB procedure 中,不把平台主干写成某个玩法的新业务真相。
|
||||||
|
|
||||||
默认工作台只提交结构化表单、图片槽位和配置 payload,不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时,必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。
|
默认工作台只提交结构化表单、图片槽位和配置 payload,不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时,必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。
|
||||||
|
|
||||||
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
|
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图、主图预览和删除确认;新玩法页面不得重复手写这些交互。主图已有图片时,默认点击图片打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方只能通过 `canUploadMainImage`、`canUseImageHistory` 等受控参数开关上传和历史入口,不得用复制组件或样式遮挡改行为。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
|
||||||
|
|
||||||
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
|
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、默认绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求;高风险撞色玩法可显式使用专用 key 色、关闭近白扣除并限制为边缘连通背景扣除。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
|
||||||
|
|
||||||
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,避免右侧裁切。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
|
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块,也不再展示“当前拼图信息”“当前敲木鱼信息”“当前世界信息”等玩法设定信息模块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,和当前步骤卡保持更大的垂直间距;预计等待左边缘、已耗时右边缘必须分别与当前步骤卡左右边缘对齐,避免右侧裁切或横向漂移。生成页顶部返回栏和状态标识不参与内容滚动,滚动只发生在进度内容区。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
|
||||||
|
|
||||||
## 草稿与作品架
|
## 草稿与作品架
|
||||||
|
|
||||||
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
||||||
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
|
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
|
||||||
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑或长按仅作为辅助操作层。
|
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示带底色的分享 icon,并统一唤起发布分享弹窗 `PublishShareModal`,不在卡片内部单独复制分享文案。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。
|
||||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||||
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
||||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。
|
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。
|
||||||
7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成;拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。
|
7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成。失败页点击重新生成时必须优先复用当前可恢复 `sessionId` 执行编译 action;只有没有可恢复 session 时才允许回退到新建草稿。拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。
|
||||||
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
||||||
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||||
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
||||||
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
|
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
|
||||||
12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys;平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。
|
12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys;平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。
|
||||||
|
|
||||||
发现页 / 推荐页公开作品卡的作者行只显示公开昵称或账号生成的脱敏手机号;不得把纯 `SY-*` 陶泥号或作品号当作卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露额外账号标识。
|
发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品。
|
||||||
|
|
||||||
平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射。
|
平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射。
|
||||||
|
|
||||||
@@ -125,10 +131,11 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
- 图像输入复用 `CreativeImageInputPanel`。
|
- 图像输入复用 `CreativeImageInputPanel`。
|
||||||
- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
|
- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
|
||||||
|
- 历史图片选择弹窗只展示缩略图与生成时间,不展示从对象路径或文件名解析出的图片名称;选中历史图后内部兜底文案统一使用“历史素材”。
|
||||||
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
|
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
|
||||||
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
|
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
|
||||||
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。
|
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。
|
||||||
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
|
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。生成完成后若自动进入草稿试玩,进入 `/runtime/puzzle` 前必须先把 `/creation/puzzle/result` 和当前 `sessionId/profileId/workId` 写成浏览器历史前一站;运行态返回按钮和系统返回都应回到结果页,不得退回生成进度页或暴露重新生成入口。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
|
||||||
- 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称;如需在内部保留模型路由,UI 只使用“标准模式”“创意模式”等产品化名称。
|
- 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称;如需在内部保留模型路由,UI 只使用“标准模式”“创意模式”等产品化名称。
|
||||||
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
|
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
|
||||||
- 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。
|
- 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。
|
||||||
@@ -139,12 +146,14 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
- 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。
|
- 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。
|
||||||
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
||||||
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
||||||
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
|
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options,不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
|
||||||
|
- 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。
|
||||||
|
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的 H5 分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。微信小程序 WebView 内复制动作必须改为小程序 `pages/web-view/index` 路径并补齐 `targetPath=/works/detail` 与 `work` 参数。推荐页当前 active 作品必须通过 `wx.miniProgram.postMessage` 同步给原生 `web-view` 页,让右上角系统“转发给朋友”和“分享到朋友圈”也使用当前作品参数生成小程序短链背后的 path。微信小程序 WebView 内的推荐页运行态需要启用分享快照安全区,把游戏画面等比缩放并保持在页面中部,避免用户直接点击小程序自带“分享到聊天”时只截到游戏画面局部。
|
||||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||||
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
|
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
|
||||||
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo-runtime-hud.webp` 卡通形象小图;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
||||||
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
||||||
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
||||||
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
||||||
@@ -155,29 +164,43 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
||||||
|
|
||||||
首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环:
|
当前定位为竖屏俯视角 2D 平台跳跃模板,链路对齐平台创作闭环:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
创作入口 -> 模板输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
创作入口 -> 主题输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
||||||
```
|
```
|
||||||
|
|
||||||
|
创作入口配置事实源仍是 SpacetimeDB `creation_entry_type_config`:默认 `visible=true`、`open=true`、`badge=可创建`、`subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。旧库中仍停留在 `subtitle=俯视角跳跃闯关` 且 `image_src=/creation-type-references/puzzle.webp` 的系统默认行会在入口配置播种流程中自动迁移;同时 `spacetime-client` 的入口配置读模型也会对同一条旧系统默认行做纠偏,避免订阅缓存长期回放老口径。后台手动改过的跳一跳入口配置不被覆盖。
|
||||||
|
|
||||||
素材生成规则固定为:
|
素材生成规则固定为:
|
||||||
|
|
||||||
1. 初始草稿生成时,角色形象单独调用一次生图;
|
1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生;
|
||||||
2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集;
|
2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色;
|
||||||
3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG;
|
3. 地板贴图只调用一次 image2,输出一张 `1024x1536` 竖版、`3列*6行`、单一纯洋红 `#FF00FF` key 安全缝 / 外圈背景的立方体主题物体 UV 展开图集;image2 要生成 18 个完整 `1x1x1` 立方体主题物体包装,每个大单元格内部固定为 `4列*3行` UV 网:第 1 行第 2 列为 `top`,第 2 行依次为 `left / front / right / back`,第 3 行第 2 列为 `bottom`,其它 UV 空位保持纯洋红。每个大单元格的六个面必须属于同一个方块化主题物体,top/front/right/back/left/bottom 之间的果皮、切面、籽点、条纹、果柄、叶片等身份特征要连续一致,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标。水果主题应生成 18 种可一眼辨认的方块水果 UV,例如方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。禁止文字、UI、底座、托盘、圆台、地板垫层、落地投影、接触阴影、方形阴影、洋红描边、紫色底边、粉色脏边、彩色光晕、发光边、透明背景、留白、自然圆形水果、自然长条香蕉、孤立水果照片、小型贴纸、纯果皮材质、纯果肉纹理、纯叶脉纹理和无法分辨具体物体的抽象纹理;真实透视、极小倒角、侧壁厚度和阴影统一由运行态 Three.js 标准 `1x1x1` 等比立方体生成。后端只把洋红 key 作为图集安全边界处理,先按 3x6 大单元格切出 18 个方块,再按每格 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再运行透明化抠图、最大 alpha 连通主体保留或透明安全边补白;若裁切后仍残留极少洋红 key 色,会转成不透明材质底色。前端和后端默认 `tilePrompt` 都必须使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlas”的口径,不再提交“正面30度主题物体 / 平台素材 / 跳台 / 地块成品 / 地砖 / 材质贴片 / 平铺纹理”等会把模型拉回 2D 地块、平台或单纯材质的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改;
|
||||||
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图;
|
4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版;左上角返回按钮不允许画进背景,而是单独生成 `backButtonAsset` 透明 PNG,OSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile;
|
||||||
5. 显式重生成角色或地块时,只重生成对应资产槽位。
|
5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-18`,每个方块再持久化 `tile-XX-top/front/right/back/left/bottom` 六个独立 slot/path,不能按重复的 `tileType` 复用槽位;`tileAssets[].faceAssets` 保存六面贴图,历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback,运行态对旧作品没有 `faceAssets` 时仍可把单张贴图应用到立方体所有面;
|
||||||
|
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮;
|
||||||
|
7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、返回按钮去绿、地板贴图图集切片和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
|
||||||
|
|
||||||
运行态规则真相必须沉到 `module-jump-hop`,前端只做蓄力表现、角色位移、投影和落地反馈。通关、失败、分数、combo、运行态快照和发布作品状态以后端为准。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
|
待解决问题(风险程度:高):跳一跳创作链路目前仍是一次 HTTP 请求内串行生成背景底图、返回按钮、地板贴图图集、切片和 OSS 写入;VectorEngine image2 单步 timeout/connect 失败会在后端最多重试 5 次,而前端只有 20 分钟总等待窗口。若某次背景底图生成接近或超过 18 分钟,前端会先报“请求超时,请稍后重试”,但后端可能继续跑完并在数分钟后写入草稿;同时因为背景、返回按钮和图集等中间资产未按阶段落库,同一 session 超时后重试会重新从背景图开始生成,存在重复生图、重复计费、用户误以为失败、作品架状态短时间不一致的风险。后续应将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,并在每个素材阶段成功后写入可恢复状态;同时收口后端全局生成 deadline、前端等待策略和失败态回写,确保超时、重试和最终成功不会互相打架。
|
||||||
|
|
||||||
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
生成页“当前跳一跳信息”只展示实际参与创作提示词的主题、地块提示词等用户可理解信息;`stylePreset` 等未参与当前 image2 提示词组装的内部风格枚举不得作为兜底内容展示,避免把 `minimal-blocks`、`paper-toy` 等工程值暴露给创作者。
|
||||||
|
|
||||||
|
运行态规则真相必须沉到 `module-jump-hop`,前端只做长按蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
|
||||||
|
|
||||||
|
每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;生成背景和游戏舞台必须覆盖整个运行态视口,HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌,以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。
|
||||||
|
|
||||||
|
运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;Three.js 平台层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,只按单一 side 等比缩放当前 / 目标 / 预览地块,并把 `tileAssets[]` 的生成切片作为主题身份方块包装贴图加载到立方体表面;单块地板保持正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转;`tileAssets[].faceAssets` 存在时,Three.js 材质刷新签名必须纳入 top/front/right/back/left/bottom 六面 texture URL,任一面异步换签或 blob URL 变化都要重建平台材质,不能只监听旧单图 `imageSrc` 或基础 render key;运行态采用约 `1.3x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块基准位于屏幕中线略下方,目标和预览地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;Three.js 相机和 DOM 角色层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转,否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉;DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;推进期存在旧地块退出保留时,Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持在 Three.js 平台层之上。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或 DOM 角色层,否则会造成背景、地块和角色层频闪。
|
||||||
|
|
||||||
|
跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;前端必须提交 `dragDistance` 以及换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端正式裁决用该方向向量计算真实落点,只有旧客户端缺失方向、方向非有限数或向量长度过小时,才 fallback 到当前地块中心指向下一块地块中心。成功判定使用下一块地块可见顶面 footprint:后端以该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%,落点进入该视觉顶面则成功,未进入则失败;旧 `landingRadius/perfectRadius` 只保留兼容读写,不再作为当前命中真相。蓄力中角色只做垂直压缩,不沿目标方向拉伸;蓄力反馈可显示朝向当前目标方向的轻量引导,但不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画:视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色沿本次提交方向弹向预测真实落点,成功也不得强制吸附回目标地块中心。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应优先用 `lastJump.landedX/landedY` 映射出的真实落点显示角色,再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口,避免先飞过很远再瞬间拉回地块造成闪现。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块只随相机推进保留在屏幕后方,不单独执行向上 / 向下飞走动画;玩家继续向前跳时,旧地块继续被新的相机推进带离视口,超过离屏阈值后自然销毁,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
|
||||||
|
|
||||||
|
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地板贴图图集和路径配置,必须先补读完整 work profile 再传入运行态。`/runtime/jump-hop?work=JH-*` 这类正式深链必须先通过公开作品号回读 gallery detail,再以 profileId 启动 published run;直接打开没有 `work` 参数的 `/runtime/jump-hop` 时不能停留在空运行态或“正在加载内容”,应回到平台首页。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
||||||
|
|
||||||
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
|
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
|
||||||
|
|
||||||
删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
|
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
|
||||||
|
|
||||||
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。
|
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,且必须持续观察后续新增图片、内联 `background-image` 和换签中的资源标记,不能只在首次挂载时扫描主图或封面;`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的同 run 相似作品推进不视为推荐作品切换,不能重新显示启动封面;如果需要跨公开作品进入下一关,则必须走推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品,`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。
|
||||||
|
|
||||||
## 敲木鱼
|
## 敲木鱼
|
||||||
|
|
||||||
@@ -192,7 +215,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
创作输入固定为:
|
创作输入固定为:
|
||||||
|
|
||||||
1. `敲什么`:敲击物单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须以默认木鱼图作为基础结构和画风参考,使用 image2 生成最终敲击物图案,上传图只作为新主题参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。
|
1. `敲什么`:敲击物单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须以默认木鱼图作为基础结构和画风参考,使用 image2 生成最终敲击物图案,上传图只作为新主题参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。
|
||||||
2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone`;`hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
|
2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;音频面板必须在前端明确显示 `最长 1 秒`。选择文件或录音结束后,前端只在浏览器本地解码并生成待提交音频对象,不在选择阶段请求 `/api/assets/direct-upload-tickets`。上传和录音统一裁掉前后声音过小片段,裁切后仍超过 1 秒时提示错误且不写入表单状态;有效音频按浏览器端近似算法做响度平衡,目标为 GY/T 377-2023 口径下的 `-15 LKFS`,并做峰值保护后重新编码为可上传 Blob。用户点击 `生成` 时才把处理后的音频直传 OSS、确认 `asset_object`,创作 session/action 只提交 `hitSoundAsset.assetObjectId`、`audioSrc` 和对象 key 等轻量字段;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone`;`hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。后端对敲木鱼创作 JSON 的放宽 body limit 仅用于兼容旧小程序 Data URL 请求,不作为新链路输入方式。
|
||||||
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
|
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
|
||||||
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
|
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
|
||||||
|
|
||||||
@@ -204,6 +227,34 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='wooden-fish'` 与 `WF-*` 公开作品号识别敲木鱼作品;公开列表应走 `wooden_fish_gallery_card_view` 订阅缓存,公开详情或运行态启动时卡片摘要不足则补读完整 work profile。
|
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='wooden-fish'` 与 `WF-*` 公开作品号识别敲木鱼作品;公开列表应走 `wooden_fish_gallery_card_view` 订阅缓存,公开详情或运行态启动时卡片摘要不足则补读完整 work profile。
|
||||||
|
|
||||||
|
## 拼消消
|
||||||
|
|
||||||
|
对外名称:拼消消。工程域与 `playId`:`puzzle-clear`。公开作品码前缀:`PC-`。当前按新增玩法 SOP 接入完整公开闭环,不复用拼图运行态规则本体。
|
||||||
|
|
||||||
|
链路为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创作入口 -> 轻表单工作台 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式运行态
|
||||||
|
```
|
||||||
|
|
||||||
|
工作台字段固定为作品标题、简介、主题词、场地底图主题词 `boardBackgroundPrompt`、中央场地底图槽位、是否 AI 生成底图。中央场地底图必须复用 `CreativeImageInputPanel`,支持上传、历史图和 AI 重绘;若用户填写 `boardBackgroundPrompt`,AI 生成底图只读取该字段,字段为空时才回退读取 `themePrompt`;用户上传底图时不再用主题词重写该资产。中央场地底图的字段名保留平台口径,但实际语义是玩家逐步消除清空棋盘后露出的主题目标图,生成尺寸必须与中央棋盘一致,按 1:1 正方形出图;prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,不再要求“画面干净”或“适合作为卡牌棋盘底图”。运行态必须把中央场地底图作为棋盘内部静态底图使用,不能降级成整页氛围背景;卡牌消除后产生的空位和拖拽源位应露出该棋盘底图。卡面背面背景 v1 使用默认占位图,不作为创作者配置项。规则参数不开放编辑:单关 `6x6`、每局 10 分钟、35 次目标消除、形状解锁、防死局发牌和半锁定规则均由后端规则集固定。
|
||||||
|
|
||||||
|
素材生成使用拼消消专用编排,但必须复用 `platform-image`、VectorEngine `gpt-image-2`、OSS、`asset_object`、换签和失败审计。素材目标是 4 张 `1024x1536` 竖版工作表,每张后台按 `4 列 x 6 行` 裁切,每格 `256x256`;服务端从工作表切出总计 95 个 1x1 卡牌碎片,再合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数固定为 35,形状配比固定为 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`。服务端先预排每个复合图案组的 sheet 布局、最终 atlas 坐标和形状,再按坐标切成 1x1 卡牌碎片作为运行态素材;sheet 生图 prompt 只能要求复合图案组可按后台 4x6 均等切成 1x1 方形小份,不能让模型在小图案上绘制切分线、边框、网格线、编号或裁切参考线。当前只有单关,同关内复合图案不重复。草稿编译和发布都必须使用 api-server 已持久化的真实 atlas / card assets,拒绝缺失、空对象键或 `placeholder` 占位素材,不允许 `spacetime-client` 或 SpacetimeDB 侧合成临时素材绕过平台图片底座。
|
||||||
|
|
||||||
|
运行态规则:
|
||||||
|
|
||||||
|
1. 单关固定为 `6x6 / 35次消除`。
|
||||||
|
2. 每局固定 10 分钟;超时只判当前关失败,可重试当前关。
|
||||||
|
3. 当前关直接出现 `1x2`、`1x3`、`2x2` 和 `2x3`。
|
||||||
|
4. 开局棋盘随机铺满并保证至少一步可解;补牌后也必须由后端保证至少一步可解。
|
||||||
|
5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落。
|
||||||
|
6. 非 2 格消除时,补牌不得破坏已完成局部;只有玩家主动交换或撞入才允许打散半锁定拼接组。
|
||||||
|
7. 正式 runtime 只消费后端 snapshot 与 action 结果;前端负责开局翻转、拖拽、掉落、消除和弹层动画。
|
||||||
|
拖拽手感必须对齐拼图模板:开局小卡片只翻转一次,交换落位不得重新翻牌;按住后可见卡片立即跟随鼠标或手指,源位置即时留出空槽;放下时被替换卡片要快速飞向对应空位;已完成局部拼接组要以连续整体呈现并可作为整组拖起。拖拽浮层必须挂到页面级 `document.body` portal,避免平台壳层 transform 让 `position: fixed` 和 `clientX/clientY` 坐标系错位。
|
||||||
|
8. 正式 `published` run 的终态事件使用 `run-finished` 和 `level-failed`,事件结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta` 和 `elapsedMs`,供基础统计与排障回读。
|
||||||
|
|
||||||
|
新增阶段为 `puzzle-clear-workspace`、`puzzle-clear-generating`、`puzzle-clear-result` 和 `puzzle-clear-runtime`;路由为 `/creation/puzzle-clear`、`/creation/puzzle-clear/generating`、`/creation/puzzle-clear/result` 与 `/runtime/puzzle-clear`。API 命名空间为 `/api/creation/puzzle-clear/*` 与 `/api/runtime/puzzle-clear/*`。验证命令见 `docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md` 与 `docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。
|
||||||
|
|
||||||
## 抓大鹅 Match3D
|
## 抓大鹅 Match3D
|
||||||
|
|
||||||
对外名称:`抓大鹅`。工程域:`match3d`。
|
对外名称:`抓大鹅`。工程域:`match3d`。
|
||||||
@@ -226,7 +277,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
当前素材生成流水线:
|
当前素材生成流水线:
|
||||||
|
|
||||||
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
|
1. 点击生成前弹出泥点确认,草稿初始生成成本来自后台入口契约 `creationTypes[].unifiedCreationSpec.mudPointCost`;抓大鹅完整草稿生成按该值一次性预扣,汪汪声浪初始三张图按该值分摊到三次素材请求,结果页单图重新生成仍按单图资产操作计费。
|
||||||
2. 先写入可恢复草稿 profile,再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析;作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。
|
2. 先写入可恢复草稿 profile,再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析;作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。
|
||||||
3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。
|
3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。
|
||||||
4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。
|
4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。
|
||||||
@@ -255,7 +306,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。
|
- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。
|
||||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。
|
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。
|
||||||
- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。
|
- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。
|
||||||
- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。
|
- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo-runtime-hud.webp` 产品 logo 小图;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。
|
||||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||||
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
||||||
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 当前产品与工程约束
|
# 当前产品与工程约束
|
||||||
|
|
||||||
更新时间:`2026-05-15`
|
更新时间:`2026-06-05`
|
||||||
|
|
||||||
## 项目定位
|
## 项目定位
|
||||||
|
|
||||||
@@ -45,9 +45,13 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
|||||||
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
|
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
|
||||||
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
|
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
|
||||||
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。
|
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。
|
||||||
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。
|
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一先通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login` 完成快捷登录。若该接口返回 `created=true`,或返回用户昵称仍是手机号、公开陶泥号、“微信旅人”等默认展示值,才展示原生 `input type="nickname"` 补充微信昵称并再次调用 `/api/auth/wechat/miniprogram-login` 写入 `displayName`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。
|
||||||
6. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。
|
6. 小程序外壳注入到 H5 URL 的 `clientType`、`clientRuntime`、`miniProgramEnv` 是宿主上下文,H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。
|
||||||
7. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。
|
7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。
|
||||||
|
8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。
|
||||||
|
9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
|
||||||
|
10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。
|
||||||
|
11. 同一账号允许多端同时在线。新增登录和单设备退出只影响对应 refresh session,不得提升账号级 `tokenVersion` 让其它设备的 access token 失效;只有“退出全部设备”、修改密码、重置密码等明确安全动作才吊销全端 refresh session 并提升 `tokenVersion`。
|
||||||
|
|
||||||
## 账户与充值
|
## 账户与充值
|
||||||
|
|
||||||
@@ -93,13 +97,13 @@ server-rs + Axum + SpacetimeDB
|
|||||||
3. 点击按钮弹出独立面板时,必须弹出 dialog / drawer / modal,不要在当前面板下方展开内容。
|
3. 点击按钮弹出独立面板时,必须弹出 dialog / drawer / modal,不要在当前面板下方展开内容。
|
||||||
4. 优先复用现有系统、页面、组件和弹层,不因一次需求新建平行系统。
|
4. 优先复用现有系统、页面、组件和弹层,不因一次需求新建平行系统。
|
||||||
5. 游戏式页面要防止文字、按钮、HUD、底部 dock、输入法和画布互相遮挡。
|
5. 游戏式页面要防止文字、按钮、HUD、底部 dock、输入法和画布互相遮挡。
|
||||||
6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,用偏移聚焦输入框,业务组件不要重复注册全局键盘适配。
|
6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,只记录键盘状态、隐藏底部 dock 并补齐浅色暴露背景,不再全局上移平台壳;业务组件不要重复注册全局键盘适配。
|
||||||
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
||||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||||
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||||
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
|
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
|
||||||
11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。
|
11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数和进度;外层任务卡不展示“去完成”等左右侧行动按钮,领取 / 去完成 / 已完成状态只在任务中心弹窗内表达。任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。用户停留在“我的”页跨过北京时间 0 点时,前端必须非阻断刷新登录态以补齐 `daily_login` 埋点,再重拉任务中心,避免继续展示上一自然日已领取状态。
|
||||||
12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
|
12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
|
||||||
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
|
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
|
||||||
14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
|
14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
|
||||||
15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
|
15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ pipeline {
|
|||||||
|
|
||||||
stage('Archive') {
|
stage('Archive') {
|
||||||
steps {
|
steps {
|
||||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,scripts/deploy/production-api-deploy.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh", fingerprint: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,11 @@ pipeline {
|
|||||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
|
||||||
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
||||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
|
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支')
|
||||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit')
|
string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit')
|
||||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
||||||
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
|
||||||
@@ -62,53 +57,6 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Checkout Deploy Scripts') {
|
|
||||||
agent {
|
|
||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
def checkoutFromRemote = { String remoteUrl ->
|
|
||||||
checkout([
|
|
||||||
$class: 'GitSCM',
|
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
|
||||||
doGenerateSubmoduleConfigurations: false,
|
|
||||||
extensions: [
|
|
||||||
[$class: 'CleanBeforeCheckout'],
|
|
||||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
|
||||||
],
|
|
||||||
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
|
||||||
])
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
checkoutFromRemote(env.GIT_REMOTE_URL)
|
|
||||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
|
||||||
} catch (error) {
|
|
||||||
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
|
||||||
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
|
||||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
script {
|
|
||||||
if (params.COMMIT_HASH?.trim()) {
|
|
||||||
echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sh '''
|
|
||||||
bash -lc '
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
|
||||||
COMMIT_HASH="" \
|
|
||||||
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
|
||||||
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
|
||||||
scripts/jenkins-checkout-source.sh
|
|
||||||
'
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Fetch Artifact') {
|
stage('Fetch Artifact') {
|
||||||
agent {
|
agent {
|
||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
||||||
@@ -117,7 +65,7 @@ pipeline {
|
|||||||
copyArtifacts(
|
copyArtifacts(
|
||||||
projectName: params.BUILD_JOB_NAME,
|
projectName: params.BUILD_JOB_NAME,
|
||||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||||
filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json",
|
filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${params.BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,scripts/deploy/production-api-deploy.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh",
|
||||||
target: '.',
|
target: '.',
|
||||||
fingerprintArtifacts: true
|
fingerprintArtifacts: true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pipeline {
|
|||||||
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
|
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
|
||||||
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
|
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
|
||||||
string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
|
string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
|
||||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址')
|
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
|
||||||
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值')
|
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值')
|
||||||
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
||||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
||||||
@@ -162,8 +162,9 @@ BASH
|
|||||||
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
|
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
|
||||||
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
|
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
|
||||||
PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
|
PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
|
||||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \
|
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}" \
|
||||||
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
|
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
|
||||||
|
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \
|
||||||
scripts/prepare-server-provision-tools.sh
|
scripts/prepare-server-provision-tools.sh
|
||||||
'
|
'
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pipeline {
|
|||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
|
||||||
|
GENARRATIVE_STDB_CACHE_ROOT = 'caches/genarrative-jenkins/stdb-module'
|
||||||
CARGO_INCREMENTAL = '0'
|
CARGO_INCREMENTAL = '0'
|
||||||
RUSTC_WRAPPER = 'sccache'
|
RUSTC_WRAPPER = 'sccache'
|
||||||
SCCACHE_CACHE_SIZE = '30G'
|
SCCACHE_CACHE_SIZE = '30G'
|
||||||
@@ -81,12 +82,15 @@ pipeline {
|
|||||||
sh '''
|
sh '''
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
workspace_tmp="${WORKSPACE_TMP:-${WORKSPACE}@tmp}"
|
stdb_cache_root="${GENARRATIVE_STDB_CACHE_ROOT:-caches/genarrative-jenkins/stdb-module}"
|
||||||
export CARGO_HOME="${workspace_tmp}/cargo-home"
|
if [[ "${stdb_cache_root}" != /* ]]; then
|
||||||
export CARGO_TARGET_DIR="${workspace_tmp}/cargo-target/prod-release"
|
stdb_cache_root="${HOME:?HOME 不能为空}/${stdb_cache_root}"
|
||||||
|
fi
|
||||||
|
export CARGO_HOME="${stdb_cache_root}/cargo-home"
|
||||||
|
export CARGO_TARGET_DIR="${stdb_cache_root}/cargo-target/prod-release"
|
||||||
export CARGO_INCREMENTAL=0
|
export CARGO_INCREMENTAL=0
|
||||||
export RUSTC_WRAPPER=sccache
|
export RUSTC_WRAPPER=sccache
|
||||||
export SCCACHE_DIR="${workspace_tmp}/sccache-stdb-module"
|
export SCCACHE_DIR="${stdb_cache_root}/sccache"
|
||||||
export SCCACHE_CACHE_SIZE=30G
|
export SCCACHE_CACHE_SIZE=30G
|
||||||
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}"
|
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}"
|
||||||
chmod +x scripts/jenkins-prepare-cargo-env.sh
|
chmod +x scripts/jenkins-prepare-cargo-env.sh
|
||||||
@@ -115,7 +119,7 @@ pipeline {
|
|||||||
|
|
||||||
stage('Archive') {
|
stage('Archive') {
|
||||||
steps {
|
steps {
|
||||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt", fingerprint: false
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt", fingerprint: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,11 @@ pipeline {
|
|||||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
|
||||||
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
||||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
|
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支')
|
||||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit')
|
string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit')
|
||||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
||||||
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名')
|
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名')
|
||||||
@@ -75,48 +70,6 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Checkout Publish Scripts') {
|
|
||||||
agent {
|
|
||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
def checkoutFromRemote = { String remoteUrl ->
|
|
||||||
checkout([
|
|
||||||
$class: 'GitSCM',
|
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
|
||||||
doGenerateSubmoduleConfigurations: false,
|
|
||||||
extensions: [
|
|
||||||
[$class: 'CleanBeforeCheckout'],
|
|
||||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
|
||||||
],
|
|
||||||
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
|
||||||
])
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
checkoutFromRemote(env.GIT_REMOTE_URL)
|
|
||||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
|
||||||
} catch (error) {
|
|
||||||
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
|
||||||
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
|
||||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sh '''
|
|
||||||
bash -lc '
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
|
||||||
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
|
||||||
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
|
||||||
scripts/jenkins-checkout-source.sh
|
|
||||||
'
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Fetch Artifact') {
|
stage('Fetch Artifact') {
|
||||||
agent {
|
agent {
|
||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
||||||
@@ -125,7 +78,7 @@ pipeline {
|
|||||||
copyArtifacts(
|
copyArtifacts(
|
||||||
projectName: params.BUILD_JOB_NAME,
|
projectName: params.BUILD_JOB_NAME,
|
||||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json",
|
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/database-backup-to-oss.mjs",
|
||||||
target: '.',
|
target: '.',
|
||||||
fingerprintArtifacts: true
|
fingerprintArtifacts: true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pipeline {
|
|||||||
|
|
||||||
stage('Archive') {
|
stage('Archive') {
|
||||||
steps {
|
steps {
|
||||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-web-deploy.sh", fingerprint: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,11 @@ pipeline {
|
|||||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
|
||||||
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
||||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
|
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支')
|
||||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit')
|
string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit')
|
||||||
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
|
||||||
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
|
||||||
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
|
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
|
||||||
@@ -49,48 +44,6 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Checkout Deploy Scripts') {
|
|
||||||
agent {
|
|
||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
def checkoutFromRemote = { String remoteUrl ->
|
|
||||||
checkout([
|
|
||||||
$class: 'GitSCM',
|
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
|
||||||
doGenerateSubmoduleConfigurations: false,
|
|
||||||
extensions: [
|
|
||||||
[$class: 'CleanBeforeCheckout'],
|
|
||||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
|
||||||
],
|
|
||||||
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
|
||||||
])
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
checkoutFromRemote(env.GIT_REMOTE_URL)
|
|
||||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
|
||||||
} catch (error) {
|
|
||||||
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
|
||||||
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
|
||||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sh '''
|
|
||||||
bash -lc '
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
|
||||||
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
|
||||||
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
|
||||||
scripts/jenkins-checkout-source.sh
|
|
||||||
'
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Fetch Artifact') {
|
stage('Fetch Artifact') {
|
||||||
agent {
|
agent {
|
||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
||||||
@@ -99,7 +52,7 @@ pipeline {
|
|||||||
copyArtifacts(
|
copyArtifacts(
|
||||||
projectName: params.BUILD_JOB_NAME,
|
projectName: params.BUILD_JOB_NAME,
|
||||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||||
filter: "build/${params.BUILD_VERSION}/web.tar.gz,build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json",
|
filter: "build/${params.BUILD_VERSION}/web.tar.gz,build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-web-deploy.sh",
|
||||||
target: '.',
|
target: '.',
|
||||||
fingerprintArtifacts: true
|
fingerprintArtifacts: true
|
||||||
)
|
)
|
||||||
|
|||||||
BIN
media/logo-runtime-hud.webp
Normal file
BIN
media/logo-runtime-hud.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"pages": ["pages/web-view/index", "pages/wechat-pay/index"],
|
"pages": [
|
||||||
|
"pages/web-view/index",
|
||||||
|
"pages/share-grid/index",
|
||||||
|
"pages/wechat-pay/index",
|
||||||
|
"pages/subscribe-message/index"
|
||||||
|
],
|
||||||
"window": {
|
"window": {
|
||||||
"navigationBarTitleText": "陶泥儿",
|
"navigationBarTitleText": "陶泥儿",
|
||||||
"navigationBarBackgroundColor": "#0b0f14",
|
"navigationBarBackgroundColor": "#0b0f14",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
|
|||||||
// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。
|
// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。
|
||||||
const MINI_PROGRAM_ENV = 'release';
|
const MINI_PROGRAM_ENV = 'release';
|
||||||
|
|
||||||
|
// 中文注释:AI 创作生成结果订阅消息模板,需与微信公众平台后台的模板 ID 保持一致。
|
||||||
|
const GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID =
|
||||||
|
'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
|
||||||
|
|
||||||
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
|
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
|
||||||
const WEB_VIEW_SOURCE_QUERY = {
|
const WEB_VIEW_SOURCE_QUERY = {
|
||||||
clientType: 'mini_program',
|
clientType: 'mini_program',
|
||||||
@@ -25,6 +29,7 @@ module.exports = {
|
|||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
DEV_API_BASE_URL,
|
DEV_API_BASE_URL,
|
||||||
DEV_WEB_VIEW_ENTRY_URL,
|
DEV_WEB_VIEW_ENTRY_URL,
|
||||||
|
GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
|
||||||
MINI_PROGRAM_APP_ID,
|
MINI_PROGRAM_APP_ID,
|
||||||
MINI_PROGRAM_ENV,
|
MINI_PROGRAM_ENV,
|
||||||
WEB_VIEW_ENTRY_URL,
|
WEB_VIEW_ENTRY_URL,
|
||||||
|
|||||||
206
miniprogram/pages/share-grid/index.js
Normal file
206
miniprogram/pages/share-grid/index.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/* global Page, wx */
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildShareGridTileFileName,
|
||||||
|
buildShareGridTilePlan,
|
||||||
|
normalizeShareGridQuery,
|
||||||
|
} = require('./index.shared');
|
||||||
|
|
||||||
|
function downloadImage(imageUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.downloadFile({
|
||||||
|
url: imageUrl,
|
||||||
|
success(response) {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
resolve(response.tempFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`封面下载失败:${response.statusCode}`));
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '封面下载失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageInfo(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.getImageInfo({
|
||||||
|
src,
|
||||||
|
success: resolve,
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '读取封面失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasNode(page) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.createSelectorQuery()
|
||||||
|
.in(page)
|
||||||
|
.select('#share-grid-canvas')
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((results) => {
|
||||||
|
const canvas = results && results[0] && results[0].node;
|
||||||
|
if (canvas) {
|
||||||
|
resolve(canvas);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error('切图画布初始化失败'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToTempFilePath(canvas, width, height) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.canvasToTempFilePath({
|
||||||
|
canvas,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
destWidth: width,
|
||||||
|
destHeight: height,
|
||||||
|
fileType: 'png',
|
||||||
|
success(response) {
|
||||||
|
resolve(response.tempFilePath);
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '导出切图失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveImageToAlbum(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.saveImageToPhotosAlbum({
|
||||||
|
filePath,
|
||||||
|
success() {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '保存到相册失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTempFileWithName(tempFilePath, fileName) {
|
||||||
|
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
|
||||||
|
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
|
||||||
|
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
|
||||||
|
return Promise.resolve(tempFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = `${userDataPath}/${fileName}`;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
fileSystem.copyFile({
|
||||||
|
srcPath: tempFilePath,
|
||||||
|
destPath: targetPath,
|
||||||
|
success() {
|
||||||
|
resolve(targetPath);
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
resolve(tempFilePath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGridTiles(page, params, localImagePath, imageInfo) {
|
||||||
|
const canvas = await getCanvasNode(page);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const image = canvas.createImage();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
image.onload = resolve;
|
||||||
|
image.onerror = () => reject(new Error('封面绘制失败'));
|
||||||
|
image.src = localImagePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
|
||||||
|
for (const tile of plan) {
|
||||||
|
canvas.width = tile.sourceWidth;
|
||||||
|
canvas.height = tile.sourceHeight;
|
||||||
|
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
tile.sourceX,
|
||||||
|
tile.sourceY,
|
||||||
|
tile.sourceWidth,
|
||||||
|
tile.sourceHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
tile.sourceWidth,
|
||||||
|
tile.sourceHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempFilePath = await canvasToTempFilePath(
|
||||||
|
canvas,
|
||||||
|
tile.sourceWidth,
|
||||||
|
tile.sourceHeight,
|
||||||
|
);
|
||||||
|
const namedFilePath = await copyTempFileWithName(
|
||||||
|
tempFilePath,
|
||||||
|
buildShareGridTileFileName(params, tile.index),
|
||||||
|
);
|
||||||
|
await saveImageToAlbum(namedFilePath);
|
||||||
|
page.setData({
|
||||||
|
savedCount: tile.index + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
errorMessage: '',
|
||||||
|
loading: true,
|
||||||
|
savedCount: 0,
|
||||||
|
title: '九宫切图',
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLoad(query = {}) {
|
||||||
|
const params = normalizeShareGridQuery(query);
|
||||||
|
this._shareGridParams = params;
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '',
|
||||||
|
loading: true,
|
||||||
|
savedCount: 0,
|
||||||
|
title: params.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!params.imageUrl) {
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '缺少封面图。',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localImagePath = await downloadImage(params.imageUrl);
|
||||||
|
const imageInfo = await getImageInfo(localImagePath);
|
||||||
|
await saveGridTiles(this, params, localImagePath, imageInfo);
|
||||||
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
savedCount: 9,
|
||||||
|
});
|
||||||
|
wx.showToast({
|
||||||
|
title: '已保存',
|
||||||
|
icon: 'success',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[share-grid] save failed', error);
|
||||||
|
this.setData({
|
||||||
|
errorMessage:
|
||||||
|
error && error.message ? error.message : '九宫切图保存失败。',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBack() {
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
});
|
||||||
3
miniprogram/pages/share-grid/index.json
Normal file
3
miniprogram/pages/share-grid/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "九宫切图"
|
||||||
|
}
|
||||||
62
miniprogram/pages/share-grid/index.shared.js
Normal file
62
miniprogram/pages/share-grid/index.shared.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const GRID_SIZE = 3;
|
||||||
|
const TILE_COUNT = GRID_SIZE * GRID_SIZE;
|
||||||
|
|
||||||
|
function normalizeQueryValue(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileNamePart(value) {
|
||||||
|
const normalized = normalizeQueryValue(value)
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.slice(0, 32);
|
||||||
|
return normalized || 'taonier';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareGridTileFileName(params, tileIndex) {
|
||||||
|
const safeTitle = sanitizeFileNamePart(params.title || params.publicWorkCode);
|
||||||
|
const safeCode = sanitizeFileNamePart(params.publicWorkCode || 'share');
|
||||||
|
const order = String(tileIndex + 1).padStart(2, '0');
|
||||||
|
return `${safeTitle}-${safeCode}-${order}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShareGridQuery(query) {
|
||||||
|
return {
|
||||||
|
imageUrl: normalizeQueryValue(query && query.imageUrl),
|
||||||
|
title: normalizeQueryValue(query && query.title) || '我的作品',
|
||||||
|
publicWorkCode: normalizeQueryValue(query && query.publicWorkCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareGridTilePlan(imageWidth, imageHeight) {
|
||||||
|
const tileWidth = Math.floor(imageWidth / GRID_SIZE);
|
||||||
|
const tileHeight = Math.floor(imageHeight / GRID_SIZE);
|
||||||
|
const plan = [];
|
||||||
|
|
||||||
|
for (let row = 0; row < GRID_SIZE; row += 1) {
|
||||||
|
for (let col = 0; col < GRID_SIZE; col += 1) {
|
||||||
|
const index = row * GRID_SIZE + col;
|
||||||
|
const sourceX = col * tileWidth;
|
||||||
|
const sourceY = row * tileHeight;
|
||||||
|
plan.push({
|
||||||
|
index,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourceWidth: col === GRID_SIZE - 1 ? imageWidth - sourceX : tileWidth,
|
||||||
|
sourceHeight: row === GRID_SIZE - 1 ? imageHeight - sourceY : tileHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GRID_SIZE,
|
||||||
|
TILE_COUNT,
|
||||||
|
buildShareGridTileFileName,
|
||||||
|
buildShareGridTilePlan,
|
||||||
|
normalizeShareGridQuery,
|
||||||
|
};
|
||||||
67
miniprogram/pages/share-grid/index.test.js
Normal file
67
miniprogram/pages/share-grid/index.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import shareGridBridge from './index.shared.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildShareGridTileFileName,
|
||||||
|
buildShareGridTilePlan,
|
||||||
|
normalizeShareGridQuery,
|
||||||
|
} = shareGridBridge;
|
||||||
|
|
||||||
|
describe('share-grid mini program bridge', () => {
|
||||||
|
test('normalizes query values and keeps a fallback title', () => {
|
||||||
|
expect(
|
||||||
|
normalizeShareGridQuery({
|
||||||
|
imageUrl: ' https://web.test/cover.png ',
|
||||||
|
publicWorkCode: ' PZ-0001 ',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
imageUrl: 'https://web.test/cover.png',
|
||||||
|
title: '我的作品',
|
||||||
|
publicWorkCode: 'PZ-0001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names tiles by title, public code and left-to-right order', () => {
|
||||||
|
const params = {
|
||||||
|
title: '星港:拼图',
|
||||||
|
publicWorkCode: 'PZ-0001',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildShareGridTileFileName(params, 0)).toBe(
|
||||||
|
'星港拼图-PZ-0001-01.png',
|
||||||
|
);
|
||||||
|
expect(buildShareGridTileFileName(params, 8)).toBe(
|
||||||
|
'星港拼图-PZ-0001-09.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds a 3x3 crop plan in reading order', () => {
|
||||||
|
const plan = buildShareGridTilePlan(900, 600);
|
||||||
|
|
||||||
|
expect(plan).toHaveLength(9);
|
||||||
|
expect(plan[0]).toMatchObject({
|
||||||
|
index: 0,
|
||||||
|
row: 0,
|
||||||
|
col: 0,
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
sourceWidth: 300,
|
||||||
|
sourceHeight: 200,
|
||||||
|
});
|
||||||
|
expect(plan[4]).toMatchObject({
|
||||||
|
index: 4,
|
||||||
|
row: 1,
|
||||||
|
col: 1,
|
||||||
|
sourceX: 300,
|
||||||
|
sourceY: 200,
|
||||||
|
});
|
||||||
|
expect(plan[8]).toMatchObject({
|
||||||
|
index: 8,
|
||||||
|
row: 2,
|
||||||
|
col: 2,
|
||||||
|
sourceX: 600,
|
||||||
|
sourceY: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
miniprogram/pages/share-grid/index.wxml
Normal file
20
miniprogram/pages/share-grid/index.wxml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<view class="share-grid-page">
|
||||||
|
<view class="share-grid-card">
|
||||||
|
<view class="share-grid-title">{{title}}</view>
|
||||||
|
<view wx:if="{{loading}}" class="share-grid-text">
|
||||||
|
正在保存 {{savedCount}}/9
|
||||||
|
</view>
|
||||||
|
<view wx:elif="{{errorMessage}}" class="share-grid-text share-grid-text--danger">
|
||||||
|
{{errorMessage}}
|
||||||
|
</view>
|
||||||
|
<view wx:else class="share-grid-text">已保存 9/9</view>
|
||||||
|
<button class="share-grid-button" bindtap="handleBack">
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<canvas
|
||||||
|
id="share-grid-canvas"
|
||||||
|
type="2d"
|
||||||
|
class="share-grid-canvas"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
60
miniprogram/pages/share-grid/index.wxss
Normal file
60
miniprogram/pages/share-grid/index.wxss
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
page {
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48rpx;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1rpx solid rgba(127, 85, 57, 0.18);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
padding: 36rpx;
|
||||||
|
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-title {
|
||||||
|
color: #332820;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-text {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: rgba(51, 40, 32, 0.68);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-text--danger {
|
||||||
|
color: #b84a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #7f5539;
|
||||||
|
color: #fffdf9;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 2.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-canvas {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
10
miniprogram/pages/subscribe-message/index.js
Normal file
10
miniprogram/pages/subscribe-message/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* global Page */
|
||||||
|
|
||||||
|
const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config');
|
||||||
|
const { createSubscribeMessagePage } = require('./index.shared');
|
||||||
|
|
||||||
|
Page(
|
||||||
|
createSubscribeMessagePage(null, {
|
||||||
|
templateId: GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
3
miniprogram/pages/subscribe-message/index.json
Normal file
3
miniprogram/pages/subscribe-message/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "生成通知"
|
||||||
|
}
|
||||||
128
miniprogram/pages/subscribe-message/index.shared.js
Normal file
128
miniprogram/pages/subscribe-message/index.shared.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/* global wx */
|
||||||
|
|
||||||
|
const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result';
|
||||||
|
|
||||||
|
function appendSubscribeResult(url, result) {
|
||||||
|
const hashIndex = String(url || '').indexOf('#');
|
||||||
|
const baseUrl =
|
||||||
|
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
||||||
|
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
||||||
|
const nextHash = rawHash
|
||||||
|
.split('&')
|
||||||
|
.filter((part) => part && !part.startsWith('wx_subscribe_result='))
|
||||||
|
.concat(`wx_subscribe_result=${encodeURIComponent(result)}`)
|
||||||
|
.join('&');
|
||||||
|
return `${baseUrl}#${nextHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubscribeResultValue(requestId, status, reason) {
|
||||||
|
const segments = [requestId, status];
|
||||||
|
if (reason) {
|
||||||
|
segments.push(encodeURIComponent(reason));
|
||||||
|
}
|
||||||
|
return segments.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPreviousWebView(requestId, status, reason) {
|
||||||
|
const result = buildSubscribeResultValue(requestId, status, reason);
|
||||||
|
wx.setStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSubscribeStatus(result, templateId) {
|
||||||
|
return result && result[templateId] === 'accept'
|
||||||
|
? 'success'
|
||||||
|
: 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSubscribeMessagePage(pageContext, options = {}) {
|
||||||
|
const templateId = String(options.templateId || '').trim();
|
||||||
|
const notifyPageResult = (methodThis, status, reason) => {
|
||||||
|
const page = pageContext ?? methodThis;
|
||||||
|
const requestId = page.requestId || '';
|
||||||
|
if (!requestId || page.hasNotifiedSubscribeResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
page.hasNotifiedSubscribeResult = true;
|
||||||
|
notifyPreviousWebView(requestId, status, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
title: '接收生成结果通知',
|
||||||
|
errorMessage: '',
|
||||||
|
requesting: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(query) {
|
||||||
|
const page = pageContext ?? this;
|
||||||
|
page.requestId = String(query.requestId || '');
|
||||||
|
page.hasNotifiedSubscribeResult = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
notifyResult(status, reason) {
|
||||||
|
notifyPageResult(this, status, reason);
|
||||||
|
},
|
||||||
|
|
||||||
|
requestSubscribe() {
|
||||||
|
const page = pageContext ?? this;
|
||||||
|
const requestId = page.requestId || '';
|
||||||
|
if (!requestId) {
|
||||||
|
page.setData({
|
||||||
|
errorMessage: '缺少订阅请求参数。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!templateId) {
|
||||||
|
notifyPageResult(this, 'skip', 'missing_template_id');
|
||||||
|
wx.navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof wx.requestSubscribeMessage !== 'function') {
|
||||||
|
notifyPageResult(this, 'skip', 'unsupported');
|
||||||
|
wx.navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.setData({
|
||||||
|
requesting: true,
|
||||||
|
errorMessage: '',
|
||||||
|
});
|
||||||
|
wx.requestSubscribeMessage({
|
||||||
|
tmplIds: [templateId],
|
||||||
|
success(result) {
|
||||||
|
notifyPageResult(
|
||||||
|
page,
|
||||||
|
resolveSubscribeStatus(result, templateId),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
notifyPageResult(
|
||||||
|
page,
|
||||||
|
'skip',
|
||||||
|
error && error.errMsg ? error.errMsg : 'failed',
|
||||||
|
);
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSkip() {
|
||||||
|
notifyPageResult(this, 'skip', 'user_skip');
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
notifyPageResult(this, 'skip', 'page_unload');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||||
|
appendSubscribeResult,
|
||||||
|
buildSubscribeResultValue,
|
||||||
|
createSubscribeMessagePage,
|
||||||
|
resolveSubscribeStatus,
|
||||||
|
};
|
||||||
93
miniprogram/pages/subscribe-message/index.test.js
Normal file
93
miniprogram/pages/subscribe-message/index.test.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import subscribeMessageBridge from './index.shared.js';
|
||||||
|
|
||||||
|
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
|
||||||
|
|
||||||
|
const {
|
||||||
|
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||||
|
appendSubscribeResult,
|
||||||
|
buildSubscribeResultValue,
|
||||||
|
createSubscribeMessagePage,
|
||||||
|
} = subscribeMessageBridge;
|
||||||
|
|
||||||
|
describe('subscribe-message mini program bridge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.wx = {
|
||||||
|
requestSubscribeMessage: vi.fn(),
|
||||||
|
setStorageSync: vi.fn(),
|
||||||
|
navigateBack: vi.fn(),
|
||||||
|
};
|
||||||
|
globalThis.getCurrentPages = vi.fn(() => []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests subscribe message and stores result before returning', () => {
|
||||||
|
const previousPage = {
|
||||||
|
data: { webViewUrl: 'https://web.test/#tab=create' },
|
||||||
|
setData: vi.fn(),
|
||||||
|
};
|
||||||
|
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
|
||||||
|
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
|
||||||
|
options.success?.({
|
||||||
|
m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const page = createSubscribeMessagePage(
|
||||||
|
{
|
||||||
|
setData: vi.fn(),
|
||||||
|
},
|
||||||
|
{ templateId: TEST_TEMPLATE_ID },
|
||||||
|
);
|
||||||
|
page.onLoad({ requestId: 'request-1' });
|
||||||
|
|
||||||
|
page.requestSubscribe();
|
||||||
|
|
||||||
|
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
|
||||||
|
tmplIds: [TEST_TEMPLATE_ID],
|
||||||
|
success: expect.any(Function),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||||
|
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||||
|
'request-1:success',
|
||||||
|
);
|
||||||
|
expect(previousPage.setData).not.toHaveBeenCalled();
|
||||||
|
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip action notifies previous web-view', () => {
|
||||||
|
const previousPage = {
|
||||||
|
data: { webViewUrl: 'https://web.test/' },
|
||||||
|
setData: vi.fn(),
|
||||||
|
};
|
||||||
|
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
|
||||||
|
const page = createSubscribeMessagePage(
|
||||||
|
{
|
||||||
|
setData: vi.fn(),
|
||||||
|
},
|
||||||
|
{ templateId: TEST_TEMPLATE_ID },
|
||||||
|
);
|
||||||
|
page.onLoad({ requestId: 'request-skip' });
|
||||||
|
|
||||||
|
page.handleSkip();
|
||||||
|
|
||||||
|
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||||
|
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||||
|
'request-skip:skip:user_skip',
|
||||||
|
);
|
||||||
|
expect(previousPage.setData).not.toHaveBeenCalled();
|
||||||
|
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appendSubscribeResult replaces stale subscribe hash', () => {
|
||||||
|
expect(
|
||||||
|
appendSubscribeResult(
|
||||||
|
'https://web.test/#old=1&wx_subscribe_result=old',
|
||||||
|
'req:skip',
|
||||||
|
),
|
||||||
|
).toBe('https://web.test/#old=1&wx_subscribe_result=req%3Askip');
|
||||||
|
expect(buildSubscribeResultValue('req-1', 'skip', 'user_cancel')).toBe(
|
||||||
|
'req-1:skip:user_cancel',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
miniprogram/pages/subscribe-message/index.wxml
Normal file
19
miniprogram/pages/subscribe-message/index.wxml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<view class="subscribe-screen">
|
||||||
|
<view class="subscribe-card">
|
||||||
|
<view class="subscribe-title">{{title}}</view>
|
||||||
|
<view wx:if="{{errorMessage}}" class="subscribe-text subscribe-text--danger">
|
||||||
|
{{errorMessage}}
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
loading="{{requesting}}"
|
||||||
|
disabled="{{requesting}}"
|
||||||
|
bindtap="requestSubscribe"
|
||||||
|
>
|
||||||
|
继续并接收通知
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button" disabled="{{requesting}}" bindtap="handleSkip">
|
||||||
|
仅继续生成
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
58
miniprogram/pages/subscribe-message/index.wxss
Normal file
58
miniprogram/pages/subscribe-message/index.wxss
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.subscribe-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48rpx;
|
||||||
|
background: #0b0f14;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560rpx;
|
||||||
|
padding: 36rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-text {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: rgba(245, 247, 251, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-text--danger {
|
||||||
|
color: #ffb4a9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.ghost-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 2.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0b0f14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.24);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(245, 247, 251, 0.86);
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@ const {
|
|||||||
WEB_VIEW_ENTRY_URL,
|
WEB_VIEW_ENTRY_URL,
|
||||||
WEB_VIEW_SOURCE_QUERY,
|
WEB_VIEW_SOURCE_QUERY,
|
||||||
} = require('../../config');
|
} = require('../../config');
|
||||||
|
const {
|
||||||
|
appendHashParams,
|
||||||
|
buildWebViewSharePath,
|
||||||
|
buildWebViewShareTimelineQuery,
|
||||||
|
resolveShareTargetFromWebViewMessage,
|
||||||
|
resolveWebViewUrlFromRuntimeConfig,
|
||||||
|
} = require('./index.shared');
|
||||||
|
|
||||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||||
@@ -19,7 +26,6 @@ const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
|
|||||||
const AUTH_ACTION_LOGIN = 'login';
|
const AUTH_ACTION_LOGIN = 'login';
|
||||||
const PAY_RESULT_RECHECK_DELAY_MS = 120;
|
const PAY_RESULT_RECHECK_DELAY_MS = 120;
|
||||||
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
|
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
|
||||||
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
|
||||||
|
|
||||||
function showWebViewShareMenu() {
|
function showWebViewShareMenu() {
|
||||||
if (typeof wx.showShareMenu !== 'function') {
|
if (typeof wx.showShareMenu !== 'function') {
|
||||||
@@ -32,17 +38,25 @@ function showWebViewShareMenu() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWebViewShareAppMessage() {
|
function resolveNativeShareQuery(page) {
|
||||||
|
return (
|
||||||
|
(page && page._currentShareTarget) ||
|
||||||
|
(page && page._lastLaunchQuery) ||
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebViewShareAppMessage(query = {}) {
|
||||||
return {
|
return {
|
||||||
title: WEB_VIEW_SHARE_TITLE,
|
title: WEB_VIEW_SHARE_TITLE,
|
||||||
path: WEB_VIEW_SHARE_PATH,
|
path: buildWebViewSharePath(query),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWebViewShareTimeline() {
|
function buildWebViewShareTimeline(query = {}) {
|
||||||
return {
|
return {
|
||||||
title: WEB_VIEW_SHARE_TITLE,
|
title: WEB_VIEW_SHARE_TITLE,
|
||||||
query: '',
|
query: buildWebViewShareTimelineQuery(query),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,54 +73,66 @@ function isConfiguredApiBaseUrl(value) {
|
|||||||
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendQuery(url, query) {
|
|
||||||
const pairs = Object.keys(query)
|
|
||||||
.filter((key) => query[key])
|
|
||||||
.map(
|
|
||||||
(key) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendHashParams(url, params) {
|
|
||||||
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
|
||||||
const pairs = Object.keys(params)
|
|
||||||
.filter((key) => params[key])
|
|
||||||
.map(
|
|
||||||
(key) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
|
||||||
);
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashIndex = url.indexOf('#');
|
|
||||||
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
|
||||||
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
|
||||||
const keptHashParts = rawHash.split('&').filter((part) => {
|
|
||||||
if (!part) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const [rawKey = ''] = part.split('=');
|
|
||||||
try {
|
|
||||||
return !nextKeys.has(decodeURIComponent(rawKey));
|
|
||||||
} catch (_error) {
|
|
||||||
return !nextKeys.has(rawKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBooleanQueryFlag(value) {
|
function parseBooleanQueryFlag(value) {
|
||||||
return value === true || value === '1' || value === 'true' || value === 'yes';
|
return value === true || value === '1' || value === 'true' || value === 'yes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNicknameInput(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNicknameForMatch(value) {
|
||||||
|
return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPhoneLikeDisplayName(value) {
|
||||||
|
const normalized = normalizeNicknameForMatch(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = normalized.replace(/\D/gu, '');
|
||||||
|
return (
|
||||||
|
/^(\+?86)?1\d{10}$/u.test(normalized) ||
|
||||||
|
/^1\d{2}\*{4}\d{4}$/u.test(normalized) ||
|
||||||
|
(/[*x]/iu.test(normalized) && digits.length >= 7) ||
|
||||||
|
digits.length >= 11
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefaultDisplayName(value, publicUserCode) {
|
||||||
|
const normalized = normalizeNicknameForMatch(value);
|
||||||
|
const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode);
|
||||||
|
if (!normalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalized === '微信旅人' ||
|
||||||
|
normalized === '玩家' ||
|
||||||
|
normalized === normalizedPublicUserCode ||
|
||||||
|
/^sy-\d{8}$/iu.test(normalized) ||
|
||||||
|
/^user[_-]/iu.test(normalized) ||
|
||||||
|
isPhoneLikeDisplayName(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRequestNicknameAfterLogin(authResult) {
|
||||||
|
const user = authResult && authResult.user ? authResult.user : {};
|
||||||
|
const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName);
|
||||||
|
if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
authResult &&
|
||||||
|
(authResult.created ||
|
||||||
|
isDefaultDisplayName(user.displayName, user.publicUserCode) ||
|
||||||
|
(wechatDisplayName &&
|
||||||
|
isDefaultDisplayName(wechatDisplayName, user.publicUserCode)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMiniProgramEnv(value) {
|
function normalizeMiniProgramEnv(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
if (normalized === 'release') {
|
if (normalized === 'release') {
|
||||||
@@ -177,22 +203,16 @@ function shouldReturnToPreviousPage(query) {
|
|||||||
return String((query && query.returnTo) || '').trim() === 'previous';
|
return String((query && query.returnTo) || '').trim() === 'previous';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWebViewUrl(authResult) {
|
function resolveWebViewUrl(authResult, launchQuery = {}) {
|
||||||
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
||||||
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
|
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
|
||||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
|
return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
|
||||||
if (!authResult || !authResult.token) {
|
...runtimeConfig,
|
||||||
return sourcedUrl;
|
webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
|
||||||
}
|
|
||||||
|
|
||||||
return appendHashParams(sourcedUrl, {
|
|
||||||
auth_provider: 'wechat',
|
|
||||||
auth_token: authResult.token,
|
|
||||||
auth_binding_status: authResult.bindingStatus,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +288,7 @@ function wxLogin() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestMiniProgramLogin(code) {
|
function requestMiniProgramLogin(code, displayName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
||||||
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
|
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
|
||||||
@@ -280,7 +300,10 @@ function requestMiniProgramLogin(code) {
|
|||||||
wx.request({
|
wx.request({
|
||||||
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
|
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { code },
|
data: {
|
||||||
|
code,
|
||||||
|
...(displayName ? { displayName } : {}),
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
|
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
|
||||||
@@ -310,7 +333,7 @@ function requestMiniProgramLogin(code) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
|
function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
||||||
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
|
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
|
||||||
@@ -322,7 +345,10 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
|
|||||||
wx.request({
|
wx.request({
|
||||||
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
|
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { wechatPhoneCode },
|
data: {
|
||||||
|
wechatPhoneCode,
|
||||||
|
...(displayName ? { displayName } : {}),
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
authorization: `Bearer ${authToken}`,
|
authorization: `Bearer ${authToken}`,
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
@@ -353,15 +379,17 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveAuthResult() {
|
async function resolveAuthResult(displayName) {
|
||||||
const code = await wxLogin();
|
const code = await wxLogin();
|
||||||
const response = await requestMiniProgramLogin(code);
|
const response = await requestMiniProgramLogin(code, displayName);
|
||||||
if (!response || !response.token) {
|
if (!response || !response.token) {
|
||||||
throw new Error('服务器未返回登录态');
|
throw new Error('服务器未返回登录态');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
token: response.token,
|
token: response.token,
|
||||||
bindingStatus: response.bindingStatus || 'pending_bind_phone',
|
bindingStatus: response.bindingStatus || 'pending_bind_phone',
|
||||||
|
user: response.user || null,
|
||||||
|
created: response.created === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +398,10 @@ Page({
|
|||||||
authResult: null,
|
authResult: null,
|
||||||
bindingPhone: false,
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
nicknameInput: '',
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage: false,
|
returnToPreviousPage: false,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
@@ -395,11 +426,12 @@ Page({
|
|||||||
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
|
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
|
||||||
this.setData({
|
this.setData({
|
||||||
authResult: null,
|
authResult: null,
|
||||||
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage: false,
|
returnToPreviousPage: false,
|
||||||
webViewUrl: resolveWebViewUrl(null),
|
webViewUrl: resolveWebViewUrl(null, query),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -414,20 +446,65 @@ Page({
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
|
authResult: null,
|
||||||
|
bindingPhone: false,
|
||||||
|
errorMessage: '',
|
||||||
|
loggingIn: true,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage,
|
returnToPreviousPage,
|
||||||
errorMessage: '',
|
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
});
|
});
|
||||||
|
await this.startAuthFlow(returnToPreviousPage, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
handleNicknameInput(event) {
|
||||||
|
this.setData({
|
||||||
|
nicknameInput: event.detail ? event.detail.value : '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleStartLogin() {
|
||||||
|
const displayName = normalizeNicknameInput(this.data.nicknameInput);
|
||||||
|
if (!displayName) {
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '请先选择或填写微信昵称。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '',
|
||||||
|
loggingIn: true,
|
||||||
|
});
|
||||||
|
await this.startAuthFlow(this.data.returnToPreviousPage, displayName);
|
||||||
|
},
|
||||||
|
|
||||||
|
async startAuthFlow(returnToPreviousPage, displayName) {
|
||||||
try {
|
try {
|
||||||
const authResult = await resolveAuthResult();
|
const authResult = await resolveAuthResult(displayName);
|
||||||
|
if (!displayName && shouldRequestNicknameAfterLogin(authResult)) {
|
||||||
|
this.setData({
|
||||||
|
authResult,
|
||||||
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
|
loading: false,
|
||||||
|
nicknameRequired: true,
|
||||||
|
phoneBindingRequired: false,
|
||||||
|
returnToPreviousPage,
|
||||||
|
webViewUrl: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (authResult.bindingStatus === 'pending_bind_phone') {
|
if (authResult.bindingStatus === 'pending_bind_phone') {
|
||||||
this.setData({
|
this.setData({
|
||||||
authResult,
|
authResult,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: true,
|
phoneBindingRequired: true,
|
||||||
returnToPreviousPage,
|
returnToPreviousPage,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
@@ -437,6 +514,16 @@ Page({
|
|||||||
|
|
||||||
if (returnToPreviousPage) {
|
if (returnToPreviousPage) {
|
||||||
persistAuthResult(authResult);
|
persistAuthResult(authResult);
|
||||||
|
this.setData({
|
||||||
|
authResult,
|
||||||
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
|
phoneBindingRequired: false,
|
||||||
|
returnToPreviousPage,
|
||||||
|
webViewUrl: '',
|
||||||
|
});
|
||||||
wx.navigateBack();
|
wx.navigateBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -444,17 +531,21 @@ Page({
|
|||||||
this.setData({
|
this.setData({
|
||||||
authResult,
|
authResult,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage,
|
returnToPreviousPage,
|
||||||
webViewUrl: resolveWebViewUrl(authResult),
|
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setData({
|
this.setData({
|
||||||
authResult: null,
|
authResult: null,
|
||||||
errorMessage:
|
errorMessage:
|
||||||
error && error.message ? error.message : '微信登录失败,请稍后重试。',
|
error && error.message ? error.message : '微信登录失败,请稍后重试。',
|
||||||
|
loggingIn: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage,
|
returnToPreviousPage,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
@@ -466,7 +557,14 @@ Page({
|
|||||||
const authResult = consumeAuthResult();
|
const authResult = consumeAuthResult();
|
||||||
if (authResult) {
|
if (authResult) {
|
||||||
this.setData({
|
this.setData({
|
||||||
webViewUrl: resolveWebViewUrl(authResult),
|
authResult,
|
||||||
|
bindingPhone: false,
|
||||||
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
|
phoneBindingRequired: false,
|
||||||
|
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +608,7 @@ Page({
|
|||||||
const response = await requestMiniProgramBindPhone(
|
const response = await requestMiniProgramBindPhone(
|
||||||
this.data.authResult.token,
|
this.data.authResult.token,
|
||||||
detail.code,
|
detail.code,
|
||||||
|
normalizeNicknameInput(this.data.nicknameInput),
|
||||||
);
|
);
|
||||||
if (!response || !response.token) {
|
if (!response || !response.token) {
|
||||||
throw new Error('服务器未返回绑定后的登录态');
|
throw new Error('服务器未返回绑定后的登录态');
|
||||||
@@ -523,7 +622,9 @@ Page({
|
|||||||
this.setData({
|
this.setData({
|
||||||
bindingPhone: false,
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
});
|
});
|
||||||
wx.navigateBack();
|
wx.navigateBack();
|
||||||
@@ -533,9 +634,14 @@ Page({
|
|||||||
authResult: nextAuthResult,
|
authResult: nextAuthResult,
|
||||||
bindingPhone: false,
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
webViewUrl: resolveWebViewUrl(nextAuthResult),
|
webViewUrl: resolveWebViewUrl(
|
||||||
|
nextAuthResult,
|
||||||
|
this._lastLaunchQuery || {},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setData({
|
this.setData({
|
||||||
@@ -553,7 +659,10 @@ Page({
|
|||||||
authResult: null,
|
authResult: null,
|
||||||
bindingPhone: false,
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
loggingIn: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
nicknameInput: '',
|
||||||
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage: false,
|
returnToPreviousPage: false,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
@@ -570,15 +679,19 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleWebViewMessage(event) {
|
handleWebViewMessage(event) {
|
||||||
// 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。
|
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
|
||||||
|
if (shareTarget) {
|
||||||
|
this._currentShareTarget = shareTarget;
|
||||||
|
}
|
||||||
|
// 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。
|
||||||
console.info('[web-view] message', event.detail);
|
console.info('[web-view] message', event.detail);
|
||||||
},
|
},
|
||||||
|
|
||||||
onShareAppMessage() {
|
onShareAppMessage() {
|
||||||
return buildWebViewShareAppMessage();
|
return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
|
||||||
},
|
},
|
||||||
|
|
||||||
onShareTimeline() {
|
onShareTimeline() {
|
||||||
return buildWebViewShareTimeline();
|
return buildWebViewShareTimeline(resolveNativeShareQuery(this));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
188
miniprogram/pages/web-view/index.shared.js
Normal file
188
miniprogram/pages/web-view/index.shared.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
|
||||||
|
const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
|
||||||
|
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
||||||
|
|
||||||
|
function trimTrailingSlash(value) {
|
||||||
|
return String(value || '').trim().replace(/\/+$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuery(url, query) {
|
||||||
|
const rawUrl = String(url || '');
|
||||||
|
const pairs = Object.keys(query)
|
||||||
|
.filter((key) => query[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = rawUrl.indexOf('#');
|
||||||
|
const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
|
||||||
|
const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : '';
|
||||||
|
return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendHashParams(url, params) {
|
||||||
|
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
||||||
|
const pairs = Object.keys(params)
|
||||||
|
.filter((key) => params[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||||
|
);
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||||
|
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||||
|
const keptHashParts = rawHash.split('&').filter((part) => {
|
||||||
|
if (!part) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [rawKey = ''] = part.split('=');
|
||||||
|
try {
|
||||||
|
return !nextKeys.has(decodeURIComponent(rawKey));
|
||||||
|
} catch (_error) {
|
||||||
|
return !nextKeys.has(rawKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTargetPath(value) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed.startsWith('/')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed.replace(/\/+$/u, '') || '/';
|
||||||
|
return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLaunchTargetQuery(query) {
|
||||||
|
const targetPath = normalizeTargetPath(query && query.targetPath);
|
||||||
|
const work = String((query && query.work) || '').trim();
|
||||||
|
if (!targetPath || !work) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetPath,
|
||||||
|
work,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) {
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(query);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendQuery(basePath, {
|
||||||
|
targetPath: launchTarget.targetPath,
|
||||||
|
work: launchTarget.work,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebViewShareTimelineQuery(query = {}) {
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(query);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URLSearchParams({
|
||||||
|
targetPath: launchTarget.targetPath,
|
||||||
|
work: launchTarget.work,
|
||||||
|
}).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShareTargetMessageData(value) {
|
||||||
|
const message = value && value.data ? value.data : value;
|
||||||
|
if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = message.payload || {};
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(payload);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...launchTarget,
|
||||||
|
title: String(payload.title || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShareTargetFromWebViewMessage(detail) {
|
||||||
|
const dataList = detail && Array.isArray(detail.data) ? detail.data : [];
|
||||||
|
for (let index = dataList.length - 1; index >= 0; index -= 1) {
|
||||||
|
const target = normalizeShareTargetMessageData(dataList[index]);
|
||||||
|
if (target) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeShareTargetMessageData(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLaunchTargetToEntryUrl(entryUrl, query) {
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(query);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return entryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEntryUrl = String(entryUrl || '').trim();
|
||||||
|
const hashIndex = rawEntryUrl.indexOf('#');
|
||||||
|
const entryWithoutHash =
|
||||||
|
hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl;
|
||||||
|
const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : '';
|
||||||
|
const queryIndex = entryWithoutHash.indexOf('?');
|
||||||
|
const entryBase =
|
||||||
|
queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash;
|
||||||
|
const entrySearch =
|
||||||
|
queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : '';
|
||||||
|
const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`;
|
||||||
|
|
||||||
|
return appendQuery(targetUrl, {
|
||||||
|
work: launchTarget.work,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWebViewUrlFromRuntimeConfig(
|
||||||
|
authResult,
|
||||||
|
launchQuery = {},
|
||||||
|
runtimeConfig = {},
|
||||||
|
) {
|
||||||
|
const entryUrl = appendLaunchTargetToEntryUrl(
|
||||||
|
String(runtimeConfig.webViewEntryUrl || '').trim(),
|
||||||
|
launchQuery,
|
||||||
|
);
|
||||||
|
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {});
|
||||||
|
if (!authResult || !authResult.token) {
|
||||||
|
return sourcedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendHashParams(sourcedUrl, {
|
||||||
|
auth_provider: 'wechat',
|
||||||
|
auth_token: authResult.token,
|
||||||
|
auth_binding_status: authResult.bindingStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appendHashParams,
|
||||||
|
appendLaunchTargetToEntryUrl,
|
||||||
|
appendQuery,
|
||||||
|
buildWebViewSharePath,
|
||||||
|
buildWebViewShareTimelineQuery,
|
||||||
|
normalizeTargetPath,
|
||||||
|
resolveShareTargetFromWebViewMessage,
|
||||||
|
resolveLaunchTargetQuery,
|
||||||
|
resolveWebViewUrlFromRuntimeConfig,
|
||||||
|
};
|
||||||
32
miniprogram/pages/web-view/index.style.test.js
Normal file
32
miniprogram/pages/web-view/index.style.test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
const PAGE_DIR = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'miniprogram/pages/web-view',
|
||||||
|
);
|
||||||
|
|
||||||
|
function readPageFile(fileName) {
|
||||||
|
return fs.readFileSync(path.join(PAGE_DIR, fileName), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mini program web-view page background', () => {
|
||||||
|
test('keeps the native web-view host light when the mobile keyboard exposes it', () => {
|
||||||
|
const wxml = readPageFile('index.wxml');
|
||||||
|
const wxss = readPageFile('index.wxss');
|
||||||
|
|
||||||
|
expect(wxml).toContain('class="web-view-host"');
|
||||||
|
expect(wxml).not.toContain('class="web-view-page"');
|
||||||
|
expect(wxss).toContain('page');
|
||||||
|
expect(wxss).toContain('.web-view-host');
|
||||||
|
expect(wxss).toContain('background: #fffdf9;');
|
||||||
|
|
||||||
|
const webViewHostBlock = wxss.slice(
|
||||||
|
wxss.indexOf('.web-view-host'),
|
||||||
|
wxss.indexOf('.setup-screen'),
|
||||||
|
);
|
||||||
|
expect(webViewHostBlock).not.toContain('#0b0f14');
|
||||||
|
});
|
||||||
|
});
|
||||||
110
miniprogram/pages/web-view/index.test.js
Normal file
110
miniprogram/pages/web-view/index.test.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import webViewBridge from './index.shared.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
appendLaunchTargetToEntryUrl,
|
||||||
|
buildWebViewSharePath,
|
||||||
|
buildWebViewShareTimelineQuery,
|
||||||
|
resolveShareTargetFromWebViewMessage,
|
||||||
|
resolveWebViewUrlFromRuntimeConfig,
|
||||||
|
} = webViewBridge;
|
||||||
|
|
||||||
|
const runtimeConfig = {
|
||||||
|
sourceQuery: {
|
||||||
|
clientType: 'mini_program',
|
||||||
|
clientRuntime: 'wechat_mini_program',
|
||||||
|
},
|
||||||
|
webViewEntryUrl: 'https://www.genarrative.world',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mini program web-view launch target', () => {
|
||||||
|
test('opens the H5 public work detail when launch query carries work params', () => {
|
||||||
|
expect(
|
||||||
|
appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678',
|
||||||
|
);
|
||||||
|
|
||||||
|
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
},
|
||||||
|
runtimeConfig,
|
||||||
|
);
|
||||||
|
const url = new URL(webViewUrl);
|
||||||
|
expect(url.pathname).toBe('/works/detail');
|
||||||
|
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
||||||
|
expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores unsupported launch target paths', () => {
|
||||||
|
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
targetPath: '/admin',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
},
|
||||||
|
runtimeConfig,
|
||||||
|
);
|
||||||
|
const url = new URL(webViewUrl);
|
||||||
|
expect(url.pathname).toBe('/');
|
||||||
|
expect(url.searchParams.get('work')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps public work params in native mini program share paths', () => {
|
||||||
|
const sharePath = buildWebViewSharePath({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
});
|
||||||
|
const url = new URL(sharePath, 'https://mini.test');
|
||||||
|
|
||||||
|
expect(url.pathname).toBe('/pages/web-view/index');
|
||||||
|
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||||
|
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
||||||
|
expect(
|
||||||
|
buildWebViewShareTimelineQuery({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
}),
|
||||||
|
).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads the latest H5 recommended work share target from web-view messages', () => {
|
||||||
|
expect(
|
||||||
|
resolveShareTargetFromWebViewMessage({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
type: 'genarrative:share-target',
|
||||||
|
payload: {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'PZ-0001',
|
||||||
|
title: '旧作品',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
type: 'genarrative:share-target',
|
||||||
|
payload: {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<block wx:if="{{webViewUrl}}">
|
<block wx:if="{{webViewUrl}}">
|
||||||
<web-view
|
<web-view
|
||||||
id="genarrative-web-view"
|
id="genarrative-web-view"
|
||||||
|
class="web-view-host"
|
||||||
src="{{webViewUrl}}"
|
src="{{webViewUrl}}"
|
||||||
bindload="handleWebViewLoad"
|
bindload="handleWebViewLoad"
|
||||||
binderror="handleWebViewError"
|
binderror="handleWebViewError"
|
||||||
@@ -8,6 +9,32 @@
|
|||||||
/>
|
/>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
|
<view wx:elif="{{nicknameRequired}}" class="setup-screen">
|
||||||
|
<view class="setup-card">
|
||||||
|
<view class="setup-title">完善昵称</view>
|
||||||
|
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
|
||||||
|
{{errorMessage}}
|
||||||
|
</view>
|
||||||
|
<input
|
||||||
|
class="nickname-input"
|
||||||
|
type="nickname"
|
||||||
|
value="{{nicknameInput}}"
|
||||||
|
placeholder="微信昵称"
|
||||||
|
disabled="{{loggingIn}}"
|
||||||
|
bindinput="handleNicknameInput"
|
||||||
|
bindblur="handleNicknameInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="retry-button"
|
||||||
|
loading="{{loggingIn}}"
|
||||||
|
disabled="{{loggingIn}}"
|
||||||
|
bindtap="handleStartLogin"
|
||||||
|
>
|
||||||
|
{{loggingIn ? '正在提交' : '确认昵称'}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view wx:elif="{{loading}}" class="setup-screen">
|
<view wx:elif="{{loading}}" class="setup-screen">
|
||||||
<view class="setup-card">
|
<view class="setup-card">
|
||||||
<view class="setup-title">正在登录</view>
|
<view class="setup-title">正在登录</view>
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
page {
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.web-view-host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
.setup-screen {
|
.setup-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -36,6 +47,19 @@
|
|||||||
color: #ffb4a9;
|
color: #ffb4a9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nickname-input {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #f5f7fb;
|
||||||
|
font-size: 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.retry-button {
|
.retry-button {
|
||||||
margin-top: 28rpx;
|
margin-top: 28rpx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"dev:api-server": "node scripts/dev.mjs api-server",
|
"dev:api-server": "node scripts/dev.mjs api-server",
|
||||||
"dev:web": "node scripts/dev.mjs web",
|
"dev:web": "node scripts/dev.mjs web",
|
||||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||||
|
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
|
||||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||||
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||||
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||||
"check:encoding": "node scripts/check-encoding.mjs",
|
"check:encoding": "node scripts/check-encoding.mjs",
|
||||||
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
|
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
|
||||||
|
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
|
||||||
"assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
|
"assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
|
||||||
"assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs",
|
"assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs",
|
||||||
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
|
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
"lint:guardrails": "npm run lint:eslint",
|
"lint:guardrails": "npm run lint:eslint",
|
||||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||||
"typecheck:guardrails": "npm run typecheck",
|
"typecheck:guardrails": "npm run typecheck",
|
||||||
"lint": "npm run check:encoding && npm run check:spacetime-schema && npm run lint:eslint && npm run typecheck",
|
"lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run lint:eslint && npm run typecheck",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
|
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
"container:ps": "node scripts/container-compose.mjs ps",
|
"container:ps": "node scripts/container-compose.mjs ps",
|
||||||
"container:config": "node scripts/container-compose.mjs config",
|
"container:config": "node scripts/container-compose.mjs config",
|
||||||
"container:k6": "node scripts/container-compose.mjs k6",
|
"container:k6": "node scripts/container-compose.mjs k6",
|
||||||
|
"container:worker-smoke": "node scripts/container-worker-smoke.mjs",
|
||||||
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
||||||
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
||||||
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ export type AuthUser = {
|
|||||||
publicUserCode: string;
|
publicUserCode: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
phoneNumber?: string | null;
|
||||||
phoneNumberMasked: string | null;
|
phoneNumberMasked: string | null;
|
||||||
loginMethod: AuthLoginMethod;
|
loginMethod: AuthLoginMethod;
|
||||||
bindingStatus: AuthBindingStatus;
|
bindingStatus: AuthBindingStatus;
|
||||||
wechatBound: boolean;
|
wechatBound: boolean;
|
||||||
|
wechatDisplayName?: string | null;
|
||||||
|
wechatAccount?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicUserSummary = {
|
export type PublicUserSummary = {
|
||||||
@@ -123,6 +126,7 @@ export type AuthWechatBindPhoneRequest = {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
wechatPhoneCode?: string;
|
wechatPhoneCode?: string;
|
||||||
|
displayName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthWechatBindPhoneResponse = {
|
export type AuthWechatBindPhoneResponse = {
|
||||||
@@ -132,12 +136,14 @@ export type AuthWechatBindPhoneResponse = {
|
|||||||
|
|
||||||
export type AuthWechatMiniProgramLoginRequest = {
|
export type AuthWechatMiniProgramLoginRequest = {
|
||||||
code: string;
|
code: string;
|
||||||
|
displayName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthWechatMiniProgramLoginResponse = {
|
export type AuthWechatMiniProgramLoginResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
bindingStatus: AuthBindingStatus;
|
bindingStatus: AuthBindingStatus;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
created: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthPhoneChangeRequest = {
|
export type AuthPhoneChangeRequest = {
|
||||||
@@ -150,8 +156,7 @@ export type AuthPhoneChangeResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AuthRefreshResponse = {
|
export type AuthRefreshResponse = {
|
||||||
ok: true;
|
token: string;
|
||||||
token?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthSessionSummary = {
|
export type AuthSessionSummary = {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface BarkBattleWorkPublishRequest {
|
|||||||
export interface BarkBattleImageAssetGenerateRequest {
|
export interface BarkBattleImageAssetGenerateRequest {
|
||||||
slot: BarkBattleAssetSlot;
|
slot: BarkBattleAssetSlot;
|
||||||
draftId?: string | null;
|
draftId?: string | null;
|
||||||
|
billingPurpose?: 'initial_draft_generation' | null;
|
||||||
config: BarkBattleConfigEditorPayload;
|
config: BarkBattleConfigEditorPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
packages/shared/src/contracts/externalGeneration.ts
Normal file
29
packages/shared/src/contracts/externalGeneration.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type ExternalGenerationJobStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface ExternalGenerationQueueOverview {
|
||||||
|
pendingCount: number;
|
||||||
|
runningCount: number;
|
||||||
|
updatedAtMicros: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalGenerationQueueOverviewResponse {
|
||||||
|
overview: ExternalGenerationQueueOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalGenerationJobStatusRecord {
|
||||||
|
operationId: string;
|
||||||
|
status: ExternalGenerationJobStatus;
|
||||||
|
phaseLabel: string;
|
||||||
|
phaseDetail: string;
|
||||||
|
progress: number;
|
||||||
|
error?: string | null;
|
||||||
|
updatedAtMicros: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalGenerationJobStatusResponse {
|
||||||
|
job: ExternalGenerationJobStatusRecord;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ export type * from './creationAudio';
|
|||||||
export type * from './hyper3d';
|
export type * from './hyper3d';
|
||||||
export type * from './jumpHop';
|
export type * from './jumpHop';
|
||||||
export type * from './puzzleCreativeTemplate';
|
export type * from './puzzleCreativeTemplate';
|
||||||
|
export type * from './puzzleClear';
|
||||||
|
export * from './playTypes';
|
||||||
export type * from './publicWork';
|
export type * from './publicWork';
|
||||||
export type * from './visualNovel';
|
export type * from './visualNovel';
|
||||||
export type * from './barkBattle';
|
export type * from './barkBattle';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
||||||
|
|
||||||
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
|
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
|
||||||
|
|
||||||
export type JumpHopStylePreset =
|
export type JumpHopStylePreset =
|
||||||
@@ -24,7 +26,6 @@ export type JumpHopTileType =
|
|||||||
|
|
||||||
export type JumpHopActionType =
|
export type JumpHopActionType =
|
||||||
| 'compile-draft'
|
| 'compile-draft'
|
||||||
| 'regenerate-character'
|
|
||||||
| 'regenerate-tiles'
|
| 'regenerate-tiles'
|
||||||
| 'update-work-meta'
|
| 'update-work-meta'
|
||||||
| 'update-difficulty';
|
| 'update-difficulty';
|
||||||
@@ -35,19 +36,21 @@ export type JumpHopJumpResult = 'miss' | 'hit' | 'perfect' | 'finish';
|
|||||||
|
|
||||||
export interface JumpHopWorkspaceCreateRequest {
|
export interface JumpHopWorkspaceCreateRequest {
|
||||||
templateId: string;
|
templateId: string;
|
||||||
workTitle: string;
|
themeText: string;
|
||||||
workDescription: string;
|
workTitle?: string;
|
||||||
themeTags: string[];
|
workDescription?: string;
|
||||||
difficulty: JumpHopDifficulty;
|
themeTags?: string[];
|
||||||
stylePreset: JumpHopStylePreset;
|
difficulty?: JumpHopDifficulty;
|
||||||
characterPrompt: string;
|
stylePreset?: JumpHopStylePreset;
|
||||||
tilePrompt: string;
|
characterPrompt?: string;
|
||||||
|
tilePrompt?: string;
|
||||||
endMoodPrompt?: string | null;
|
endMoodPrompt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopActionRequest {
|
export interface JumpHopActionRequest {
|
||||||
actionType: JumpHopActionType;
|
actionType: JumpHopActionType;
|
||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
|
themeText?: string | null;
|
||||||
workTitle?: string | null;
|
workTitle?: string | null;
|
||||||
workDescription?: string | null;
|
workDescription?: string | null;
|
||||||
themeTags?: string[] | null;
|
themeTags?: string[] | null;
|
||||||
@@ -60,6 +63,7 @@ export interface JumpHopActionRequest {
|
|||||||
tileAtlasAsset?: JumpHopCharacterAsset | null;
|
tileAtlasAsset?: JumpHopCharacterAsset | null;
|
||||||
tileAssets?: JumpHopTileAsset[] | null;
|
tileAssets?: JumpHopTileAsset[] | null;
|
||||||
coverComposite?: string | null;
|
coverComposite?: string | null;
|
||||||
|
backButtonAsset?: JumpHopCharacterAsset | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopCharacterAsset {
|
export interface JumpHopCharacterAsset {
|
||||||
@@ -73,16 +77,58 @@ export interface JumpHopCharacterAsset {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JumpHopDefaultCharacter {
|
||||||
|
characterId: string;
|
||||||
|
displayName: string;
|
||||||
|
modelKind: 'builtin-three';
|
||||||
|
bodyColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JumpHopTileAsset {
|
export interface JumpHopTileAsset {
|
||||||
tileType: JumpHopTileType;
|
tileType: JumpHopTileType;
|
||||||
|
tileId?: string;
|
||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
imageObjectKey: string;
|
imageObjectKey: string;
|
||||||
assetObjectId: string;
|
assetObjectId: string;
|
||||||
sourceAtlasCell: string;
|
sourceAtlasCell: string;
|
||||||
|
atlasRow?: number;
|
||||||
|
atlasCol?: number;
|
||||||
visualWidth: number;
|
visualWidth: number;
|
||||||
visualHeight: number;
|
visualHeight: number;
|
||||||
topSurfaceRadius: number;
|
topSurfaceRadius: number;
|
||||||
landingRadius: number;
|
landingRadius: number;
|
||||||
|
faceAssets?: JumpHopTileFaceAssets | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JumpHopTileFaceKey =
|
||||||
|
| 'top'
|
||||||
|
| 'front'
|
||||||
|
| 'right'
|
||||||
|
| 'back'
|
||||||
|
| 'left'
|
||||||
|
| 'bottom';
|
||||||
|
|
||||||
|
export interface JumpHopTileFaceAsset {
|
||||||
|
face: JumpHopTileFaceKey;
|
||||||
|
assetId: string;
|
||||||
|
imageSrc: string;
|
||||||
|
imageObjectKey: string;
|
||||||
|
assetObjectId: string;
|
||||||
|
generationProvider: string;
|
||||||
|
prompt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sourceAtlasCell: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JumpHopTileFaceAssets {
|
||||||
|
top: JumpHopTileFaceAsset;
|
||||||
|
front: JumpHopTileFaceAsset;
|
||||||
|
right: JumpHopTileFaceAsset;
|
||||||
|
back: JumpHopTileFaceAsset;
|
||||||
|
left: JumpHopTileFaceAsset;
|
||||||
|
bottom: JumpHopTileFaceAsset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopScoring {
|
export interface JumpHopScoring {
|
||||||
@@ -126,11 +172,13 @@ export interface JumpHopDraftResponse {
|
|||||||
templateId: string;
|
templateId: string;
|
||||||
templateName: string;
|
templateName: string;
|
||||||
profileId: string | null;
|
profileId: string | null;
|
||||||
|
themeText: string;
|
||||||
workTitle: string;
|
workTitle: string;
|
||||||
workDescription: string;
|
workDescription: string;
|
||||||
themeTags: string[];
|
themeTags: string[];
|
||||||
difficulty: JumpHopDifficulty;
|
difficulty: JumpHopDifficulty;
|
||||||
stylePreset: JumpHopStylePreset;
|
stylePreset: JumpHopStylePreset;
|
||||||
|
defaultCharacter?: JumpHopDefaultCharacter | null;
|
||||||
characterPrompt: string;
|
characterPrompt: string;
|
||||||
tilePrompt: string;
|
tilePrompt: string;
|
||||||
endMoodPrompt: string | null;
|
endMoodPrompt: string | null;
|
||||||
@@ -139,6 +187,7 @@ export interface JumpHopDraftResponse {
|
|||||||
tileAssets: JumpHopTileAsset[];
|
tileAssets: JumpHopTileAsset[];
|
||||||
path: JumpHopPath | null;
|
path: JumpHopPath | null;
|
||||||
coverComposite: string | null;
|
coverComposite: string | null;
|
||||||
|
backButtonAsset?: JumpHopCharacterAsset | null;
|
||||||
generationStatus: JumpHopGenerationStatus;
|
generationStatus: JumpHopGenerationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +208,7 @@ export interface JumpHopActionResponse {
|
|||||||
actionType: JumpHopActionType;
|
actionType: JumpHopActionType;
|
||||||
session: JumpHopSessionSnapshotResponse;
|
session: JumpHopSessionSnapshotResponse;
|
||||||
work: JumpHopWorkProfileResponse | null;
|
work: JumpHopWorkProfileResponse | null;
|
||||||
|
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopWorkSummaryResponse {
|
export interface JumpHopWorkSummaryResponse {
|
||||||
@@ -167,6 +217,7 @@ export interface JumpHopWorkSummaryResponse {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
sourceSessionId: string | null;
|
sourceSessionId: string | null;
|
||||||
|
themeText: string;
|
||||||
workTitle: string;
|
workTitle: string;
|
||||||
workDescription: string;
|
workDescription: string;
|
||||||
themeTags: string[];
|
themeTags: string[];
|
||||||
@@ -185,9 +236,11 @@ export interface JumpHopWorkProfileResponse {
|
|||||||
summary: JumpHopWorkSummaryResponse;
|
summary: JumpHopWorkSummaryResponse;
|
||||||
draft: JumpHopDraftResponse;
|
draft: JumpHopDraftResponse;
|
||||||
path: JumpHopPath;
|
path: JumpHopPath;
|
||||||
|
defaultCharacter?: JumpHopDefaultCharacter | null;
|
||||||
characterAsset: JumpHopCharacterAsset;
|
characterAsset: JumpHopCharacterAsset;
|
||||||
tileAtlasAsset: JumpHopCharacterAsset;
|
tileAtlasAsset: JumpHopCharacterAsset;
|
||||||
tileAssets: JumpHopTileAsset[];
|
tileAssets: JumpHopTileAsset[];
|
||||||
|
backButtonAsset?: JumpHopCharacterAsset | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopWorksResponse {
|
export interface JumpHopWorksResponse {
|
||||||
@@ -208,6 +261,7 @@ export interface JumpHopGalleryCardResponse {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
authorDisplayName: string;
|
authorDisplayName: string;
|
||||||
|
themeText: string;
|
||||||
workTitle: string;
|
workTitle: string;
|
||||||
workDescription: string;
|
workDescription: string;
|
||||||
coverImageSrc: string | null;
|
coverImageSrc: string | null;
|
||||||
@@ -237,6 +291,8 @@ export interface JumpHopRuntimeRunSnapshotResponse {
|
|||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
status: JumpHopRunStatus;
|
status: JumpHopRunStatus;
|
||||||
currentPlatformIndex: number;
|
currentPlatformIndex: number;
|
||||||
|
successfulJumpCount: number;
|
||||||
|
durationMs: number;
|
||||||
score: number;
|
score: number;
|
||||||
combo: number;
|
combo: number;
|
||||||
path: JumpHopPath;
|
path: JumpHopPath;
|
||||||
@@ -251,10 +307,13 @@ export interface JumpHopRunResponse {
|
|||||||
|
|
||||||
export interface JumpHopStartRunRequest {
|
export interface JumpHopStartRunRequest {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
runtimeMode?: 'draft' | 'published';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopJumpRequest {
|
export interface JumpHopJumpRequest {
|
||||||
chargeMs: number;
|
dragDistance: number;
|
||||||
|
dragVectorX?: number;
|
||||||
|
dragVectorY?: number;
|
||||||
clientEventId: string;
|
clientEventId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,3 +324,18 @@ export interface JumpHopRestartRunRequest {
|
|||||||
export interface JumpHopJumpResponse {
|
export interface JumpHopJumpResponse {
|
||||||
run: JumpHopRuntimeRunSnapshotResponse;
|
run: JumpHopRuntimeRunSnapshotResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JumpHopLeaderboardEntry {
|
||||||
|
rank: number;
|
||||||
|
playerId: string;
|
||||||
|
displayName: string;
|
||||||
|
successfulJumpCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JumpHopLeaderboardResponse {
|
||||||
|
profileId: string;
|
||||||
|
items: JumpHopLeaderboardEntry[];
|
||||||
|
viewerBest?: JumpHopLeaderboardEntry | null;
|
||||||
|
}
|
||||||
|
|||||||
72
packages/shared/src/contracts/playTypes.ts
Normal file
72
packages/shared/src/contracts/playTypes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export const PLATFORM_CREATION_TYPE_IDS = [
|
||||||
|
'rpg',
|
||||||
|
'big-fish',
|
||||||
|
'puzzle',
|
||||||
|
'puzzle-clear',
|
||||||
|
'match3d',
|
||||||
|
'jump-hop',
|
||||||
|
'wooden-fish',
|
||||||
|
'square-hole',
|
||||||
|
'bark-battle',
|
||||||
|
'visual-novel',
|
||||||
|
'baby-object-match',
|
||||||
|
'creative-agent',
|
||||||
|
'airp',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PlatformCreationTypeId =
|
||||||
|
(typeof PLATFORM_CREATION_TYPE_IDS)[number];
|
||||||
|
|
||||||
|
const PLATFORM_CREATION_TYPE_ID_SET: ReadonlySet<string> = new Set(
|
||||||
|
PLATFORM_CREATION_TYPE_IDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isPlatformCreationTypeId(
|
||||||
|
value: string,
|
||||||
|
): value is PlatformCreationTypeId {
|
||||||
|
return PLATFORM_CREATION_TYPE_ID_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPlatformCreationTypeId(
|
||||||
|
value: string,
|
||||||
|
): PlatformCreationTypeId {
|
||||||
|
if (isPlatformCreationTypeId(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知创作类型:${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PUBLIC_WORK_SOURCE_TYPES = [
|
||||||
|
'custom-world',
|
||||||
|
'big-fish',
|
||||||
|
'puzzle',
|
||||||
|
'puzzle-clear',
|
||||||
|
'jump-hop',
|
||||||
|
'wooden-fish',
|
||||||
|
'match3d',
|
||||||
|
'square-hole',
|
||||||
|
'visual-novel',
|
||||||
|
'bark-battle',
|
||||||
|
'edutainment',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PublicWorkSourceType = (typeof PUBLIC_WORK_SOURCE_TYPES)[number];
|
||||||
|
|
||||||
|
const PUBLIC_WORK_SOURCE_TYPE_SET: ReadonlySet<string> = new Set(
|
||||||
|
PUBLIC_WORK_SOURCE_TYPES,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isPublicWorkSourceType(
|
||||||
|
value: string,
|
||||||
|
): value is PublicWorkSourceType {
|
||||||
|
return PUBLIC_WORK_SOURCE_TYPE_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPublicWorkSourceType(value: string): PublicWorkSourceType {
|
||||||
|
if (isPublicWorkSourceType(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知公开作品类型:${value}`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import type { PublicWorkSourceType } from './playTypes';
|
||||||
|
|
||||||
export interface PublicWorkGalleryEntryResponse {
|
export interface PublicWorkGalleryEntryResponse {
|
||||||
sourceType: string;
|
sourceType: PublicWorkSourceType;
|
||||||
workId: string;
|
workId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
sourceSessionId?: string | null;
|
sourceSessionId?: string | null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
|
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
|
||||||
|
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
||||||
|
|
||||||
export type PuzzleAgentSuggestedActionType =
|
export type PuzzleAgentSuggestedActionType =
|
||||||
| 'request_summary'
|
| 'request_summary'
|
||||||
@@ -41,6 +42,7 @@ export interface PuzzleAgentOperationRecord {
|
|||||||
phaseDetail: string;
|
phaseDetail: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PuzzleAgentActionRequest =
|
export type PuzzleAgentActionRequest =
|
||||||
|
|||||||
229
packages/shared/src/contracts/puzzleClear.ts
Normal file
229
packages/shared/src/contracts/puzzleClear.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
||||||
|
|
||||||
|
export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
|
||||||
|
|
||||||
|
export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
|
||||||
|
|
||||||
|
export type PuzzleClearOrientation = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
export type PuzzleClearRunStatus =
|
||||||
|
| 'playing'
|
||||||
|
| 'level_failed'
|
||||||
|
| 'level_cleared'
|
||||||
|
| 'finished';
|
||||||
|
|
||||||
|
export type PuzzleClearActionType =
|
||||||
|
| 'compile-draft'
|
||||||
|
| 'regenerate-atlas'
|
||||||
|
| 'update-work-meta'
|
||||||
|
| 'update-board-background';
|
||||||
|
|
||||||
|
export interface PuzzleClearImageAsset {
|
||||||
|
assetId: string;
|
||||||
|
imageSrc: string;
|
||||||
|
imageObjectKey: string;
|
||||||
|
assetObjectId: string;
|
||||||
|
generationProvider: string;
|
||||||
|
prompt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearPatternGroup {
|
||||||
|
groupId: string;
|
||||||
|
shape: PuzzleClearShapeKind;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
atlasX: number;
|
||||||
|
atlasY: number;
|
||||||
|
atlasWidth: number;
|
||||||
|
atlasHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearCardAsset {
|
||||||
|
cardId: string;
|
||||||
|
groupId: string;
|
||||||
|
shape: PuzzleClearShapeKind;
|
||||||
|
orientation: PuzzleClearOrientation;
|
||||||
|
partX: number;
|
||||||
|
partY: number;
|
||||||
|
imageSrc: string;
|
||||||
|
imageObjectKey: string;
|
||||||
|
assetObjectId: string;
|
||||||
|
sourceAtlasCell: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearWorkspaceCreateRequest {
|
||||||
|
templateId: 'puzzle-clear' | string;
|
||||||
|
workTitle: string;
|
||||||
|
workDescription: string;
|
||||||
|
themePrompt: string;
|
||||||
|
boardBackgroundPrompt: string;
|
||||||
|
generateBoardBackground: boolean;
|
||||||
|
boardBackgroundAsset?: PuzzleClearImageAsset | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearActionRequest {
|
||||||
|
actionType: PuzzleClearActionType;
|
||||||
|
profileId?: string | null;
|
||||||
|
workTitle?: string | null;
|
||||||
|
workDescription?: string | null;
|
||||||
|
themePrompt?: string | null;
|
||||||
|
boardBackgroundPrompt?: string | null;
|
||||||
|
generateBoardBackground?: boolean | null;
|
||||||
|
boardBackgroundAsset?: PuzzleClearImageAsset | null;
|
||||||
|
atlasAsset?: PuzzleClearImageAsset | null;
|
||||||
|
patternGroups?: PuzzleClearPatternGroup[] | null;
|
||||||
|
cardAssets?: PuzzleClearCardAsset[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearDraftResponse {
|
||||||
|
templateId: string;
|
||||||
|
templateName: string;
|
||||||
|
profileId: string | null;
|
||||||
|
workTitle: string;
|
||||||
|
workDescription: string;
|
||||||
|
themePrompt: string;
|
||||||
|
boardBackgroundPrompt: string;
|
||||||
|
generateBoardBackground: boolean;
|
||||||
|
boardBackgroundAsset: PuzzleClearImageAsset | null;
|
||||||
|
cardBackImageSrc: string | null;
|
||||||
|
atlasAsset: PuzzleClearImageAsset | null;
|
||||||
|
patternGroups: PuzzleClearPatternGroup[];
|
||||||
|
cardAssets: PuzzleClearCardAsset[];
|
||||||
|
generationStatus: PuzzleClearGenerationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearSessionSnapshotResponse {
|
||||||
|
sessionId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
status: PuzzleClearGenerationStatus;
|
||||||
|
draft: PuzzleClearDraftResponse | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearSessionResponse {
|
||||||
|
session: PuzzleClearSessionSnapshotResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearActionResponse {
|
||||||
|
actionType: PuzzleClearActionType;
|
||||||
|
session: PuzzleClearSessionSnapshotResponse;
|
||||||
|
work: PuzzleClearWorkProfileResponse | null;
|
||||||
|
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearWorkSummaryResponse {
|
||||||
|
runtimeKind: 'puzzle-clear';
|
||||||
|
workId: string;
|
||||||
|
profileId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
sourceSessionId: string | null;
|
||||||
|
workTitle: string;
|
||||||
|
workDescription: string;
|
||||||
|
themePrompt: string;
|
||||||
|
coverImageSrc: string | null;
|
||||||
|
publicationStatus: string;
|
||||||
|
playCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
publishReady: boolean;
|
||||||
|
generationStatus: PuzzleClearGenerationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearWorkProfileResponse {
|
||||||
|
summary: PuzzleClearWorkSummaryResponse;
|
||||||
|
draft: PuzzleClearDraftResponse;
|
||||||
|
boardBackgroundAsset: PuzzleClearImageAsset | null;
|
||||||
|
atlasAsset: PuzzleClearImageAsset;
|
||||||
|
patternGroups: PuzzleClearPatternGroup[];
|
||||||
|
cardAssets: PuzzleClearCardAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearWorksResponse {
|
||||||
|
items: PuzzleClearWorkSummaryResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearWorkDetailResponse {
|
||||||
|
item: PuzzleClearWorkProfileResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearWorkMutationResponse {
|
||||||
|
item: PuzzleClearWorkProfileResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearGalleryCardResponse
|
||||||
|
extends PuzzleClearWorkSummaryResponse {
|
||||||
|
publicWorkCode?: string;
|
||||||
|
authorDisplayName?: string;
|
||||||
|
recentPlayCount7d?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearGalleryResponse {
|
||||||
|
items: PuzzleClearGalleryCardResponse[];
|
||||||
|
hasMore: boolean;
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearGalleryDetailResponse {
|
||||||
|
item: PuzzleClearWorkProfileResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearBoardCell {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
card: PuzzleClearCardAsset | null;
|
||||||
|
lockedGroupId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearBoardSnapshot {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
cells: PuzzleClearBoardCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearRuntimeSnapshotResponse {
|
||||||
|
runId: string;
|
||||||
|
profileId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
runtimeMode?: 'draft' | 'published';
|
||||||
|
status: PuzzleClearRunStatus;
|
||||||
|
levelIndex: number;
|
||||||
|
clearsDone: number;
|
||||||
|
targetClears: number;
|
||||||
|
levelDurationSeconds: number;
|
||||||
|
levelStartedAtMs: number;
|
||||||
|
board: PuzzleClearBoardSnapshot;
|
||||||
|
readyColumns: PuzzleClearCardAsset[][];
|
||||||
|
startedAtMs: number;
|
||||||
|
finishedAtMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearRunResponse {
|
||||||
|
run: PuzzleClearRuntimeSnapshotResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearStartRunRequest {
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearSwapRequest {
|
||||||
|
fromRow: number;
|
||||||
|
fromCol: number;
|
||||||
|
toRow: number;
|
||||||
|
toCol: number;
|
||||||
|
clientActionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearRetryLevelRequest {
|
||||||
|
clientActionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearNextLevelRequest {
|
||||||
|
clientActionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleClearTimeUpRequest {
|
||||||
|
clientActionId: string;
|
||||||
|
}
|
||||||
@@ -136,7 +136,6 @@ export interface DragPuzzlePieceRequest {
|
|||||||
|
|
||||||
export interface AdvancePuzzleNextLevelRequest {
|
export interface AdvancePuzzleNextLevelRequest {
|
||||||
targetProfileId?: string | null;
|
targetProfileId?: string | null;
|
||||||
preferSimilarWork?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsePuzzleRuntimePropRequest {
|
export interface UsePuzzleRuntimePropRequest {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
||||||
|
|
||||||
export type WoodenFishGenerationStatus =
|
export type WoodenFishGenerationStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'generating'
|
| 'generating'
|
||||||
@@ -104,6 +106,7 @@ export interface WoodenFishActionResponse {
|
|||||||
actionType: WoodenFishActionType;
|
actionType: WoodenFishActionType;
|
||||||
session: WoodenFishSessionSnapshotResponse;
|
session: WoodenFishSessionSnapshotResponse;
|
||||||
work: WoodenFishWorkProfileResponse | null;
|
work: WoodenFishWorkProfileResponse | null;
|
||||||
|
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WoodenFishWorkSummaryResponse {
|
export interface WoodenFishWorkSummaryResponse {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export type * from './contracts/creativeAgent';
|
|||||||
export type * from './contracts/customWorldAgent';
|
export type * from './contracts/customWorldAgent';
|
||||||
export * from './contracts/edutainmentBabyDrawing';
|
export * from './contracts/edutainmentBabyDrawing';
|
||||||
export * from './contracts/edutainmentBabyObject';
|
export * from './contracts/edutainmentBabyObject';
|
||||||
|
export * from './contracts/externalGeneration';
|
||||||
export type * from './contracts/hyper3d';
|
export type * from './contracts/hyper3d';
|
||||||
export * from './contracts/match3dAgent';
|
export * from './contracts/match3dAgent';
|
||||||
export * from './contracts/match3dRuntime';
|
export * from './contracts/match3dRuntime';
|
||||||
export * from './contracts/match3dWorks';
|
export * from './contracts/match3dWorks';
|
||||||
|
export * from './contracts/playTypes';
|
||||||
export * from './contracts/puzzleAgentActions';
|
export * from './contracts/puzzleAgentActions';
|
||||||
export * from './contracts/puzzleAgentDraft';
|
export * from './contracts/puzzleAgentDraft';
|
||||||
export * from './contracts/puzzleAgentSession';
|
export * from './contracts/puzzleAgentSession';
|
||||||
|
|||||||
BIN
public/branding/jump-hop-taonier-character.png
Normal file
BIN
public/branding/jump-hop-taonier-character.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
public/creation-type-references/jump-hop.webp
Normal file
BIN
public/creation-type-references/jump-hop.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -445,7 +445,7 @@ if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
|
|||||||
write_migration_bootstrap_secret_file
|
write_migration_bootstrap_secret_file
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy"
|
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/scripts/ops" "${TARGET_DIR}/deploy"
|
||||||
cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh"
|
cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh"
|
||||||
cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh"
|
cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh"
|
||||||
cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh"
|
cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh"
|
||||||
@@ -466,6 +466,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}
|
|||||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||||
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
||||||
|
copy_required_file "${SCRIPT_DIR}/ops/production-health-patrol.mjs" "${TARGET_DIR}/scripts/ops/production-health-patrol.mjs" "生产健康巡检脚本"
|
||||||
|
|
||||||
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
|
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
|
||||||
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
|
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
|
||||||
@@ -485,7 +486,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
|||||||
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
||||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||||
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
||||||
|
|
||||||
## 生产部署口径
|
## 生产部署口径
|
||||||
|
|||||||
104
scripts/check-production-ops-guardrails.mjs
Normal file
104
scripts/check-production-ops-guardrails.mjs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{
|
||||||
|
file: 'deploy/systemd/genarrative-database-backup.service',
|
||||||
|
includes: '--restart-service-after genarrative-api.service',
|
||||||
|
reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起依赖它的 API 服务。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'deploy/systemd/genarrative-health-patrol.service',
|
||||||
|
includes: 'scripts/ops/production-health-patrol.mjs',
|
||||||
|
reason: '健康巡检 systemd service 必须调用随 API release 发布的巡检脚本。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'deploy/systemd/genarrative-health-patrol.timer',
|
||||||
|
includes: 'genarrative-health-patrol.service',
|
||||||
|
reason: '健康巡检 timer 必须绑定巡检 service。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/jenkins-server-provision.sh',
|
||||||
|
includes: 'genarrative-health-patrol.timer',
|
||||||
|
reason: 'Server-Provision 必须安装并启用健康巡检 timer。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/jenkins-server-provision.sh',
|
||||||
|
includes: 'genarrative-external-generation-controller.service',
|
||||||
|
reason: 'Server-Provision 必须安装并启用外部生成 worker controller。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/jenkins-server-provision.sh',
|
||||||
|
includes: 'genarrative-external-generation-worker@1.service',
|
||||||
|
reason: 'Server-Provision 必须启用外部生成保底 worker 实例。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/deploy/production-api-deploy.sh',
|
||||||
|
includes: 'ensure_default_worker_service',
|
||||||
|
reason: 'API Deploy 必须在缺少 worker 实例时补启动默认外部生成 worker。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/deploy/production-api-deploy.sh',
|
||||||
|
includes: 'wait_for_worker_services',
|
||||||
|
reason: 'API Deploy 必须等待外部生成 worker 实例 active。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/deploy/production-api-deploy.sh',
|
||||||
|
includes: 'wait_for_worker_controller_service',
|
||||||
|
reason: 'API Deploy 必须重启并验活外部生成 worker controller。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'deploy/systemd/genarrative-external-generation-worker@.service',
|
||||||
|
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-worker',
|
||||||
|
reason: '外部生成 worker 模板必须作为独立 worker 进程角色运行。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'deploy/systemd/genarrative-external-generation-controller.service',
|
||||||
|
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-controller',
|
||||||
|
reason: '外部生成 worker controller 必须作为独立进程角色运行。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/ops/production-health-patrol.mjs',
|
||||||
|
includes: 'checkActiveWorkerInstances',
|
||||||
|
reason: '生产健康巡检必须检查至少一个外部生成 worker 实例 active。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/build-production-release.sh',
|
||||||
|
includes: 'production-health-patrol.mjs',
|
||||||
|
reason: '生产 API release 必须携带健康巡检脚本。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/deploy/production-api-deploy.sh',
|
||||||
|
includes: 'production-health-patrol.mjs',
|
||||||
|
reason: 'API deploy 必须把健康巡检脚本复制到 current release。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'jenkins/Jenkinsfile.production-api-build',
|
||||||
|
includes: 'scripts/ops/production-health-patrol.mjs',
|
||||||
|
reason: 'API Build 归档必须包含健康巡检脚本。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'jenkins/Jenkinsfile.production-api-deploy',
|
||||||
|
includes: 'scripts/ops/production-health-patrol.mjs',
|
||||||
|
reason: 'API Deploy 复制上游产物时必须包含健康巡检脚本。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const content = readFileSync(check.file, 'utf8');
|
||||||
|
if (!content.includes(check.includes)) {
|
||||||
|
failed = true;
|
||||||
|
console.error(
|
||||||
|
`[check:production-ops] ${check.file} 缺少 ${check.includes}。${check.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[check:production-ops] OK');
|
||||||
86
scripts/check-server-provision-tools.sh
Executable file
86
scripts/check-server-provision-tools.sh
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TMP_ROOT="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_ROOT}"' EXIT
|
||||||
|
|
||||||
|
WORK_DIR="${TMP_ROOT}/workspace"
|
||||||
|
FAKE_BIN_DIR="${TMP_ROOT}/fake-bin"
|
||||||
|
TARGET_BIN_DIR="${TMP_ROOT}/target-bin"
|
||||||
|
SPACETIME_ROOT_DIR="${TMP_ROOT}/stdb"
|
||||||
|
OUTPUT_LOG="${TMP_ROOT}/prepare.log"
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"${WORK_DIR}" \
|
||||||
|
"${FAKE_BIN_DIR}" \
|
||||||
|
"${TARGET_BIN_DIR}" \
|
||||||
|
"${SPACETIME_ROOT_DIR}/bin/current"
|
||||||
|
|
||||||
|
cat >"${FAKE_BIN_DIR}/curl" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "curl should not be called when target tools are already ready" >&2
|
||||||
|
exit 97
|
||||||
|
EOF
|
||||||
|
cat >"${FAKE_BIN_DIR}/wget" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "wget should not be called when target tools are already ready" >&2
|
||||||
|
exit 97
|
||||||
|
EOF
|
||||||
|
chmod +x "${FAKE_BIN_DIR}/curl" "${FAKE_BIN_DIR}/wget"
|
||||||
|
|
||||||
|
cat >"${TARGET_BIN_DIR}/otelcol-contrib" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "otelcol-contrib version 0.151.0"
|
||||||
|
EOF
|
||||||
|
chmod +x "${TARGET_BIN_DIR}/otelcol-contrib"
|
||||||
|
|
||||||
|
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "spacetimedb-cli 2.5.0"
|
||||||
|
EOF
|
||||||
|
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "spacetimedb-standalone 2.5.0"
|
||||||
|
EOF
|
||||||
|
chmod +x \
|
||||||
|
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
|
||||||
|
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone"
|
||||||
|
|
||||||
|
if ! (
|
||||||
|
cd "${WORK_DIR}"
|
||||||
|
PATH="${FAKE_BIN_DIR}:${PATH}" \
|
||||||
|
WORKSPACE="${WORK_DIR}" \
|
||||||
|
PROVISION_TOOLS_DIR="provision-tools" \
|
||||||
|
PROVISION_DOWNLOADS_DIR="downloads" \
|
||||||
|
PROVISION_TOOLS_TMP_PARENT="${WORK_DIR}/.tmp/server-provision-tools" \
|
||||||
|
PROVISION_REQUIRE_LOCAL_DOWNLOADS="true" \
|
||||||
|
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
|
||||||
|
OTELCOL_VERSION="0.151.0" \
|
||||||
|
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
|
||||||
|
SPACETIME_EXPECTED_VERSION="2.5.0" \
|
||||||
|
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
|
||||||
|
>"${OUTPUT_LOG}" 2>&1
|
||||||
|
); then
|
||||||
|
echo "[check-server-provision-tools] prepare-server-provision-tools.sh 执行失败。" >&2
|
||||||
|
cat "${OUTPUT_LOG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -q "复用目标机已有 otelcol-contrib" "${OUTPUT_LOG}"
|
||||||
|
grep -q "复用目标机已有 SpacetimeDB 安装" "${OUTPUT_LOG}"
|
||||||
|
grep -q "otelcol-contrib 0.151.0 target existing" "${WORK_DIR}/provision-tools/MANIFEST.txt"
|
||||||
|
grep -q "spacetime target existing" "${WORK_DIR}/provision-tools/MANIFEST.txt"
|
||||||
|
|
||||||
|
test -x "${WORK_DIR}/provision-tools/otelcol-contrib"
|
||||||
|
test -x "${WORK_DIR}/provision-tools/spacetime/spacetime"
|
||||||
|
test -x "${WORK_DIR}/provision-tools/spacetime/bin/current/spacetimedb-cli"
|
||||||
|
test -x "${WORK_DIR}/provision-tools/spacetime/bin/current/spacetimedb-standalone"
|
||||||
|
|
||||||
|
if grep -q "下载 " "${OUTPUT_LOG}"; then
|
||||||
|
echo "[check-server-provision-tools] 已有目标机工具时不应进入下载分支。" >&2
|
||||||
|
cat "${OUTPUT_LOG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[check-server-provision-tools] OK"
|
||||||
@@ -475,10 +475,16 @@ function loadBaseSources(baseRef) {
|
|||||||
|
|
||||||
function getChangedFiles(baseRef) {
|
function getChangedFiles(baseRef) {
|
||||||
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
||||||
const untrackedOutput =
|
const untrackedModuleOutput =
|
||||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
|
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
|
||||||
|
const untrackedBindingsOutput =
|
||||||
|
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
|
||||||
return new Set(
|
return new Set(
|
||||||
[...diffOutput.split(/\u0000/u), ...untrackedOutput.split(/\u0000/u)]
|
[
|
||||||
|
...diffOutput.split(/\u0000/u),
|
||||||
|
...untrackedModuleOutput.split(/\u0000/u),
|
||||||
|
...untrackedBindingsOutput.split(/\u0000/u),
|
||||||
|
]
|
||||||
.map(normalizePath)
|
.map(normalizePath)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
|
|||||||
839
scripts/container-worker-smoke.mjs
Normal file
839
scripts/container-worker-smoke.mjs
Normal file
@@ -0,0 +1,839 @@
|
|||||||
|
import {spawn} from 'node:child_process';
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const [, , rawCommand = 'help', ...rawArgs] = process.argv;
|
||||||
|
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml');
|
||||||
|
const smokeDir = path.join('deploy', 'container', 'worker-smoke');
|
||||||
|
const envPath = path.join(smokeDir, 'api-server.env');
|
||||||
|
const statePath = path.join(smokeDir, 'state.json');
|
||||||
|
const localImageDir = path.join(smokeDir, 'image');
|
||||||
|
const localImageDockerfilePath = path.join(localImageDir, 'Dockerfile.local');
|
||||||
|
const localImageBinaryPath = path.join(localImageDir, 'api-server');
|
||||||
|
const localCargoTargetDir = path.join('server-rs', 'target-worker-smoke');
|
||||||
|
const localSpacetimeImageDir = path.join(smokeDir, 'spacetimedb-image');
|
||||||
|
const localSpacetimeDockerfilePath = path.join(localSpacetimeImageDir, 'Dockerfile.local');
|
||||||
|
const localSpacetimeBinaryPath = path.join(localSpacetimeImageDir, 'spacetime');
|
||||||
|
const localSpacetimeStandalonePath = path.join(
|
||||||
|
localSpacetimeImageDir,
|
||||||
|
'spacetimedb-standalone',
|
||||||
|
);
|
||||||
|
const projectName = process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke';
|
||||||
|
const defaultDatabase =
|
||||||
|
process.env.GENARRATIVE_WORKER_SMOKE_DATABASE || 'genarrative-worker-smoke';
|
||||||
|
|
||||||
|
const command = rawCommand.trim();
|
||||||
|
const supportedCommands = new Set([
|
||||||
|
'help',
|
||||||
|
'init',
|
||||||
|
'build',
|
||||||
|
'up-spacetime',
|
||||||
|
'publish',
|
||||||
|
'up',
|
||||||
|
'enqueue',
|
||||||
|
'status',
|
||||||
|
'api-update',
|
||||||
|
'scale',
|
||||||
|
'logs',
|
||||||
|
'ps',
|
||||||
|
'down',
|
||||||
|
'smoke',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!supportedCommands.has(command)) {
|
||||||
|
printHelp(true);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[worker-smoke] ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
switch (command) {
|
||||||
|
case 'help':
|
||||||
|
printHelp(false);
|
||||||
|
return;
|
||||||
|
case 'init':
|
||||||
|
await ensureStateAndEnv({force: rawArgs.includes('--force')});
|
||||||
|
return;
|
||||||
|
case 'build':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await buildRuntimeImages();
|
||||||
|
return;
|
||||||
|
case 'up-spacetime':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await ensureSpacetimeImage();
|
||||||
|
await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']);
|
||||||
|
await waitForSpacetime();
|
||||||
|
return;
|
||||||
|
case 'publish':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await publishModule();
|
||||||
|
return;
|
||||||
|
case 'up':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await upRuntime();
|
||||||
|
await waitForApi();
|
||||||
|
return;
|
||||||
|
case 'enqueue':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await enqueueSmokeJob();
|
||||||
|
return;
|
||||||
|
case 'status':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await printQueueStatus();
|
||||||
|
return;
|
||||||
|
case 'api-update':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await apiOnlyUpdate({build: rawArgs.includes('--build')});
|
||||||
|
return;
|
||||||
|
case 'scale':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await scaleWorkers(rawArgs[0] ?? '1');
|
||||||
|
return;
|
||||||
|
case 'logs':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await dockerCompose(['logs', ...rawArgs]);
|
||||||
|
return;
|
||||||
|
case 'ps':
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await dockerCompose(['ps', ...rawArgs]);
|
||||||
|
return;
|
||||||
|
case 'down':
|
||||||
|
await ensureStateAndEnv({create: false});
|
||||||
|
await dockerCompose(['down', ...rawArgs]);
|
||||||
|
return;
|
||||||
|
case 'smoke':
|
||||||
|
await runSmoke();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`未知命令: ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSmoke() {
|
||||||
|
if (rawArgs.includes('--force')) {
|
||||||
|
await ensureStateAndEnv();
|
||||||
|
await dockerComposeCapture(['down', '-v'], {allowFailure: true});
|
||||||
|
}
|
||||||
|
const state = await ensureStateAndEnv({force: rawArgs.includes('--force')});
|
||||||
|
await assertSavedPortsAvailableForNewProject(state);
|
||||||
|
console.log(
|
||||||
|
`[worker-smoke] 使用隔离环境 project=${projectName} database=${state.database}`,
|
||||||
|
);
|
||||||
|
await buildRuntimeImages();
|
||||||
|
await ensureSpacetimeImage();
|
||||||
|
await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']);
|
||||||
|
await waitForSpacetime();
|
||||||
|
await publishModule();
|
||||||
|
await upRuntime();
|
||||||
|
await waitForApi();
|
||||||
|
await assertWorkersRunning();
|
||||||
|
|
||||||
|
const beforeWorkerIds = await getContainerIds('external-generation-worker');
|
||||||
|
console.log(`[worker-smoke] worker 容器: ${beforeWorkerIds.join(', ')}`);
|
||||||
|
|
||||||
|
const firstJobId = await enqueueSmokeJob({label: 'before-api-update'});
|
||||||
|
await waitForJobConsumed(firstJobId);
|
||||||
|
|
||||||
|
await apiOnlyUpdate({build: false});
|
||||||
|
const afterWorkerIds = await getContainerIds('external-generation-worker');
|
||||||
|
if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) {
|
||||||
|
throw new Error(
|
||||||
|
`api-update 后 worker 容器发生变化: before=${beforeWorkerIds.join(',')} after=${afterWorkerIds.join(',')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('[worker-smoke] api-only 更新未重建 worker 容器。');
|
||||||
|
|
||||||
|
const secondJobId = await enqueueSmokeJob({label: 'after-api-update'});
|
||||||
|
await waitForJobConsumed(secondJobId);
|
||||||
|
await printQueueStatus();
|
||||||
|
console.log('[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRuntimeImages() {
|
||||||
|
const imageMode = resolveImageMode();
|
||||||
|
if (imageMode === 'local-binary') {
|
||||||
|
await buildLocalBinaryRuntimeImages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await dockerCompose(['build', 'api-server', 'external-generation-worker']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImageMode() {
|
||||||
|
if (rawArgs.includes('--local-binary')) {
|
||||||
|
return 'local-binary';
|
||||||
|
}
|
||||||
|
const envMode = process.env.GENARRATIVE_WORKER_SMOKE_IMAGE_MODE;
|
||||||
|
if (!envMode || envMode === 'dockerfile') {
|
||||||
|
return 'dockerfile';
|
||||||
|
}
|
||||||
|
if (envMode === 'local-binary') {
|
||||||
|
return 'local-binary';
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`GENARRATIVE_WORKER_SMOKE_IMAGE_MODE 仅支持 dockerfile 或 local-binary: ${envMode}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLocalBinaryRuntimeImages() {
|
||||||
|
const profile =
|
||||||
|
rawArgs.includes('--release') ||
|
||||||
|
process.env.GENARRATIVE_WORKER_SMOKE_CARGO_PROFILE === 'release'
|
||||||
|
? 'release'
|
||||||
|
: 'debug';
|
||||||
|
const buildArgs = ['build', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'];
|
||||||
|
if (profile === 'release') {
|
||||||
|
buildArgs.push('--release');
|
||||||
|
}
|
||||||
|
const cargoImage = resolveLocalBinaryCargoImage();
|
||||||
|
const cargoHome = resolveLocalBinaryCargoHome();
|
||||||
|
mkdirSync(cargoHome, {recursive: true});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[worker-smoke] 使用 ${cargoImage} 复用本机 Cargo 缓存构建 ${profile} api-server 二进制。`,
|
||||||
|
);
|
||||||
|
await run('docker', [
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'-u',
|
||||||
|
currentUserSpec(),
|
||||||
|
'-v',
|
||||||
|
`${projectRoot}:/workspace`,
|
||||||
|
'-v',
|
||||||
|
`${cargoHome}:/cargo-home`,
|
||||||
|
'-w',
|
||||||
|
'/workspace',
|
||||||
|
'-e',
|
||||||
|
'HOME=/cargo-home',
|
||||||
|
'-e',
|
||||||
|
'CARGO_HOME=/cargo-home',
|
||||||
|
'-e',
|
||||||
|
`CARGO_TARGET_DIR=/workspace/${toContainerPath(localCargoTargetDir)}`,
|
||||||
|
cargoImage,
|
||||||
|
'cargo',
|
||||||
|
'--config',
|
||||||
|
'build.rustc-wrapper=""',
|
||||||
|
'--config',
|
||||||
|
'target.x86_64-unknown-linux-gnu.linker="cc"',
|
||||||
|
'--config',
|
||||||
|
'target.x86_64-unknown-linux-gnu.rustflags=[]',
|
||||||
|
...buildArgs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sourceBinaryPath = path.join(localCargoTargetDir, profile, 'api-server');
|
||||||
|
if (!existsSync(sourceBinaryPath)) {
|
||||||
|
throw new Error(`未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(localImageDir, {recursive: true});
|
||||||
|
copyFileSync(sourceBinaryPath, localImageBinaryPath);
|
||||||
|
chmodSync(localImageBinaryPath, 0o755);
|
||||||
|
|
||||||
|
const baseImage = await resolveLocalBinaryBaseImage();
|
||||||
|
writeFileSync(localImageDockerfilePath, buildLocalBinaryDockerfile(baseImage), 'utf8');
|
||||||
|
|
||||||
|
await run('docker', [
|
||||||
|
'build',
|
||||||
|
'-f',
|
||||||
|
localImageDockerfilePath,
|
||||||
|
'-t',
|
||||||
|
`${projectName}-api-server`,
|
||||||
|
'-t',
|
||||||
|
`${projectName}-external-generation-worker`,
|
||||||
|
localImageDir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalBinaryCargoImage() {
|
||||||
|
return process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalBinaryCargoHome() {
|
||||||
|
if (process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME) {
|
||||||
|
return path.resolve(process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME);
|
||||||
|
}
|
||||||
|
if (!process.env.HOME) {
|
||||||
|
throw new Error('未找到 HOME,无法挂载本机 Cargo 缓存。');
|
||||||
|
}
|
||||||
|
return path.join(process.env.HOME, '.cargo');
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUserSpec() {
|
||||||
|
if (typeof process.getuid === 'function' && typeof process.getgid === 'function') {
|
||||||
|
return `${process.getuid()}:${process.getgid()}`;
|
||||||
|
}
|
||||||
|
return '0:0';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSpacetimeImage() {
|
||||||
|
if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const imageName = localSpacetimeImageName();
|
||||||
|
const existingImage = await runCapture('docker', ['image', 'inspect', imageName], {
|
||||||
|
allowFailure: true,
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
if (existingImage.code === 0 && !rawArgs.includes('--force')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spacetimePath = await resolveSpacetimeBinaryPath();
|
||||||
|
if (!spacetimePath) {
|
||||||
|
throw new Error('未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。');
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(localSpacetimeImageDir, {recursive: true});
|
||||||
|
copyFileSync(spacetimePath, localSpacetimeBinaryPath);
|
||||||
|
chmodSync(localSpacetimeBinaryPath, 0o755);
|
||||||
|
const standalonePath = path.join(path.dirname(spacetimePath), 'spacetimedb-standalone');
|
||||||
|
if (!existsSync(standalonePath)) {
|
||||||
|
throw new Error(`未找到本机 spacetimedb-standalone: ${standalonePath}`);
|
||||||
|
}
|
||||||
|
copyFileSync(standalonePath, localSpacetimeStandalonePath);
|
||||||
|
chmodSync(localSpacetimeStandalonePath, 0o755);
|
||||||
|
writeFileSync(localSpacetimeDockerfilePath, buildLocalSpacetimeDockerfile(), 'utf8');
|
||||||
|
|
||||||
|
console.log(`[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`);
|
||||||
|
await run('docker', [
|
||||||
|
'build',
|
||||||
|
'-f',
|
||||||
|
localSpacetimeDockerfilePath,
|
||||||
|
'-t',
|
||||||
|
imageName,
|
||||||
|
localSpacetimeImageDir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalSpacetimeDockerfile() {
|
||||||
|
return `FROM debian:bookworm-slim
|
||||||
|
WORKDIR /var/lib/spacetimedb
|
||||||
|
RUN apt-get update && \\
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates libstdc++6 zlib1g && \\
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY spacetime /usr/local/bin/spacetime
|
||||||
|
COPY spacetimedb-standalone /usr/local/bin/spacetimedb-standalone
|
||||||
|
RUN chmod 0755 /usr/local/bin/spacetime /usr/local/bin/spacetimedb-standalone
|
||||||
|
ENTRYPOINT ["spacetime"]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSpacetimeBinaryPath() {
|
||||||
|
if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN) {
|
||||||
|
return process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN;
|
||||||
|
}
|
||||||
|
const versionResult = await runCapture('spacetime', ['--version'], {quiet: true});
|
||||||
|
const pathMatch = versionResult.stdout.match(/^spacetime Path:\s*(.+)$/mu);
|
||||||
|
if (pathMatch?.[1]) {
|
||||||
|
return pathMatch[1].trim();
|
||||||
|
}
|
||||||
|
const whichResult = await runCapture('which', ['spacetime'], {quiet: true});
|
||||||
|
return whichResult.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLocalBinaryBaseImage() {
|
||||||
|
if (process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE) {
|
||||||
|
return process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE;
|
||||||
|
}
|
||||||
|
return 'debian:bookworm-slim';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalBinaryDockerfile(baseImage) {
|
||||||
|
return `FROM ${baseImage}
|
||||||
|
WORKDIR /srv/genarrative
|
||||||
|
RUN apt-get update && \\
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl libssl3 zlib1g libzstd1 && \\
|
||||||
|
rm -rf /var/lib/apt/lists/* && \\
|
||||||
|
(id -u genarrative >/dev/null 2>&1 || useradd --system --create-home --home-dir /srv/genarrative --shell /usr/sbin/nologin genarrative)
|
||||||
|
COPY api-server /usr/local/bin/api-server
|
||||||
|
RUN chmod 0755 /usr/local/bin/api-server && \\
|
||||||
|
mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox && \\
|
||||||
|
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
|
||||||
|
USER genarrative
|
||||||
|
EXPOSE 8082
|
||||||
|
ENV GENARRATIVE_ENV=container \\
|
||||||
|
GENARRATIVE_API_HOST=0.0.0.0 \\
|
||||||
|
GENARRATIVE_API_PORT=8082 \\
|
||||||
|
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
||||||
|
CMD ["api-server"]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContainerPath(localPath) {
|
||||||
|
return localPath.split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upRuntime() {
|
||||||
|
const services = ['api-server', 'external-generation-worker'];
|
||||||
|
if (rawArgs.includes('--with-nginx')) {
|
||||||
|
services.push('nginx');
|
||||||
|
}
|
||||||
|
await dockerCompose(['up', '-d', ...services]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureStateAndEnv(options = {}) {
|
||||||
|
const {force = false, create = true} = options;
|
||||||
|
if (!create && !existsSync(statePath)) {
|
||||||
|
return defaultState();
|
||||||
|
}
|
||||||
|
mkdirSync(smokeDir, {recursive: true});
|
||||||
|
|
||||||
|
if (!existsSync(statePath) || force) {
|
||||||
|
const state = {
|
||||||
|
database: defaultDatabase,
|
||||||
|
spacetimePort: await findAvailablePort(
|
||||||
|
Number(process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_PORT || 19101),
|
||||||
|
),
|
||||||
|
httpPort: await findAvailablePort(
|
||||||
|
Number(process.env.GENARRATIVE_WORKER_SMOKE_HTTP_PORT || 19080),
|
||||||
|
),
|
||||||
|
otlpGrpcPort: await findAvailablePort(
|
||||||
|
Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_GRPC_PORT || 15317),
|
||||||
|
),
|
||||||
|
otlpHttpPort: await findAvailablePort(
|
||||||
|
Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_HTTP_PORT || 15318),
|
||||||
|
),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readState();
|
||||||
|
if (!existsSync(envPath) || force) {
|
||||||
|
writeFileSync(envPath, buildSmokeEnv(state), 'utf8');
|
||||||
|
}
|
||||||
|
console.log(`[worker-smoke] env=${envPath}`);
|
||||||
|
console.log(`[worker-smoke] state=${statePath}`);
|
||||||
|
console.log(`[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`);
|
||||||
|
console.log(`[worker-smoke] Nginx=http://127.0.0.1:${state.httpPort}`);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSmokeEnv(state) {
|
||||||
|
return `# 本文件由 scripts/container-worker-smoke.mjs 生成,仅用于本机隔离 worker smoke。
|
||||||
|
# 不要在这里写真实生产密钥;目录 deploy/container/worker-smoke/ 已被 gitignore。
|
||||||
|
GENARRATIVE_ENV=container-worker-smoke
|
||||||
|
GENARRATIVE_API_HOST=0.0.0.0
|
||||||
|
GENARRATIVE_API_PORT=8082
|
||||||
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
|
GENARRATIVE_API_LISTEN_BACKLOG=256
|
||||||
|
GENARRATIVE_API_WORKER_THREADS=2
|
||||||
|
GENARRATIVE_PROCESS_ROLE=api
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=1
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=500
|
||||||
|
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=60
|
||||||
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=64
|
||||||
|
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=32
|
||||||
|
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=16
|
||||||
|
GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=8
|
||||||
|
GENARRATIVE_TRACKING_OUTBOX_ENABLED=false
|
||||||
|
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
||||||
|
|
||||||
|
GENARRATIVE_OTEL_ENABLED=false
|
||||||
|
OTEL_SERVICE_NAME=genarrative-worker-smoke-api
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=worker-smoke,service.namespace=genarrative
|
||||||
|
|
||||||
|
GENARRATIVE_INTERNAL_API_SECRET=worker-smoke-internal-secret
|
||||||
|
GENARRATIVE_JWT_ISSUER=genarrative-worker-smoke
|
||||||
|
GENARRATIVE_JWT_SECRET=worker-smoke-jwt-secret
|
||||||
|
AUTH_REFRESH_COOKIE_SECURE=false
|
||||||
|
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true
|
||||||
|
|
||||||
|
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
|
||||||
|
GENARRATIVE_SPACETIME_DATABASE=${state.database}
|
||||||
|
GENARRATIVE_SPACETIME_TOKEN=
|
||||||
|
GENARRATIVE_SPACETIME_POOL_SIZE=2
|
||||||
|
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
|
||||||
|
|
||||||
|
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||||
|
GENARRATIVE_LLM_BASE_URL=
|
||||||
|
GENARRATIVE_LLM_API_KEY=
|
||||||
|
GENARRATIVE_LLM_MODEL=
|
||||||
|
VECTOR_ENGINE_BASE_URL=
|
||||||
|
VECTOR_ENGINE_API_KEY=
|
||||||
|
ALIYUN_OSS_BUCKET=
|
||||||
|
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||||
|
ALIYUN_OSS_ACCESS_KEY_ID=
|
||||||
|
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||||
|
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultState() {
|
||||||
|
return {
|
||||||
|
database: defaultDatabase,
|
||||||
|
spacetimePort: 19101,
|
||||||
|
httpPort: 19080,
|
||||||
|
otlpGrpcPort: 15317,
|
||||||
|
otlpHttpPort: 15318,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
if (!existsSync(statePath)) {
|
||||||
|
return defaultState();
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(statePath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailablePort(startPort) {
|
||||||
|
for (let port = startPort; port < startPort + 100; port += 1) {
|
||||||
|
if (await isPortAvailable(port)) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`未找到可用端口: ${startPort}-${startPort + 99}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortAvailable(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', () => resolve(false));
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => resolve(true));
|
||||||
|
});
|
||||||
|
server.listen(port, '127.0.0.1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishModule() {
|
||||||
|
const state = readState();
|
||||||
|
const serverUrl = spacetimeServerUrl(state);
|
||||||
|
const publishArgs = [
|
||||||
|
'publish',
|
||||||
|
state.database,
|
||||||
|
'--server',
|
||||||
|
serverUrl,
|
||||||
|
'--module-path',
|
||||||
|
'server-rs/crates/spacetime-module',
|
||||||
|
'--delete-data=on-conflict',
|
||||||
|
'--anonymous',
|
||||||
|
'--yes=all',
|
||||||
|
'--no-config',
|
||||||
|
];
|
||||||
|
const buildOptions = process.env.GENARRATIVE_WORKER_SMOKE_STDB_BUILD_OPTIONS;
|
||||||
|
if (buildOptions) {
|
||||||
|
publishArgs.push('--build-options', buildOptions);
|
||||||
|
}
|
||||||
|
await run('spacetime', publishArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueSmokeJob(options = {}) {
|
||||||
|
if (!rawArgs.includes('--no-worker-check')) {
|
||||||
|
await assertWorkersRunning();
|
||||||
|
}
|
||||||
|
const state = readState();
|
||||||
|
const nowMicros = Date.now() * 1000;
|
||||||
|
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
const jobId = `extgen-smoke-${suffix}`;
|
||||||
|
const label = options.label || rawArgs[0] || 'manual';
|
||||||
|
const input = {
|
||||||
|
job_id: jobId,
|
||||||
|
dedupe_key: `worker-smoke:${label}:${suffix}`,
|
||||||
|
job_kind: 'worker_smoke_unsupported',
|
||||||
|
owner_user_id: 'worker-smoke-user',
|
||||||
|
source_module: 'worker-smoke',
|
||||||
|
source_entity_id: `worker-smoke-entity-${suffix}`,
|
||||||
|
request_label: `worker-smoke ${label}`,
|
||||||
|
request_payload_json: JSON.stringify({label, suffix}),
|
||||||
|
max_attempts: 1,
|
||||||
|
available_at_micros: nowMicros,
|
||||||
|
created_at_micros: nowMicros,
|
||||||
|
};
|
||||||
|
|
||||||
|
await run('spacetime', [
|
||||||
|
'call',
|
||||||
|
'--server',
|
||||||
|
spacetimeServerUrl(state),
|
||||||
|
'--anonymous',
|
||||||
|
'--yes',
|
||||||
|
'--no-config',
|
||||||
|
state.database,
|
||||||
|
'enqueue_external_generation_job_and_return',
|
||||||
|
JSON.stringify(input),
|
||||||
|
]);
|
||||||
|
console.log(`[worker-smoke] 已入队测试 job: ${jobId}`);
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printQueueStatus() {
|
||||||
|
console.log('[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:');
|
||||||
|
await printServiceLogs('external-generation-worker', 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForJobConsumed(jobId) {
|
||||||
|
const deadline = Date.now() + 60_000;
|
||||||
|
let lastOutput = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await dockerComposeCapture(
|
||||||
|
['logs', '--no-color', 'external-generation-worker'],
|
||||||
|
{allowFailure: true, quiet: true},
|
||||||
|
);
|
||||||
|
lastOutput = `${result.stdout}\n${result.stderr}`;
|
||||||
|
if (lastOutput.includes(jobId) && lastOutput.includes('暂不支持的任务类型')) {
|
||||||
|
console.log(`[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
await printServiceLogs('external-generation-worker', 120);
|
||||||
|
throw new Error(`等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSavedPortsAvailableForNewProject(state) {
|
||||||
|
const existingContainers = await getProjectContainerIds();
|
||||||
|
if (existingContainers.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ports = [
|
||||||
|
['SpacetimeDB', state.spacetimePort],
|
||||||
|
['Nginx', state.httpPort],
|
||||||
|
['OTLP gRPC', state.otlpGrpcPort],
|
||||||
|
['OTLP HTTP', state.otlpHttpPort],
|
||||||
|
];
|
||||||
|
for (const [label, port] of ports) {
|
||||||
|
if (!(await isPortAvailable(port))) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} 端口 ${port} 已被占用;可执行 npm run container:worker-smoke -- smoke --force 重新分配隔离端口。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectContainerIds() {
|
||||||
|
const result = await dockerComposeCapture(['ps', '-q'], {
|
||||||
|
allowFailure: true,
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
if (result.code !== 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertWorkersRunning() {
|
||||||
|
const result = await dockerComposeCapture(
|
||||||
|
['ps', '--status', 'running', '-q', 'external-generation-worker'],
|
||||||
|
{allowFailure: true, quiet: true},
|
||||||
|
);
|
||||||
|
const workerIds = result.stdout
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (result.code === 0 && workerIds.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await printServiceLogs('external-generation-worker', 80);
|
||||||
|
throw new Error('external-generation-worker 未处于 running 状态,已输出最近日志。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printServiceLogs(service, tail = 80) {
|
||||||
|
await dockerComposeCapture(['logs', '--tail', String(tail), service], {
|
||||||
|
allowFailure: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSpacetime() {
|
||||||
|
const state = readState();
|
||||||
|
const url = `${spacetimeServerUrl(state)}/v1/ping`;
|
||||||
|
await waitForHttp(url, 'SpacetimeDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForApi() {
|
||||||
|
const deadline = Date.now() + 120_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await dockerComposeCapture(
|
||||||
|
['exec', '-T', 'api-server', 'curl', '-fsS', 'http://127.0.0.1:8082/healthz'],
|
||||||
|
{allowFailure: true, quiet: true},
|
||||||
|
);
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('[worker-smoke] api-server 已就绪: api-server:8082/healthz');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
throw new Error('api-server 等待超时: api-server:8082/healthz');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHttp(url, label) {
|
||||||
|
const deadline = Date.now() + 120_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await runCapture('curl', ['-fsS', '--max-time', '3', url], {
|
||||||
|
allowFailure: true,
|
||||||
|
});
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log(`[worker-smoke] ${label} 已就绪: ${url}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
throw new Error(`${label} 等待超时: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiOnlyUpdate({build}) {
|
||||||
|
const beforeWorkerIds = await getContainerIds('external-generation-worker');
|
||||||
|
const args = ['up', '-d', '--no-deps', '--force-recreate'];
|
||||||
|
if (build) {
|
||||||
|
args.push('--build');
|
||||||
|
}
|
||||||
|
args.push('api-server');
|
||||||
|
await dockerCompose(args);
|
||||||
|
await waitForApi();
|
||||||
|
const afterWorkerIds = await getContainerIds('external-generation-worker');
|
||||||
|
if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) {
|
||||||
|
throw new Error('API-only 更新不应重建 external-generation-worker 容器');
|
||||||
|
}
|
||||||
|
console.log('[worker-smoke] API-only 更新完成,worker 容器保持不变。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scaleWorkers(rawCount) {
|
||||||
|
const count = Number.parseInt(rawCount, 10);
|
||||||
|
if (!Number.isInteger(count) || count < 0 || count > 16) {
|
||||||
|
throw new Error(`worker 数量必须是 0-16 的整数: ${rawCount}`);
|
||||||
|
}
|
||||||
|
await dockerCompose([
|
||||||
|
'up',
|
||||||
|
'-d',
|
||||||
|
'--scale',
|
||||||
|
`external-generation-worker=${count}`,
|
||||||
|
'external-generation-worker',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerIds(service) {
|
||||||
|
const result = await dockerComposeCapture(['ps', '-q', service]);
|
||||||
|
return result.stdout
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dockerCompose(args) {
|
||||||
|
await run('docker', composeArgs(args), {env: composeEnv()});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dockerComposeCapture(args, options = {}) {
|
||||||
|
return runCapture('docker', composeArgs(args), {
|
||||||
|
env: composeEnv(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeArgs(args) {
|
||||||
|
return ['compose', '-p', projectName, '-f', composeFile, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeEnv() {
|
||||||
|
const state = readState();
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
GENARRATIVE_CONTAINER_API_ENV_FILE: './worker-smoke/api-server.env',
|
||||||
|
GENARRATIVE_CONTAINER_SPACETIME_IMAGE:
|
||||||
|
process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || localSpacetimeImageName(),
|
||||||
|
GENARRATIVE_CONTAINER_SPACETIME_PORT: String(state.spacetimePort),
|
||||||
|
GENARRATIVE_CONTAINER_HTTP_PORT: String(state.httpPort),
|
||||||
|
GENARRATIVE_CONTAINER_OTLP_GRPC_PORT: String(state.otlpGrpcPort),
|
||||||
|
GENARRATIVE_CONTAINER_OTLP_HTTP_PORT: String(state.otlpHttpPort),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSpacetimeImageName() {
|
||||||
|
return `${projectName}-spacetimedb:2.5.0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spacetimeServerUrl(state) {
|
||||||
|
return `http://127.0.0.1:${state.spacetimePort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(commandName, args, options = {}) {
|
||||||
|
const result = await runCapture(commandName, args, options);
|
||||||
|
if (result.code !== 0 && !options.allowFailure) {
|
||||||
|
throw new Error(`${commandName} ${args.join(' ')} 失败,exit=${result.code}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCapture(commandName, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(commandName, args, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: options.env ?? process.env,
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout?.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
stdout += text;
|
||||||
|
if (!options.quiet) {
|
||||||
|
process.stdout.write(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
stderr += text;
|
||||||
|
if (!options.quiet) {
|
||||||
|
process.stderr.write(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
reject(new Error(`${commandName} 被信号终止: ${signal}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({code: code ?? 0, stdout, stderr});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(isError) {
|
||||||
|
const output = isError ? console.error : console.log;
|
||||||
|
output(`Usage: npm run container:worker-smoke -- <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init [--force] 生成隔离 env 与端口 state
|
||||||
|
build [--local-binary] [--release]
|
||||||
|
构建 api-server / worker 镜像;--local-binary 让容器内 Cargo 复用本机缓存
|
||||||
|
up-spacetime 启动隔离 SpacetimeDB 与 otelcol
|
||||||
|
publish 向隔离 SpacetimeDB 发布 spacetime-module
|
||||||
|
up [--with-nginx] 启动 api-server / worker;需要 Nginx 时显式加 --with-nginx
|
||||||
|
enqueue [label] [--no-worker-check]
|
||||||
|
写入一个 unsupported 测试 job,验证 worker claim/fail
|
||||||
|
status 查看最近 worker 日志;external_generation_job 是 private table
|
||||||
|
api-update [--build] 仅重建/重启 api-server,不触碰 worker
|
||||||
|
scale <n> 调整 external-generation-worker 实例数
|
||||||
|
ps 查看隔离 compose 状态
|
||||||
|
logs [service] 查看隔离 compose 日志
|
||||||
|
down [-v] 停止隔离 compose,-v 会清理数据卷
|
||||||
|
smoke [--force] [--local-binary] [--release]
|
||||||
|
一键执行 build -> publish -> up -> enqueue -> api-update -> enqueue
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
|
|||||||
function usage() {
|
function usage() {
|
||||||
console.log(`用法:
|
console.log(`用法:
|
||||||
npm run database:backup:oss -- [--data-dir <path>] [--work-dir <path>] [--bucket <bucket>] [--object-prefix <prefix>] [--keep-local]
|
npm run database:backup:oss -- [--data-dir <path>] [--work-dir <path>] [--bucket <bucket>] [--object-prefix <prefix>] [--keep-local]
|
||||||
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--defer-upload]
|
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--restart-service-after genarrative-api.service] [--defer-upload]
|
||||||
node scripts/database-backup-to-oss.mjs --upload-archive <path>
|
node scripts/database-backup-to-oss.mjs --upload-archive <path>
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -100,6 +100,7 @@ function parseArgs(argv) {
|
|||||||
envFiles: [],
|
envFiles: [],
|
||||||
keepLocal: false,
|
keepLocal: false,
|
||||||
stopService: '',
|
stopService: '',
|
||||||
|
restartServicesAfter: [],
|
||||||
database: '',
|
database: '',
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
deferUpload: false,
|
deferUpload: false,
|
||||||
@@ -159,6 +160,9 @@ function parseArgs(argv) {
|
|||||||
case '--stop-service':
|
case '--stop-service':
|
||||||
options.stopService = readValue();
|
options.stopService = readValue();
|
||||||
break;
|
break;
|
||||||
|
case '--restart-service-after':
|
||||||
|
options.restartServicesAfter.push(readValue());
|
||||||
|
break;
|
||||||
case '--keep-local':
|
case '--keep-local':
|
||||||
options.keepLocal = true;
|
options.keepLocal = true;
|
||||||
break;
|
break;
|
||||||
@@ -266,6 +270,16 @@ function startServiceIfNeeded(serviceName, wasStopped) {
|
|||||||
runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'});
|
runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restartServicesAfterBackup(serviceNames) {
|
||||||
|
for (const serviceName of serviceNames) {
|
||||||
|
if (!serviceName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`[database-backup] 冷备份后重启依赖服务: ${serviceName}`);
|
||||||
|
runCommand('systemctl', ['restart', serviceName], {stdio: 'inherit'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createArchive({dataDir, workDir, fileName}) {
|
function createArchive({dataDir, workDir, fileName}) {
|
||||||
if (!existsSync(dataDir)) {
|
if (!existsSync(dataDir)) {
|
||||||
throw new Error(`数据库数据目录不存在: ${dataDir}`);
|
throw new Error(`数据库数据目录不存在: ${dataDir}`);
|
||||||
@@ -510,6 +524,13 @@ async function main() {
|
|||||||
} finally {
|
} finally {
|
||||||
startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped);
|
startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped);
|
||||||
}
|
}
|
||||||
|
restartServicesAfterBackup([
|
||||||
|
...String(env.GENARRATIVE_DATABASE_BACKUP_RESTART_SERVICE_AFTER ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
...args.restartServicesAfter,
|
||||||
|
]);
|
||||||
|
|
||||||
const manifestPath = `${archivePath}.manifest.json`;
|
const manifestPath = `${archivePath}.manifest.json`;
|
||||||
writeManifest({
|
writeManifest({
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ set -euo pipefail
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
用法:
|
用法:
|
||||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
||||||
|
默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。
|
||||||
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
||||||
失败时保留维护模式。
|
失败时保留维护模式。
|
||||||
EOF
|
EOF
|
||||||
@@ -223,12 +224,144 @@ ensure_runtime_env_and_dirs() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
list_worker_services() {
|
||||||
|
local pattern="$1"
|
||||||
|
|
||||||
|
if [[ -z "${pattern}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl list-units --all --plain --no-legend "${pattern}" 2>/dev/null | awk '{print $1}' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_default_worker_service() {
|
||||||
|
local pattern="$1"
|
||||||
|
local default_service="genarrative-external-generation-worker@1.service"
|
||||||
|
local template_service="genarrative-external-generation-worker@.service"
|
||||||
|
local services=()
|
||||||
|
|
||||||
|
if [[ -z "${pattern}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${pattern}" != "genarrative-external-generation-worker@*.service" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! systemctl cat "${template_service}" >/dev/null 2>&1; then
|
||||||
|
echo "[production-api-deploy] 缺少外部生成 worker systemd 模板: ${template_service}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t services < <(list_worker_services "${pattern}")
|
||||||
|
if [[ "${#services[@]}" -gt 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[production-api-deploy] 未发现外部生成 worker 实例,启用并启动默认实例: ${default_service}"
|
||||||
|
systemctl enable --now "${default_service}"
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_worker_services() {
|
||||||
|
local pattern="$1"
|
||||||
|
local services=()
|
||||||
|
|
||||||
|
if [[ -z "${pattern}" ]]; then
|
||||||
|
echo "[production-api-deploy] 跳过外部生成 worker 重启。"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_default_worker_service "${pattern}"
|
||||||
|
mapfile -t services < <(list_worker_services "${pattern}")
|
||||||
|
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||||
|
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[production-api-deploy] 重启外部生成 worker: ${services[*]}"
|
||||||
|
systemctl restart "${services[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_worker_services() {
|
||||||
|
local pattern="$1"
|
||||||
|
local services=()
|
||||||
|
local all_active
|
||||||
|
|
||||||
|
if [[ -z "${pattern}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t services < <(list_worker_services "${pattern}")
|
||||||
|
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||||
|
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[production-api-deploy] 等待外部生成 worker active: ${services[*]}"
|
||||||
|
for _ in {1..30}; do
|
||||||
|
all_active=1
|
||||||
|
for service in "${services[@]}"; do
|
||||||
|
if ! systemctl is-active --quiet "${service}"; then
|
||||||
|
all_active=0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "${all_active}" -eq 1 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
systemctl --no-pager --full status "${services[@]}" || true
|
||||||
|
echo "[production-api-deploy] 外部生成 worker 未在超时时间内进入 active,发布失败。" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_worker_controller_service() {
|
||||||
|
local service="$1"
|
||||||
|
|
||||||
|
if [[ -z "${service}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! systemctl cat "${service}" >/dev/null 2>&1; then
|
||||||
|
echo "[production-api-deploy] 缺少外部生成 worker controller systemd 单元: ${service}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[production-api-deploy] 启用并重启外部生成 worker controller: ${service}"
|
||||||
|
systemctl enable "${service}"
|
||||||
|
systemctl restart "${service}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_worker_controller_service() {
|
||||||
|
local service="$1"
|
||||||
|
|
||||||
|
if [[ -z "${service}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[production-api-deploy] 等待外部生成 worker controller active: ${service}"
|
||||||
|
for _ in {1..30}; do
|
||||||
|
if systemctl is-active --quiet "${service}"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
systemctl --no-pager --full status "${service}" || true
|
||||||
|
echo "[production-api-deploy] 外部生成 worker controller 未在超时时间内进入 active,发布失败。" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SOURCE_DIR=""
|
SOURCE_DIR=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
RELEASE_ROOT="/opt/genarrative/releases"
|
RELEASE_ROOT="/opt/genarrative/releases"
|
||||||
CURRENT_LINK="/opt/genarrative/current"
|
CURRENT_LINK="/opt/genarrative/current"
|
||||||
SERVICE_NAME="genarrative-api.service"
|
SERVICE_NAME="genarrative-api.service"
|
||||||
|
WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service"
|
||||||
|
WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service"
|
||||||
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
||||||
API_ENV_FILE="/etc/genarrative/api-server.env"
|
API_ENV_FILE="/etc/genarrative/api-server.env"
|
||||||
DATABASE=""
|
DATABASE=""
|
||||||
@@ -261,6 +394,22 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SERVICE_NAME="${2:?缺少 --service 的值}"
|
SERVICE_NAME="${2:?缺少 --service 的值}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--worker-service-pattern)
|
||||||
|
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--no-worker-services)
|
||||||
|
WORKER_SERVICE_PATTERN=""
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--worker-controller-service)
|
||||||
|
WORKER_CONTROLLER_SERVICE="${2:?缺少 --worker-controller-service 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--no-worker-controller)
|
||||||
|
WORKER_CONTROLLER_SERVICE=""
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--health-url)
|
--health-url)
|
||||||
HEALTH_URL="${2:?缺少 --health-url 的值}"
|
HEALTH_URL="${2:?缺少 --health-url 的值}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -332,13 +481,34 @@ mkdir -p "${RELEASE_DIR}"
|
|||||||
cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server"
|
cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server"
|
||||||
chmod +x "${RELEASE_DIR}/api-server"
|
chmod +x "${RELEASE_DIR}/api-server"
|
||||||
|
|
||||||
SCRIPT_SOURCE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts"
|
BACKUP_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||||
mkdir -p "${RELEASE_DIR}/scripts"
|
WORKSPACE_BACKUP_SCRIPT_SOURCE="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts/database-backup-to-oss.mjs"
|
||||||
if [[ -f "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" ]]; then
|
HEALTH_PATROL_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/ops/production-health-patrol.mjs"
|
||||||
cp "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
|
WORKSPACE_HEALTH_PATROL_SCRIPT_SOURCE="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts/ops/production-health-patrol.mjs"
|
||||||
chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
|
mkdir -p "${RELEASE_DIR}/scripts" "${RELEASE_DIR}/scripts/ops"
|
||||||
else
|
if [[ ! -f "${BACKUP_SCRIPT_SOURCE}" ]]; then
|
||||||
echo "[production-api-deploy] 未找到数据库备份脚本,release 目录不会包含 scripts/database-backup-to-oss.mjs" >&2
|
if [[ -f "${WORKSPACE_BACKUP_SCRIPT_SOURCE}" ]]; then
|
||||||
|
echo "[production-api-deploy] 发布产物缺少 scripts/database-backup-to-oss.mjs,回退使用部署工作区脚本;请重新触发包含该脚本的 API 构建。" >&2
|
||||||
|
BACKUP_SCRIPT_SOURCE="${WORKSPACE_BACKUP_SCRIPT_SOURCE}"
|
||||||
|
else
|
||||||
|
echo "[production-api-deploy] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cp "${BACKUP_SCRIPT_SOURCE}" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||||
|
chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||||
|
if [[ ! -f "${HEALTH_PATROL_SCRIPT_SOURCE}" ]]; then
|
||||||
|
if [[ -f "${WORKSPACE_HEALTH_PATROL_SCRIPT_SOURCE}" ]]; then
|
||||||
|
echo "[production-api-deploy] 发布产物缺少 scripts/ops/production-health-patrol.mjs,回退使用部署工作区脚本;请重新触发包含该脚本的 API 构建。" >&2
|
||||||
|
HEALTH_PATROL_SCRIPT_SOURCE="${WORKSPACE_HEALTH_PATROL_SCRIPT_SOURCE}"
|
||||||
|
else
|
||||||
|
echo "[production-api-deploy] 未找到生产健康巡检脚本,跳过复制;genarrative-health-patrol.service 会因脚本缺失而跳过执行。" >&2
|
||||||
|
HEALTH_PATROL_SCRIPT_SOURCE=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -n "${HEALTH_PATROL_SCRIPT_SOURCE}" ]]; then
|
||||||
|
cp "${HEALTH_PATROL_SCRIPT_SOURCE}" "${RELEASE_DIR}/scripts/ops/production-health-patrol.mjs"
|
||||||
|
chmod 0644 "${RELEASE_DIR}/scripts/ops/production-health-patrol.mjs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then
|
if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then
|
||||||
@@ -362,6 +532,10 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
|
|||||||
|
|
||||||
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
|
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
|
||||||
systemctl restart "${SERVICE_NAME}"
|
systemctl restart "${SERVICE_NAME}"
|
||||||
|
restart_worker_services "${WORKER_SERVICE_PATTERN}"
|
||||||
|
wait_for_worker_services "${WORKER_SERVICE_PATTERN}"
|
||||||
|
ensure_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
|
||||||
|
wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
|
||||||
|
|
||||||
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
||||||
for _ in {1..30}; do
|
for _ in {1..30}; do
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ prepare_async_backup() {
|
|||||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||||
--database "${DATABASE}" \
|
--database "${DATABASE}" \
|
||||||
--stop-service spacetimedb.service \
|
--stop-service spacetimedb.service \
|
||||||
|
--restart-service-after genarrative-api.service \
|
||||||
--defer-upload \
|
--defer-upload \
|
||||||
--result-file "${ASYNC_BACKUP_STATUS_FILE}"
|
--result-file "${ASYNC_BACKUP_STATUS_FILE}"
|
||||||
}
|
}
|
||||||
@@ -257,7 +258,8 @@ case "${BACKUP_MODE}" in
|
|||||||
--env-file /etc/genarrative/api-server.env \
|
--env-file /etc/genarrative/api-server.env \
|
||||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||||
--database "${DATABASE}" \
|
--database "${DATABASE}" \
|
||||||
--stop-service spacetimedb.service
|
--stop-service spacetimedb.service \
|
||||||
|
--restart-service-after genarrative-api.service
|
||||||
;;
|
;;
|
||||||
skip)
|
skip)
|
||||||
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
|
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
|
||||||
|
|||||||
@@ -88,6 +88,29 @@ describe('dev utils env merge', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('本地短信 smoke 可以用 mock 验证码覆盖真实短信 provider 口径', () => {
|
||||||
|
withTempEnvFiles(
|
||||||
|
{
|
||||||
|
'.env.local': [
|
||||||
|
'SMS_AUTH_ENABLED=true',
|
||||||
|
'SMS_AUTH_PROVIDER=mock',
|
||||||
|
'SMS_AUTH_MOCK_VERIFY_CODE=123456',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
(_env, tempDir) => {
|
||||||
|
const env = mergeApiServerEnv(tempDir, {
|
||||||
|
SMS_AUTH_ENABLED: 'true',
|
||||||
|
SMS_AUTH_PROVIDER: 'aliyun',
|
||||||
|
SMS_AUTH_MOCK_VERIFY_CODE: '654321',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.SMS_AUTH_ENABLED).toBe('true');
|
||||||
|
expect(env.SMS_AUTH_PROVIDER).toBe('mock');
|
||||||
|
expect(env.SMS_AUTH_MOCK_VERIFY_CODE).toBe('123456');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
|
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
|
||||||
withTempEnvFiles(
|
withTempEnvFiles(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
statSync,
|
statSync,
|
||||||
watch,
|
watch,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -36,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
|||||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||||
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
||||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||||
|
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
|
||||||
|
|
||||||
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
||||||
const SERVICE_ALIASES = new Map([
|
const SERVICE_ALIASES = new Map([
|
||||||
@@ -398,6 +400,39 @@ function requireCommand(command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSccacheRustcWrapper(value) {
|
||||||
|
const wrapper = String(value ?? '').trim();
|
||||||
|
if (!wrapper) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
|
||||||
|
return command === 'sccache' || command === 'sccache.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalRustProcessEnv(env, options = {}) {
|
||||||
|
const mergedEnv = {...env};
|
||||||
|
const wrappers = [
|
||||||
|
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
|
||||||
|
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
|
||||||
|
].filter(Boolean);
|
||||||
|
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
|
||||||
|
if (customWrapper) {
|
||||||
|
mergedEnv.RUSTC_WRAPPER = customWrapper;
|
||||||
|
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
|
||||||
|
return mergedEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
||||||
|
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
||||||
|
if (options.log !== false) {
|
||||||
|
console.warn(
|
||||||
|
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mergedEnv;
|
||||||
|
}
|
||||||
|
|
||||||
function readWorkspaceSpacetimeVersion() {
|
function readWorkspaceSpacetimeVersion() {
|
||||||
const manifestText = readFileSync(manifestPath, 'utf8');
|
const manifestText = readFileSync(manifestPath, 'utf8');
|
||||||
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
|
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
|
||||||
@@ -407,7 +442,11 @@ function readWorkspaceSpacetimeVersion() {
|
|||||||
if (!version) {
|
if (!version) {
|
||||||
throw new Error('无法从 server-rs/Cargo.toml 读取 spacetimedb 版本');
|
throw new Error('无法从 server-rs/Cargo.toml 读取 spacetimedb 版本');
|
||||||
}
|
}
|
||||||
return version;
|
return normalizeCargoVersionRequirement(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCargoVersionRequirement(version) {
|
||||||
|
return version.replace(/^=/u, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSpacetimeToolVersion(output) {
|
function parseSpacetimeToolVersion(output) {
|
||||||
@@ -446,7 +485,7 @@ function assertSpacetimeToolVersionMatchesWorkspace({
|
|||||||
[
|
[
|
||||||
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
||||||
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
||||||
`请执行 spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion} 后重新运行本命令。`,
|
`请先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`,
|
||||||
].join(''),
|
].join(''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,9 +513,11 @@ function assertReusableSpacetimeProcessVersionMatchesWorkspace({
|
|||||||
[
|
[
|
||||||
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
||||||
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
||||||
'请停止当前 SpacetimeDB 进程,执行 spacetime version use ',
|
'请停止当前 SpacetimeDB 进程,先直接升级并切换到锁定版本: spacetime version install ',
|
||||||
workspaceVersion,
|
workspaceVersion,
|
||||||
' 后重新运行 npm run dev:spacetime。',
|
' && spacetime version use ',
|
||||||
|
workspaceVersion,
|
||||||
|
',然后重新运行 npm run dev:spacetime。',
|
||||||
].join(''),
|
].join(''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -771,7 +812,7 @@ class DevRunner {
|
|||||||
this.writeDevStackState();
|
this.writeDevStackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareLinuxPortRange(command) {
|
async prepareLinuxPortRange() {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1264,7 @@ class DevRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async publishSpacetimeModule() {
|
async publishSpacetimeModule() {
|
||||||
const env = {...this.baseEnv};
|
const env = buildLocalRustProcessEnv(this.baseEnv);
|
||||||
this.prepareMigrationBootstrapSecret(env);
|
this.prepareMigrationBootstrapSecret(env);
|
||||||
|
|
||||||
const args = buildSpacetimePublishArgs({
|
const args = buildSpacetimePublishArgs({
|
||||||
@@ -1286,7 +1327,7 @@ class DevRunner {
|
|||||||
await this.ensureApiServerSpacetimeToken();
|
await this.ensureApiServerSpacetimeToken();
|
||||||
|
|
||||||
const mergedEnv = buildApiServerProcessEnv({
|
const mergedEnv = buildApiServerProcessEnv({
|
||||||
baseEnv: this.baseEnv,
|
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
|
||||||
options: this.options,
|
options: this.options,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
});
|
});
|
||||||
@@ -2046,6 +2087,36 @@ function normalizePath(path) {
|
|||||||
return path.replace(/\\/gu, '/');
|
return path.replace(/\\/gu, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDirectExecutionPath(path) {
|
||||||
|
return normalizePath(path).replace(/^\/([A-Za-z]:\/)/u, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRealpath(pathValue) {
|
||||||
|
try {
|
||||||
|
return realpathSync(pathValue);
|
||||||
|
} catch {
|
||||||
|
return resolve(pathValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) {
|
||||||
|
if (!argv1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
normalizeDirectExecutionPath(resolvePath(argv1)) ===
|
||||||
|
normalizeDirectExecutionPath(resolvePath(fileURLToPath(moduleUrl)))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
normalizeDirectExecutionPath(resolve(argv1)) ===
|
||||||
|
normalizeDirectExecutionPath(fileURLToPath(moduleUrl))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildSpacetimePublishArgs({database, server, preserveDatabase}) {
|
function buildSpacetimePublishArgs({database, server, preserveDatabase}) {
|
||||||
const args = [
|
const args = [
|
||||||
'publish',
|
'publish',
|
||||||
@@ -2089,17 +2160,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DevRunner,
|
|
||||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||||
assertSpacetimeToolVersionMatchesWorkspace,
|
assertSpacetimeToolVersionMatchesWorkspace,
|
||||||
buildApiServerProcessEnv,
|
buildApiServerProcessEnv,
|
||||||
buildDevStackSnapshot,
|
buildDevStackSnapshot,
|
||||||
|
buildLocalRustProcessEnv,
|
||||||
buildSpacetimePublishArgs,
|
buildSpacetimePublishArgs,
|
||||||
createDevServerSpawnOptions,
|
createDevServerSpawnOptions,
|
||||||
createWatchConfigs,
|
createWatchConfigs,
|
||||||
|
DevRunner,
|
||||||
|
isDirectModuleExecution,
|
||||||
isSpacetimePublishPermissionError,
|
isSpacetimePublishPermissionError,
|
||||||
parseSpacetimeToolVersion,
|
normalizeCargoVersionRequirement,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
|
parseSpacetimeToolVersion,
|
||||||
resolveDevStackStatePath,
|
resolveDevStackStatePath,
|
||||||
shouldAcceptWatchEvent,
|
shouldAcceptWatchEvent,
|
||||||
};
|
};
|
||||||
@@ -2129,6 +2203,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
if (isDirectModuleExecution(process.argv[1], import.meta.url)) {
|
||||||
void main();
|
void main();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user