From c5763fdf258c27ad05e4d0f2448f22bb6ffab990 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 11 Jun 2026 21:32:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BD=9C=E5=93=81=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一发布分享弹窗为作品分享卡片 支持下载分享卡与小程序九宫切图保存 小程序复制链接改为可直达作品详情的 web-view 路径 修复本地 dev Rust 构建绕过损坏 sccache 补充分享链路与 dev 启动文档和测试 --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/development-workflow.md | 2 + .hermes/shared-memory/pitfalls.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- miniprogram/app.json | 1 + miniprogram/pages/share-grid/index.js | 206 +++++++++ miniprogram/pages/share-grid/index.json | 3 + miniprogram/pages/share-grid/index.shared.js | 62 +++ miniprogram/pages/share-grid/index.test.js | 67 +++ miniprogram/pages/share-grid/index.wxml | 20 + miniprogram/pages/share-grid/index.wxss | 60 +++ miniprogram/pages/web-view/index.js | 73 +--- miniprogram/pages/web-view/index.shared.js | 129 ++++++ miniprogram/pages/web-view/index.test.js | 56 +++ scripts/dev.mjs | 47 +- scripts/dev.test.ts | 34 +- .../common/PublishShareModal.test.tsx | 107 ++++- src/components/common/PublishShareModal.tsx | 331 ++++++++------ .../common/publishShareCardImage.test.ts | 146 +++++++ .../common/publishShareCardImage.ts | 403 ++++++++++++++++++ .../common/publishShareModalModel.ts | 53 ++- ...ustomWorldCreationHub.interaction.test.tsx | 3 + .../CustomWorldCreationHub.tsx | 10 +- .../custom-world-home/CustomWorldWorkCard.tsx | 19 +- .../custom-world-home/creationWorkShelf.ts | 48 ++- .../PlatformEntryFlowShellImpl.test.ts | 4 +- .../PlatformEntryFlowShellImpl.tsx | 82 ++-- .../platformGenerationProgressTickState.ts | 30 ++ src/components/rpg-entry/RpgEntryHomeView.tsx | 34 +- .../rpg-entry/rpgEntryWorldPresentation.ts | 50 ++- src/index.css | 13 + src/index.test.ts | 12 + src/main.tsx | 8 + src/services/assetReadUrlService.test.ts | 24 ++ src/services/assetReadUrlService.ts | 10 +- src/services/wechatMiniProgramShareGrid.ts | 96 +++++ 37 files changed, 1958 insertions(+), 305 deletions(-) create mode 100644 miniprogram/pages/share-grid/index.js create mode 100644 miniprogram/pages/share-grid/index.json create mode 100644 miniprogram/pages/share-grid/index.shared.js create mode 100644 miniprogram/pages/share-grid/index.test.js create mode 100644 miniprogram/pages/share-grid/index.wxml create mode 100644 miniprogram/pages/share-grid/index.wxss create mode 100644 miniprogram/pages/web-view/index.shared.js create mode 100644 miniprogram/pages/web-view/index.test.js create mode 100644 src/components/common/publishShareCardImage.test.ts create mode 100644 src/components/common/publishShareCardImage.ts create mode 100644 src/components/platform-entry/platformGenerationProgressTickState.ts create mode 100644 src/services/wechatMiniProgramShareGrid.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1e958aca..5eceed86 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 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 从左到右、从上到下裁切并保存。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。 +- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/publishShareModalModel.ts`、`src/components/common/publishShareCardImage.ts`、`src/services/wechatMiniProgramShareGrid.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`、`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-07 推荐页运行态先封面预载再 ready 渐隐 - 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 9fad4640..bd6ee849 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.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 仍沿用原有端口探测与漂移逻辑。 +本地 `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 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 2f0629bb..c46f0de1 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1245,8 +1245,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` 可以执行,但构建在调用包装器时失败。 - 原因:环境、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`。 -- 验证:`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 直接构建”后仍继续真实构建。 +- 处理:保留 `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` 能输出版本;本地 `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`。 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 14db7a20..05dec659 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-06-05` +更新时间:`2026-06-08` ## 标准开发流程 @@ -47,6 +47,8 @@ npm run dev:api-server Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 端口探测与漂移逻辑运行,不读这个系统级注册表。 +本地 `npm run dev` 和 `npm run dev:api-server` / `npm run dev:spacetime` 的 Rust 子构建会自动绕过仓库 `server-rs/.cargo/config.toml` 里的 `sccache` wrapper,避免本机 sccache daemon、shim 或缓存通道异常阻断 `spacetime publish` / `api-server` 启动;如果开发者显式设置了非 sccache 的 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER`,脚本会保留该自定义 wrapper。生产和 Jenkins 构建仍按对应流水线的 sccache 策略执行,不以本地 dev 口径替代。 + 后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 8d697380..16085346 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -52,7 +52,7 @@ 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。 -3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示带底色的分享 icon,并统一唤起发布分享弹窗 `PublishShareModal`,不在卡片内部单独复制分享文案。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。 +3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示带底色的分享 icon,并统一唤起发布分享弹窗 `PublishShareModal`,不在卡片内部单独复制分享文案。`PublishShareModal` 必须渲染通用分享卡片,卡片展示作品封面、作品类型和作品名称,底部提供复制分享链接与下载分享卡图片;普通 H5 复制公开作品 H5 链接,微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,且路径缺少直达参数时必须补 `targetPath=/works/detail` 与 `work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL;当 H5 运行在微信小程序 WebView 内且有封面图时,额外提供九宫切图入口,由小程序原生页把封面图按 3x3 从左到右、从上到下裁切并保存。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。 @@ -134,7 +134,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 - 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜和下一关按钮。 - 推荐页嵌入拼图运行态时,“下一关”必须走推荐页统一相邻作品切换流程,不得由拼图 runtime 自己传递 `preferSimilarWork` 或私自把当前 run handoff 到其它拼图作品。点击后应与推荐页底部“下一个”使用同一套 `activeRecommendEntryKey` / 推荐队列切换和新作品启动语义,推荐卡标题、分享 / 点赞 / 改造基准都由统一推荐切换结果决定。切换发起前仍必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;后续局部同步状态由推荐页启动新作品的统一 busy 表现承接。 -- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。 +- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的 H5 分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`;微信小程序 WebView 内复制动作必须改为小程序 web-view 路径并补齐 `targetPath=/works/detail` 与 `work` 参数。微信小程序 WebView 内的推荐页运行态需要启用分享快照安全区,把游戏画面等比缩放并保持在页面中部,避免用户直接点击小程序自带“分享到聊天”时只截到游戏画面局部。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 推荐页作品点赞必须按前端全局公开作品 `sourceType` 联合类型明确分流;暂未接入点赞后端的玩法直接报“该作品类型暂不支持点赞”,不能显示开放兜底文案,也不能落入 RPG / custom-world 默认点赞路径。特别是 `WF-*` 敲木鱼作品不得调用 `/api/runtime/custom-world-gallery/.../like`。前端全局创作类型 / 公开作品类型定义以 `packages/shared/src/contracts/playTypes.ts` 为准,新增玩法必须先补类型再补推荐页、详情页、分类页和公开互动分支。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 diff --git a/miniprogram/app.json b/miniprogram/app.json index dc8c7a3d..e7dca1ce 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -1,6 +1,7 @@ { "pages": [ "pages/web-view/index", + "pages/share-grid/index", "pages/wechat-pay/index", "pages/subscribe-message/index" ], diff --git a/miniprogram/pages/share-grid/index.js b/miniprogram/pages/share-grid/index.js new file mode 100644 index 00000000..2cae173c --- /dev/null +++ b/miniprogram/pages/share-grid/index.js @@ -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(); + }, +}); diff --git a/miniprogram/pages/share-grid/index.json b/miniprogram/pages/share-grid/index.json new file mode 100644 index 00000000..491e238c --- /dev/null +++ b/miniprogram/pages/share-grid/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "九宫切图" +} diff --git a/miniprogram/pages/share-grid/index.shared.js b/miniprogram/pages/share-grid/index.shared.js new file mode 100644 index 00000000..36b913f8 --- /dev/null +++ b/miniprogram/pages/share-grid/index.shared.js @@ -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, +}; diff --git a/miniprogram/pages/share-grid/index.test.js b/miniprogram/pages/share-grid/index.test.js new file mode 100644 index 00000000..832f7890 --- /dev/null +++ b/miniprogram/pages/share-grid/index.test.js @@ -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, + }); + }); +}); diff --git a/miniprogram/pages/share-grid/index.wxml b/miniprogram/pages/share-grid/index.wxml new file mode 100644 index 00000000..0d17300b --- /dev/null +++ b/miniprogram/pages/share-grid/index.wxml @@ -0,0 +1,20 @@ + + + + diff --git a/miniprogram/pages/share-grid/index.wxss b/miniprogram/pages/share-grid/index.wxss new file mode 100644 index 00000000..e34ca465 --- /dev/null +++ b/miniprogram/pages/share-grid/index.wxss @@ -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; +} diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index c7d221dc..91de532d 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -10,6 +10,10 @@ const { WEB_VIEW_ENTRY_URL, WEB_VIEW_SOURCE_QUERY, } = require('../../config'); +const { + appendHashParams, + resolveWebViewUrlFromRuntimeConfig, +} = require('./index.shared'); const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; @@ -59,50 +63,6 @@ function isConfiguredApiBaseUrl(value) { 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) { return value === true || value === '1' || value === 'true' || value === 'yes'; } @@ -233,22 +193,16 @@ function shouldReturnToPreviousPage(query) { return String((query && query.returnTo) || '').trim() === 'previous'; } -function resolveWebViewUrl(authResult) { +function resolveWebViewUrl(authResult, launchQuery = {}) { const runtimeConfig = resolveMiniProgramRuntimeConfig(); const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); if (!isConfiguredEntryUrl(entryUrl)) { return ''; } - 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, + return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { + ...runtimeConfig, + webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), }); } @@ -467,7 +421,7 @@ Page({ loading: false, phoneBindingRequired: false, returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(null), + webViewUrl: resolveWebViewUrl(null, query), }); return; } @@ -572,7 +526,7 @@ Page({ nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), }); } catch (error) { this.setData({ @@ -600,7 +554,7 @@ Page({ loading: false, nicknameRequired: false, phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), }); } @@ -674,7 +628,10 @@ Page({ loading: false, nicknameRequired: false, phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(nextAuthResult), + webViewUrl: resolveWebViewUrl( + nextAuthResult, + this._lastLaunchQuery || {}, + ), }); } catch (error) { this.setData({ diff --git a/miniprogram/pages/web-view/index.shared.js b/miniprogram/pages/web-view/index.shared.js new file mode 100644 index 00000000..6357960c --- /dev/null +++ b/miniprogram/pages/web-view/index.shared.js @@ -0,0 +1,129 @@ +const ALLOWED_TARGET_PATHS = new Set(['/works/detail']); + +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 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, + normalizeTargetPath, + resolveLaunchTargetQuery, + resolveWebViewUrlFromRuntimeConfig, +}; diff --git a/miniprogram/pages/web-view/index.test.js b/miniprogram/pages/web-view/index.test.js new file mode 100644 index 00000000..a677d747 --- /dev/null +++ b/miniprogram/pages/web-view/index.test.js @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest'; + +import webViewBridge from './index.shared.js'; + +const { + appendLaunchTargetToEntryUrl, + 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(); + }); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index dd737e08..eb959365 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml'); const modulePath = resolve(serverRsDir, 'crates/spacetime-module'); const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs'); 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_ALIASES = new Map([ @@ -399,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() { const manifestText = readFileSync(manifestPath, 'utf8'); const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec( @@ -776,7 +810,7 @@ class DevRunner { this.writeDevStackState(); } - async prepareLinuxPortRange(command) { + async prepareLinuxPortRange() { if (process.platform !== 'linux') { return; } @@ -1228,7 +1262,7 @@ class DevRunner { } async publishSpacetimeModule() { - const env = {...this.baseEnv}; + const env = buildLocalRustProcessEnv(this.baseEnv); this.prepareMigrationBootstrapSecret(env); const args = buildSpacetimePublishArgs({ @@ -1291,7 +1325,7 @@ class DevRunner { await this.ensureApiServerSpacetimeToken(); const mergedEnv = buildApiServerProcessEnv({ - baseEnv: this.baseEnv, + baseEnv: buildLocalRustProcessEnv(this.baseEnv), options: this.options, state: this.state, }); @@ -2124,19 +2158,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) { } export { - DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildDevStackSnapshot, + buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, - isSpacetimePublishPermissionError, + DevRunner, isDirectModuleExecution, + isSpacetimePublishPermissionError, normalizeCargoVersionRequirement, - parseSpacetimeToolVersion, parseArgs, + parseSpacetimeToolVersion, resolveDevStackStatePath, shouldAcceptWatchEvent, }; diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index cf3640b5..b4d3d80e 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -5,19 +5,20 @@ import {join} from 'node:path'; import {afterEach, describe, expect, test, vi} from 'vitest'; import { - DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildDevStackSnapshot, + buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, + DevRunner, isDirectModuleExecution, isSpacetimePublishPermissionError, normalizeCargoVersionRequirement, - parseSpacetimeToolVersion, parseArgs, + parseSpacetimeToolVersion, resolveDevStackStatePath, shouldAcceptWatchEvent, } from './dev.mjs'; @@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => { }); }); +describe('dev scheduler Rust build env', () => { + test('local dev Rust env bypasses project sccache wrapper', () => { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: '/usr/bin/sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache'); + expect(env.RUSTC_WRAPPER).not.toBe('sccache'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER); + }); + + test('local dev Rust env keeps healthy custom wrapper untouched', () => { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: 'custom-wrapper', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).toBe('custom-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper'); + }); +}); + describe('dev scheduler stack state file', () => { test('状态文件路径固定在根目录 .app/dev-stack.json', () => { expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe( diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx index fd9bb40c..9dcd000b 100644 --- a/src/components/common/PublishShareModal.test.tsx +++ b/src/components/common/PublishShareModal.test.tsx @@ -10,24 +10,39 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import * as clipboardService from '../../services/clipboard'; +import * as shareGridService from '../../services/wechatMiniProgramShareGrid'; import { PublishShareModal } from './PublishShareModal'; import { + buildMiniProgramPublishSharePath, + buildPublishShareCardFileName, + buildPublishShareCopyUrl, buildPublishShareText, + buildPublishShareUrl, type PublishShareModalPayload, } from './publishShareModalModel'; vi.mock('../../services/clipboard', () => ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../../services/wechatMiniProgramShareGrid', () => ({ + canUseWechatMiniProgramShareGrid: vi.fn(() => false), + openWechatMiniProgramShareGridPage: vi.fn(), +})); const payload: PublishShareModalPayload = { title: '暖灯猫街', publicWorkCode: 'PZ-00000001', stage: 'puzzle-gallery-detail', + workTypeLabel: '拼图', + coverImageSrc: '/cover.png', }; afterEach(() => { vi.clearAllMocks(); + vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue( + false, + ); + window.history.replaceState(null, '', '/'); }); describe('PublishShareModal', () => { @@ -39,7 +54,40 @@ describe('PublishShareModal', () => { expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001'); }); - test('renders share text and channel icons, then copies from main button', async () => { + test('builds the card file name without unsafe path characters', () => { + expect( + buildPublishShareCardFileName({ + title: '暖灯:猫街', + publicWorkCode: 'PZ-00000001', + }), + ).toBe('暖灯猫街-PZ-00000001.png'); + }); + + test('builds a mini program share path with public work detail params', () => { + const sharePath = buildMiniProgramPublishSharePath(payload); + 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('PZ-00000001'); + expect(buildPublishShareCopyUrl(payload, { miniProgramRuntime: true })).toBe( + sharePath, + ); + }); + + test('keeps existing mini program share params and fills missing detail params', () => { + const sharePath = buildMiniProgramPublishSharePath( + payload, + '/pages/web-view/index?scene=poster', + ); + const url = new URL(sharePath, 'https://mini.test'); + + expect(url.searchParams.get('scene')).toBe('poster'); + expect(url.searchParams.get('targetPath')).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('PZ-00000001'); + }); + + test('renders the share card and copies the public link', async () => { vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); render( @@ -52,26 +100,55 @@ describe('PublishShareModal', () => { expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('rounded-[1.75rem]'); expect(dialog.getAttribute('style')).toBeNull(); - expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy(); - expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy(); - expect( - within(dialog).getByTestId('share-channel-logo-wechat'), - ).toBeTruthy(); - expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy(); - expect( - within(dialog).getByTestId('share-channel-logo-douyin'), - ).toBeTruthy(); + expect(within(dialog).getByRole('region', { name: '分享卡片' })).toBeTruthy(); + expect(within(dialog).getByText('拼图')).toBeTruthy(); + expect(within(dialog).getByText('暖灯猫街')).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '下载卡片' })).toBeTruthy(); + expect(within(dialog).queryByRole('button', { name: '九宫切图' })).toBeNull(); - fireEvent.click(within(dialog).getByRole('button', { name: '分享' })); + fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' })); expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( - expect.stringContaining('作品号:PZ-00000001'), + buildPublishShareUrl(payload), ); await waitFor(() => { expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy(); }); }); + + test('copies the mini program link inside mini program web-view', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', + ); + + render( + {}} />, + ); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + buildMiniProgramPublishSharePath(payload), + ); + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy(); + }); + }); + + test('shows the mini program grid action only inside mini program runtime', () => { + vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue( + true, + ); + + render( + {}} />, + ); + + expect(screen.getByRole('button', { name: '九宫切图' })).toBeTruthy(); + }); }); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index b23849c7..755733ea 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -1,10 +1,18 @@ -import { Check, Copy } from 'lucide-react'; +import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; +import { isWechatMiniProgramWebViewRuntime } from '../../services/authService'; import { copyTextToClipboard } from '../../services/clipboard'; -import { useAuthUi } from '../auth/AuthUiContext'; import { - buildPublishShareText, + canUseWechatMiniProgramShareGrid, + openWechatMiniProgramShareGridPage, +} from '../../services/wechatMiniProgramShareGrid'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { downloadPublishShareCardImage } from './publishShareCardImage'; +import { + buildPublishShareCopyUrl, type PublishShareModalPayload, } from './publishShareModalModel'; import { UnifiedModal } from './UnifiedModal'; @@ -15,78 +23,27 @@ type PublishShareModalProps = { onClose: () => void; }; -type ShareChannelId = 'wechat' | 'qq' | 'douyin'; +type ActionState = 'idle' | 'success' | 'failed'; -type ShareChannel = { - id: ShareChannelId; - label: string; - iconClassName: string; -}; - -// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。 -const SHARE_CHANNEL_ICON_PATHS: Record = { - wechat: - 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z', - qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673', - douyin: - 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z', -}; - -const SHARE_CHANNELS = [ - { - id: 'wechat', - label: '微信', - iconClassName: 'bg-[#07c160] text-white', - }, - { - id: 'qq', - label: 'QQ', - iconClassName: 'bg-[#12b7f5] text-white', - }, - { - id: 'douyin', - label: '抖音', - iconClassName: 'bg-black text-white', - }, -] as const satisfies readonly ShareChannel[]; - -function ShareChannelLogo({ channel }: { channel: ShareChannel }) { - const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id]; - - if (channel.id === 'douyin') { - return ( - - ); - } +function normalizePayloadTitle(payload: PublishShareModalPayload | null) { + return payload?.title.trim() || '我的作品'; +} +function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) { return ( - + payload?.coverImageSrc?.trim() || + payload?.fallbackCoverImageSrc?.trim() || + '' ); } +function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) { + return payload?.workTypeLabel?.trim() || '互动作品'; +} + /** - * 发布完成后的分享弹窗。 - * 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK,可以只替换这里的渠道点击逻辑。 + * 发布完成后的通用分享弹窗。 + * 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。 */ export function PublishShareModal({ open, @@ -94,14 +51,24 @@ export function PublishShareModal({ onClose, }: PublishShareModalProps) { const platformTheme = useAuthUi()?.platformTheme ?? 'light'; - const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( - 'idle', - ); + const [copyState, setCopyState] = useState('idle'); + const [downloadState, setDownloadState] = useState('idle'); + const [gridState, setGridState] = useState('idle'); const resetTimerRef = useRef(null); - const shareText = useMemo( - () => (payload ? buildPublishShareText(payload) : ''), + const shareCopyUrl = useMemo( + () => + payload + ? buildPublishShareCopyUrl(payload, { + miniProgramRuntime: isWechatMiniProgramWebViewRuntime(), + }) + : '', [payload], ); + const title = normalizePayloadTitle(payload); + const coverImageSrc = resolvePayloadCoverImageSrc(payload); + const workTypeLabel = resolvePayloadWorkTypeLabel(payload); + const showMiniProgramGridButton = + canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc); useEffect( () => () => { @@ -114,25 +81,92 @@ export function PublishShareModal({ useEffect(() => { setCopyState('idle'); + setDownloadState('idle'); + setGridState('idle'); }, [payload?.publicWorkCode]); - const copyShareText = () => { - if (!shareText) { + const scheduleStateReset = () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + setDownloadState('idle'); + setGridState('idle'); + }, 1400); + }; + + const copyShareLink = () => { + if (!shareCopyUrl) { return; } - void copyTextToClipboard(shareText).then((copied) => { - setCopyState(copied ? 'copied' : 'failed'); - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - } - resetTimerRef.current = window.setTimeout(() => { - resetTimerRef.current = null; - setCopyState('idle'); - }, 1400); + void copyTextToClipboard(shareCopyUrl).then((copied) => { + setCopyState(copied ? 'success' : 'failed'); + scheduleStateReset(); }); }; + const resolveMiniProgramGridCover = async () => { + if (!coverImageSrc) { + return ''; + } + + return await resolveAssetReadUrl(coverImageSrc, { + expireSeconds: 600, + }).catch(() => coverImageSrc); + }; + + const downloadShareCard = () => { + if (!payload) { + return; + } + + setDownloadState('idle'); + void downloadPublishShareCardImage( + { + ...payload, + title, + workTypeLabel, + coverImageSrc, + }, + coverImageSrc, + ) + .then((downloaded) => { + setDownloadState(downloaded ? 'success' : 'failed'); + scheduleStateReset(); + }) + .catch(() => { + setDownloadState('failed'); + scheduleStateReset(); + }); + }; + + const openMiniProgramGridDownload = () => { + if (!payload || !coverImageSrc) { + return; + } + + setGridState('idle'); + void resolveMiniProgramGridCover() + .then((resolvedCoverImageSrc) => + openWechatMiniProgramShareGridPage({ + imageUrl: resolvedCoverImageSrc, + title, + publicWorkCode: payload.publicWorkCode, + }), + ) + .then((opened) => { + setGridState(opened ? 'success' : 'failed'); + scheduleStateReset(); + }) + .catch(() => { + setGridState('failed'); + scheduleStateReset(); + }); + }; + return ( - {SHARE_CHANNELS.map((channel) => { - return ( - - ); - })} +
+ + + {showMiniProgramGridButton ? ( + + ) : null}
} > -
-
- {shareText} -
-
- +
+ {coverImageSrc ? ( + + ) : ( +
+ {Array.from(title)[0] ?? '陶'} +
+ )} +
+
+
+ {workTypeLabel} +
+

+ {title} +

+
+ + {payload?.publicWorkCode} +
+
+
); } diff --git a/src/components/common/publishShareCardImage.test.ts b/src/components/common/publishShareCardImage.test.ts new file mode 100644 index 00000000..26487be0 --- /dev/null +++ b/src/components/common/publishShareCardImage.test.ts @@ -0,0 +1,146 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { readAssetBytes } from '../../services/assetReadUrlService'; +import { + downloadPublishShareCardImage, + resolvePublishShareCardCanvasImageSource, +} from './publishShareCardImage'; + +vi.mock('../../services/assetReadUrlService', async () => { + const actual = + await vi.importActual( + '../../services/assetReadUrlService', + ); + return { + ...actual, + readAssetBytes: vi.fn(), + }; +}); + +const createObjectUrl = vi.fn(() => 'blob:share-card-cover'); +const revokeObjectUrl = vi.fn(); +const fillTextCalls: string[] = []; + +function installObjectUrlMocks() { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrl, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectUrl, + }); +} + +function installCanvasMocks() { + class MockImage { + crossOrigin = ''; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 900; + naturalHeight = 900; + width = 900; + height = 900; + + set src(_value: string) { + this.onload?.(); + } + } + + vi.stubGlobal('Image', MockImage); + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node); + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + beginPath: vi.fn(), + clearRect: vi.fn(), + clip: vi.fn(), + closePath: vi.fn(), + createLinearGradient: vi.fn(() => ({ + addColorStop: vi.fn(), + })), + drawImage: vi.fn(), + fill: vi.fn(), + fillRect: vi.fn(), + fillText: vi.fn((text: string) => { + fillTextCalls.push(text); + }), + lineTo: vi.fn(), + measureText: vi.fn((text: string) => ({ + width: Array.from(text).length * 32, + })), + moveTo: vi.fn(), + quadraticCurveTo: vi.fn(), + restore: vi.fn(), + save: vi.fn(), + stroke: vi.fn(), + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation( + (callback: BlobCallback) => { + callback(new Blob(['share-card'], { type: 'image/png' })); + }, + ); +} + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + fillTextCalls.length = 0; +}); + +describe('publishShareCardImage', () => { + test('loads generated covers through same-origin bytes before drawing to canvas', async () => { + installObjectUrlMocks(); + vi.mocked(readAssetBytes).mockResolvedValue( + new Response(new Blob(['cover-bytes'], { type: 'image/png' })), + ); + + const imageSource = await resolvePublishShareCardCanvasImageSource( + '/generated-puzzle-assets/session/profile/covers/main.png', + ); + + expect(readAssetBytes).toHaveBeenCalledWith( + '/generated-puzzle-assets/session/profile/covers/main.png', + { expireSeconds: 600 }, + ); + expect(imageSource.src).toBe('blob:share-card-cover'); + + imageSource.release(); + + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:share-card-cover'); + }); + + test('keeps ordinary public covers as their original source', async () => { + const imageSource = await resolvePublishShareCardCanvasImageSource( + '/creation-type-references/puzzle.webp', + ); + + expect(readAssetBytes).not.toHaveBeenCalled(); + expect(imageSource.src).toBe('/creation-type-references/puzzle.webp'); + }); + + test('exports the same card content as the modal instead of adding extra branding', async () => { + installObjectUrlMocks(); + installCanvasMocks(); + + await expect( + downloadPublishShareCardImage( + { + title: '三叶草', + publicWorkCode: 'PZ-BE68CC73', + stage: 'puzzle-gallery-detail', + workTypeLabel: '拼图', + coverImageSrc: '/cover.png', + }, + '/cover.png', + ), + ).resolves.toBe(true); + + expect(fillTextCalls).toContain('拼图'); + expect(fillTextCalls).toContain('三叶草'); + expect(fillTextCalls).toContain('PZ-BE68CC73'); + expect(fillTextCalls).not.toContain('陶泥儿'); + }); +}); diff --git a/src/components/common/publishShareCardImage.ts b/src/components/common/publishShareCardImage.ts new file mode 100644 index 00000000..24e51ef2 --- /dev/null +++ b/src/components/common/publishShareCardImage.ts @@ -0,0 +1,403 @@ +import { + readAssetBytes, + shouldResolveAssetReadUrl, +} from '../../services/assetReadUrlService'; +import { + buildPublishShareCardFileName, + type PublishShareModalPayload, +} from './publishShareModalModel'; + +const CARD_WIDTH = 1080; +const CARD_HEIGHT = 1440; +const CARD_RADIUS = 24; +const COVER_X = 0; +const COVER_Y = 0; +const COVER_SIZE = CARD_WIDTH; +const CONTENT_PADDING_X = 48; +const CONTENT_TOP = COVER_Y + COVER_SIZE + 48; +const TYPE_PILL_HEIGHT = 64; + +type PublishShareCardTheme = { + background: string; + border: string; + neutralBackground: string; + accentText: string; + titleText: string; + mutedText: string; +}; + +function resolveCssColor(variableName: string, fallback: string) { + if (typeof document === 'undefined') { + return fallback; + } + + const value = getComputedStyle(document.documentElement) + .getPropertyValue(variableName) + .trim(); + return value || fallback; +} + +function resolvePublishShareCardTheme(): PublishShareCardTheme { + return { + background: '#fffaf4', + border: resolveCssColor('--platform-subpanel-border', '#ead9c7'), + neutralBackground: resolveCssColor('--platform-neutral-bg', '#f2e3d5'), + accentText: resolveCssColor('--platform-accent-text', '#7f5539'), + titleText: resolveCssColor('--platform-text-strong', '#332820'), + mutedText: resolveCssColor('--platform-text-muted', '#a88e7c'), + }; +} + +function drawRoundedRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + context.beginPath(); + context.moveTo(x + radius, y); + context.lineTo(x + width - radius, y); + context.quadraticCurveTo(x + width, y, x + width, y + radius); + context.lineTo(x + width, y + height - radius); + context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + context.lineTo(x + radius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - radius); + context.lineTo(x, y + radius); + context.quadraticCurveTo(x, y, x + radius, y); + context.closePath(); +} + +function drawWrappedText( + context: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + lineHeight: number, + maxLines: number, +) { + const chars = Array.from(text.trim() || '我的作品'); + const lines: string[] = []; + let currentLine = ''; + + for (const char of chars) { + const nextLine = `${currentLine}${char}`; + if (currentLine && context.measureText(nextLine).width > maxWidth) { + lines.push(currentLine); + currentLine = char; + if (lines.length >= maxLines) { + break; + } + } else { + currentLine = nextLine; + } + } + + if (lines.length < maxLines && currentLine) { + lines.push(currentLine); + } + + lines.slice(0, maxLines).forEach((line, index) => { + const isLast = index === maxLines - 1 && lines.length >= maxLines; + let displayLine = line; + while ( + isLast && + displayLine.length > 1 && + context.measureText(`${displayLine}...`).width > maxWidth + ) { + displayLine = displayLine.slice(0, -1); + } + context.fillText(isLast ? `${displayLine}...` : displayLine, x, y + index * lineHeight); + }); +} + +function shouldLoadImageWithAnonymousCors(src: string) { + if ( + src.startsWith('data:') || + src.startsWith('blob:') || + typeof window === 'undefined' + ) { + return false; + } + + try { + const parsedUrl = new URL(src, window.location.origin); + return ( + /^https?:$/u.test(parsedUrl.protocol) && + parsedUrl.origin !== window.location.origin + ); + } catch { + return false; + } +} + +export async function resolvePublishShareCardCanvasImageSource(src: string) { + const normalizedSrc = src.trim(); + if (!normalizedSrc) { + return { + src: '', + release() {}, + }; + } + + if (!shouldResolveAssetReadUrl(normalizedSrc)) { + return { + src: normalizedSrc, + release() {}, + }; + } + + const response = await readAssetBytes(normalizedSrc, { + expireSeconds: 600, + }); + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + + return { + src: objectUrl, + release() { + URL.revokeObjectURL(objectUrl); + }, + }; +} + +function loadCanvasImage(src: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + if (shouldLoadImageWithAnonymousCors(src)) { + image.crossOrigin = 'anonymous'; + } + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('分享卡封面加载失败')); + image.src = src; + }); +} + +function drawImageCover( + context: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number, +) { + const sourceWidth = image.naturalWidth || image.width; + const sourceHeight = image.naturalHeight || image.height; + const sourceRatio = sourceWidth / Math.max(1, sourceHeight); + const targetRatio = width / Math.max(1, height); + const cropWidth = sourceRatio > targetRatio ? sourceHeight * targetRatio : sourceWidth; + const cropHeight = sourceRatio > targetRatio ? sourceHeight : sourceWidth / targetRatio; + const cropX = (sourceWidth - cropWidth) / 2; + const cropY = (sourceHeight - cropHeight) / 2; + + context.drawImage( + image, + cropX, + cropY, + cropWidth, + cropHeight, + x, + y, + width, + height, + ); +} + +function drawCoverFallback( + context: CanvasRenderingContext2D, + payload: PublishShareModalPayload, +) { + const gradient = context.createLinearGradient( + COVER_X, + COVER_Y, + COVER_X + COVER_SIZE, + COVER_Y + COVER_SIZE, + ); + gradient.addColorStop(0, '#f6c58d'); + gradient.addColorStop(0.48, '#e7b7b7'); + gradient.addColorStop(1, '#9bbfd1'); + context.fillStyle = gradient; + context.fillRect(COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE); + + context.fillStyle = 'rgba(255, 255, 255, 0.82)'; + context.font = '900 156px sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + const initial = Array.from(payload.title.trim() || '陶')[0] ?? '陶'; + context.fillText(initial, CARD_WIDTH / 2, COVER_Y + COVER_SIZE / 2); +} + +function drawCopyIcon( + context: CanvasRenderingContext2D, + x: number, + y: number, + color: string, +) { + context.strokeStyle = color; + context.lineWidth = 4; + drawRoundedRect(context, x + 10, y, 24, 30, 4); + context.stroke(); + drawRoundedRect(context, x, y + 10, 24, 30, 4); + context.stroke(); +} + +async function drawShareCard( + context: CanvasRenderingContext2D, + payload: PublishShareModalPayload, + coverImageSrc: string, +) { + const theme = resolvePublishShareCardTheme(); + context.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT); + + context.save(); + drawRoundedRect(context, 0, 0, CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS); + context.clip(); + + context.fillStyle = theme.background; + context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT); + + if (coverImageSrc) { + const canvasImageSource = + await resolvePublishShareCardCanvasImageSource(coverImageSrc); + try { + const image = await loadCanvasImage(canvasImageSource.src); + drawImageCover(context, image, COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE); + } finally { + canvasImageSource.release(); + } + } else { + drawCoverFallback(context, payload); + } + + const typeLabel = payload.workTypeLabel?.trim() || '互动作品'; + context.font = '800 36px sans-serif'; + const pillWidth = Math.min( + CARD_WIDTH - CONTENT_PADDING_X * 2, + Math.max(180, context.measureText(typeLabel).width + 72), + ); + const pillY = CONTENT_TOP; + context.fillStyle = theme.neutralBackground; + drawRoundedRect( + context, + CONTENT_PADDING_X, + pillY, + pillWidth, + TYPE_PILL_HEIGHT, + TYPE_PILL_HEIGHT / 2, + ); + context.fill(); + context.fillStyle = theme.accentText; + context.textAlign = 'left'; + context.textBaseline = 'middle'; + context.fillText(typeLabel, CONTENT_PADDING_X + 36, pillY + TYPE_PILL_HEIGHT / 2); + + context.fillStyle = theme.titleText; + context.font = '900 72px sans-serif'; + context.textBaseline = 'top'; + drawWrappedText( + context, + payload.title, + CONTENT_PADDING_X, + pillY + 92, + CARD_WIDTH - CONTENT_PADDING_X * 2, + 84, + 2, + ); + + const code = payload.publicWorkCode.trim(); + if (code) { + const codeY = CARD_HEIGHT - 74; + context.fillStyle = theme.mutedText; + context.font = '700 34px sans-serif'; + context.textBaseline = 'middle'; + drawCopyIcon(context, CONTENT_PADDING_X, codeY - 20, theme.mutedText); + context.fillText(code, CONTENT_PADDING_X + 54, codeY); + } + + context.restore(); + + context.strokeStyle = theme.border; + context.lineWidth = 3; + drawRoundedRect(context, 1.5, 1.5, CARD_WIDTH - 3, CARD_HEIGHT - 3, CARD_RADIUS); + context.stroke(); +} + +function canvasToBlob(canvas: HTMLCanvasElement) { + return new Promise((resolve, reject) => { + if (typeof canvas.toBlob !== 'function') { + try { + const dataUrl = canvas.toDataURL('image/png'); + const binary = atob(dataUrl.split(',')[1] ?? ''); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + resolve(new Blob([bytes], { type: 'image/png' })); + } catch (error) { + reject(error); + } + return; + } + + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('分享卡导出失败')); + } + }, 'image/png'); + }); +} + +function triggerDownload(blob: Blob, fileName: string) { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + +export async function downloadPublishShareCardImage( + payload: PublishShareModalPayload, + coverImageSrc: string, +) { + if (typeof document === 'undefined') { + return false; + } + + const canvas = document.createElement('canvas'); + canvas.width = CARD_WIDTH; + canvas.height = CARD_HEIGHT; + const context = canvas.getContext('2d'); + if (!context) { + return false; + } + + try { + await drawShareCard(context, payload, coverImageSrc); + triggerDownload( + await canvasToBlob(canvas), + buildPublishShareCardFileName(payload), + ); + return true; + } catch { + const fallbackCanvas = document.createElement('canvas'); + fallbackCanvas.width = CARD_WIDTH; + fallbackCanvas.height = CARD_HEIGHT; + const fallbackContext = fallbackCanvas.getContext('2d'); + if (!fallbackContext) { + return false; + } + await drawShareCard(fallbackContext, payload, ''); + triggerDownload( + await canvasToBlob(fallbackCanvas), + buildPublishShareCardFileName(payload), + ); + return true; + } +} diff --git a/src/components/common/publishShareModalModel.ts b/src/components/common/publishShareModalModel.ts index 3c360d36..65b9e513 100644 --- a/src/components/common/publishShareModalModel.ts +++ b/src/components/common/publishShareModalModel.ts @@ -1,13 +1,19 @@ import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import type { SelectionStage } from '../platform-entry/platformEntryTypes'; +const MINI_PROGRAM_WEB_VIEW_PAGE_PATH = '/pages/web-view/index'; +const MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH = '/works/detail'; + export type PublishShareModalPayload = { title: string; publicWorkCode: string; stage: SelectionStage; + workTypeLabel?: string | null; + coverImageSrc?: string | null; + fallbackCoverImageSrc?: string | null; }; -function buildShareUrl(payload: PublishShareModalPayload) { +export function buildPublishShareUrl(payload: PublishShareModalPayload) { const sharePath = buildPublicWorkStagePath( payload.stage, payload.publicWorkCode, @@ -18,13 +24,56 @@ function buildShareUrl(payload: PublishShareModalPayload) { : new URL(sharePath, window.location.origin).href; } +export function buildMiniProgramPublishSharePath( + payload: PublishShareModalPayload, + basePath = MINI_PROGRAM_WEB_VIEW_PAGE_PATH, +) { + const [path = MINI_PROGRAM_WEB_VIEW_PAGE_PATH, rawSearch = ''] = + basePath.split('?'); + const params = new URLSearchParams(rawSearch); + const publicWorkCode = payload.publicWorkCode.trim(); + + if (!params.has('targetPath')) { + params.set('targetPath', MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH); + } + if (publicWorkCode && !params.has('work')) { + params.set('work', publicWorkCode); + } + + const queryString = params.toString(); + return queryString ? `${path}?${queryString}` : path; +} + +export function buildPublishShareCopyUrl( + payload: PublishShareModalPayload, + options: { miniProgramRuntime?: boolean } = {}, +) { + return options.miniProgramRuntime + ? buildMiniProgramPublishSharePath(payload) + : buildPublishShareUrl(payload); +} + export function buildPublishShareText(payload: PublishShareModalPayload) { const publicWorkCode = payload.publicWorkCode.trim(); const title = payload.title.trim() || '我的作品'; - return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildShareUrl({ + return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildPublishShareUrl({ ...payload, publicWorkCode, title, })}`; } + +export function buildPublishShareCardFileName( + payload: Pick, +) { + const title = payload.title.trim() || '我的作品'; + const publicWorkCode = payload.publicWorkCode.trim() || 'share'; + const safeTitle = Array.from(title) + .filter((char) => !/[\\/:*?"<>|]/u.test(char)) + .join('') + .replace(/\s+/gu, '-') + .slice(0, 28) + .trim(); + return `${safeTitle || '我的作品'}-${publicWorkCode}.png`; +} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 650a6d6e..6083337c 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -1093,6 +1093,9 @@ test('creation hub published share icon opens unified share payload without open title: '沉钟拼图', publicWorkCode: 'PZ-PROFILE1', stage: 'puzzle-gallery-detail', + workTypeLabel: '拼图', + coverImageSrc: null, + fallbackCoverImageSrc: '/creation-type-references/puzzle.webp', }); expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index aaea5cb7..fdf79ac8 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -8,10 +8,10 @@ import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/co import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import type { CustomWorldProfile } from '../../types'; @@ -23,10 +23,12 @@ import type { import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; import { buildCreationWorkShelfItems, - getCreationWorkShelfItemTime, + CREATION_WORK_KIND_FALLBACK_COVER, type CreationWorkShelfItem, type CreationWorkShelfMetricId, type CreationWorkShelfRuntimeState, + describeCreationWorkShelfKind, + getCreationWorkShelfItemTime, } from './creationWorkShelf'; import { CustomWorldCreationStartCard, @@ -202,6 +204,9 @@ function buildCreationWorkShelfSharePayload( title: item.title, publicWorkCode, stage, + workTypeLabel: describeCreationWorkShelfKind(item.kind), + coverImageSrc: item.coverImageSrc, + fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER[item.kind], }; } @@ -351,6 +356,7 @@ export function CustomWorldCreationHub({ puzzleClearItems, puzzleItems, rpgLibraryEntries, + squareHoleItems, onOpenJumpHopDetail, jumpHopItems, woodenFishItems, diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 72ac97c0..d9f9a1c1 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -7,8 +7,8 @@ import { Trash2, } from 'lucide-react'; import { - default as React, type CSSProperties, + default as React, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type TouchEvent as ReactTouchEvent, @@ -24,9 +24,9 @@ import { formatPlatformWorkDisplayTag, } from '../rpg-entry/rpgEntryWorldPresentation'; import { + CREATION_WORK_KIND_FALLBACK_COVER, type CreationWorkShelfBadgeTone, type CreationWorkShelfItem, - type CreationWorkShelfKind, type CreationWorkShelfMetric, type CreationWorkShelfMetricId, formatCreationMetricCount, @@ -55,21 +55,6 @@ const SWIPE_ACTION_WIDTH_PX = 76; const SWIPE_REVEAL_THRESHOLD_PX = 42; const SWIPE_DIRECTION_LOCK_PX = 8; const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = []; -const CREATION_WORK_KIND_FALLBACK_COVER: Record = - { - rpg: '/creation-type-references/rpg.webp', - 'big-fish': '/creation-type-references/big-fish.webp', - match3d: '/creation-type-references/match3d.webp', - 'square-hole': '/creation-type-references/square-hole.webp', - 'jump-hop': '/creation-type-references/jump-hop.webp', - 'wooden-fish': '/wooden-fish/default-hit-object.png', - 'puzzle-clear': '/creation-type-references/puzzle.webp', - puzzle: '/creation-type-references/puzzle.webp', - 'baby-object-match': '/creation-type-references/creative-agent.webp', - 'bark-battle': '/creation-type-references/bark-battle.webp', - 'visual-novel': '/creation-type-references/visual-novel.webp', - }; - function easeOutCubic(progress: number) { return 1 - (1 - progress) ** 3; } diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 1a75dc8a..d0022f21 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -2,20 +2,20 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, - buildCustomWorldPublicWorkCode, buildBarkBattlePublicWorkCode, buildBigFishPublicWorkCode, + buildCustomWorldPublicWorkCode, buildJumpHopPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzleClearPublicWorkCode, @@ -44,6 +44,50 @@ export type CreationWorkShelfKind = | 'baby-object-match' | 'bark-battle' | 'visual-novel'; + +export const CREATION_WORK_KIND_FALLBACK_COVER: Record< + CreationWorkShelfKind, + string +> = { + rpg: '/creation-type-references/rpg.webp', + 'big-fish': '/creation-type-references/big-fish.webp', + match3d: '/creation-type-references/match3d.webp', + 'square-hole': '/creation-type-references/square-hole.webp', + 'jump-hop': '/creation-type-references/jump-hop.webp', + 'wooden-fish': '/wooden-fish/default-hit-object.png', + 'puzzle-clear': '/creation-type-references/puzzle.webp', + puzzle: '/creation-type-references/puzzle.webp', + 'baby-object-match': '/creation-type-references/creative-agent.webp', + 'bark-battle': '/creation-type-references/bark-battle.webp', + 'visual-novel': '/creation-type-references/visual-novel.webp', +}; + +export function describeCreationWorkShelfKind(kind: CreationWorkShelfKind) { + switch (kind) { + case 'rpg': + return 'RPG世界'; + case 'big-fish': + return '大鱼吃小鱼'; + case 'match3d': + return '抓大鹅'; + case 'square-hole': + return '方洞挑战'; + case 'jump-hop': + return '跳一跳'; + case 'wooden-fish': + return '敲木鱼'; + case 'puzzle-clear': + return '拼消消'; + case 'puzzle': + return '拼图'; + case 'baby-object-match': + return '宝贝识物'; + case 'bark-battle': + return '汪汪声浪'; + case 'visual-novel': + return '视觉小说'; + } +} export type CreationWorkShelfStatus = 'draft' | 'published'; export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral'; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts index 23b75efa..554ad85d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest'; +import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress'; import { resolveMiniGameGenerationProgressTickState, -} from './PlatformEntryFlowShellImpl'; -import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress'; +} from './platformGenerationProgressTickState'; describe('resolveMiniGameGenerationProgressTickState', () => { test('returns jump hop and wooden fish generation states for progress ticking', () => { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a5311c80..cadbbe54 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -382,6 +382,7 @@ import { UnifiedModal } from '../common/UnifiedModal'; import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel'; import { buildCreationWorkShelfItems, + CREATION_WORK_KIND_FALLBACK_COVER, type CreationWorkShelfItem, isPersistedBarkBattleDraftGenerating, isPersistedPuzzleDraftGenerating, @@ -389,6 +390,7 @@ import { } from '../custom-world-home/creationWorkShelf'; import { buildPlatformPublicGalleryCardKey, + describePublicGalleryCardKind, isBarkBattleGalleryEntry, isBigFishGalleryEntry, isCustomWorldGalleryEntry, @@ -412,6 +414,8 @@ import { mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, + resolvePlatformWorldCoverImage, + resolvePlatformWorldFallbackCoverImage, } from '../rpg-entry/rpgEntryWorldPresentation'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; @@ -471,6 +475,7 @@ import { type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { resolveMiniGameGenerationProgressTickState } from './platformGenerationProgressTickState'; import { buildPlatformRecommendedEntries } from './platformRecommendation'; import { PlatformTaskCompletionDialog, @@ -523,31 +528,6 @@ type PuzzleBackgroundCompileTask = { error: string | null; }; -type MiniGameGenerationProgressTickStateMap = Partial< - Record ->; - -export function resolveMiniGameGenerationProgressTickState( - selectionStage: SelectionStage, - states: MiniGameGenerationProgressTickStateMap, -) { - const stageKindMap: Partial< - Record - > = { - 'puzzle-generating': 'puzzle', - 'big-fish-generating': 'big-fish', - 'square-hole-generating': 'square-hole', - 'match3d-generating': 'match3d', - 'baby-object-match-generating': 'baby-object-match', - 'jump-hop-generating': 'jump-hop', - 'puzzle-clear-generating': 'puzzle-clear', - 'wooden-fish-generating': 'wooden-fish', - }; - const kind = stageKindMap[selectionStage]; - - return kind ? (states[kind] ?? null) : null; -} - type PuzzleDetailReturnTarget = { tab: PlatformHomeTab; }; @@ -5274,6 +5254,9 @@ export function PlatformEntryFlowShellImpl({ title: entry.worldName, publicWorkCode, stage: resolveRecommendEntryShareStage(entry), + workTypeLabel: describePublicGalleryCardKind(entry), + coverImageSrc: resolvePlatformWorldCoverImage(entry), + fallbackCoverImageSrc: resolvePlatformWorldFallbackCoverImage(entry), }); }, [openPublishShareModal], @@ -5302,6 +5285,9 @@ export function PlatformEntryFlowShellImpl({ title: galleryEntry?.worldName || profileName, publicWorkCode, stage: 'work-detail', + workTypeLabel: 'RPG世界', + coverImageSrc: galleryEntry?.coverImageSrc ?? null, + fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER.rpg, }); }, [openPublishShareModal, platformBootstrap], @@ -6022,6 +6008,12 @@ export function PlatformEntryFlowShellImpl({ response.session.sessionId, ), stage: 'big-fish-runtime', + workTypeLabel: '大鱼吃小鱼', + coverImageSrc: + response.session.assetSlots.find( + (slot) => slot.status === 'ready' && slot.assetUrl?.trim(), + )?.assetUrl ?? null, + fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER['big-fish'], }); } if (payload.action !== 'big_fish_compile_draft') { @@ -6745,6 +6737,9 @@ export function PlatformEntryFlowShellImpl({ galleryDetail.item.profileId, ), stage: 'puzzle-gallery-detail', + workTypeLabel: '拼图', + coverImageSrc: galleryDetail.item.coverImageSrc, + fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER.puzzle, }); } }, @@ -9300,6 +9295,10 @@ export function PlatformEntryFlowShellImpl({ title: publishedWithAssets.title, publicWorkCode, stage: 'work-detail', + workTypeLabel: '汪汪声浪', + coverImageSrc: publishedWithAssets.uiBackgroundImageSrc ?? null, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER['bark-battle'], }); } catch (error) { setBarkBattleError( @@ -9428,6 +9427,13 @@ export function PlatformEntryFlowShellImpl({ response.publicWorkCode || buildBabyObjectMatchPublicWorkCode(response.draft.profileId), stage: 'work-detail', + workTypeLabel: '宝贝识物', + coverImageSrc: + response.draft.visualPackage?.assets[0]?.imageSrc ?? + response.draft.itemAssets[0]?.imageSrc ?? + null, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER['baby-object-match'], }); } catch (error) { setBabyObjectMatchError( @@ -9664,6 +9670,10 @@ export function PlatformEntryFlowShellImpl({ publishedResponse.work.summary.profileId, ), stage: 'work-detail', + workTypeLabel: '视觉小说', + coverImageSrc: publishedResponse.work.summary.coverImageSrc, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER['visual-novel'], }); } catch (error) { setVisualNovelError( @@ -10252,6 +10262,9 @@ export function PlatformEntryFlowShellImpl({ response.item.summary.profileId, ), stage: 'work-detail', + workTypeLabel: '跳一跳', + coverImageSrc: response.item.summary.coverImageSrc, + fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER['jump-hop'], }); } catch (error) { setJumpHopError( @@ -10679,6 +10692,10 @@ export function PlatformEntryFlowShellImpl({ title: response.item.summary.workTitle || '拼消消', publicWorkCode, stage: 'work-detail', + workTypeLabel: '拼消消', + coverImageSrc: response.item.summary.coverImageSrc, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER['puzzle-clear'], }); } catch (error) { setPuzzleClearError( @@ -11213,6 +11230,10 @@ export function PlatformEntryFlowShellImpl({ response.item.summary.profileId, ), stage: 'work-detail', + workTypeLabel: '敲木鱼', + coverImageSrc: response.item.summary.coverImageSrc, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER['wooden-fish'], }); } catch (error) { setWoodenFishError( @@ -18510,6 +18531,13 @@ export function PlatformEntryFlowShellImpl({ normalizedProfile.profileId, ), stage: 'work-detail', + workTypeLabel: '抓大鹅', + coverImageSrc: + normalizedProfile.coverImageSrc ?? + normalizedProfile.backgroundImageSrc ?? + null, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER.match3d, }); }} onStartTestRun={(profile, options) => { @@ -18972,6 +19000,10 @@ export function PlatformEntryFlowShellImpl({ profile.profileId, ), stage: 'work-detail', + workTypeLabel: '方洞挑战', + coverImageSrc: profile.coverImageSrc ?? null, + fallbackCoverImageSrc: + CREATION_WORK_KIND_FALLBACK_COVER['square-hole'], }); }} onStartTestRun={(profile) => { diff --git a/src/components/platform-entry/platformGenerationProgressTickState.ts b/src/components/platform-entry/platformGenerationProgressTickState.ts new file mode 100644 index 00000000..2427f536 --- /dev/null +++ b/src/components/platform-entry/platformGenerationProgressTickState.ts @@ -0,0 +1,30 @@ +import type { + MiniGameDraftGenerationKind, + MiniGameDraftGenerationState, +} from '../../services/miniGameDraftGenerationProgress'; +import type { SelectionStage } from './platformEntryTypes'; + +type MiniGameGenerationProgressTickStateMap = Partial< + Record +>; + +export function resolveMiniGameGenerationProgressTickState( + selectionStage: SelectionStage, + states: MiniGameGenerationProgressTickStateMap, +) { + const stageKindMap: Partial< + Record + > = { + 'puzzle-generating': 'puzzle', + 'big-fish-generating': 'big-fish', + 'square-hole-generating': 'square-hole', + 'match3d-generating': 'match3d', + 'baby-object-match-generating': 'baby-object-match', + 'jump-hop-generating': 'jump-hop', + 'puzzle-clear-generating': 'puzzle-clear', + 'wooden-fish-generating': 'wooden-fish', + }; + const kind = stageKindMap[selectionStage]; + + return kind ? (states[kind] ?? null) : null; +} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index d7ba0c8b..d90c580a 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -144,6 +144,7 @@ import { buildPlatformPublicGalleryCardKey, buildPlatformWorldDisplayTags, describePlatformThemeLabel, + describePublicGalleryCardKind, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTag, formatPlatformWorldTime, @@ -2374,39 +2375,6 @@ async function getPublicWorkAuthorSummary( return null; } -function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { - if (isBigFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('大鱼吃小鱼'); - } - if (isPuzzleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('拼图'); - } - if (isPuzzleClearGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('拼消消'); - } - if (isMatch3DGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('抓大鹅'); - } - if (isSquareHoleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('方洞挑战'); - } - if (isJumpHopGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('跳一跳'); - } - if (isWoodenFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('敲木鱼'); - } - if (isVisualNovelGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('视觉小说'); - } - if (isBarkBattleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('汪汪声浪'); - } - if (isEdutainmentGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag(entry.templateName); - } - return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode)); -} function getPublicAuthorAvatarLabel(authorDisplayName: string) { return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; } diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 47c5eff5..d60c8825 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -1,5 +1,5 @@ -import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; @@ -7,23 +7,23 @@ import type { JumpHopGalleryCardResponse, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; -import type { - PuzzleClearGalleryCardResponse, - PuzzleClearWorkProfileResponse, - PuzzleClearWorkSummaryResponse, -} from '../../../packages/shared/src/contracts/puzzleClear'; import type { Match3DGeneratedBackgroundAsset, Match3DGeneratedItemAsset, Match3DWorkSummary, } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes'; import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { + PuzzleClearGalleryCardResponse, + PuzzleClearWorkProfileResponse, + PuzzleClearWorkSummaryResponse, +} from '../../../packages/shared/src/contracts/puzzleClear'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; -import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes'; import type { SquareHoleHoleOption, SquareHoleShapeOption, @@ -1077,6 +1077,42 @@ export function buildPlatformWorldDisplayTags( return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit); } +export function describePublicGalleryCardKind( + entry: PlatformPublicGalleryCard, +) { + if (isBigFishGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('大鱼吃小鱼'); + } + if (isPuzzleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('拼图'); + } + if (isPuzzleClearGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('拼消消'); + } + if (isMatch3DGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('抓大鹅'); + } + if (isSquareHoleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('方洞挑战'); + } + if (isJumpHopGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('跳一跳'); + } + if (isWoodenFishGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('敲木鱼'); + } + if (isVisualNovelGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('视觉小说'); + } + if (isBarkBattleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('汪汪声浪'); + } + if (isEdutainmentGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag(entry.templateName); + } + return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode)); +} + export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { if (isBigFishGalleryEntry(entry)) { return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼']; diff --git a/src/index.css b/src/index.css index f6a2754e..11018e4a 100644 --- a/src/index.css +++ b/src/index.css @@ -5303,6 +5303,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: var(--platform-recommend-runtime-shadow); } + html[data-wechat-mini-program-runtime='true'] + .platform-recommend-runtime-panel { + max-height: min(76dvh, 42rem); + transform: scale(0.88); + transform-origin: center; + } + + html[data-wechat-mini-program-runtime='true'] + .platform-recommend-swipe-card__visual { + transform: scale(0.88); + transform-origin: center; + } + .platform-recommend-runtime-viewport { position: absolute; inset: 0; diff --git a/src/index.test.ts b/src/index.test.ts index 292f0d87..df2c36b0 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -114,6 +114,18 @@ describe('index stylesheet unread dots', () => { expect(block).toContain('var(--platform-cool-bg)'); expect(block).not.toContain('background: transparent;'); }); + + it('keeps mini program recommend runtime inside a share snapshot safe area', () => { + const css = readIndexCss(); + const block = getCssBlock( + css, + "html[data-wechat-mini-program-runtime='true']\n .platform-recommend-runtime-panel", + ); + + expect(block).toContain('max-height: min(76dvh, 42rem);'); + expect(block).toContain('transform: scale(0.88);'); + expect(block).toContain('transform-origin: center;'); + }); }); describe('index stylesheet draft mobile cards', () => { diff --git a/src/main.tsx b/src/main.tsx index cd1d11b6..cca97716 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock'; import {resolveAppRoute} from './routing/appRoutes'; import {RouteImageReadyGate} from './routing/RouteImageReadyGate'; import {RouteLoadingScreen} from './routing/RouteLoadingScreen'; +import {isWechatMiniProgramWebViewRuntime} from './services/authService'; type AppRoot = ReturnType; @@ -26,11 +27,18 @@ if (!rootElement) { throw new Error('Missing #root container'); } +function markWechatMiniProgramRuntime() { + if (isWechatMiniProgramWebViewRuntime()) { + document.documentElement.dataset.wechatMiniProgramRuntime = 'true'; + } +} + const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement); const RouteComponent = route.Component; lockMobileViewportZoom(); stabilizeMobileViewportKeyboardFocus(); +markWechatMiniProgramRuntime(); root.render( diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index 086c2141..e83f5381 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -415,4 +415,28 @@ describe('assetReadUrlService', () => { 'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png', ); }); + + test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { + 'Content-Type': 'image/png', + }, + }), + ); + + const response = await readAssetBytes( + 'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc', + { expireSeconds: 300 }, + ); + + expect(response.headers.get('content-type')).toBe('image/png'); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + '/api/assets/read-bytes?', + ); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + 'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png', + ); + }); }); diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 6c591bd7..e5691d2e 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -4,8 +4,8 @@ import { } from '../../packages/shared/src/http'; import { ApiClientError, - BACKGROUND_AUTH_REQUEST_OPTIONS, type ApiRequestOptions, + BACKGROUND_AUTH_REQUEST_OPTIONS, fetchWithApiAuth, requestJson, } from './apiClient'; @@ -376,7 +376,11 @@ export async function readAssetBytes( throw new Error('资源路径不能为空'); } - if (!isGeneratedLegacyPath(value)) { + const legacyPath = isGeneratedLegacyPath(value) + ? value + : resolveGeneratedLegacyPathFromUrl(value); + + if (!legacyPath) { const response = await fetch(value, { signal: options.signal }); if (!response.ok) { throw new Error('读取资源内容失败'); @@ -386,7 +390,7 @@ export async function readAssetBytes( // 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。 const searchParams = buildAssetReadSearchParams({ - legacyPublicPath: value, + legacyPublicPath: legacyPath, expireSeconds: options.expireSeconds, }); const response = await fetchWithApiAuth( diff --git a/src/services/wechatMiniProgramShareGrid.ts b/src/services/wechatMiniProgramShareGrid.ts new file mode 100644 index 00000000..549fcded --- /dev/null +++ b/src/services/wechatMiniProgramShareGrid.ts @@ -0,0 +1,96 @@ +import { isWechatMiniProgramWebViewRuntime } from './authService'; + +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const SHARE_GRID_PAGE_URL = '/pages/share-grid/index'; + +function loadWechatMiniProgramBridge() { + if ( + typeof window === 'undefined' || + !isWechatMiniProgramWebViewRuntime() + ) { + return Promise.reject(new Error('not_mini_program')); + } + + if (window.wx?.miniProgram?.navigateTo) { + return Promise.resolve(window.wx); + } + + return new Promise>((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${WECHAT_JS_SDK_URL}"]`, + ); + const complete = () => { + if (window.wx?.miniProgram?.navigateTo) { + resolve(window.wx); + } else { + reject(new Error('wechat_js_sdk_unavailable')); + } + }; + + if (existingScript) { + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener( + 'error', + () => reject(new Error('wechat_js_sdk_load_failed')), + { once: true }, + ); + complete(); + return; + } + + const script = document.createElement('script'); + script.src = WECHAT_JS_SDK_URL; + script.async = true; + script.onload = complete; + script.onerror = () => reject(new Error('wechat_js_sdk_load_failed')); + document.head.appendChild(script); + }); +} + +function buildAbsoluteUrl(value: string) { + if (typeof window === 'undefined') { + return value; + } + + return new URL(value, window.location.origin).href; +} + +export function canUseWechatMiniProgramShareGrid() { + return isWechatMiniProgramWebViewRuntime(); +} + +export async function openWechatMiniProgramShareGridPage(params: { + imageUrl: string; + title: string; + publicWorkCode: string; +}) { + const imageUrl = params.imageUrl.trim(); + if (!imageUrl) { + return false; + } + + const wxBridge = await loadWechatMiniProgramBridge(); + const miniProgram = wxBridge.miniProgram; + if (!miniProgram?.navigateTo) { + return false; + } + + const searchParams = new URLSearchParams({ + imageUrl: buildAbsoluteUrl(imageUrl), + title: params.title.trim() || '我的作品', + publicWorkCode: params.publicWorkCode.trim(), + }); + const url = `${SHARE_GRID_PAGE_URL}?${searchParams.toString()}`; + + return await new Promise((resolve) => { + miniProgram.navigateTo?.({ + url, + success() { + resolve(true); + }, + fail() { + resolve(false); + }, + }); + }); +}