合并最新 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
+15
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 边界不够清晰。
@@ -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`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
+2 -2
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 / 一体化脚本
+2
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)。
@@ -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` 输出和现有部署文档为准。
@@ -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 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
+1
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"
], ],
+206
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();
},
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "九宫切图"
}
@@ -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,
};
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'vitest';
import shareGridBridge from './index.shared.js';
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = shareGridBridge;
describe('share-grid mini program bridge', () => {
test('normalizes query values and keeps a fallback title', () => {
expect(
normalizeShareGridQuery({
imageUrl: ' https://web.test/cover.png ',
publicWorkCode: ' PZ-0001 ',
}),
).toEqual({
imageUrl: 'https://web.test/cover.png',
title: '我的作品',
publicWorkCode: 'PZ-0001',
});
});
test('names tiles by title, public code and left-to-right order', () => {
const params = {
title: '星港:拼图',
publicWorkCode: 'PZ-0001',
};
expect(buildShareGridTileFileName(params, 0)).toBe(
'星港拼图-PZ-0001-01.png',
);
expect(buildShareGridTileFileName(params, 8)).toBe(
'星港拼图-PZ-0001-09.png',
);
});
test('builds a 3x3 crop plan in reading order', () => {
const plan = buildShareGridTilePlan(900, 600);
expect(plan).toHaveLength(9);
expect(plan[0]).toMatchObject({
index: 0,
row: 0,
col: 0,
sourceX: 0,
sourceY: 0,
sourceWidth: 300,
sourceHeight: 200,
});
expect(plan[4]).toMatchObject({
index: 4,
row: 1,
col: 1,
sourceX: 300,
sourceY: 200,
});
expect(plan[8]).toMatchObject({
index: 8,
row: 2,
col: 2,
sourceX: 600,
sourceY: 400,
});
});
});
+20
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>
+60
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;
}
+36 -65
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));
}, },
}); });
+188
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,
};
+110
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: '汪汪声浪',
});
});
});
+1
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",
+41 -6
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,
}; };
+32 -2
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(
+1760 -19
View File
File diff suppressed because it is too large Load Diff
+1
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",
@@ -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",
] }
File diff suppressed because it is too large Load Diff
@@ -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"
);
}
}
@@ -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);
}
}

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