合并最新 origin/master

补合 master 最新小程序分享、开发脚本与 server-manager-panel 更新

保留外部生成 worker 分支已有改动,继续本地合并不推送
This commit is contained in:
2026-06-11 23:13:43 +08:00
48 changed files with 5640 additions and 263 deletions

View File

@@ -16,6 +16,14 @@
--- ---
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
- 背景release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。
- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias不保存服务器密钥或凭据巡检通过 `ssh <alias> sh -s` 执行只读脚本,服务操作只允许 `start``stop``restart` 并限制 systemd unit 名字符集。
- 影响范围:本地运维工具入口、`package.json``server-manager:panel`、开发运维文档和团队共享工作流。
- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml``cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml``npm run check:encoding`
- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`
## 2026-06-10 公开作品互动能力进入后台全局配置 ## 2026-06-10 公开作品互动能力进入后台全局配置
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 - 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。
@@ -32,6 +40,13 @@
- 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403``https://git.genarrative.world/` 原入口应保持 `200` - 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403``https://git.genarrative.world/` 原入口应保持 `200`
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-08 通用分享统一为作品分享卡片
- 背景:已发布作品的分享入口需要同时支持网页复制链接、下载可传播的分享卡,以及微信小程序内的九宫切图;推荐页在小程序内直接使用系统“分享到聊天”时,宿主快照只截页面中部,容易裁掉游戏主体,且原生分享默认只能拿到小程序页面启动参数。
- 决策:统一分享入口继续收口到 `PublishShareModal`,分享卡展示作品封面、作品类型、作品名称和公开作品号,底部提供“复制链接”和“下载卡片”。普通 H5 复制公开作品 H5 URL微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,缺少直达参数时补 `targetPath=/works/detail``work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL。当 H5 运行在微信小程序 WebView 内且存在封面图时,额外显示“九宫切图”,跳转小程序原生 `pages/share-grid/index`,由原生页按 3x3 从左到右、从上到下裁切并保存。推荐页当前作品会通过 `wx.miniProgram.postMessage` 同步给小程序原生 `web-view` 页,右上角系统分享优先使用该目标生成带作品参数的小程序路径。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。
- 影响范围:`src/components/common/PublishShareModal.tsx``src/components/common/publishShareModalModel.ts``src/components/common/publishShareCardImage.ts``src/services/wechatMiniProgramShareGrid.ts``src/services/wechatMiniProgramShareTarget.ts``miniprogram/pages/web-view/``miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。
- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts``npm run test -- miniprogram/pages/share-grid/index.test.js``npm run test -- src/index.test.ts -t "mini program recommend runtime"``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-08 微信能力按领域收口 ## 2026-06-08 微信能力按领域收口
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth``platform-wechat`,支付协议细节和业务 handler 边界不够清晰。 - 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth``platform-wechat`,支付协议细节和业务 handler 边界不够清晰。

View File

@@ -52,6 +52,8 @@ npm run dev
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start``start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效Windows 仍沿用原有端口探测与漂移逻辑。 Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start``start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效Windows 仍沿用原有端口探测与漂移逻辑。
本地 `npm run dev``npm run dev:spacetime``npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper避免损坏的本机 cache daemon 阻断 `spacetime publish``api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。
该命令会启动: 该命令会启动:
- SpacetimeDB standalone - SpacetimeDB standalone
@@ -95,6 +97,15 @@ npm run dev:web
npm run dev:admin-web npm run dev:admin-web
``` ```
本地 SSH 服务器管理面板:
```bash
npm run server-manager:panel
```
该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart <unit>` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。
面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR` `npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。 开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。

View File

@@ -1312,8 +1312,8 @@
- 现象Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)``sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 - 现象Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)``sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时Cargo 的 `sccache rustc -vV` 可能先超时。 - 原因环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时Cargo 的 `sccache rustc -vV` 可能先超时。
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc` - 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server``scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper自动绕过项目默认 sccache避免损坏的 daemon 阻断 `spacetime publish``api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。 - 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish``api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。
- 关联:`scripts/dev.mjs``jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` - 关联:`scripts/dev.mjs``jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本

View File

@@ -21,6 +21,8 @@
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。 微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。

View File

@@ -0,0 +1,82 @@
# 本地 SSH 服务器管理面板技术方案
日期:`2026-06-11`
## 背景
release / dev 等服务器的日常巡检已经有 `genarrative-health-patrol.timer``/readyz``/healthz`、SpacetimeDB `/v1/ping` 和 systemd 状态文件,但开发者本地仍需要在多个 SSH alias 之间切换命令。服务器管理面板用于把这些只读巡检和少量 systemd 服务操作收敛到一个本地桌面入口。
## 范围
- 使用 Rust `egui` / `eframe` 实现本地桌面面板,不接入线上 Web 后台,不暴露公网端口。
- 从本机 `~/.ssh/config``Host` alias 发现服务器;只展示不含通配符的 alias。
- 支持多个服务器,左侧服务器侧边栏可收起。
- 主面板展示硬件状态、服务状态、HTTP 健康探测和生产健康巡检状态。
- 支持对允许的 systemd unit 执行启动、关闭、重启。
## 命令入口
```bash
npm run server-manager:panel
```
等价于:
```bash
cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml
```
面板启动时会自动查找本机中文字体并注入 egui 字体集,优先使用 `Noto Sans CJK SC`,其次使用文泉驿 / Droid fallback。若某台开发机字体路径特殊可用 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指定;普通 `.ttf` 可省略 `|index`
## SSH 约定
本地 `~/.ssh/config` 中需要存在类似:
```sshconfig
Host dev
HostName 10.2.0.10
User genarrative
Host release
HostName genarrative.world
User genarrative
```
面板通过 `ssh <alias> sh -s` 执行远端只读巡检脚本。服务操作使用:
```bash
sudo -n systemctl <start|stop|restart> <unit>
```
若 SSH 用户是 root则直接执行 `systemctl`。非 root 用户需要提前配置只允许目标 unit 的无密码 sudo否则面板会显示 sudo 权限错误,不会弹出交互密码输入。
## 健康检查内容
只读巡检覆盖:
- 主机名、内核、运行时长、CPU 核数 / 型号、load average。
- 内存 / swap 使用情况。
- `/``/var``/opt``/stdb``/data` 中存在路径的磁盘使用率。
- `genarrative-api.service``spacetimedb.service``nginx.service``genarrative-health-patrol.timer``genarrative-database-backup.timer` 的 systemd 状态。
- `http://127.0.0.1:8082/healthz``/readyz``http://127.0.0.1:3101/v1/ping` 和代表性公开接口。
- `/var/lib/genarrative/health-patrol/status.json` 的最近巡检状态。
- 若服务器安装了 `sensors`,附带温度 / 风扇等硬件传感器摘要。
## 服务操作安全边界
面板只允许 `start``stop``restart` 三种动作,并且 unit 名必须匹配安全字符集:
```text
A-Z a-z 0-9 . _ - @ :
```
服务操作会先出现确认弹窗,避免误点。第一版默认列出 Genarrative 生产相关 unit并提供“其他 unit”输入框该输入框仍只会执行 `systemctl` 的三种受控动作,不提供任意命令执行入口。
## 状态判定
- service / HTTP 探测失败:`CRITICAL`
- 磁盘使用率 `>= 95%``CRITICAL``>= 85%``WARNING`
- 内存使用率 `>= 95%``CRITICAL``>= 85%``WARNING`
- 生产健康巡检状态沿用 `OK / WARNING / CRITICAL`
面板状态只是本地巡检视图,最终运维事实仍以服务器上的 systemd、journal、Nginx 日志、`production-health-patrol.mjs` 输出和现有部署文档为准。

View File

@@ -148,6 +148,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 - 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest推荐卡片的后台读写请求仍使用 local auth impact避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 - 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest推荐卡片的后台读写请求仍使用 local auth impact避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
- 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。 - 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的 H5 分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。微信小程序 WebView 内复制动作必须改为小程序 `pages/web-view/index` 路径并补齐 `targetPath=/works/detail``work` 参数。推荐页当前 active 作品必须通过 `wx.miniProgram.postMessage` 同步给原生 `web-view` 页,让右上角系统“转发给朋友”和“分享到朋友圈”也使用当前作品参数生成小程序短链背后的 path。微信小程序 WebView 内的推荐页运行态需要启用分享快照安全区,把游戏画面等比缩放并保持在页面中部,避免用户直接点击小程序自带“分享到聊天”时只截到游戏画面局部。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。

View File

@@ -1,6 +1,7 @@
{ {
"pages": [ "pages": [
"pages/web-view/index", "pages/web-view/index",
"pages/share-grid/index",
"pages/wechat-pay/index", "pages/wechat-pay/index",
"pages/subscribe-message/index" "pages/subscribe-message/index"
], ],

View File

@@ -0,0 +1,206 @@
/* global Page, wx */
/* eslint-disable no-console */
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = require('./index.shared');
function downloadImage(imageUrl) {
return new Promise((resolve, reject) => {
wx.downloadFile({
url: imageUrl,
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.tempFilePath);
return;
}
reject(new Error(`封面下载失败:${response.statusCode}`));
},
fail(error) {
reject(new Error(error.errMsg || '封面下载失败'));
},
});
});
}
function getImageInfo(src) {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail(error) {
reject(new Error(error.errMsg || '读取封面失败'));
},
});
});
}
function getCanvasNode(page) {
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(page)
.select('#share-grid-canvas')
.fields({ node: true, size: true })
.exec((results) => {
const canvas = results && results[0] && results[0].node;
if (canvas) {
resolve(canvas);
return;
}
reject(new Error('切图画布初始化失败'));
});
});
}
function canvasToTempFilePath(canvas, width, height) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
width,
height,
destWidth: width,
destHeight: height,
fileType: 'png',
success(response) {
resolve(response.tempFilePath);
},
fail(error) {
reject(new Error(error.errMsg || '导出切图失败'));
},
});
});
}
function saveImageToAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success() {
resolve();
},
fail(error) {
reject(new Error(error.errMsg || '保存到相册失败'));
},
});
});
}
function copyTempFileWithName(tempFilePath, fileName) {
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
return Promise.resolve(tempFilePath);
}
const targetPath = `${userDataPath}/${fileName}`;
return new Promise((resolve) => {
fileSystem.copyFile({
srcPath: tempFilePath,
destPath: targetPath,
success() {
resolve(targetPath);
},
fail() {
resolve(tempFilePath);
},
});
});
}
async function saveGridTiles(page, params, localImagePath, imageInfo) {
const canvas = await getCanvasNode(page);
const context = canvas.getContext('2d');
const image = canvas.createImage();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error('封面绘制失败'));
image.src = localImagePath;
});
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
for (const tile of plan) {
canvas.width = tile.sourceWidth;
canvas.height = tile.sourceHeight;
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
context.drawImage(
image,
tile.sourceX,
tile.sourceY,
tile.sourceWidth,
tile.sourceHeight,
0,
0,
tile.sourceWidth,
tile.sourceHeight,
);
const tempFilePath = await canvasToTempFilePath(
canvas,
tile.sourceWidth,
tile.sourceHeight,
);
const namedFilePath = await copyTempFileWithName(
tempFilePath,
buildShareGridTileFileName(params, tile.index),
);
await saveImageToAlbum(namedFilePath);
page.setData({
savedCount: tile.index + 1,
});
}
}
Page({
data: {
errorMessage: '',
loading: true,
savedCount: 0,
title: '九宫切图',
},
async onLoad(query = {}) {
const params = normalizeShareGridQuery(query);
this._shareGridParams = params;
this.setData({
errorMessage: '',
loading: true,
savedCount: 0,
title: params.title,
});
if (!params.imageUrl) {
this.setData({
errorMessage: '缺少封面图。',
loading: false,
});
return;
}
try {
const localImagePath = await downloadImage(params.imageUrl);
const imageInfo = await getImageInfo(localImagePath);
await saveGridTiles(this, params, localImagePath, imageInfo);
this.setData({
loading: false,
savedCount: 9,
});
wx.showToast({
title: '已保存',
icon: 'success',
});
} catch (error) {
console.error('[share-grid] save failed', error);
this.setData({
errorMessage:
error && error.message ? error.message : '九宫切图保存失败。',
loading: false,
});
}
},
handleBack() {
wx.navigateBack();
},
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "九宫切图"
}

View File

@@ -0,0 +1,62 @@
const GRID_SIZE = 3;
const TILE_COUNT = GRID_SIZE * GRID_SIZE;
function normalizeQueryValue(value) {
return String(value || '').trim();
}
function sanitizeFileNamePart(value) {
const normalized = normalizeQueryValue(value)
.replace(/[\\/:*?"<>|]/g, '')
.replace(/\s+/g, '-')
.slice(0, 32);
return normalized || 'taonier';
}
function buildShareGridTileFileName(params, tileIndex) {
const safeTitle = sanitizeFileNamePart(params.title || params.publicWorkCode);
const safeCode = sanitizeFileNamePart(params.publicWorkCode || 'share');
const order = String(tileIndex + 1).padStart(2, '0');
return `${safeTitle}-${safeCode}-${order}.png`;
}
function normalizeShareGridQuery(query) {
return {
imageUrl: normalizeQueryValue(query && query.imageUrl),
title: normalizeQueryValue(query && query.title) || '我的作品',
publicWorkCode: normalizeQueryValue(query && query.publicWorkCode),
};
}
function buildShareGridTilePlan(imageWidth, imageHeight) {
const tileWidth = Math.floor(imageWidth / GRID_SIZE);
const tileHeight = Math.floor(imageHeight / GRID_SIZE);
const plan = [];
for (let row = 0; row < GRID_SIZE; row += 1) {
for (let col = 0; col < GRID_SIZE; col += 1) {
const index = row * GRID_SIZE + col;
const sourceX = col * tileWidth;
const sourceY = row * tileHeight;
plan.push({
index,
row,
col,
sourceX,
sourceY,
sourceWidth: col === GRID_SIZE - 1 ? imageWidth - sourceX : tileWidth,
sourceHeight: row === GRID_SIZE - 1 ? imageHeight - sourceY : tileHeight,
});
}
}
return plan;
}
module.exports = {
GRID_SIZE,
TILE_COUNT,
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from 'vitest';
import shareGridBridge from './index.shared.js';
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = shareGridBridge;
describe('share-grid mini program bridge', () => {
test('normalizes query values and keeps a fallback title', () => {
expect(
normalizeShareGridQuery({
imageUrl: ' https://web.test/cover.png ',
publicWorkCode: ' PZ-0001 ',
}),
).toEqual({
imageUrl: 'https://web.test/cover.png',
title: '我的作品',
publicWorkCode: 'PZ-0001',
});
});
test('names tiles by title, public code and left-to-right order', () => {
const params = {
title: '星港:拼图',
publicWorkCode: 'PZ-0001',
};
expect(buildShareGridTileFileName(params, 0)).toBe(
'星港拼图-PZ-0001-01.png',
);
expect(buildShareGridTileFileName(params, 8)).toBe(
'星港拼图-PZ-0001-09.png',
);
});
test('builds a 3x3 crop plan in reading order', () => {
const plan = buildShareGridTilePlan(900, 600);
expect(plan).toHaveLength(9);
expect(plan[0]).toMatchObject({
index: 0,
row: 0,
col: 0,
sourceX: 0,
sourceY: 0,
sourceWidth: 300,
sourceHeight: 200,
});
expect(plan[4]).toMatchObject({
index: 4,
row: 1,
col: 1,
sourceX: 300,
sourceY: 200,
});
expect(plan[8]).toMatchObject({
index: 8,
row: 2,
col: 2,
sourceX: 600,
sourceY: 400,
});
});
});

View File

@@ -0,0 +1,20 @@
<view class="share-grid-page">
<view class="share-grid-card">
<view class="share-grid-title">{{title}}</view>
<view wx:if="{{loading}}" class="share-grid-text">
正在保存 {{savedCount}}/9
</view>
<view wx:elif="{{errorMessage}}" class="share-grid-text share-grid-text--danger">
{{errorMessage}}
</view>
<view wx:else class="share-grid-text">已保存 9/9</view>
<button class="share-grid-button" bindtap="handleBack">
返回
</button>
</view>
<canvas
id="share-grid-canvas"
type="2d"
class="share-grid-canvas"
></canvas>
</view>

View File

@@ -0,0 +1,60 @@
page {
background: #fffdf9;
}
.share-grid-page {
min-height: 100vh;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #fffdf9;
}
.share-grid-card {
width: 100%;
max-width: 560rpx;
box-sizing: border-box;
border: 1rpx solid rgba(127, 85, 57, 0.18);
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.92);
padding: 36rpx;
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
}
.share-grid-title {
color: #332820;
font-size: 34rpx;
font-weight: 700;
line-height: 1.35;
}
.share-grid-text {
margin-top: 18rpx;
color: rgba(51, 40, 32, 0.68);
font-size: 26rpx;
line-height: 1.55;
}
.share-grid-text--danger {
color: #b84a3d;
}
.share-grid-button {
margin-top: 28rpx;
width: 100%;
border-radius: 8rpx;
background: #7f5539;
color: #fffdf9;
font-size: 28rpx;
line-height: 2.6;
}
.share-grid-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
}

View File

@@ -10,6 +10,13 @@ const {
WEB_VIEW_ENTRY_URL, WEB_VIEW_ENTRY_URL,
WEB_VIEW_SOURCE_QUERY, WEB_VIEW_SOURCE_QUERY,
} = require('../../config'); } = require('../../config');
const {
appendHashParams,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
resolveShareTargetFromWebViewMessage,
resolveWebViewUrlFromRuntimeConfig,
} = require('./index.shared');
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
@@ -19,7 +26,6 @@ const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
const AUTH_ACTION_LOGIN = 'login'; const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120; const PAY_RESULT_RECHECK_DELAY_MS = 120;
const WEB_VIEW_SHARE_TITLE = '陶泥儿'; const WEB_VIEW_SHARE_TITLE = '陶泥儿';
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
function showWebViewShareMenu() { function showWebViewShareMenu() {
if (typeof wx.showShareMenu !== 'function') { if (typeof wx.showShareMenu !== 'function') {
@@ -32,17 +38,25 @@ function showWebViewShareMenu() {
}); });
} }
function buildWebViewShareAppMessage() { function resolveNativeShareQuery(page) {
return (
(page && page._currentShareTarget) ||
(page && page._lastLaunchQuery) ||
{}
);
}
function buildWebViewShareAppMessage(query = {}) {
return { return {
title: WEB_VIEW_SHARE_TITLE, title: WEB_VIEW_SHARE_TITLE,
path: WEB_VIEW_SHARE_PATH, path: buildWebViewSharePath(query),
}; };
} }
function buildWebViewShareTimeline() { function buildWebViewShareTimeline(query = {}) {
return { return {
title: WEB_VIEW_SHARE_TITLE, title: WEB_VIEW_SHARE_TITLE,
query: '', query: buildWebViewShareTimelineQuery(query),
}; };
} }
@@ -59,50 +73,6 @@ function isConfiguredApiBaseUrl(value) {
return /^https:\/\/[^/]+/i.test(String(value || '').trim()); return /^https:\/\/[^/]+/i.test(String(value || '').trim());
} }
function appendQuery(url, query) {
const pairs = Object.keys(query)
.filter((key) => query[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
);
if (pairs.length === 0) {
return url;
}
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
}
function appendHashParams(url, params) {
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
const pairs = Object.keys(params)
.filter((key) => params[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
);
if (pairs.length === 0) {
return url;
}
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const keptHashParts = rawHash.split('&').filter((part) => {
if (!part) {
return false;
}
const [rawKey = ''] = part.split('=');
try {
return !nextKeys.has(decodeURIComponent(rawKey));
} catch (_error) {
return !nextKeys.has(rawKey);
}
});
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
}
function parseBooleanQueryFlag(value) { function parseBooleanQueryFlag(value) {
return value === true || value === '1' || value === 'true' || value === 'yes'; return value === true || value === '1' || value === 'true' || value === 'yes';
} }
@@ -233,22 +203,16 @@ function shouldReturnToPreviousPage(query) {
return String((query && query.returnTo) || '').trim() === 'previous'; return String((query && query.returnTo) || '').trim() === 'previous';
} }
function resolveWebViewUrl(authResult) { function resolveWebViewUrl(authResult, launchQuery = {}) {
const runtimeConfig = resolveMiniProgramRuntimeConfig(); const runtimeConfig = resolveMiniProgramRuntimeConfig();
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
if (!isConfiguredEntryUrl(entryUrl)) { if (!isConfiguredEntryUrl(entryUrl)) {
return ''; return '';
} }
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery); return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
if (!authResult || !authResult.token) { ...runtimeConfig,
return sourcedUrl; webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
}
return appendHashParams(sourcedUrl, {
auth_provider: 'wechat',
auth_token: authResult.token,
auth_binding_status: authResult.bindingStatus,
}); });
} }
@@ -467,7 +431,7 @@ Page({
loading: false, loading: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage: false, returnToPreviousPage: false,
webViewUrl: resolveWebViewUrl(null), webViewUrl: resolveWebViewUrl(null, query),
}); });
return; return;
} }
@@ -572,7 +536,7 @@ Page({
nicknameRequired: false, nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
returnToPreviousPage, returnToPreviousPage,
webViewUrl: resolveWebViewUrl(authResult), webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
}); });
} catch (error) { } catch (error) {
this.setData({ this.setData({
@@ -600,7 +564,7 @@ Page({
loading: false, loading: false,
nicknameRequired: false, nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult), webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
}); });
} }
@@ -674,7 +638,10 @@ Page({
loading: false, loading: false,
nicknameRequired: false, nicknameRequired: false,
phoneBindingRequired: false, phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(nextAuthResult), webViewUrl: resolveWebViewUrl(
nextAuthResult,
this._lastLaunchQuery || {},
),
}); });
} catch (error) { } catch (error) {
this.setData({ this.setData({
@@ -712,15 +679,19 @@ Page({
}, },
handleWebViewMessage(event) { handleWebViewMessage(event) {
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
if (shareTarget) {
this._currentShareTarget = shareTarget;
}
// 中文注释:支付和订阅消息都由独立 native 页面承接web-view 消息只保留调试输出。 // 中文注释:支付和订阅消息都由独立 native 页面承接web-view 消息只保留调试输出。
console.info('[web-view] message', event.detail); console.info('[web-view] message', event.detail);
}, },
onShareAppMessage() { onShareAppMessage() {
return buildWebViewShareAppMessage(); return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
}, },
onShareTimeline() { onShareTimeline() {
return buildWebViewShareTimeline(); return buildWebViewShareTimeline(resolveNativeShareQuery(this));
}, },
}); });

View File

@@ -0,0 +1,188 @@
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
function trimTrailingSlash(value) {
return String(value || '').trim().replace(/\/+$/u, '');
}
function appendQuery(url, query) {
const rawUrl = String(url || '');
const pairs = Object.keys(query)
.filter((key) => query[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
);
if (pairs.length === 0) {
return rawUrl;
}
const hashIndex = rawUrl.indexOf('#');
const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : '';
return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`;
}
function appendHashParams(url, params) {
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
const pairs = Object.keys(params)
.filter((key) => params[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
);
if (pairs.length === 0) {
return url;
}
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const keptHashParts = rawHash.split('&').filter((part) => {
if (!part) {
return false;
}
const [rawKey = ''] = part.split('=');
try {
return !nextKeys.has(decodeURIComponent(rawKey));
} catch (_error) {
return !nextKeys.has(rawKey);
}
});
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
}
function normalizeTargetPath(value) {
const trimmed = String(value || '').trim();
if (!trimmed.startsWith('/')) {
return '';
}
const normalized = trimmed.replace(/\/+$/u, '') || '/';
return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : '';
}
function resolveLaunchTargetQuery(query) {
const targetPath = normalizeTargetPath(query && query.targetPath);
const work = String((query && query.work) || '').trim();
if (!targetPath || !work) {
return {};
}
return {
targetPath,
work,
};
}
function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) {
const launchTarget = resolveLaunchTargetQuery(query);
if (!launchTarget.targetPath) {
return basePath;
}
return appendQuery(basePath, {
targetPath: launchTarget.targetPath,
work: launchTarget.work,
});
}
function buildWebViewShareTimelineQuery(query = {}) {
const launchTarget = resolveLaunchTargetQuery(query);
if (!launchTarget.targetPath) {
return '';
}
return new URLSearchParams({
targetPath: launchTarget.targetPath,
work: launchTarget.work,
}).toString();
}
function normalizeShareTargetMessageData(value) {
const message = value && value.data ? value.data : value;
if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) {
return null;
}
const payload = message.payload || {};
const launchTarget = resolveLaunchTargetQuery(payload);
if (!launchTarget.targetPath) {
return null;
}
return {
...launchTarget,
title: String(payload.title || '').trim(),
};
}
function resolveShareTargetFromWebViewMessage(detail) {
const dataList = detail && Array.isArray(detail.data) ? detail.data : [];
for (let index = dataList.length - 1; index >= 0; index -= 1) {
const target = normalizeShareTargetMessageData(dataList[index]);
if (target) {
return target;
}
}
return normalizeShareTargetMessageData(detail);
}
function appendLaunchTargetToEntryUrl(entryUrl, query) {
const launchTarget = resolveLaunchTargetQuery(query);
if (!launchTarget.targetPath) {
return entryUrl;
}
const rawEntryUrl = String(entryUrl || '').trim();
const hashIndex = rawEntryUrl.indexOf('#');
const entryWithoutHash =
hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl;
const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : '';
const queryIndex = entryWithoutHash.indexOf('?');
const entryBase =
queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash;
const entrySearch =
queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : '';
const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`;
return appendQuery(targetUrl, {
work: launchTarget.work,
});
}
function resolveWebViewUrlFromRuntimeConfig(
authResult,
launchQuery = {},
runtimeConfig = {},
) {
const entryUrl = appendLaunchTargetToEntryUrl(
String(runtimeConfig.webViewEntryUrl || '').trim(),
launchQuery,
);
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {});
if (!authResult || !authResult.token) {
return sourcedUrl;
}
return appendHashParams(sourcedUrl, {
auth_provider: 'wechat',
auth_token: authResult.token,
auth_binding_status: authResult.bindingStatus,
});
}
module.exports = {
appendHashParams,
appendLaunchTargetToEntryUrl,
appendQuery,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
normalizeTargetPath,
resolveShareTargetFromWebViewMessage,
resolveLaunchTargetQuery,
resolveWebViewUrlFromRuntimeConfig,
};

View File

@@ -0,0 +1,110 @@
import { describe, expect, test } from 'vitest';
import webViewBridge from './index.shared.js';
const {
appendLaunchTargetToEntryUrl,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
resolveShareTargetFromWebViewMessage,
resolveWebViewUrlFromRuntimeConfig,
} = webViewBridge;
const runtimeConfig = {
sourceQuery: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
webViewEntryUrl: 'https://www.genarrative.world',
};
describe('mini program web-view launch target', () => {
test('opens the H5 public work detail when launch query carries work params', () => {
expect(
appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', {
targetPath: '/works/detail',
work: 'BB-12345678',
}),
).toBe(
'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678',
);
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
null,
{
targetPath: '/works/detail',
work: 'BB-12345678',
},
runtimeConfig,
);
const url = new URL(webViewUrl);
expect(url.pathname).toBe('/works/detail');
expect(url.searchParams.get('work')).toBe('BB-12345678');
expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program');
});
test('ignores unsupported launch target paths', () => {
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
null,
{
targetPath: '/admin',
work: 'BB-12345678',
},
runtimeConfig,
);
const url = new URL(webViewUrl);
expect(url.pathname).toBe('/');
expect(url.searchParams.get('work')).toBeNull();
});
test('keeps public work params in native mini program share paths', () => {
const sharePath = buildWebViewSharePath({
targetPath: '/works/detail',
work: 'BB-12345678',
});
const url = new URL(sharePath, 'https://mini.test');
expect(url.pathname).toBe('/pages/web-view/index');
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
expect(url.searchParams.get('work')).toBe('BB-12345678');
expect(
buildWebViewShareTimelineQuery({
targetPath: '/works/detail',
work: 'BB-12345678',
}),
).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678');
});
test('reads the latest H5 recommended work share target from web-view messages', () => {
expect(
resolveShareTargetFromWebViewMessage({
data: [
{
data: {
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'PZ-0001',
title: '旧作品',
},
},
},
{
data: {
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
},
},
},
],
}),
).toEqual({
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
});
});
});

View File

@@ -9,6 +9,7 @@
"dev:api-server": "node scripts/dev.mjs api-server", "dev:api-server": "node scripts/dev.mjs api-server",
"dev:web": "node scripts/dev.mjs web", "dev:web": "node scripts/dev.mjs web",
"dev:admin-web": "node scripts/dev.mjs admin-web", "dev:admin-web": "node scripts/dev.mjs admin-web",
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"otel:debug": "node scripts/run-otelcol.mjs debug", "otel:debug": "node scripts/run-otelcol.mjs debug",
"otel:rider": "node scripts/run-otelcol.mjs rider", "otel:rider": "node scripts/run-otelcol.mjs rider",

View File

@@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module'); const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs'); const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web'); const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web']; const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SERVICE_ALIASES = new Map([ const SERVICE_ALIASES = new Map([
@@ -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() { function readWorkspaceSpacetimeVersion() {
const manifestText = readFileSync(manifestPath, 'utf8'); const manifestText = readFileSync(manifestPath, 'utf8');
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec( const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
@@ -776,7 +810,7 @@ class DevRunner {
this.writeDevStackState(); this.writeDevStackState();
} }
async prepareLinuxPortRange(command) { async prepareLinuxPortRange() {
if (process.platform !== 'linux') { if (process.platform !== 'linux') {
return; return;
} }
@@ -1228,7 +1262,7 @@ class DevRunner {
} }
async publishSpacetimeModule() { async publishSpacetimeModule() {
const env = {...this.baseEnv}; const env = buildLocalRustProcessEnv(this.baseEnv);
this.prepareMigrationBootstrapSecret(env); this.prepareMigrationBootstrapSecret(env);
const args = buildSpacetimePublishArgs({ const args = buildSpacetimePublishArgs({
@@ -1291,7 +1325,7 @@ class DevRunner {
await this.ensureApiServerSpacetimeToken(); await this.ensureApiServerSpacetimeToken();
const mergedEnv = buildApiServerProcessEnv({ const mergedEnv = buildApiServerProcessEnv({
baseEnv: this.baseEnv, baseEnv: buildLocalRustProcessEnv(this.baseEnv),
options: this.options, options: this.options,
state: this.state, state: this.state,
}); });
@@ -2124,19 +2158,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
} }
export { export {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace, assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv, buildApiServerProcessEnv,
buildDevStackSnapshot, buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs, buildSpacetimePublishArgs,
createDevServerSpawnOptions, createDevServerSpawnOptions,
createWatchConfigs, createWatchConfigs,
isSpacetimePublishPermissionError, DevRunner,
isDirectModuleExecution, isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement, normalizeCargoVersionRequirement,
parseSpacetimeToolVersion,
parseArgs, parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath, resolveDevStackStatePath,
shouldAcceptWatchEvent, shouldAcceptWatchEvent,
}; };

View File

@@ -5,19 +5,20 @@ import {join} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest'; import {afterEach, describe, expect, test, vi} from 'vitest';
import { import {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace, assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv, buildApiServerProcessEnv,
buildDevStackSnapshot, buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs, buildSpacetimePublishArgs,
createDevServerSpawnOptions, createDevServerSpawnOptions,
createWatchConfigs, createWatchConfigs,
DevRunner,
isDirectModuleExecution, isDirectModuleExecution,
isSpacetimePublishPermissionError, isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement, normalizeCargoVersionRequirement,
parseSpacetimeToolVersion,
parseArgs, parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath, resolveDevStackStatePath,
shouldAcceptWatchEvent, shouldAcceptWatchEvent,
} from './dev.mjs'; } 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', () => { describe('dev scheduler stack state file', () => {
test('状态文件路径固定在根目录 .app/dev-stack.json', () => { test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe( expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(

1779
server-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ members = [
"crates/platform-wechat", "crates/platform-wechat",
"crates/platform-speech", "crates/platform-speech",
"crates/platform-agent", "crates/platform-agent",
"crates/server-manager-panel",
"crates/shared-contracts", "crates/shared-contracts",
"crates/shared-kernel", "crates/shared-kernel",
"crates/shared-logging", "crates/shared-logging",

View File

@@ -0,0 +1,13 @@
[package]
name = "server-manager-panel"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
eframe = { version = "0.33", default-features = false, features = [
"default_fonts",
"glow",
"wayland",
"x11",
] }

View File

@@ -0,0 +1,577 @@
use eframe::egui;
use crate::health::{
DiskSnapshot, HealthLevel, MemorySnapshot, ProbeSnapshot, ServerHealthReport, ServiceSnapshot,
};
use crate::remote::{
RemoteEvent, RemoteReceiver, RemoteSender, ServiceAction, channel, spawn_health_check,
spawn_service_action,
};
use crate::ssh_config::{SshAlias, discover_ssh_aliases};
const DEFAULT_MANAGED_SERVICES: &[&str] = &[
"genarrative-api.service",
"spacetimedb.service",
"nginx.service",
"genarrative-health-patrol.timer",
"genarrative-database-backup.timer",
];
#[derive(Debug)]
pub struct ServerManagerApp {
servers: Vec<ServerState>,
selected_alias: Option<String>,
sidebar_collapsed: bool,
tx: RemoteSender,
rx: RemoteReceiver,
pending_confirmation: Option<ServiceConfirmation>,
custom_service_name: String,
}
impl Default for ServerManagerApp {
fn default() -> Self {
let (tx, rx) = channel();
let aliases = discover_ssh_aliases();
let selected_alias = aliases.first().map(|alias| alias.name.clone());
Self {
servers: aliases.into_iter().map(ServerState::new).collect(),
selected_alias,
sidebar_collapsed: false,
tx,
rx,
pending_confirmation: None,
custom_service_name: String::new(),
}
}
}
impl eframe::App for ServerManagerApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.drain_remote_events(ctx);
self.render_confirm_dialog(ctx);
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
ui.horizontal(|ui| {
if ui
.button(if self.sidebar_collapsed {
"展开侧栏"
} else {
"收起侧栏"
})
.clicked()
{
self.sidebar_collapsed = !self.sidebar_collapsed;
}
if ui.button("重新读取 SSH alias").clicked() {
self.reload_aliases();
}
if let Some(alias) = self.selected_alias.clone() {
if ui.button("刷新当前服务器").clicked() {
self.refresh_server(&alias);
}
}
ui.separator();
ui.label("本地 SSH alias 管理");
});
});
if !self.sidebar_collapsed {
egui::SidePanel::left("server_sidebar")
.resizable(true)
.default_width(260.0)
.width_range(180.0..=360.0)
.show(ctx, |ui| self.render_sidebar(ui));
}
egui::CentralPanel::default().show(ctx, |ui| {
if self.servers.is_empty() {
self.render_empty_state(ui);
return;
}
let Some(alias) = self.selected_alias.clone() else {
self.render_empty_state(ui);
return;
};
if let Some(index) = self.server_index(&alias) {
self.render_server_detail(ui, index);
} else {
ui.label("请选择服务器");
}
});
}
}
impl ServerManagerApp {
fn drain_remote_events(&mut self, ctx: &egui::Context) {
while let Ok(event) = self.rx.try_recv() {
match event {
RemoteEvent::Health { alias, result } => {
if let Some(server) = self.server_mut(&alias) {
server.loading = false;
match result {
Ok(report) => {
server.error = None;
server.report = Some(report);
}
Err(error) => {
server.error = Some(error);
}
}
}
}
RemoteEvent::ServiceAction {
alias,
service,
action,
result,
} => {
if let Some(server) = self.server_mut(&alias) {
server.action_in_progress = None;
server.action_log = Some(format!(
"{} {}: {}\n{}{}",
action.label(),
service,
result.summary,
result.stdout,
result.stderr
));
server.loading = true;
spawn_health_check(alias, self.tx.clone());
}
}
}
ctx.request_repaint();
}
}
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
ui.heading("服务器");
ui.add_space(8.0);
let mut refresh_alias: Option<String> = None;
for server in &mut self.servers {
let selected = self.selected_alias.as_deref() == Some(server.alias.name.as_str());
let response = ui.selectable_label(selected, server_label(server));
if response.clicked() {
self.selected_alias = Some(server.alias.name.clone());
}
response.on_hover_text(server.alias.source.display().to_string());
ui.horizontal(|ui| {
let status = server.status();
ui.colored_label(level_color(status), status.label());
if server.loading {
ui.spinner();
}
if ui.small_button("刷新").clicked() {
refresh_alias = Some(server.alias.name.clone());
}
});
ui.add_space(6.0);
}
if let Some(alias) = refresh_alias {
self.refresh_server(&alias);
}
}
fn render_empty_state(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.heading("未发现 SSH alias");
ui.label("请在 ~/.ssh/config 中配置 Host alias 后重新读取。");
if ui.button("重新读取").clicked() {
self.reload_aliases();
}
});
}
fn render_server_detail(&mut self, ui: &mut egui::Ui, index: usize) {
let alias = self.servers[index].alias.name.clone();
let status = self.servers[index].status();
let loading = self.servers[index].loading;
let report = self.servers[index].report.clone();
let error = self.servers[index].error.clone();
let action_log = self.servers[index].action_log.clone();
ui.horizontal(|ui| {
ui.heading(&alias);
ui.colored_label(level_color(status), status.label());
if loading {
ui.spinner();
}
});
ui.add_space(8.0);
if let Some(error) = error {
ui.colored_label(warning_color(), format!("SSH 巡检失败:{error}"));
ui.add_space(8.0);
}
if let Some(report) = report {
self.render_report(ui, &alias, &report);
} else {
ui.label("尚未执行巡检。");
}
ui.add_space(12.0);
self.render_service_controls(ui, &alias, index);
if let Some(log) = action_log {
ui.add_space(12.0);
egui::CollapsingHeader::new("最近一次服务操作输出")
.default_open(true)
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut log.clone())
.font(egui::TextStyle::Monospace)
.desired_rows(8)
.interactive(false),
);
});
}
}
fn render_report(&self, ui: &mut egui::Ui, alias: &str, report: &ServerHealthReport) {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
info_chip(ui, "主机", value_or_dash(&report.host.hostname));
info_chip(ui, "内核", value_or_dash(&report.host.kernel));
info_chip(ui, "运行时间", value_or_dash(&report.host.uptime));
info_chip(ui, "检查时间", value_or_dash(&report.checked_at));
});
ui.add_space(10.0);
egui::CollapsingHeader::new("硬件状态")
.default_open(true)
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
info_chip(ui, "CPU", value_or_dash(&report.hardware.cpu_model));
info_chip(ui, "核心", value_or_dash(&report.hardware.cpu_cores));
info_chip(ui, "负载", value_or_dash(&report.hardware.load_average));
});
ui.add_space(6.0);
memory_row(ui, "内存", &report.hardware.memory);
memory_row(ui, "Swap", &report.hardware.swap);
ui.add_space(6.0);
for disk in &report.hardware.disks {
disk_row(ui, disk);
}
ui.add_space(6.0);
for sensor in &report.hardware.sensors {
ui.label(sensor);
}
});
egui::CollapsingHeader::new("服务状态")
.default_open(true)
.show(ui, |ui| {
egui::Grid::new(format!("{alias}_services"))
.striped(true)
.show(ui, |ui| {
ui.strong("服务");
ui.strong("状态");
ui.strong("子状态");
ui.strong("Unit");
ui.end_row();
for service in &report.services {
service_row(ui, service);
}
});
});
egui::CollapsingHeader::new("HTTP 探测")
.default_open(true)
.show(ui, |ui| {
egui::Grid::new(format!("{alias}_probes"))
.striped(true)
.show(ui, |ui| {
ui.strong("探测");
ui.strong("状态码");
ui.strong("耗时");
ui.strong("目标");
ui.end_row();
for probe in &report.probes {
probe_row(ui, probe);
}
});
});
if let Some(patrol) = &report.health_patrol {
egui::CollapsingHeader::new("生产健康巡检")
.default_open(true)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.colored_label(level_color(patrol.level), &patrol.status);
ui.label(value_or_dash(&patrol.checked_at));
ui.label(value_or_dash(&patrol.summary));
});
});
}
egui::CollapsingHeader::new("原始巡检输出").show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut report.raw_output.clone())
.font(egui::TextStyle::Monospace)
.desired_rows(12)
.interactive(false),
);
});
});
}
fn render_service_controls(&mut self, ui: &mut egui::Ui, alias: &str, index: usize) {
ui.heading("服务控制");
ui.add_space(4.0);
let action_in_progress = self.servers[index].action_in_progress.clone();
for service in DEFAULT_MANAGED_SERVICES {
ui.horizontal(|ui| {
ui.label(*service);
for action in [
ServiceAction::Start,
ServiceAction::Stop,
ServiceAction::Restart,
] {
let disabled = action_in_progress.is_some();
if ui
.add_enabled(!disabled, egui::Button::new(action.label()))
.clicked()
{
self.pending_confirmation = Some(ServiceConfirmation {
alias: alias.to_owned(),
service: (*service).to_owned(),
action,
});
}
}
});
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("其他 unit");
ui.text_edit_singleline(&mut self.custom_service_name);
if ui.button("启动").clicked() {
self.confirm_custom_service(alias, ServiceAction::Start);
}
if ui.button("关闭").clicked() {
self.confirm_custom_service(alias, ServiceAction::Stop);
}
if ui.button("重启").clicked() {
self.confirm_custom_service(alias, ServiceAction::Restart);
}
});
if let Some(action) = action_in_progress {
ui.label(format!("正在执行:{action}"));
}
}
fn render_confirm_dialog(&mut self, ctx: &egui::Context) {
let Some(confirmation) = self.pending_confirmation.clone() else {
return;
};
egui::Window::new("确认服务操作")
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label(format!(
"确认在 {}{} {}",
confirmation.alias,
confirmation.action.label(),
confirmation.service
));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("确认").clicked() {
self.execute_service_action(&confirmation);
self.pending_confirmation = None;
}
if ui.button("取消").clicked() {
self.pending_confirmation = None;
}
});
});
}
fn reload_aliases(&mut self) {
let aliases = discover_ssh_aliases();
self.servers = aliases.into_iter().map(ServerState::new).collect();
self.selected_alias = self.servers.first().map(|server| server.alias.name.clone());
}
fn refresh_server(&mut self, alias: &str) {
if let Some(server) = self.server_mut(alias) {
server.loading = true;
server.error = None;
}
spawn_health_check(alias.to_owned(), self.tx.clone());
}
fn confirm_custom_service(&mut self, alias: &str, action: ServiceAction) {
let service = self.custom_service_name.trim();
if service.is_empty() {
return;
}
self.pending_confirmation = Some(ServiceConfirmation {
alias: alias.to_owned(),
service: service.to_owned(),
action,
});
}
fn execute_service_action(&mut self, confirmation: &ServiceConfirmation) {
if let Some(server) = self.server_mut(&confirmation.alias) {
server.action_in_progress = Some(format!(
"{} {}",
confirmation.action.label(),
confirmation.service
));
server.action_log = None;
}
spawn_service_action(
confirmation.alias.clone(),
confirmation.service.clone(),
confirmation.action,
self.tx.clone(),
);
}
fn server_index(&self, alias: &str) -> Option<usize> {
self.servers
.iter()
.position(|server| server.alias.name == alias)
}
fn server_mut(&mut self, alias: &str) -> Option<&mut ServerState> {
self.servers
.iter_mut()
.find(|server| server.alias.name == alias)
}
}
#[derive(Debug, Clone)]
struct ServiceConfirmation {
alias: String,
service: String,
action: ServiceAction,
}
#[derive(Debug)]
struct ServerState {
alias: SshAlias,
report: Option<ServerHealthReport>,
loading: bool,
error: Option<String>,
action_in_progress: Option<String>,
action_log: Option<String>,
}
impl ServerState {
fn new(alias: SshAlias) -> Self {
Self {
alias,
report: None,
loading: false,
error: None,
action_in_progress: None,
action_log: None,
}
}
fn status(&self) -> HealthLevel {
if self.error.is_some() {
HealthLevel::Critical
} else {
self.report
.as_ref()
.map(|report| report.status)
.unwrap_or(HealthLevel::Unknown)
}
}
}
fn server_label(server: &ServerState) -> String {
let prefix = match server.status() {
HealthLevel::Ok => "[OK]",
HealthLevel::Warning => "[!]",
HealthLevel::Critical => "[X]",
HealthLevel::Unknown => "[?]",
};
format!("{prefix} {}", server.alias.name)
}
fn service_row(ui: &mut egui::Ui, service: &ServiceSnapshot) {
ui.label(&service.name);
ui.colored_label(level_color(service.level), &service.active);
ui.label(&service.sub);
ui.label(&service.unit_file);
ui.end_row();
}
fn probe_row(ui: &mut egui::Ui, probe: &ProbeSnapshot) {
ui.label(&probe.name);
ui.colored_label(level_color(probe.level), &probe.http_code);
ui.label(
probe
.elapsed_ms
.map(|elapsed| format!("{elapsed}ms"))
.unwrap_or_else(|| "-".to_owned()),
);
ui.label(&probe.target);
ui.end_row();
}
fn memory_row(ui: &mut egui::Ui, label: &str, memory: &MemorySnapshot) {
let percent = memory.used_percent.unwrap_or_default();
ui.horizontal(|ui| {
ui.label(label);
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
ui.label(format!(
"已用 {} / 总计 {},可用 {}",
value_or_dash(&memory.used),
value_or_dash(&memory.total),
value_or_dash(&memory.available)
));
});
}
fn disk_row(ui: &mut egui::Ui, disk: &DiskSnapshot) {
let percent = disk.used_percent.unwrap_or_default();
ui.horizontal(|ui| {
ui.label(&disk.mount);
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
ui.label(format!(
"{} 已用 {} / {},可用 {}",
disk.filesystem, disk.used, disk.size, disk.available
));
});
}
fn info_chip(ui: &mut egui::Ui, label: &str, value: &str) {
ui.group(|ui| {
ui.vertical(|ui| {
ui.small(label);
ui.label(value);
});
});
}
fn value_or_dash(value: &str) -> &str {
if value.trim().is_empty() { "-" } else { value }
}
fn level_color(level: HealthLevel) -> egui::Color32 {
match level {
HealthLevel::Ok => egui::Color32::from_rgb(38, 166, 91),
HealthLevel::Warning => egui::Color32::from_rgb(214, 137, 16),
HealthLevel::Critical => egui::Color32::from_rgb(205, 66, 70),
HealthLevel::Unknown => egui::Color32::from_rgb(120, 126, 136),
}
}
fn warning_color() -> egui::Color32 {
egui::Color32::from_rgb(205, 66, 70)
}

View File

@@ -0,0 +1,128 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use eframe::egui::{FontData, FontDefinitions, FontFamily};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CjkFontCandidate {
pub path: PathBuf,
pub index: u32,
}
pub fn install_cjk_font(ctx: &eframe::egui::Context) -> Option<CjkFontCandidate> {
let candidate = find_cjk_font_candidate()?;
let bytes = std::fs::read(&candidate.path).ok()?;
let mut font_data = FontData::from_owned(bytes);
font_data.index = candidate.index;
let mut definitions = FontDefinitions::default();
definitions
.font_data
.insert("genarrative-cjk".to_owned(), Arc::new(font_data));
// 中文注释:作为 fallback 注入,保留 egui 默认拉丁/图标字体,同时补齐中文 glyph。
for family in [FontFamily::Proportional, FontFamily::Monospace] {
definitions
.families
.entry(family)
.or_default()
.push("genarrative-cjk".to_owned());
}
ctx.set_fonts(definitions);
Some(candidate)
}
pub fn find_cjk_font_candidate() -> Option<CjkFontCandidate> {
if let Ok(path) = std::env::var("GENARRATIVE_SERVER_PANEL_CJK_FONT") {
if let Some(candidate) = parse_font_spec(&path) {
return Some(candidate);
}
}
const KNOWN_PATHS: &[(&str, u32)] = &[
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2),
("/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc", 2),
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0),
("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0),
(
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
0,
),
(
"/home/dsk/.local/share/fonts/genarrative-cjk/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
0,
),
];
for (path, index) in KNOWN_PATHS {
if Path::new(path).is_file() {
return Some(CjkFontCandidate {
path: PathBuf::from(path),
index: *index,
});
}
}
for family in [
"Noto Sans CJK SC",
"WenQuanYi Zen Hei",
"Droid Sans Fallback",
] {
if let Some(candidate) = find_with_fc_match(family) {
return Some(candidate);
}
}
None
}
fn parse_font_spec(raw: &str) -> Option<CjkFontCandidate> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let (path, index) = trimmed
.rsplit_once('|')
.and_then(|(path, index)| Some((path, index.parse().ok()?)))
.unwrap_or((trimmed, 0));
let path = PathBuf::from(path);
path.is_file().then_some(CjkFontCandidate { path, index })
}
fn find_with_fc_match(family: &str) -> Option<CjkFontCandidate> {
let output = Command::new("fc-match")
.arg("-f")
.arg("%{file}|%{index}\n")
.arg(family)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().find_map(parse_font_spec)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_font_path_with_index() {
let candidate = parse_font_spec("/tmp/missing-font.ttc|2");
assert_eq!(candidate, None);
}
#[test]
fn finds_existing_system_cjk_font() {
let candidate = find_cjk_font_candidate();
assert!(
candidate
.as_ref()
.is_some_and(|candidate| candidate.path.is_file()),
"expected at least one CJK font on this development host"
);
}
}

View File

@@ -0,0 +1,474 @@
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum HealthLevel {
Unknown,
Ok,
Warning,
Critical,
}
impl HealthLevel {
pub fn label(self) -> &'static str {
match self {
HealthLevel::Unknown => "未知",
HealthLevel::Ok => "正常",
HealthLevel::Warning => "警告",
HealthLevel::Critical => "异常",
}
}
pub fn rank(self) -> u8 {
match self {
HealthLevel::Unknown => 1,
HealthLevel::Ok => 0,
HealthLevel::Warning => 2,
HealthLevel::Critical => 3,
}
}
}
#[derive(Debug, Clone)]
pub struct ServerHealthReport {
pub status: HealthLevel,
pub checked_at: String,
pub host: HostSnapshot,
pub hardware: HardwareSnapshot,
pub services: Vec<ServiceSnapshot>,
pub probes: Vec<ProbeSnapshot>,
pub health_patrol: Option<HealthPatrolSnapshot>,
pub raw_output: String,
}
#[derive(Debug, Clone, Default)]
pub struct HostSnapshot {
pub hostname: String,
pub kernel: String,
pub uptime: String,
}
#[derive(Debug, Clone, Default)]
pub struct HardwareSnapshot {
pub cpu_model: String,
pub cpu_cores: String,
pub load_average: String,
pub memory: MemorySnapshot,
pub swap: MemorySnapshot,
pub disks: Vec<DiskSnapshot>,
pub sensors: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct MemorySnapshot {
pub total: String,
pub used: String,
pub free: String,
pub available: String,
pub used_percent: Option<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct DiskSnapshot {
pub mount: String,
pub filesystem: String,
pub size: String,
pub used: String,
pub available: String,
pub used_percent: Option<u8>,
}
#[derive(Debug, Clone)]
pub struct ServiceSnapshot {
pub name: String,
pub active: String,
pub sub: String,
pub unit_file: String,
pub level: HealthLevel,
}
#[derive(Debug, Clone)]
pub struct ProbeSnapshot {
pub name: String,
pub target: String,
pub http_code: String,
pub elapsed_ms: Option<u64>,
pub level: HealthLevel,
}
#[derive(Debug, Clone)]
pub struct HealthPatrolSnapshot {
pub status: String,
pub checked_at: String,
pub summary: String,
pub level: HealthLevel,
}
pub fn parse_health_report(raw_output: &str) -> ServerHealthReport {
let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut current = String::new();
for line in raw_output.lines() {
if let Some(name) = parse_section_marker(line) {
current = name.to_owned();
sections.entry(current.clone()).or_default();
} else if !current.is_empty() {
sections
.entry(current.clone())
.or_default()
.push(line.to_owned());
}
}
let mut report = ServerHealthReport {
status: HealthLevel::Unknown,
checked_at: section_value(&sections, "checked_at").unwrap_or_default(),
host: parse_host(&sections),
hardware: parse_hardware(&sections),
services: parse_services(&sections),
probes: parse_probes(&sections),
health_patrol: parse_health_patrol(&sections),
raw_output: raw_output.to_owned(),
};
report.status = summarize_report(&report);
report
}
pub fn summarize_report(report: &ServerHealthReport) -> HealthLevel {
let mut status = HealthLevel::Ok;
for level in report
.services
.iter()
.map(|service| service.level)
.chain(report.probes.iter().map(|probe| probe.level))
.chain(report.health_patrol.iter().map(|patrol| patrol.level))
{
if level.rank() > status.rank() {
status = level;
}
}
if let Some(used_percent) = report.hardware.memory.used_percent {
let memory_level = if used_percent >= 95 {
HealthLevel::Critical
} else if used_percent >= 85 {
HealthLevel::Warning
} else {
HealthLevel::Ok
};
if memory_level.rank() > status.rank() {
status = memory_level;
}
}
for disk in &report.hardware.disks {
let disk_level = match disk.used_percent {
Some(percent) if percent >= 95 => HealthLevel::Critical,
Some(percent) if percent >= 85 => HealthLevel::Warning,
_ => HealthLevel::Ok,
};
if disk_level.rank() > status.rank() {
status = disk_level;
}
}
status
}
fn parse_section_marker(line: &str) -> Option<&str> {
line.strip_prefix("==GENARRATIVE_PANEL:")
.and_then(|rest| rest.strip_suffix("=="))
}
fn section_value(sections: &BTreeMap<String, Vec<String>>, name: &str) -> Option<String> {
sections.get(name).and_then(|lines| {
lines
.iter()
.map(|line| line.trim())
.find(|line| !line.is_empty())
.map(str::to_owned)
})
}
fn parse_host(sections: &BTreeMap<String, Vec<String>>) -> HostSnapshot {
HostSnapshot {
hostname: section_value(sections, "hostname").unwrap_or_default(),
kernel: section_value(sections, "kernel").unwrap_or_default(),
uptime: section_value(sections, "uptime").unwrap_or_default(),
}
}
fn parse_hardware(sections: &BTreeMap<String, Vec<String>>) -> HardwareSnapshot {
HardwareSnapshot {
cpu_model: section_value(sections, "cpu_model").unwrap_or_default(),
cpu_cores: section_value(sections, "cpu_cores").unwrap_or_default(),
load_average: section_value(sections, "load_average").unwrap_or_default(),
memory: parse_memory(section_value(sections, "memory").as_deref()),
swap: parse_memory(section_value(sections, "swap").as_deref()),
disks: parse_disks(sections),
sensors: sections.get("sensors").cloned().unwrap_or_default(),
}
}
fn parse_memory(value: Option<&str>) -> MemorySnapshot {
let Some(value) = value else {
return MemorySnapshot::default();
};
let parts: Vec<&str> = value.split('|').collect();
MemorySnapshot {
total: parts.first().copied().unwrap_or_default().to_owned(),
used: parts.get(1).copied().unwrap_or_default().to_owned(),
free: parts.get(2).copied().unwrap_or_default().to_owned(),
available: parts.get(3).copied().unwrap_or_default().to_owned(),
used_percent: parts.get(4).and_then(|value| parse_percent(value)),
}
}
fn parse_disks(sections: &BTreeMap<String, Vec<String>>) -> Vec<DiskSnapshot> {
sections
.get("disks")
.into_iter()
.flatten()
.filter_map(|line| {
let parts: Vec<&str> = line.split('|').collect();
(parts.len() >= 6).then(|| DiskSnapshot {
filesystem: parts[0].to_owned(),
size: parts[1].to_owned(),
used: parts[2].to_owned(),
available: parts[3].to_owned(),
used_percent: parse_percent(parts[4]),
mount: parts[5].to_owned(),
})
})
.collect()
}
fn parse_services(sections: &BTreeMap<String, Vec<String>>) -> Vec<ServiceSnapshot> {
sections
.get("services")
.into_iter()
.flatten()
.filter_map(|line| {
let parts: Vec<&str> = line.split('|').collect();
(parts.len() >= 4).then(|| {
let active = parts[1].to_owned();
let sub = parts[2].to_owned();
let level = if active == "active" {
HealthLevel::Ok
} else if active == "unknown" || active == "inactive" {
HealthLevel::Warning
} else {
HealthLevel::Critical
};
ServiceSnapshot {
name: parts[0].to_owned(),
active,
sub,
unit_file: parts[3].to_owned(),
level,
}
})
})
.collect()
}
fn parse_probes(sections: &BTreeMap<String, Vec<String>>) -> Vec<ProbeSnapshot> {
sections
.get("probes")
.into_iter()
.flatten()
.filter_map(|line| {
let parts: Vec<&str> = line.split('|').collect();
(parts.len() >= 4).then(|| {
let http_code = parts[2].to_owned();
let elapsed_ms = parts[3].parse().ok();
let level = if http_code.starts_with('2') {
HealthLevel::Ok
} else if http_code == "000" {
HealthLevel::Critical
} else {
HealthLevel::Critical
};
ProbeSnapshot {
name: parts[0].to_owned(),
target: parts[1].to_owned(),
http_code,
elapsed_ms,
level,
}
})
})
.collect()
}
fn parse_health_patrol(sections: &BTreeMap<String, Vec<String>>) -> Option<HealthPatrolSnapshot> {
let line = section_value(sections, "health_patrol")?;
let parts: Vec<&str> = line.split('|').collect();
let status = parts.first().copied().unwrap_or_default().to_owned();
let level = match status.as_str() {
"OK" => HealthLevel::Ok,
"WARNING" => HealthLevel::Warning,
"CRITICAL" => HealthLevel::Critical,
_ => HealthLevel::Unknown,
};
Some(HealthPatrolSnapshot {
status,
checked_at: parts.get(1).copied().unwrap_or_default().to_owned(),
summary: parts.get(2).copied().unwrap_or_default().to_owned(),
level,
})
}
fn parse_percent(value: &str) -> Option<u8> {
value.trim_end_matches('%').parse().ok()
}
pub const HEALTH_SCRIPT: &str = r#"set -eu
print_section() {
printf '==GENARRATIVE_PANEL:%s==\n' "$1"
}
print_section checked_at
date -Is 2>/dev/null || date
print_section hostname
hostname 2>/dev/null || true
print_section kernel
uname -srmo 2>/dev/null || uname -a 2>/dev/null || true
print_section uptime
uptime -p 2>/dev/null || uptime 2>/dev/null || true
print_section cpu_model
awk -F: '/model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null || true
print_section cpu_cores
nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || true
print_section load_average
cat /proc/loadavg 2>/dev/null | awk '{print $1" "$2" "$3}' || true
print_section memory
awk '
/^MemTotal:/ {total=$2}
/^MemFree:/ {free=$2}
/^MemAvailable:/ {available=$2}
END {
if (total > 0) {
used = total - free
percent = int((used * 100 + total / 2) / total)
printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, available/1048576, percent
}
}
' /proc/meminfo 2>/dev/null || true
print_section swap
awk '
/^SwapTotal:/ {total=$2}
/^SwapFree:/ {free=$2}
END {
if (total > 0) {
used = total - free
percent = int((used * 100 + total / 2) / total)
printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, free/1048576, percent
} else {
print "0 GiB|0 GiB|0 GiB|0 GiB|0%"
}
}
' /proc/meminfo 2>/dev/null || true
print_section disks
for mount in / /var /opt /stdb /data; do
if [ -e "$mount" ]; then
df -hP "$mount" 2>/dev/null | awk 'NR == 2 {print $1"|"$2"|"$3"|"$4"|"$5"|"$6}'
fi
done | awk '!seen[$6]++'
print_section sensors
if command -v sensors >/dev/null 2>&1; then
sensors 2>/dev/null | sed -n '1,20p'
else
echo "sensors 未安装"
fi
print_section services
for service in genarrative-api.service spacetimedb.service nginx.service genarrative-health-patrol.timer genarrative-database-backup.timer; do
active=$(systemctl is-active "$service" 2>/dev/null || true)
sub=$(systemctl show "$service" -p SubState --value 2>/dev/null || true)
unit_file=$(systemctl show "$service" -p UnitFileState --value 2>/dev/null || true)
[ -n "$active" ] || active="unknown"
[ -n "$sub" ] || sub="unknown"
[ -n "$unit_file" ] || unit_file="unknown"
printf '%s|%s|%s|%s\n' "$service" "$active" "$sub" "$unit_file"
done
print_section probes
probe() {
name="$1"
url="$2"
tmp=$(mktemp)
code=$(curl -fsS -m 5 -o /dev/null -w '%{http_code}|%{time_total}' "$url" 2>"$tmp" || true)
if [ -z "$code" ]; then
code="000|0"
fi
http_code=${code%%|*}
time_total=${code#*|}
elapsed_ms=$(awk "BEGIN {printf \"%d\", $time_total * 1000}")
printf '%s|%s|%s|%s\n' "$name" "$url" "$http_code" "$elapsed_ms"
rm -f "$tmp"
}
probe "api:/healthz" "http://127.0.0.1:8082/healthz"
probe "api:/readyz" "http://127.0.0.1:8082/readyz"
probe "spacetimedb:/v1/ping" "http://127.0.0.1:3101/v1/ping"
probe "public:/api/creation-entry/config" "http://127.0.0.1:8082/api/creation-entry/config"
probe "public:/api/runtime/puzzle/gallery" "http://127.0.0.1:8082/api/runtime/puzzle/gallery"
print_section health_patrol
if [ -r /var/lib/genarrative/health-patrol/status.json ]; then
node -e '
const fs = require("fs");
const payload = JSON.parse(fs.readFileSync("/var/lib/genarrative/health-patrol/status.json", "utf8"));
const status = payload.status || "UNKNOWN";
const checkedAt = payload.checkedAt || "";
const checks = Array.isArray(payload.checks) ? payload.checks : [];
const summary = checks.filter((check) => check.status && check.status !== "OK").slice(0, 3).map((check) => `${check.name}:${check.status}`).join(",");
console.log(`${status}|${checkedAt}|${summary}`);
' 2>/dev/null || echo "UNKNOWN||状态文件解析失败"
else
echo "UNKNOWN||未找到 /var/lib/genarrative/health-patrol/status.json"
fi
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_report_sections() {
let report = parse_health_report(
r#"==GENARRATIVE_PANEL:checked_at==
2026-06-11T12:00:00+08:00
==GENARRATIVE_PANEL:hostname==
release
==GENARRATIVE_PANEL:memory==
2.0 GiB|1.0 GiB|1.0 GiB|1.0 GiB|50%
==GENARRATIVE_PANEL:disks==
/dev/sda1|40G|20G|20G|50%|/
==GENARRATIVE_PANEL:services==
genarrative-api.service|active|running|enabled
spacetimedb.service|failed|failed|enabled
==GENARRATIVE_PANEL:probes==
api:/readyz|http://127.0.0.1:8082/readyz|200|18
==GENARRATIVE_PANEL:health_patrol==
WARNING|2026-06-11T11:59:00Z|journal:WARNING
"#,
);
assert_eq!(report.host.hostname, "release");
assert_eq!(report.hardware.memory.used_percent, Some(50));
assert_eq!(report.services.len(), 2);
assert_eq!(report.probes[0].http_code, "200");
assert_eq!(report.status, HealthLevel::Critical);
}
}

View File

@@ -0,0 +1,5 @@
pub mod app;
pub mod fonts;
pub mod health;
pub mod remote;
pub mod ssh_config;

View File

@@ -0,0 +1,21 @@
use eframe::egui;
use server_manager_panel::app::ServerManagerApp;
use server_manager_panel::fonts::install_cjk_font;
fn main() -> eframe::Result<()> {
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1180.0, 760.0])
.with_min_inner_size([920.0, 620.0]),
..Default::default()
};
eframe::run_native(
"Genarrative 服务器管理面板",
native_options,
Box::new(|cc| {
install_cjk_font(&cc.egui_ctx);
Ok(Box::new(ServerManagerApp::default()))
}),
)
}

View File

@@ -0,0 +1,231 @@
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use crate::health::{HEALTH_SCRIPT, ServerHealthReport, parse_health_report};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceAction {
Start,
Stop,
Restart,
}
impl ServiceAction {
pub fn as_systemctl_arg(self) -> &'static str {
match self {
ServiceAction::Start => "start",
ServiceAction::Stop => "stop",
ServiceAction::Restart => "restart",
}
}
pub fn label(self) -> &'static str {
match self {
ServiceAction::Start => "启动",
ServiceAction::Stop => "关闭",
ServiceAction::Restart => "重启",
}
}
}
#[derive(Debug, Clone)]
pub struct RemoteCommandResult {
pub success: bool,
pub summary: String,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug)]
pub enum RemoteEvent {
Health {
alias: String,
result: Result<ServerHealthReport, String>,
},
ServiceAction {
alias: String,
service: String,
action: ServiceAction,
result: RemoteCommandResult,
},
}
pub type RemoteSender = mpsc::Sender<RemoteEvent>;
pub type RemoteReceiver = mpsc::Receiver<RemoteEvent>;
pub fn channel() -> (RemoteSender, RemoteReceiver) {
mpsc::channel()
}
pub fn spawn_health_check(alias: String, tx: RemoteSender) {
thread::spawn(move || {
let result =
run_ssh_script(&alias, HEALTH_SCRIPT, Duration::from_secs(20)).and_then(|output| {
if output.success {
Ok(parse_health_report(&output.stdout))
} else {
Err(format_remote_error(&output))
}
});
let _ = tx.send(RemoteEvent::Health { alias, result });
});
}
pub fn spawn_service_action(
alias: String,
service: String,
action: ServiceAction,
tx: RemoteSender,
) {
thread::spawn(move || {
let result = if is_safe_service_name(&service) {
run_ssh_script(
&alias,
&build_service_action_script(&service, action),
Duration::from_secs(20),
)
.unwrap_or_else(|error| RemoteCommandResult {
success: false,
summary: error,
stdout: String::new(),
stderr: String::new(),
})
} else {
RemoteCommandResult {
success: false,
summary: "服务名包含不允许的字符".to_owned(),
stdout: String::new(),
stderr: String::new(),
}
};
let _ = tx.send(RemoteEvent::ServiceAction {
alias,
service,
action,
result,
});
});
}
pub fn is_safe_service_name(service: &str) -> bool {
!service.is_empty()
&& service.len() <= 128
&& service.bytes().all(|byte| {
matches!(
byte,
b'a'..=b'z'
| b'A'..=b'Z'
| b'0'..=b'9'
| b'.'
| b'_'
| b'-'
| b'@'
| b':'
)
})
}
fn build_service_action_script(service: &str, action: ServiceAction) -> String {
format!(
r#"set -eu
service='{service}'
action='{action}'
if [ "$(id -u)" = "0" ]; then
systemctl "$action" "$service"
else
sudo -n systemctl "$action" "$service"
fi
systemctl is-active "$service" || true
systemctl status "$service" --no-pager -l -n 12 || true
"#,
service = service,
action = action.as_systemctl_arg()
)
}
fn run_ssh_script(
alias: &str,
script: &str,
timeout: Duration,
) -> Result<RemoteCommandResult, String> {
let started = Instant::now();
let mut child = Command::new("ssh")
.arg("-o")
.arg("BatchMode=yes")
.arg("-o")
.arg("ConnectTimeout=8")
.arg(alias)
.arg("sh")
.arg("-s")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("无法启动 ssh: {error}"))?;
{
// 中文注释:写完脚本后必须关闭 stdin让远端 `sh -s` 收到 EOF 并开始退出。
let Some(mut stdin) = child.stdin.take() else {
return Err("无法写入 ssh stdin".to_owned());
};
stdin
.write_all(script.as_bytes())
.map_err(|error| format!("写入远端脚本失败: {error}"))?;
}
loop {
match child.try_wait() {
Ok(Some(_status)) => {
let output = child
.wait_with_output()
.map_err(|error| format!("读取 ssh 输出失败: {error}"))?;
let success = output.status.success();
return Ok(RemoteCommandResult {
success,
summary: if success {
"执行成功".to_owned()
} else {
format!("ssh 退出码 {:?}", output.status.code())
},
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(None) if started.elapsed() >= timeout => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("ssh 执行超过 {}", timeout.as_secs()));
}
Ok(None) => thread::sleep(Duration::from_millis(80)),
Err(error) => return Err(format!("等待 ssh 进程失败: {error}")),
}
}
}
fn format_remote_error(result: &RemoteCommandResult) -> String {
let stderr = result.stderr.trim();
let stdout = result.stdout.trim();
if !stderr.is_empty() {
format!("{}: {}", result.summary, stderr)
} else if !stdout.is_empty() {
format!("{}: {}", result.summary, stdout)
} else {
result.summary.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_systemd_unit_names_only() {
assert!(is_safe_service_name("genarrative-api.service"));
assert!(is_safe_service_name("worker@1.service"));
assert!(!is_safe_service_name("api.service;rm -rf /"));
assert!(!is_safe_service_name(""));
}
}

View File

@@ -0,0 +1,143 @@
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshAlias {
pub name: String,
pub source: PathBuf,
}
pub fn discover_ssh_aliases() -> Vec<SshAlias> {
let Some(home) = std::env::var_os("HOME") else {
return Vec::new();
};
let config_path = PathBuf::from(home).join(".ssh/config");
discover_from_file(&config_path)
}
pub fn discover_from_file(path: &Path) -> Vec<SshAlias> {
let mut visited = HashSet::new();
let mut aliases = Vec::new();
discover_inner(path, &mut visited, &mut aliases);
dedupe_aliases(aliases)
}
fn discover_inner(path: &Path, visited: &mut HashSet<PathBuf>, aliases: &mut Vec<SshAlias>) {
let Ok(canonical) = path.canonicalize() else {
return;
};
if !visited.insert(canonical.clone()) {
return;
}
let Ok(content) = fs::read_to_string(&canonical) else {
return;
};
for line in content.lines() {
let trimmed = trim_comment(line);
let mut parts = trimmed.split_whitespace();
let Some(keyword) = parts.next() else {
continue;
};
if keyword.eq_ignore_ascii_case("host") {
aliases.extend(parts.filter_map(|name| {
is_concrete_alias(name).then(|| SshAlias {
name: name.to_owned(),
source: canonical.clone(),
})
}));
} else if keyword.eq_ignore_ascii_case("include") {
for include in parts {
for include_path in expand_include_path(include, canonical.parent()) {
discover_inner(&include_path, visited, aliases);
}
}
}
}
}
fn dedupe_aliases(aliases: Vec<SshAlias>) -> Vec<SshAlias> {
let mut seen = HashSet::new();
let mut deduped = Vec::new();
for alias in aliases {
if seen.insert(alias.name.clone()) {
deduped.push(alias);
}
}
deduped
}
fn trim_comment(line: &str) -> &str {
line.split('#').next().unwrap_or("").trim()
}
fn is_concrete_alias(value: &str) -> bool {
!value.is_empty()
&& !value.starts_with('-')
&& !value.starts_with('!')
&& !value.contains('*')
&& !value.contains('?')
&& !value.contains('%')
&& !value.contains('/')
}
fn expand_include_path(raw: &str, parent: Option<&Path>) -> Vec<PathBuf> {
if raw.contains('*') || raw.contains('?') {
// 中文注释SSH Include 支持复杂 glob面板只解析普通文件避免误扫过大目录。
return Vec::new();
}
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(rest))
} else {
let path = PathBuf::from(raw);
if path.is_absolute() {
Some(path)
} else {
parent.map(|base| base.join(path))
}
};
expanded.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_parser_ignores_wildcards_and_negations() {
let mut aliases = Vec::new();
let source = PathBuf::from("/tmp/config");
for line in [
"Host dev release *.internal !blocked",
"Host github.com",
"Host ?pattern",
"Host -bad",
] {
let trimmed = trim_comment(line);
let mut parts = trimmed.split_whitespace();
let keyword = parts.next().unwrap();
if keyword.eq_ignore_ascii_case("host") {
aliases.extend(parts.filter_map(|name| {
is_concrete_alias(name).then(|| SshAlias {
name: name.to_owned(),
source: source.clone(),
})
}));
}
}
let names: Vec<_> = dedupe_aliases(aliases)
.into_iter()
.map(|alias| alias.name)
.collect();
assert_eq!(names, ["dev", "release", "github.com"]);
}
#[test]
fn comment_trimming_keeps_plain_aliases() {
assert_eq!(trim_comment(" Host dev # release host "), "Host dev");
}
}

View File

@@ -10,24 +10,39 @@ import {
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard'; import * as clipboardService from '../../services/clipboard';
import * as shareGridService from '../../services/wechatMiniProgramShareGrid';
import { PublishShareModal } from './PublishShareModal'; import { PublishShareModal } from './PublishShareModal';
import { import {
buildMiniProgramPublishSharePath,
buildPublishShareCardFileName,
buildPublishShareCopyUrl,
buildPublishShareText, buildPublishShareText,
buildPublishShareUrl,
type PublishShareModalPayload, type PublishShareModalPayload,
} from './publishShareModalModel'; } from './publishShareModalModel';
vi.mock('../../services/clipboard', () => ({ vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(), copyTextToClipboard: vi.fn(),
})); }));
vi.mock('../../services/wechatMiniProgramShareGrid', () => ({
canUseWechatMiniProgramShareGrid: vi.fn(() => false),
openWechatMiniProgramShareGridPage: vi.fn(),
}));
const payload: PublishShareModalPayload = { const payload: PublishShareModalPayload = {
title: '暖灯猫街', title: '暖灯猫街',
publicWorkCode: 'PZ-00000001', publicWorkCode: 'PZ-00000001',
stage: 'puzzle-gallery-detail', stage: 'puzzle-gallery-detail',
workTypeLabel: '拼图',
coverImageSrc: '/cover.png',
}; };
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
false,
);
window.history.replaceState(null, '', '/');
}); });
describe('PublishShareModal', () => { describe('PublishShareModal', () => {
@@ -39,7 +54,40 @@ describe('PublishShareModal', () => {
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001'); 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); vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render( render(
@@ -52,26 +100,55 @@ describe('PublishShareModal', () => {
expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('rounded-[1.75rem]'); expect(dialog.className).toContain('rounded-[1.75rem]');
expect(dialog.getAttribute('style')).toBeNull(); expect(dialog.getAttribute('style')).toBeNull();
expect(within(dialog).getByText(//u)).toBeTruthy(); expect(within(dialog).getByRole('region', { name: '分享卡片' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy(); expect(within(dialog).getByText('拼图')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy(); expect(within(dialog).getByText('暖灯猫街')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy(); expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy(); expect(within(dialog).getByRole('button', { name: '下载卡片' })).toBeTruthy();
expect( expect(within(dialog).queryByRole('button', { name: '九宫切图' })).toBeNull();
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();
fireEvent.click(within(dialog).getByRole('button', { name: '分享' })); fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-00000001'), buildPublishShareUrl(payload),
); );
await waitFor(() => { await waitFor(() => {
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy(); 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(
<PublishShareModal open payload={payload} onClose={() => {}} />,
);
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(
<PublishShareModal open payload={payload} onClose={() => {}} />,
);
expect(screen.getByRole('button', { name: '九宫切图' })).toBeTruthy();
});
}); });

View File

@@ -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 { useEffect, useMemo, useRef, useState } from 'react';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard'; import { copyTextToClipboard } from '../../services/clipboard';
import { useAuthUi } from '../auth/AuthUiContext';
import { import {
buildPublishShareText, canUseWechatMiniProgramShareGrid,
openWechatMiniProgramShareGridPage,
} from '../../services/wechatMiniProgramShareGrid';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { downloadPublishShareCardImage } from './publishShareCardImage';
import {
buildPublishShareCopyUrl,
type PublishShareModalPayload, type PublishShareModalPayload,
} from './publishShareModalModel'; } from './publishShareModalModel';
import { UnifiedModal } from './UnifiedModal'; import { UnifiedModal } from './UnifiedModal';
@@ -15,78 +23,27 @@ type PublishShareModalProps = {
onClose: () => void; onClose: () => void;
}; };
type ShareChannelId = 'wechat' | 'qq' | 'douyin'; type ActionState = 'idle' | 'success' | 'failed';
type ShareChannel = { function normalizePayloadTitle(payload: PublishShareModalPayload | null) {
id: ShareChannelId; return payload?.title.trim() || '我的作品';
label: string; }
iconClassName: string;
};
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。 function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) {
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
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 ( return (
<svg payload?.coverImageSrc?.trim() ||
viewBox="-1 -1 26 26" payload?.fallbackCoverImageSrc?.trim() ||
aria-hidden="true" ''
focusable="false"
className="h-6 w-6 overflow-visible"
data-share-channel-logo={channel.id}
data-testid={`share-channel-logo-${channel.id}`}
>
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
<path d={iconPath} fill="currentColor" />
</svg>
); );
} }
return ( function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
<svg return payload?.workTypeLabel?.trim() || '互动作品';
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
className="h-6 w-6"
data-share-channel-logo={channel.id}
data-testid={`share-channel-logo-${channel.id}`}
>
<path d={iconPath} fill="currentColor" />
</svg>
);
} }
/** /**
* 发布完成后的分享弹窗。 * 发布完成后的通用分享弹窗。
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK可以只替换这里的渠道点击逻辑 * 分享事实仍来自公开作品号与 stage弹窗只负责把它表现成可复制、可下载的分享卡
*/ */
export function PublishShareModal({ export function PublishShareModal({
open, open,
@@ -94,14 +51,24 @@ export function PublishShareModal({
onClose, onClose,
}: PublishShareModalProps) { }: PublishShareModalProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( const [copyState, setCopyState] = useState<ActionState>('idle');
'idle', const [downloadState, setDownloadState] = useState<ActionState>('idle');
); const [gridState, setGridState] = useState<ActionState>('idle');
const resetTimerRef = useRef<number | null>(null); const resetTimerRef = useRef<number | null>(null);
const shareText = useMemo( const shareCopyUrl = useMemo(
() => (payload ? buildPublishShareText(payload) : ''), () =>
payload
? buildPublishShareCopyUrl(payload, {
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
})
: '',
[payload], [payload],
); );
const title = normalizePayloadTitle(payload);
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
const showMiniProgramGridButton =
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
useEffect( useEffect(
() => () => { () => () => {
@@ -114,22 +81,89 @@ export function PublishShareModal({
useEffect(() => { useEffect(() => {
setCopyState('idle'); setCopyState('idle');
setDownloadState('idle');
setGridState('idle');
}, [payload?.publicWorkCode]); }, [payload?.publicWorkCode]);
const copyShareText = () => { const scheduleStateReset = () => {
if (!shareText) {
return;
}
void copyTextToClipboard(shareText).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
if (resetTimerRef.current !== null) { if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current); window.clearTimeout(resetTimerRef.current);
} }
resetTimerRef.current = window.setTimeout(() => { resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null; resetTimerRef.current = null;
setCopyState('idle'); setCopyState('idle');
setDownloadState('idle');
setGridState('idle');
}, 1400); }, 1400);
};
const copyShareLink = () => {
if (!shareCopyUrl) {
return;
}
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();
}); });
}; };
@@ -142,53 +176,98 @@ export function PublishShareModal({
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`} overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]" panelClassName="platform-remap-surface rounded-[1.75rem]"
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5" bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5" footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5"
footer={ footer={
<div className="grid w-full grid-cols-3 gap-3"> <div
{SHARE_CHANNELS.map((channel) => { className={`grid w-full gap-3 ${
return ( showMiniProgramGridButton ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2'
<button }`}
key={channel.id}
type="button"
onClick={copyShareText}
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
aria-label={`分享到${channel.label}`}
title={channel.label}
> >
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
>
<ShareChannelLogo channel={channel} />
</span>
<span>{channel.label}</span>
</button>
);
})}
</div>
}
>
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{shareText}
</div>
</div>
<button <button
type="button" type="button"
onClick={copyShareText} onClick={copyShareLink}
disabled={!shareText} disabled={!shareCopyUrl}
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55" className="platform-button platform-button--primary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
> >
{copyState === 'copied' ? ( {copyState === 'success' ? (
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
) : ( ) : (
<Copy className="h-4 w-4" /> <Link2 className="h-4 w-4" />
)} )}
{copyState === 'copied' {copyState === 'success'
? '已复制' ? '已复制'
: copyState === 'failed' : copyState === 'failed'
? '复制失败' ? '复制失败'
: '分享'} : '复制链接'}
</button> </button>
<button
type="button"
onClick={downloadShareCard}
disabled={!payload}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
>
{downloadState === 'success' ? (
<Check className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
{downloadState === 'success'
? '已下载'
: downloadState === 'failed'
? '下载失败'
: '下载卡片'}
</button>
{showMiniProgramGridButton ? (
<button
type="button"
onClick={openMiniProgramGridDownload}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm"
>
{gridState === 'success' ? (
<Check className="h-4 w-4" />
) : (
<Grid3X3 className="h-4 w-4" />
)}
{gridState === 'success'
? '已打开'
: gridState === 'failed'
? '打开失败'
: '九宫切图'}
</button>
) : null}
</div>
}
>
<section
className="overflow-hidden rounded-lg border border-[var(--platform-subpanel-border)] bg-white/78 shadow-[0_18px_42px_rgba(127,85,57,0.12)]"
aria-label="分享卡片"
>
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(135deg,#f4c38b,#e7b5b7_48%,#9bbfd1)]">
{coverImageSrc ? (
<ResolvedAssetImage
src={coverImageSrc}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-6xl font-black text-white/84">
{Array.from(title)[0] ?? '陶'}
</div>
)}
</div>
<div className="space-y-3 px-4 py-4">
<div className="inline-flex max-w-full items-center rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-accent-text)]">
<span className="truncate">{workTypeLabel}</span>
</div>
<h3 className="line-clamp-2 text-lg font-black leading-snug text-[var(--platform-text-strong)]">
{title}
</h3>
<div className="flex min-w-0 items-center gap-2 text-xs font-bold text-[var(--platform-text-muted)]">
<Copy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{payload?.publicWorkCode}</span>
</div>
</div>
</section>
</UnifiedModal> </UnifiedModal>
); );
} }

View File

@@ -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<typeof import('../../services/assetReadUrlService')>(
'../../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('陶泥儿');
});
});

View File

@@ -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<HTMLImageElement>((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<Blob>((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;
}
}

View File

@@ -1,13 +1,19 @@
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import type { SelectionStage } from '../platform-entry/platformEntryTypes'; 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 = { export type PublishShareModalPayload = {
title: string; title: string;
publicWorkCode: string; publicWorkCode: string;
stage: SelectionStage; stage: SelectionStage;
workTypeLabel?: string | null;
coverImageSrc?: string | null;
fallbackCoverImageSrc?: string | null;
}; };
function buildShareUrl(payload: PublishShareModalPayload) { export function buildPublishShareUrl(payload: PublishShareModalPayload) {
const sharePath = buildPublicWorkStagePath( const sharePath = buildPublicWorkStagePath(
payload.stage, payload.stage,
payload.publicWorkCode, payload.publicWorkCode,
@@ -18,13 +24,56 @@ function buildShareUrl(payload: PublishShareModalPayload) {
: new URL(sharePath, window.location.origin).href; : 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) { export function buildPublishShareText(payload: PublishShareModalPayload) {
const publicWorkCode = payload.publicWorkCode.trim(); const publicWorkCode = payload.publicWorkCode.trim();
const title = payload.title.trim() || '我的作品'; const title = payload.title.trim() || '我的作品';
return `邀请你来玩《${title}\n作品号${publicWorkCode}\n${buildShareUrl({ return `邀请你来玩《${title}\n作品号${publicWorkCode}\n${buildPublishShareUrl({
...payload, ...payload,
publicWorkCode, publicWorkCode,
title, title,
})}`; })}`;
} }
export function buildPublishShareCardFileName(
payload: Pick<PublishShareModalPayload, 'title' | 'publicWorkCode'>,
) {
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`;
}

View File

@@ -1093,6 +1093,9 @@ test('creation hub published share icon opens unified share payload without open
title: '沉钟拼图', title: '沉钟拼图',
publicWorkCode: 'PZ-PROFILE1', publicWorkCode: 'PZ-PROFILE1',
stage: 'puzzle-gallery-detail', stage: 'puzzle-gallery-detail',
workTypeLabel: '拼图',
coverImageSrc: null,
fallbackCoverImageSrc: '/creation-type-references/puzzle.webp',
}); });
expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
}); });

View File

@@ -7,8 +7,8 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
default as React,
type CSSProperties, type CSSProperties,
default as React,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent, type TouchEvent as ReactTouchEvent,
@@ -24,9 +24,9 @@ import {
formatPlatformWorkDisplayTag, formatPlatformWorkDisplayTag,
} from '../rpg-entry/rpgEntryWorldPresentation'; } from '../rpg-entry/rpgEntryWorldPresentation';
import { import {
CREATION_WORK_KIND_FALLBACK_COVER,
type CreationWorkShelfBadgeTone, type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem, type CreationWorkShelfItem,
type CreationWorkShelfKind,
type CreationWorkShelfMetric, type CreationWorkShelfMetric,
type CreationWorkShelfMetricId, type CreationWorkShelfMetricId,
formatCreationMetricCount, formatCreationMetricCount,
@@ -55,21 +55,6 @@ const SWIPE_ACTION_WIDTH_PX = 76;
const SWIPE_REVEAL_THRESHOLD_PX = 42; const SWIPE_REVEAL_THRESHOLD_PX = 42;
const SWIPE_DIRECTION_LOCK_PX = 8; const SWIPE_DIRECTION_LOCK_PX = 8;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = []; const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
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',
};
function easeOutCubic(progress: number) { function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3; return 1 - (1 - progress) ** 3;
} }

View File

@@ -44,6 +44,50 @@ export type CreationWorkShelfKind =
| 'baby-object-match' | 'baby-object-match'
| 'bark-battle' | 'bark-battle'
| 'visual-novel'; | '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 CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral'; export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';

View File

@@ -353,6 +353,7 @@ import {
updateVisualNovelWork, updateVisualNovelWork,
} from '../../services/visual-novel-works'; } from '../../services/visual-novel-works';
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe'; import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
import { postWechatMiniProgramShareTarget } from '../../services/wechatMiniProgramShareTarget';
import { import {
woodenFishClient, woodenFishClient,
type WoodenFishGalleryCardResponse, type WoodenFishGalleryCardResponse,
@@ -378,6 +379,7 @@ import {
selectAdjacentPlatformRecommendEntry, selectAdjacentPlatformRecommendEntry,
} from '../rpg-entry/rpgEntryPublicGalleryViewModel'; } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import { import {
describePublicGalleryCardKind,
isBigFishGalleryEntry, isBigFishGalleryEntry,
isEdutainmentGalleryEntry, isEdutainmentGalleryEntry,
isJumpHopGalleryEntry, isJumpHopGalleryEntry,
@@ -387,6 +389,8 @@ import {
mapPuzzleWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard, type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode, resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldFallbackCoverImage,
} from '../rpg-entry/rpgEntryWorldPresentation'; } from '../rpg-entry/rpgEntryWorldPresentation';
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
@@ -844,6 +848,25 @@ function resolveRecommendEntryShareStage(
return 'work-detail'; return 'work-detail';
} }
function postRecommendEntryMiniProgramShareTarget(
entry: PlatformPublicGalleryCard | null | undefined,
) {
if (!entry) {
return false;
}
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
return false;
}
return postWechatMiniProgramShareTarget({
targetPath: '/works/detail',
work: publicWorkCode,
title: entry.worldName,
});
}
function pushPuzzleResultHistoryEntry( function pushPuzzleResultHistoryEntry(
session: PuzzleAgentSessionSnapshot | null, session: PuzzleAgentSessionSnapshot | null,
) { ) {
@@ -2994,10 +3017,14 @@ export function PlatformEntryFlowShellImpl({
return; return;
} }
postRecommendEntryMiniProgramShareTarget(entry);
openPublishShareModal({ openPublishShareModal({
title: entry.worldName, title: entry.worldName,
publicWorkCode, publicWorkCode,
stage: resolveRecommendEntryShareStage(entry), stage: resolveRecommendEntryShareStage(entry),
workTypeLabel: describePublicGalleryCardKind(entry),
coverImageSrc: resolvePlatformWorldCoverImage(entry),
fallbackCoverImageSrc: resolvePlatformWorldFallbackCoverImage(entry),
}); });
}, },
[openPublishShareModal], [openPublishShareModal],
@@ -13734,6 +13761,22 @@ export function PlatformEntryFlowShellImpl({
puzzleRun?.currentLevel?.profileId ?? null, puzzleRun?.currentLevel?.profileId ?? null,
}); });
useEffect(() => {
if (
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
!activeRecommendEntry
) {
return;
}
postRecommendEntryMiniProgramShareTarget(activeRecommendEntry);
}, [
activeRecommendEntry,
platformBootstrap.platformTab,
selectionStage,
]);
useEffect(() => { useEffect(() => {
const decision = resolvePlatformRecommendRuntimeAutoStartDecision({ const decision = resolvePlatformRecommendRuntimeAutoStartDecision({
isDesktopLayout, isDesktopLayout,

View File

@@ -0,0 +1,30 @@
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'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;
}

View File

@@ -7,23 +7,23 @@ import type {
JumpHopGalleryCardResponse, JumpHopGalleryCardResponse,
JumpHopWorkProfileResponse, JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop'; } from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleClearGalleryCardResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import type { import type {
Match3DGeneratedBackgroundAsset, Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset, Match3DGeneratedItemAsset,
Match3DWorkSummary, Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks'; } 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 { 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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { import type {
CustomWorldGalleryCard, CustomWorldGalleryCard,
CustomWorldLibraryEntry, CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
import type { import type {
SquareHoleHoleOption, SquareHoleHoleOption,
SquareHoleShapeOption, SquareHoleShapeOption,
@@ -1162,6 +1162,42 @@ export function buildPlatformWorldDisplayTags(
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit); 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) { export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
if (isBigFishGalleryEntry(entry)) { if (isBigFishGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼']; return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];

View File

@@ -5303,6 +5303,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
box-shadow: var(--platform-recommend-runtime-shadow); 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 { .platform-recommend-runtime-viewport {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -114,6 +114,18 @@ describe('index stylesheet unread dots', () => {
expect(block).toContain('var(--platform-cool-bg)'); expect(block).toContain('var(--platform-cool-bg)');
expect(block).not.toContain('background: transparent;'); 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', () => { describe('index stylesheet draft mobile cards', () => {

View File

@@ -10,6 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock';
import {resolveAppRoute} from './routing/appRoutes'; import {resolveAppRoute} from './routing/appRoutes';
import {RouteImageReadyGate} from './routing/RouteImageReadyGate'; import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
import {RouteLoadingScreen} from './routing/RouteLoadingScreen'; import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
import {isWechatMiniProgramWebViewRuntime} from './services/authService';
type AppRoot = ReturnType<typeof createRoot>; type AppRoot = ReturnType<typeof createRoot>;
@@ -26,11 +27,18 @@ if (!rootElement) {
throw new Error('Missing #root container'); throw new Error('Missing #root container');
} }
function markWechatMiniProgramRuntime() {
if (isWechatMiniProgramWebViewRuntime()) {
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
}
}
const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement); const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement);
const RouteComponent = route.Component; const RouteComponent = route.Component;
lockMobileViewportZoom(); lockMobileViewportZoom();
stabilizeMobileViewportKeyboardFocus(); stabilizeMobileViewportKeyboardFocus();
markWechatMiniProgramRuntime();
root.render( root.render(
<StrictMode> <StrictMode>

View File

@@ -415,4 +415,28 @@ describe('assetReadUrlService', () => {
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png', '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',
);
});
}); });

View File

@@ -4,8 +4,8 @@ import {
} from '../../packages/shared/src/http'; } from '../../packages/shared/src/http';
import { import {
ApiClientError, ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
type ApiRequestOptions, type ApiRequestOptions,
BACKGROUND_AUTH_REQUEST_OPTIONS,
fetchWithApiAuth, fetchWithApiAuth,
requestJson, requestJson,
} from './apiClient'; } from './apiClient';
@@ -376,7 +376,11 @@ export async function readAssetBytes(
throw new Error('资源路径不能为空'); throw new Error('资源路径不能为空');
} }
if (!isGeneratedLegacyPath(value)) { const legacyPath = isGeneratedLegacyPath(value)
? value
: resolveGeneratedLegacyPathFromUrl(value);
if (!legacyPath) {
const response = await fetch(value, { signal: options.signal }); const response = await fetch(value, { signal: options.signal });
if (!response.ok) { if (!response.ok) {
throw new Error('读取资源内容失败'); throw new Error('读取资源内容失败');
@@ -386,7 +390,7 @@ export async function readAssetBytes(
// 中文注释:这里要拿图片字节转 Data URL不能直接 fetch OSS 签名 URL否则浏览器会受 bucket CORS 限制。 // 中文注释:这里要拿图片字节转 Data URL不能直接 fetch OSS 签名 URL否则浏览器会受 bucket CORS 限制。
const searchParams = buildAssetReadSearchParams({ const searchParams = buildAssetReadSearchParams({
legacyPublicPath: value, legacyPublicPath: legacyPath,
expireSeconds: options.expireSeconds, expireSeconds: options.expireSeconds,
}); });
const response = await fetchWithApiAuth( const response = await fetchWithApiAuth(

View File

@@ -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<NonNullable<Window['wx']>>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`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<boolean>((resolve) => {
miniProgram.navigateTo?.({
url,
success() {
resolve(true);
},
fail() {
resolve(false);
},
});
});
}

View File

@@ -0,0 +1,66 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
buildWechatMiniProgramShareTargetMessage,
postWechatMiniProgramShareTarget,
} from './wechatMiniProgramShareTarget';
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, 'wx');
window.history.replaceState(null, '', '/');
});
describe('wechatMiniProgramShareTarget', () => {
test('builds a compact share target message for mini program native share', () => {
expect(
buildWechatMiniProgramShareTargetMessage({
targetPath: '/works/detail',
work: ' BB-12345678 ',
title: ' 汪汪声浪 ',
}),
).toEqual({
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
},
});
});
test('posts the current recommended work to mini program web-view host', () => {
const postMessage = vi.fn();
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
postMessage,
},
};
expect(
postWechatMiniProgramShareTarget({
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
}),
).toBe(true);
expect(postMessage).toHaveBeenCalledWith({
data: {
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
},
},
});
});
});

View File

@@ -0,0 +1,60 @@
import { isWechatMiniProgramWebViewRuntime } from './authService';
const MESSAGE_TYPE = 'genarrative:share-target';
export type WechatMiniProgramShareTarget = {
targetPath: '/works/detail';
work: string;
title?: string | null;
};
function normalizeShareTarget(
target: WechatMiniProgramShareTarget | null | undefined,
) {
const work = target?.work?.trim() ?? '';
if (!work) {
return null;
}
return {
targetPath: '/works/detail' as const,
work,
title: target?.title?.trim() || undefined,
};
}
export function buildWechatMiniProgramShareTargetMessage(
target: WechatMiniProgramShareTarget | null | undefined,
) {
const normalizedTarget = normalizeShareTarget(target);
return normalizedTarget
? {
type: MESSAGE_TYPE,
payload: normalizedTarget,
}
: null;
}
export function postWechatMiniProgramShareTarget(
target: WechatMiniProgramShareTarget | null | undefined,
) {
if (
typeof window === 'undefined' ||
!isWechatMiniProgramWebViewRuntime() ||
typeof window.wx?.miniProgram?.postMessage !== 'function'
) {
return false;
}
const message = buildWechatMiniProgramShareTargetMessage(target);
if (!message) {
return false;
}
// 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页,
// 小程序页据此把右上角系统分享指向当前推荐作品。
window.wx.miniProgram.postMessage({
data: message,
});
return true;
}