合并最新 origin/master
补合 master 最新小程序分享、开发脚本与 server-manager-panel 更新 保留外部生成 worker 分支已有改动,继续本地合并不推送
This commit is contained in:
@@ -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`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
||||||
|
|||||||
@@ -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 / 一体化脚本
|
||||||
|
|||||||
@@ -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,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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
page {
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48rpx;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1rpx solid rgba(127, 85, 57, 0.18);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
padding: 36rpx;
|
||||||
|
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-title {
|
||||||
|
color: #332820;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-text {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: rgba(51, 40, 32, 0.68);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-text--danger {
|
||||||
|
color: #b84a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #7f5539;
|
||||||
|
color: #fffdf9;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 2.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-canvas {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
@@ -10,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));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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: '汪汪声浪',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
Generated
+1760
-19
File diff suppressed because it is too large
Load Diff
@@ -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(§ions, "checked_at").unwrap_or_default(),
|
||||||
|
host: parse_host(§ions),
|
||||||
|
hardware: parse_hardware(§ions),
|
||||||
|
services: parse_services(§ions),
|
||||||
|
probes: parse_probes(§ions),
|
||||||
|
health_patrol: parse_health_patrol(§ions),
|
||||||
|
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
Reference in New Issue
Block a user