合并最新 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)。
|
||||||
|
|||||||
82
docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md
Normal file
82
docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 本地 SSH 服务器管理面板技术方案
|
||||||
|
|
||||||
|
日期:`2026-06-11`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
release / dev 等服务器的日常巡检已经有 `genarrative-health-patrol.timer`、`/readyz`、`/healthz`、SpacetimeDB `/v1/ping` 和 systemd 状态文件,但开发者本地仍需要在多个 SSH alias 之间切换命令。服务器管理面板用于把这些只读巡检和少量 systemd 服务操作收敛到一个本地桌面入口。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 使用 Rust `egui` / `eframe` 实现本地桌面面板,不接入线上 Web 后台,不暴露公网端口。
|
||||||
|
- 从本机 `~/.ssh/config` 的 `Host` alias 发现服务器;只展示不含通配符的 alias。
|
||||||
|
- 支持多个服务器,左侧服务器侧边栏可收起。
|
||||||
|
- 主面板展示硬件状态、服务状态、HTTP 健康探测和生产健康巡检状态。
|
||||||
|
- 支持对允许的 systemd unit 执行启动、关闭、重启。
|
||||||
|
|
||||||
|
## 命令入口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server-manager:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
等价于:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
面板启动时会自动查找本机中文字体并注入 egui 字体集,优先使用 `Noto Sans CJK SC`,其次使用文泉驿 / Droid fallback。若某台开发机字体路径特殊,可用 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指定;普通 `.ttf` 可省略 `|index`。
|
||||||
|
|
||||||
|
## SSH 约定
|
||||||
|
|
||||||
|
本地 `~/.ssh/config` 中需要存在类似:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host dev
|
||||||
|
HostName 10.2.0.10
|
||||||
|
User genarrative
|
||||||
|
|
||||||
|
Host release
|
||||||
|
HostName genarrative.world
|
||||||
|
User genarrative
|
||||||
|
```
|
||||||
|
|
||||||
|
面板通过 `ssh <alias> sh -s` 执行远端只读巡检脚本。服务操作使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -n systemctl <start|stop|restart> <unit>
|
||||||
|
```
|
||||||
|
|
||||||
|
若 SSH 用户是 root,则直接执行 `systemctl`。非 root 用户需要提前配置只允许目标 unit 的无密码 sudo;否则面板会显示 sudo 权限错误,不会弹出交互密码输入。
|
||||||
|
|
||||||
|
## 健康检查内容
|
||||||
|
|
||||||
|
只读巡检覆盖:
|
||||||
|
|
||||||
|
- 主机名、内核、运行时长、CPU 核数 / 型号、load average。
|
||||||
|
- 内存 / swap 使用情况。
|
||||||
|
- `/`、`/var`、`/opt`、`/stdb`、`/data` 中存在路径的磁盘使用率。
|
||||||
|
- `genarrative-api.service`、`spacetimedb.service`、`nginx.service`、`genarrative-health-patrol.timer`、`genarrative-database-backup.timer` 的 systemd 状态。
|
||||||
|
- `http://127.0.0.1:8082/healthz`、`/readyz`、`http://127.0.0.1:3101/v1/ping` 和代表性公开接口。
|
||||||
|
- `/var/lib/genarrative/health-patrol/status.json` 的最近巡检状态。
|
||||||
|
- 若服务器安装了 `sensors`,附带温度 / 风扇等硬件传感器摘要。
|
||||||
|
|
||||||
|
## 服务操作安全边界
|
||||||
|
|
||||||
|
面板只允许 `start`、`stop`、`restart` 三种动作,并且 unit 名必须匹配安全字符集:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A-Z a-z 0-9 . _ - @ :
|
||||||
|
```
|
||||||
|
|
||||||
|
服务操作会先出现确认弹窗,避免误点。第一版默认列出 Genarrative 生产相关 unit,并提供“其他 unit”输入框;该输入框仍只会执行 `systemctl` 的三种受控动作,不提供任意命令执行入口。
|
||||||
|
|
||||||
|
## 状态判定
|
||||||
|
|
||||||
|
- service / HTTP 探测失败:`CRITICAL`。
|
||||||
|
- 磁盘使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||||
|
- 内存使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||||
|
- 生产健康巡检状态沿用 `OK / WARNING / CRITICAL`。
|
||||||
|
|
||||||
|
面板状态只是本地巡检视图,最终运维事实仍以服务器上的 systemd、journal、Nginx 日志、`production-health-patrol.mjs` 输出和现有部署文档为准。
|
||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
206
miniprogram/pages/share-grid/index.js
Normal file
206
miniprogram/pages/share-grid/index.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/* global Page, wx */
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildShareGridTileFileName,
|
||||||
|
buildShareGridTilePlan,
|
||||||
|
normalizeShareGridQuery,
|
||||||
|
} = require('./index.shared');
|
||||||
|
|
||||||
|
function downloadImage(imageUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.downloadFile({
|
||||||
|
url: imageUrl,
|
||||||
|
success(response) {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
resolve(response.tempFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`封面下载失败:${response.statusCode}`));
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '封面下载失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageInfo(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.getImageInfo({
|
||||||
|
src,
|
||||||
|
success: resolve,
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '读取封面失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasNode(page) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.createSelectorQuery()
|
||||||
|
.in(page)
|
||||||
|
.select('#share-grid-canvas')
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((results) => {
|
||||||
|
const canvas = results && results[0] && results[0].node;
|
||||||
|
if (canvas) {
|
||||||
|
resolve(canvas);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error('切图画布初始化失败'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToTempFilePath(canvas, width, height) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.canvasToTempFilePath({
|
||||||
|
canvas,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
destWidth: width,
|
||||||
|
destHeight: height,
|
||||||
|
fileType: 'png',
|
||||||
|
success(response) {
|
||||||
|
resolve(response.tempFilePath);
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '导出切图失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveImageToAlbum(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.saveImageToPhotosAlbum({
|
||||||
|
filePath,
|
||||||
|
success() {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '保存到相册失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTempFileWithName(tempFilePath, fileName) {
|
||||||
|
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
|
||||||
|
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
|
||||||
|
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
|
||||||
|
return Promise.resolve(tempFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = `${userDataPath}/${fileName}`;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
fileSystem.copyFile({
|
||||||
|
srcPath: tempFilePath,
|
||||||
|
destPath: targetPath,
|
||||||
|
success() {
|
||||||
|
resolve(targetPath);
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
resolve(tempFilePath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGridTiles(page, params, localImagePath, imageInfo) {
|
||||||
|
const canvas = await getCanvasNode(page);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const image = canvas.createImage();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
image.onload = resolve;
|
||||||
|
image.onerror = () => reject(new Error('封面绘制失败'));
|
||||||
|
image.src = localImagePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
|
||||||
|
for (const tile of plan) {
|
||||||
|
canvas.width = tile.sourceWidth;
|
||||||
|
canvas.height = tile.sourceHeight;
|
||||||
|
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
tile.sourceX,
|
||||||
|
tile.sourceY,
|
||||||
|
tile.sourceWidth,
|
||||||
|
tile.sourceHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
tile.sourceWidth,
|
||||||
|
tile.sourceHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempFilePath = await canvasToTempFilePath(
|
||||||
|
canvas,
|
||||||
|
tile.sourceWidth,
|
||||||
|
tile.sourceHeight,
|
||||||
|
);
|
||||||
|
const namedFilePath = await copyTempFileWithName(
|
||||||
|
tempFilePath,
|
||||||
|
buildShareGridTileFileName(params, tile.index),
|
||||||
|
);
|
||||||
|
await saveImageToAlbum(namedFilePath);
|
||||||
|
page.setData({
|
||||||
|
savedCount: tile.index + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
errorMessage: '',
|
||||||
|
loading: true,
|
||||||
|
savedCount: 0,
|
||||||
|
title: '九宫切图',
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLoad(query = {}) {
|
||||||
|
const params = normalizeShareGridQuery(query);
|
||||||
|
this._shareGridParams = params;
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '',
|
||||||
|
loading: true,
|
||||||
|
savedCount: 0,
|
||||||
|
title: params.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!params.imageUrl) {
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '缺少封面图。',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localImagePath = await downloadImage(params.imageUrl);
|
||||||
|
const imageInfo = await getImageInfo(localImagePath);
|
||||||
|
await saveGridTiles(this, params, localImagePath, imageInfo);
|
||||||
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
savedCount: 9,
|
||||||
|
});
|
||||||
|
wx.showToast({
|
||||||
|
title: '已保存',
|
||||||
|
icon: 'success',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[share-grid] save failed', error);
|
||||||
|
this.setData({
|
||||||
|
errorMessage:
|
||||||
|
error && error.message ? error.message : '九宫切图保存失败。',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBack() {
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
});
|
||||||
3
miniprogram/pages/share-grid/index.json
Normal file
3
miniprogram/pages/share-grid/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "九宫切图"
|
||||||
|
}
|
||||||
62
miniprogram/pages/share-grid/index.shared.js
Normal file
62
miniprogram/pages/share-grid/index.shared.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const GRID_SIZE = 3;
|
||||||
|
const TILE_COUNT = GRID_SIZE * GRID_SIZE;
|
||||||
|
|
||||||
|
function normalizeQueryValue(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileNamePart(value) {
|
||||||
|
const normalized = normalizeQueryValue(value)
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.slice(0, 32);
|
||||||
|
return normalized || 'taonier';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareGridTileFileName(params, tileIndex) {
|
||||||
|
const safeTitle = sanitizeFileNamePart(params.title || params.publicWorkCode);
|
||||||
|
const safeCode = sanitizeFileNamePart(params.publicWorkCode || 'share');
|
||||||
|
const order = String(tileIndex + 1).padStart(2, '0');
|
||||||
|
return `${safeTitle}-${safeCode}-${order}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShareGridQuery(query) {
|
||||||
|
return {
|
||||||
|
imageUrl: normalizeQueryValue(query && query.imageUrl),
|
||||||
|
title: normalizeQueryValue(query && query.title) || '我的作品',
|
||||||
|
publicWorkCode: normalizeQueryValue(query && query.publicWorkCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareGridTilePlan(imageWidth, imageHeight) {
|
||||||
|
const tileWidth = Math.floor(imageWidth / GRID_SIZE);
|
||||||
|
const tileHeight = Math.floor(imageHeight / GRID_SIZE);
|
||||||
|
const plan = [];
|
||||||
|
|
||||||
|
for (let row = 0; row < GRID_SIZE; row += 1) {
|
||||||
|
for (let col = 0; col < GRID_SIZE; col += 1) {
|
||||||
|
const index = row * GRID_SIZE + col;
|
||||||
|
const sourceX = col * tileWidth;
|
||||||
|
const sourceY = row * tileHeight;
|
||||||
|
plan.push({
|
||||||
|
index,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourceWidth: col === GRID_SIZE - 1 ? imageWidth - sourceX : tileWidth,
|
||||||
|
sourceHeight: row === GRID_SIZE - 1 ? imageHeight - sourceY : tileHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GRID_SIZE,
|
||||||
|
TILE_COUNT,
|
||||||
|
buildShareGridTileFileName,
|
||||||
|
buildShareGridTilePlan,
|
||||||
|
normalizeShareGridQuery,
|
||||||
|
};
|
||||||
67
miniprogram/pages/share-grid/index.test.js
Normal file
67
miniprogram/pages/share-grid/index.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import shareGridBridge from './index.shared.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildShareGridTileFileName,
|
||||||
|
buildShareGridTilePlan,
|
||||||
|
normalizeShareGridQuery,
|
||||||
|
} = shareGridBridge;
|
||||||
|
|
||||||
|
describe('share-grid mini program bridge', () => {
|
||||||
|
test('normalizes query values and keeps a fallback title', () => {
|
||||||
|
expect(
|
||||||
|
normalizeShareGridQuery({
|
||||||
|
imageUrl: ' https://web.test/cover.png ',
|
||||||
|
publicWorkCode: ' PZ-0001 ',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
imageUrl: 'https://web.test/cover.png',
|
||||||
|
title: '我的作品',
|
||||||
|
publicWorkCode: 'PZ-0001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names tiles by title, public code and left-to-right order', () => {
|
||||||
|
const params = {
|
||||||
|
title: '星港:拼图',
|
||||||
|
publicWorkCode: 'PZ-0001',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildShareGridTileFileName(params, 0)).toBe(
|
||||||
|
'星港拼图-PZ-0001-01.png',
|
||||||
|
);
|
||||||
|
expect(buildShareGridTileFileName(params, 8)).toBe(
|
||||||
|
'星港拼图-PZ-0001-09.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds a 3x3 crop plan in reading order', () => {
|
||||||
|
const plan = buildShareGridTilePlan(900, 600);
|
||||||
|
|
||||||
|
expect(plan).toHaveLength(9);
|
||||||
|
expect(plan[0]).toMatchObject({
|
||||||
|
index: 0,
|
||||||
|
row: 0,
|
||||||
|
col: 0,
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
sourceWidth: 300,
|
||||||
|
sourceHeight: 200,
|
||||||
|
});
|
||||||
|
expect(plan[4]).toMatchObject({
|
||||||
|
index: 4,
|
||||||
|
row: 1,
|
||||||
|
col: 1,
|
||||||
|
sourceX: 300,
|
||||||
|
sourceY: 200,
|
||||||
|
});
|
||||||
|
expect(plan[8]).toMatchObject({
|
||||||
|
index: 8,
|
||||||
|
row: 2,
|
||||||
|
col: 2,
|
||||||
|
sourceX: 600,
|
||||||
|
sourceY: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
miniprogram/pages/share-grid/index.wxml
Normal file
20
miniprogram/pages/share-grid/index.wxml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<view class="share-grid-page">
|
||||||
|
<view class="share-grid-card">
|
||||||
|
<view class="share-grid-title">{{title}}</view>
|
||||||
|
<view wx:if="{{loading}}" class="share-grid-text">
|
||||||
|
正在保存 {{savedCount}}/9
|
||||||
|
</view>
|
||||||
|
<view wx:elif="{{errorMessage}}" class="share-grid-text share-grid-text--danger">
|
||||||
|
{{errorMessage}}
|
||||||
|
</view>
|
||||||
|
<view wx:else class="share-grid-text">已保存 9/9</view>
|
||||||
|
<button class="share-grid-button" bindtap="handleBack">
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<canvas
|
||||||
|
id="share-grid-canvas"
|
||||||
|
type="2d"
|
||||||
|
class="share-grid-canvas"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
60
miniprogram/pages/share-grid/index.wxss
Normal file
60
miniprogram/pages/share-grid/index.wxss
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
page {
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48rpx;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1rpx solid rgba(127, 85, 57, 0.18);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
padding: 36rpx;
|
||||||
|
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-title {
|
||||||
|
color: #332820;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-text {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: rgba(51, 40, 32, 0.68);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-text--danger {
|
||||||
|
color: #b84a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #7f5539;
|
||||||
|
color: #fffdf9;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 2.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-grid-canvas {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
@@ -10,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
miniprogram/pages/web-view/index.shared.js
Normal file
188
miniprogram/pages/web-view/index.shared.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
|
||||||
|
const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
|
||||||
|
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
||||||
|
|
||||||
|
function trimTrailingSlash(value) {
|
||||||
|
return String(value || '').trim().replace(/\/+$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuery(url, query) {
|
||||||
|
const rawUrl = String(url || '');
|
||||||
|
const pairs = Object.keys(query)
|
||||||
|
.filter((key) => query[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = rawUrl.indexOf('#');
|
||||||
|
const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
|
||||||
|
const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : '';
|
||||||
|
return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendHashParams(url, params) {
|
||||||
|
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
||||||
|
const pairs = Object.keys(params)
|
||||||
|
.filter((key) => params[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||||
|
);
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||||
|
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||||
|
const keptHashParts = rawHash.split('&').filter((part) => {
|
||||||
|
if (!part) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [rawKey = ''] = part.split('=');
|
||||||
|
try {
|
||||||
|
return !nextKeys.has(decodeURIComponent(rawKey));
|
||||||
|
} catch (_error) {
|
||||||
|
return !nextKeys.has(rawKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTargetPath(value) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed.startsWith('/')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed.replace(/\/+$/u, '') || '/';
|
||||||
|
return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLaunchTargetQuery(query) {
|
||||||
|
const targetPath = normalizeTargetPath(query && query.targetPath);
|
||||||
|
const work = String((query && query.work) || '').trim();
|
||||||
|
if (!targetPath || !work) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetPath,
|
||||||
|
work,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) {
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(query);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendQuery(basePath, {
|
||||||
|
targetPath: launchTarget.targetPath,
|
||||||
|
work: launchTarget.work,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebViewShareTimelineQuery(query = {}) {
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(query);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URLSearchParams({
|
||||||
|
targetPath: launchTarget.targetPath,
|
||||||
|
work: launchTarget.work,
|
||||||
|
}).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShareTargetMessageData(value) {
|
||||||
|
const message = value && value.data ? value.data : value;
|
||||||
|
if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = message.payload || {};
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(payload);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...launchTarget,
|
||||||
|
title: String(payload.title || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShareTargetFromWebViewMessage(detail) {
|
||||||
|
const dataList = detail && Array.isArray(detail.data) ? detail.data : [];
|
||||||
|
for (let index = dataList.length - 1; index >= 0; index -= 1) {
|
||||||
|
const target = normalizeShareTargetMessageData(dataList[index]);
|
||||||
|
if (target) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeShareTargetMessageData(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLaunchTargetToEntryUrl(entryUrl, query) {
|
||||||
|
const launchTarget = resolveLaunchTargetQuery(query);
|
||||||
|
if (!launchTarget.targetPath) {
|
||||||
|
return entryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEntryUrl = String(entryUrl || '').trim();
|
||||||
|
const hashIndex = rawEntryUrl.indexOf('#');
|
||||||
|
const entryWithoutHash =
|
||||||
|
hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl;
|
||||||
|
const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : '';
|
||||||
|
const queryIndex = entryWithoutHash.indexOf('?');
|
||||||
|
const entryBase =
|
||||||
|
queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash;
|
||||||
|
const entrySearch =
|
||||||
|
queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : '';
|
||||||
|
const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`;
|
||||||
|
|
||||||
|
return appendQuery(targetUrl, {
|
||||||
|
work: launchTarget.work,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWebViewUrlFromRuntimeConfig(
|
||||||
|
authResult,
|
||||||
|
launchQuery = {},
|
||||||
|
runtimeConfig = {},
|
||||||
|
) {
|
||||||
|
const entryUrl = appendLaunchTargetToEntryUrl(
|
||||||
|
String(runtimeConfig.webViewEntryUrl || '').trim(),
|
||||||
|
launchQuery,
|
||||||
|
);
|
||||||
|
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {});
|
||||||
|
if (!authResult || !authResult.token) {
|
||||||
|
return sourcedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendHashParams(sourcedUrl, {
|
||||||
|
auth_provider: 'wechat',
|
||||||
|
auth_token: authResult.token,
|
||||||
|
auth_binding_status: authResult.bindingStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appendHashParams,
|
||||||
|
appendLaunchTargetToEntryUrl,
|
||||||
|
appendQuery,
|
||||||
|
buildWebViewSharePath,
|
||||||
|
buildWebViewShareTimelineQuery,
|
||||||
|
normalizeTargetPath,
|
||||||
|
resolveShareTargetFromWebViewMessage,
|
||||||
|
resolveLaunchTargetQuery,
|
||||||
|
resolveWebViewUrlFromRuntimeConfig,
|
||||||
|
};
|
||||||
110
miniprogram/pages/web-view/index.test.js
Normal file
110
miniprogram/pages/web-view/index.test.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import webViewBridge from './index.shared.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
appendLaunchTargetToEntryUrl,
|
||||||
|
buildWebViewSharePath,
|
||||||
|
buildWebViewShareTimelineQuery,
|
||||||
|
resolveShareTargetFromWebViewMessage,
|
||||||
|
resolveWebViewUrlFromRuntimeConfig,
|
||||||
|
} = webViewBridge;
|
||||||
|
|
||||||
|
const runtimeConfig = {
|
||||||
|
sourceQuery: {
|
||||||
|
clientType: 'mini_program',
|
||||||
|
clientRuntime: 'wechat_mini_program',
|
||||||
|
},
|
||||||
|
webViewEntryUrl: 'https://www.genarrative.world',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mini program web-view launch target', () => {
|
||||||
|
test('opens the H5 public work detail when launch query carries work params', () => {
|
||||||
|
expect(
|
||||||
|
appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678',
|
||||||
|
);
|
||||||
|
|
||||||
|
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
},
|
||||||
|
runtimeConfig,
|
||||||
|
);
|
||||||
|
const url = new URL(webViewUrl);
|
||||||
|
expect(url.pathname).toBe('/works/detail');
|
||||||
|
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
||||||
|
expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores unsupported launch target paths', () => {
|
||||||
|
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
targetPath: '/admin',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
},
|
||||||
|
runtimeConfig,
|
||||||
|
);
|
||||||
|
const url = new URL(webViewUrl);
|
||||||
|
expect(url.pathname).toBe('/');
|
||||||
|
expect(url.searchParams.get('work')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps public work params in native mini program share paths', () => {
|
||||||
|
const sharePath = buildWebViewSharePath({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
});
|
||||||
|
const url = new URL(sharePath, 'https://mini.test');
|
||||||
|
|
||||||
|
expect(url.pathname).toBe('/pages/web-view/index');
|
||||||
|
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||||
|
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
||||||
|
expect(
|
||||||
|
buildWebViewShareTimelineQuery({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
}),
|
||||||
|
).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads the latest H5 recommended work share target from web-view messages', () => {
|
||||||
|
expect(
|
||||||
|
resolveShareTargetFromWebViewMessage({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
type: 'genarrative:share-target',
|
||||||
|
payload: {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'PZ-0001',
|
||||||
|
title: '旧作品',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
type: 'genarrative:share-target',
|
||||||
|
payload: {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import {join} from 'node:path';
|
|||||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DevRunner,
|
|
||||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||||
assertSpacetimeToolVersionMatchesWorkspace,
|
assertSpacetimeToolVersionMatchesWorkspace,
|
||||||
buildApiServerProcessEnv,
|
buildApiServerProcessEnv,
|
||||||
buildDevStackSnapshot,
|
buildDevStackSnapshot,
|
||||||
|
buildLocalRustProcessEnv,
|
||||||
buildSpacetimePublishArgs,
|
buildSpacetimePublishArgs,
|
||||||
createDevServerSpawnOptions,
|
createDevServerSpawnOptions,
|
||||||
createWatchConfigs,
|
createWatchConfigs,
|
||||||
|
DevRunner,
|
||||||
isDirectModuleExecution,
|
isDirectModuleExecution,
|
||||||
isSpacetimePublishPermissionError,
|
isSpacetimePublishPermissionError,
|
||||||
normalizeCargoVersionRequirement,
|
normalizeCargoVersionRequirement,
|
||||||
parseSpacetimeToolVersion,
|
|
||||||
parseArgs,
|
parseArgs,
|
||||||
|
parseSpacetimeToolVersion,
|
||||||
resolveDevStackStatePath,
|
resolveDevStackStatePath,
|
||||||
shouldAcceptWatchEvent,
|
shouldAcceptWatchEvent,
|
||||||
} from './dev.mjs';
|
} from './dev.mjs';
|
||||||
@@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('dev scheduler Rust build env', () => {
|
||||||
|
test('local dev Rust env bypasses project sccache wrapper', () => {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{
|
||||||
|
RUSTC_WRAPPER: '/usr/bin/sccache',
|
||||||
|
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||||
|
},
|
||||||
|
{log: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
|
||||||
|
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
|
||||||
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
|
||||||
|
const env = buildLocalRustProcessEnv(
|
||||||
|
{
|
||||||
|
RUSTC_WRAPPER: 'custom-wrapper',
|
||||||
|
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||||
|
},
|
||||||
|
{log: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||||
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('dev scheduler stack state file', () => {
|
describe('dev scheduler stack state file', () => {
|
||||||
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
||||||
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
||||||
|
|||||||
1779
server-rs/Cargo.lock
generated
1779
server-rs/Cargo.lock
generated
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",
|
||||||
|
|||||||
13
server-rs/crates/server-manager-panel/Cargo.toml
Normal file
13
server-rs/crates/server-manager-panel/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "server-manager-panel"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = { version = "0.33", default-features = false, features = [
|
||||||
|
"default_fonts",
|
||||||
|
"glow",
|
||||||
|
"wayland",
|
||||||
|
"x11",
|
||||||
|
] }
|
||||||
577
server-rs/crates/server-manager-panel/src/app.rs
Normal file
577
server-rs/crates/server-manager-panel/src/app.rs
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::health::{
|
||||||
|
DiskSnapshot, HealthLevel, MemorySnapshot, ProbeSnapshot, ServerHealthReport, ServiceSnapshot,
|
||||||
|
};
|
||||||
|
use crate::remote::{
|
||||||
|
RemoteEvent, RemoteReceiver, RemoteSender, ServiceAction, channel, spawn_health_check,
|
||||||
|
spawn_service_action,
|
||||||
|
};
|
||||||
|
use crate::ssh_config::{SshAlias, discover_ssh_aliases};
|
||||||
|
|
||||||
|
const DEFAULT_MANAGED_SERVICES: &[&str] = &[
|
||||||
|
"genarrative-api.service",
|
||||||
|
"spacetimedb.service",
|
||||||
|
"nginx.service",
|
||||||
|
"genarrative-health-patrol.timer",
|
||||||
|
"genarrative-database-backup.timer",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ServerManagerApp {
|
||||||
|
servers: Vec<ServerState>,
|
||||||
|
selected_alias: Option<String>,
|
||||||
|
sidebar_collapsed: bool,
|
||||||
|
tx: RemoteSender,
|
||||||
|
rx: RemoteReceiver,
|
||||||
|
pending_confirmation: Option<ServiceConfirmation>,
|
||||||
|
custom_service_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerManagerApp {
|
||||||
|
fn default() -> Self {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
let aliases = discover_ssh_aliases();
|
||||||
|
let selected_alias = aliases.first().map(|alias| alias.name.clone());
|
||||||
|
Self {
|
||||||
|
servers: aliases.into_iter().map(ServerState::new).collect(),
|
||||||
|
selected_alias,
|
||||||
|
sidebar_collapsed: false,
|
||||||
|
tx,
|
||||||
|
rx,
|
||||||
|
pending_confirmation: None,
|
||||||
|
custom_service_name: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for ServerManagerApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.drain_remote_events(ctx);
|
||||||
|
self.render_confirm_dialog(ctx);
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(if self.sidebar_collapsed {
|
||||||
|
"展开侧栏"
|
||||||
|
} else {
|
||||||
|
"收起侧栏"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.sidebar_collapsed = !self.sidebar_collapsed;
|
||||||
|
}
|
||||||
|
if ui.button("重新读取 SSH alias").clicked() {
|
||||||
|
self.reload_aliases();
|
||||||
|
}
|
||||||
|
if let Some(alias) = self.selected_alias.clone() {
|
||||||
|
if ui.button("刷新当前服务器").clicked() {
|
||||||
|
self.refresh_server(&alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.label("本地 SSH alias 管理");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if !self.sidebar_collapsed {
|
||||||
|
egui::SidePanel::left("server_sidebar")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(260.0)
|
||||||
|
.width_range(180.0..=360.0)
|
||||||
|
.show(ctx, |ui| self.render_sidebar(ui));
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
if self.servers.is_empty() {
|
||||||
|
self.render_empty_state(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(alias) = self.selected_alias.clone() else {
|
||||||
|
self.render_empty_state(ui);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(index) = self.server_index(&alias) {
|
||||||
|
self.render_server_detail(ui, index);
|
||||||
|
} else {
|
||||||
|
ui.label("请选择服务器");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerManagerApp {
|
||||||
|
fn drain_remote_events(&mut self, ctx: &egui::Context) {
|
||||||
|
while let Ok(event) = self.rx.try_recv() {
|
||||||
|
match event {
|
||||||
|
RemoteEvent::Health { alias, result } => {
|
||||||
|
if let Some(server) = self.server_mut(&alias) {
|
||||||
|
server.loading = false;
|
||||||
|
match result {
|
||||||
|
Ok(report) => {
|
||||||
|
server.error = None;
|
||||||
|
server.report = Some(report);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
server.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RemoteEvent::ServiceAction {
|
||||||
|
alias,
|
||||||
|
service,
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
if let Some(server) = self.server_mut(&alias) {
|
||||||
|
server.action_in_progress = None;
|
||||||
|
server.action_log = Some(format!(
|
||||||
|
"{} {}: {}\n{}{}",
|
||||||
|
action.label(),
|
||||||
|
service,
|
||||||
|
result.summary,
|
||||||
|
result.stdout,
|
||||||
|
result.stderr
|
||||||
|
));
|
||||||
|
server.loading = true;
|
||||||
|
spawn_health_check(alias, self.tx.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.heading("服务器");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let mut refresh_alias: Option<String> = None;
|
||||||
|
|
||||||
|
for server in &mut self.servers {
|
||||||
|
let selected = self.selected_alias.as_deref() == Some(server.alias.name.as_str());
|
||||||
|
let response = ui.selectable_label(selected, server_label(server));
|
||||||
|
if response.clicked() {
|
||||||
|
self.selected_alias = Some(server.alias.name.clone());
|
||||||
|
}
|
||||||
|
response.on_hover_text(server.alias.source.display().to_string());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let status = server.status();
|
||||||
|
ui.colored_label(level_color(status), status.label());
|
||||||
|
if server.loading {
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
if ui.small_button("刷新").clicked() {
|
||||||
|
refresh_alias = Some(server.alias.name.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(alias) = refresh_alias {
|
||||||
|
self.refresh_server(&alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_empty_state(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.heading("未发现 SSH alias");
|
||||||
|
ui.label("请在 ~/.ssh/config 中配置 Host alias 后重新读取。");
|
||||||
|
if ui.button("重新读取").clicked() {
|
||||||
|
self.reload_aliases();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_server_detail(&mut self, ui: &mut egui::Ui, index: usize) {
|
||||||
|
let alias = self.servers[index].alias.name.clone();
|
||||||
|
let status = self.servers[index].status();
|
||||||
|
let loading = self.servers[index].loading;
|
||||||
|
let report = self.servers[index].report.clone();
|
||||||
|
let error = self.servers[index].error.clone();
|
||||||
|
let action_log = self.servers[index].action_log.clone();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.heading(&alias);
|
||||||
|
ui.colored_label(level_color(status), status.label());
|
||||||
|
if loading {
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
if let Some(error) = error {
|
||||||
|
ui.colored_label(warning_color(), format!("SSH 巡检失败:{error}"));
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(report) = report {
|
||||||
|
self.render_report(ui, &alias, &report);
|
||||||
|
} else {
|
||||||
|
ui.label("尚未执行巡检。");
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
self.render_service_controls(ui, &alias, index);
|
||||||
|
|
||||||
|
if let Some(log) = action_log {
|
||||||
|
ui.add_space(12.0);
|
||||||
|
egui::CollapsingHeader::new("最近一次服务操作输出")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::multiline(&mut log.clone())
|
||||||
|
.font(egui::TextStyle::Monospace)
|
||||||
|
.desired_rows(8)
|
||||||
|
.interactive(false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_report(&self, ui: &mut egui::Ui, alias: &str, report: &ServerHealthReport) {
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
info_chip(ui, "主机", value_or_dash(&report.host.hostname));
|
||||||
|
info_chip(ui, "内核", value_or_dash(&report.host.kernel));
|
||||||
|
info_chip(ui, "运行时间", value_or_dash(&report.host.uptime));
|
||||||
|
info_chip(ui, "检查时间", value_or_dash(&report.checked_at));
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
egui::CollapsingHeader::new("硬件状态")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
info_chip(ui, "CPU", value_or_dash(&report.hardware.cpu_model));
|
||||||
|
info_chip(ui, "核心", value_or_dash(&report.hardware.cpu_cores));
|
||||||
|
info_chip(ui, "负载", value_or_dash(&report.hardware.load_average));
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
memory_row(ui, "内存", &report.hardware.memory);
|
||||||
|
memory_row(ui, "Swap", &report.hardware.swap);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
for disk in &report.hardware.disks {
|
||||||
|
disk_row(ui, disk);
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
for sensor in &report.hardware.sensors {
|
||||||
|
ui.label(sensor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CollapsingHeader::new("服务状态")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::Grid::new(format!("{alias}_services"))
|
||||||
|
.striped(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.strong("服务");
|
||||||
|
ui.strong("状态");
|
||||||
|
ui.strong("子状态");
|
||||||
|
ui.strong("Unit");
|
||||||
|
ui.end_row();
|
||||||
|
for service in &report.services {
|
||||||
|
service_row(ui, service);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CollapsingHeader::new("HTTP 探测")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::Grid::new(format!("{alias}_probes"))
|
||||||
|
.striped(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.strong("探测");
|
||||||
|
ui.strong("状态码");
|
||||||
|
ui.strong("耗时");
|
||||||
|
ui.strong("目标");
|
||||||
|
ui.end_row();
|
||||||
|
for probe in &report.probes {
|
||||||
|
probe_row(ui, probe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(patrol) = &report.health_patrol {
|
||||||
|
egui::CollapsingHeader::new("生产健康巡检")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(level_color(patrol.level), &patrol.status);
|
||||||
|
ui.label(value_or_dash(&patrol.checked_at));
|
||||||
|
ui.label(value_or_dash(&patrol.summary));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::CollapsingHeader::new("原始巡检输出").show(ui, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::multiline(&mut report.raw_output.clone())
|
||||||
|
.font(egui::TextStyle::Monospace)
|
||||||
|
.desired_rows(12)
|
||||||
|
.interactive(false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_service_controls(&mut self, ui: &mut egui::Ui, alias: &str, index: usize) {
|
||||||
|
ui.heading("服务控制");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
let action_in_progress = self.servers[index].action_in_progress.clone();
|
||||||
|
for service in DEFAULT_MANAGED_SERVICES {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(*service);
|
||||||
|
for action in [
|
||||||
|
ServiceAction::Start,
|
||||||
|
ServiceAction::Stop,
|
||||||
|
ServiceAction::Restart,
|
||||||
|
] {
|
||||||
|
let disabled = action_in_progress.is_some();
|
||||||
|
if ui
|
||||||
|
.add_enabled(!disabled, egui::Button::new(action.label()))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.pending_confirmation = Some(ServiceConfirmation {
|
||||||
|
alias: alias.to_owned(),
|
||||||
|
service: (*service).to_owned(),
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("其他 unit");
|
||||||
|
ui.text_edit_singleline(&mut self.custom_service_name);
|
||||||
|
if ui.button("启动").clicked() {
|
||||||
|
self.confirm_custom_service(alias, ServiceAction::Start);
|
||||||
|
}
|
||||||
|
if ui.button("关闭").clicked() {
|
||||||
|
self.confirm_custom_service(alias, ServiceAction::Stop);
|
||||||
|
}
|
||||||
|
if ui.button("重启").clicked() {
|
||||||
|
self.confirm_custom_service(alias, ServiceAction::Restart);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(action) = action_in_progress {
|
||||||
|
ui.label(format!("正在执行:{action}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_confirm_dialog(&mut self, ctx: &egui::Context) {
|
||||||
|
let Some(confirmation) = self.pending_confirmation.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
egui::Window::new("确认服务操作")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.label(format!(
|
||||||
|
"确认在 {} 上{} {}?",
|
||||||
|
confirmation.alias,
|
||||||
|
confirmation.action.label(),
|
||||||
|
confirmation.service
|
||||||
|
));
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("确认").clicked() {
|
||||||
|
self.execute_service_action(&confirmation);
|
||||||
|
self.pending_confirmation = None;
|
||||||
|
}
|
||||||
|
if ui.button("取消").clicked() {
|
||||||
|
self.pending_confirmation = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_aliases(&mut self) {
|
||||||
|
let aliases = discover_ssh_aliases();
|
||||||
|
self.servers = aliases.into_iter().map(ServerState::new).collect();
|
||||||
|
self.selected_alias = self.servers.first().map(|server| server.alias.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_server(&mut self, alias: &str) {
|
||||||
|
if let Some(server) = self.server_mut(alias) {
|
||||||
|
server.loading = true;
|
||||||
|
server.error = None;
|
||||||
|
}
|
||||||
|
spawn_health_check(alias.to_owned(), self.tx.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_custom_service(&mut self, alias: &str, action: ServiceAction) {
|
||||||
|
let service = self.custom_service_name.trim();
|
||||||
|
if service.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pending_confirmation = Some(ServiceConfirmation {
|
||||||
|
alias: alias.to_owned(),
|
||||||
|
service: service.to_owned(),
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_service_action(&mut self, confirmation: &ServiceConfirmation) {
|
||||||
|
if let Some(server) = self.server_mut(&confirmation.alias) {
|
||||||
|
server.action_in_progress = Some(format!(
|
||||||
|
"{} {}",
|
||||||
|
confirmation.action.label(),
|
||||||
|
confirmation.service
|
||||||
|
));
|
||||||
|
server.action_log = None;
|
||||||
|
}
|
||||||
|
spawn_service_action(
|
||||||
|
confirmation.alias.clone(),
|
||||||
|
confirmation.service.clone(),
|
||||||
|
confirmation.action,
|
||||||
|
self.tx.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_index(&self, alias: &str) -> Option<usize> {
|
||||||
|
self.servers
|
||||||
|
.iter()
|
||||||
|
.position(|server| server.alias.name == alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_mut(&mut self, alias: &str) -> Option<&mut ServerState> {
|
||||||
|
self.servers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|server| server.alias.name == alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ServiceConfirmation {
|
||||||
|
alias: String,
|
||||||
|
service: String,
|
||||||
|
action: ServiceAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ServerState {
|
||||||
|
alias: SshAlias,
|
||||||
|
report: Option<ServerHealthReport>,
|
||||||
|
loading: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
action_in_progress: Option<String>,
|
||||||
|
action_log: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerState {
|
||||||
|
fn new(alias: SshAlias) -> Self {
|
||||||
|
Self {
|
||||||
|
alias,
|
||||||
|
report: None,
|
||||||
|
loading: false,
|
||||||
|
error: None,
|
||||||
|
action_in_progress: None,
|
||||||
|
action_log: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(&self) -> HealthLevel {
|
||||||
|
if self.error.is_some() {
|
||||||
|
HealthLevel::Critical
|
||||||
|
} else {
|
||||||
|
self.report
|
||||||
|
.as_ref()
|
||||||
|
.map(|report| report.status)
|
||||||
|
.unwrap_or(HealthLevel::Unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_label(server: &ServerState) -> String {
|
||||||
|
let prefix = match server.status() {
|
||||||
|
HealthLevel::Ok => "[OK]",
|
||||||
|
HealthLevel::Warning => "[!]",
|
||||||
|
HealthLevel::Critical => "[X]",
|
||||||
|
HealthLevel::Unknown => "[?]",
|
||||||
|
};
|
||||||
|
format!("{prefix} {}", server.alias.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_row(ui: &mut egui::Ui, service: &ServiceSnapshot) {
|
||||||
|
ui.label(&service.name);
|
||||||
|
ui.colored_label(level_color(service.level), &service.active);
|
||||||
|
ui.label(&service.sub);
|
||||||
|
ui.label(&service.unit_file);
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_row(ui: &mut egui::Ui, probe: &ProbeSnapshot) {
|
||||||
|
ui.label(&probe.name);
|
||||||
|
ui.colored_label(level_color(probe.level), &probe.http_code);
|
||||||
|
ui.label(
|
||||||
|
probe
|
||||||
|
.elapsed_ms
|
||||||
|
.map(|elapsed| format!("{elapsed}ms"))
|
||||||
|
.unwrap_or_else(|| "-".to_owned()),
|
||||||
|
);
|
||||||
|
ui.label(&probe.target);
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memory_row(ui: &mut egui::Ui, label: &str, memory: &MemorySnapshot) {
|
||||||
|
let percent = memory.used_percent.unwrap_or_default();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(label);
|
||||||
|
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
|
||||||
|
ui.label(format!(
|
||||||
|
"已用 {} / 总计 {},可用 {}",
|
||||||
|
value_or_dash(&memory.used),
|
||||||
|
value_or_dash(&memory.total),
|
||||||
|
value_or_dash(&memory.available)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disk_row(ui: &mut egui::Ui, disk: &DiskSnapshot) {
|
||||||
|
let percent = disk.used_percent.unwrap_or_default();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(&disk.mount);
|
||||||
|
ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%")));
|
||||||
|
ui.label(format!(
|
||||||
|
"{} 已用 {} / {},可用 {}",
|
||||||
|
disk.filesystem, disk.used, disk.size, disk.available
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_chip(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.small(label);
|
||||||
|
ui.label(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_or_dash(value: &str) -> &str {
|
||||||
|
if value.trim().is_empty() { "-" } else { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_color(level: HealthLevel) -> egui::Color32 {
|
||||||
|
match level {
|
||||||
|
HealthLevel::Ok => egui::Color32::from_rgb(38, 166, 91),
|
||||||
|
HealthLevel::Warning => egui::Color32::from_rgb(214, 137, 16),
|
||||||
|
HealthLevel::Critical => egui::Color32::from_rgb(205, 66, 70),
|
||||||
|
HealthLevel::Unknown => egui::Color32::from_rgb(120, 126, 136),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warning_color() -> egui::Color32 {
|
||||||
|
egui::Color32::from_rgb(205, 66, 70)
|
||||||
|
}
|
||||||
128
server-rs/crates/server-manager-panel/src/fonts.rs
Normal file
128
server-rs/crates/server-manager-panel/src/fonts.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use eframe::egui::{FontData, FontDefinitions, FontFamily};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CjkFontCandidate {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_cjk_font(ctx: &eframe::egui::Context) -> Option<CjkFontCandidate> {
|
||||||
|
let candidate = find_cjk_font_candidate()?;
|
||||||
|
let bytes = std::fs::read(&candidate.path).ok()?;
|
||||||
|
let mut font_data = FontData::from_owned(bytes);
|
||||||
|
font_data.index = candidate.index;
|
||||||
|
|
||||||
|
let mut definitions = FontDefinitions::default();
|
||||||
|
definitions
|
||||||
|
.font_data
|
||||||
|
.insert("genarrative-cjk".to_owned(), Arc::new(font_data));
|
||||||
|
|
||||||
|
// 中文注释:作为 fallback 注入,保留 egui 默认拉丁/图标字体,同时补齐中文 glyph。
|
||||||
|
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||||
|
definitions
|
||||||
|
.families
|
||||||
|
.entry(family)
|
||||||
|
.or_default()
|
||||||
|
.push("genarrative-cjk".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set_fonts(definitions);
|
||||||
|
Some(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_cjk_font_candidate() -> Option<CjkFontCandidate> {
|
||||||
|
if let Ok(path) = std::env::var("GENARRATIVE_SERVER_PANEL_CJK_FONT") {
|
||||||
|
if let Some(candidate) = parse_font_spec(&path) {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_PATHS: &[(&str, u32)] = &[
|
||||||
|
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2),
|
||||||
|
("/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc", 2),
|
||||||
|
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0),
|
||||||
|
("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0),
|
||||||
|
(
|
||||||
|
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/home/dsk/.local/share/fonts/genarrative-cjk/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (path, index) in KNOWN_PATHS {
|
||||||
|
if Path::new(path).is_file() {
|
||||||
|
return Some(CjkFontCandidate {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
index: *index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for family in [
|
||||||
|
"Noto Sans CJK SC",
|
||||||
|
"WenQuanYi Zen Hei",
|
||||||
|
"Droid Sans Fallback",
|
||||||
|
] {
|
||||||
|
if let Some(candidate) = find_with_fc_match(family) {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_font_spec(raw: &str) -> Option<CjkFontCandidate> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (path, index) = trimmed
|
||||||
|
.rsplit_once('|')
|
||||||
|
.and_then(|(path, index)| Some((path, index.parse().ok()?)))
|
||||||
|
.unwrap_or((trimmed, 0));
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
path.is_file().then_some(CjkFontCandidate { path, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_with_fc_match(family: &str) -> Option<CjkFontCandidate> {
|
||||||
|
let output = Command::new("fc-match")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("%{file}|%{index}\n")
|
||||||
|
.arg(family)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
stdout.lines().find_map(parse_font_spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_font_path_with_index() {
|
||||||
|
let candidate = parse_font_spec("/tmp/missing-font.ttc|2");
|
||||||
|
assert_eq!(candidate, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_existing_system_cjk_font() {
|
||||||
|
let candidate = find_cjk_font_candidate();
|
||||||
|
assert!(
|
||||||
|
candidate
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|candidate| candidate.path.is_file()),
|
||||||
|
"expected at least one CJK font on this development host"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
474
server-rs/crates/server-manager-panel/src/health.rs
Normal file
474
server-rs/crates/server-manager-panel/src/health.rs
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum HealthLevel {
|
||||||
|
Unknown,
|
||||||
|
Ok,
|
||||||
|
Warning,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthLevel {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
HealthLevel::Unknown => "未知",
|
||||||
|
HealthLevel::Ok => "正常",
|
||||||
|
HealthLevel::Warning => "警告",
|
||||||
|
HealthLevel::Critical => "异常",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rank(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
HealthLevel::Unknown => 1,
|
||||||
|
HealthLevel::Ok => 0,
|
||||||
|
HealthLevel::Warning => 2,
|
||||||
|
HealthLevel::Critical => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServerHealthReport {
|
||||||
|
pub status: HealthLevel,
|
||||||
|
pub checked_at: String,
|
||||||
|
pub host: HostSnapshot,
|
||||||
|
pub hardware: HardwareSnapshot,
|
||||||
|
pub services: Vec<ServiceSnapshot>,
|
||||||
|
pub probes: Vec<ProbeSnapshot>,
|
||||||
|
pub health_patrol: Option<HealthPatrolSnapshot>,
|
||||||
|
pub raw_output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HostSnapshot {
|
||||||
|
pub hostname: String,
|
||||||
|
pub kernel: String,
|
||||||
|
pub uptime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HardwareSnapshot {
|
||||||
|
pub cpu_model: String,
|
||||||
|
pub cpu_cores: String,
|
||||||
|
pub load_average: String,
|
||||||
|
pub memory: MemorySnapshot,
|
||||||
|
pub swap: MemorySnapshot,
|
||||||
|
pub disks: Vec<DiskSnapshot>,
|
||||||
|
pub sensors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct MemorySnapshot {
|
||||||
|
pub total: String,
|
||||||
|
pub used: String,
|
||||||
|
pub free: String,
|
||||||
|
pub available: String,
|
||||||
|
pub used_percent: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DiskSnapshot {
|
||||||
|
pub mount: String,
|
||||||
|
pub filesystem: String,
|
||||||
|
pub size: String,
|
||||||
|
pub used: String,
|
||||||
|
pub available: String,
|
||||||
|
pub used_percent: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServiceSnapshot {
|
||||||
|
pub name: String,
|
||||||
|
pub active: String,
|
||||||
|
pub sub: String,
|
||||||
|
pub unit_file: String,
|
||||||
|
pub level: HealthLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProbeSnapshot {
|
||||||
|
pub name: String,
|
||||||
|
pub target: String,
|
||||||
|
pub http_code: String,
|
||||||
|
pub elapsed_ms: Option<u64>,
|
||||||
|
pub level: HealthLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HealthPatrolSnapshot {
|
||||||
|
pub status: String,
|
||||||
|
pub checked_at: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub level: HealthLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_health_report(raw_output: &str) -> ServerHealthReport {
|
||||||
|
let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
|
||||||
|
for line in raw_output.lines() {
|
||||||
|
if let Some(name) = parse_section_marker(line) {
|
||||||
|
current = name.to_owned();
|
||||||
|
sections.entry(current.clone()).or_default();
|
||||||
|
} else if !current.is_empty() {
|
||||||
|
sections
|
||||||
|
.entry(current.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(line.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut report = ServerHealthReport {
|
||||||
|
status: HealthLevel::Unknown,
|
||||||
|
checked_at: section_value(§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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
server-rs/crates/server-manager-panel/src/lib.rs
Normal file
5
server-rs/crates/server-manager-panel/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod fonts;
|
||||||
|
pub mod health;
|
||||||
|
pub mod remote;
|
||||||
|
pub mod ssh_config;
|
||||||
21
server-rs/crates/server-manager-panel/src/main.rs
Normal file
21
server-rs/crates/server-manager-panel/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use server_manager_panel::app::ServerManagerApp;
|
||||||
|
use server_manager_panel::fonts::install_cjk_font;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let native_options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([1180.0, 760.0])
|
||||||
|
.with_min_inner_size([920.0, 620.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Genarrative 服务器管理面板",
|
||||||
|
native_options,
|
||||||
|
Box::new(|cc| {
|
||||||
|
install_cjk_font(&cc.egui_ctx);
|
||||||
|
Ok(Box::new(ServerManagerApp::default()))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
231
server-rs/crates/server-manager-panel/src/remote.rs
Normal file
231
server-rs/crates/server-manager-panel/src/remote.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::health::{HEALTH_SCRIPT, ServerHealthReport, parse_health_report};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ServiceAction {
|
||||||
|
Start,
|
||||||
|
Stop,
|
||||||
|
Restart,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceAction {
|
||||||
|
pub fn as_systemctl_arg(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ServiceAction::Start => "start",
|
||||||
|
ServiceAction::Stop => "stop",
|
||||||
|
ServiceAction::Restart => "restart",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ServiceAction::Start => "启动",
|
||||||
|
ServiceAction::Stop => "关闭",
|
||||||
|
ServiceAction::Restart => "重启",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RemoteCommandResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub summary: String,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RemoteEvent {
|
||||||
|
Health {
|
||||||
|
alias: String,
|
||||||
|
result: Result<ServerHealthReport, String>,
|
||||||
|
},
|
||||||
|
ServiceAction {
|
||||||
|
alias: String,
|
||||||
|
service: String,
|
||||||
|
action: ServiceAction,
|
||||||
|
result: RemoteCommandResult,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type RemoteSender = mpsc::Sender<RemoteEvent>;
|
||||||
|
pub type RemoteReceiver = mpsc::Receiver<RemoteEvent>;
|
||||||
|
|
||||||
|
pub fn channel() -> (RemoteSender, RemoteReceiver) {
|
||||||
|
mpsc::channel()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_health_check(alias: String, tx: RemoteSender) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result =
|
||||||
|
run_ssh_script(&alias, HEALTH_SCRIPT, Duration::from_secs(20)).and_then(|output| {
|
||||||
|
if output.success {
|
||||||
|
Ok(parse_health_report(&output.stdout))
|
||||||
|
} else {
|
||||||
|
Err(format_remote_error(&output))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = tx.send(RemoteEvent::Health { alias, result });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_service_action(
|
||||||
|
alias: String,
|
||||||
|
service: String,
|
||||||
|
action: ServiceAction,
|
||||||
|
tx: RemoteSender,
|
||||||
|
) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = if is_safe_service_name(&service) {
|
||||||
|
run_ssh_script(
|
||||||
|
&alias,
|
||||||
|
&build_service_action_script(&service, action),
|
||||||
|
Duration::from_secs(20),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|error| RemoteCommandResult {
|
||||||
|
success: false,
|
||||||
|
summary: error,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: String::new(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
RemoteCommandResult {
|
||||||
|
success: false,
|
||||||
|
summary: "服务名包含不允许的字符".to_owned(),
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: String::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = tx.send(RemoteEvent::ServiceAction {
|
||||||
|
alias,
|
||||||
|
service,
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_safe_service_name(service: &str) -> bool {
|
||||||
|
!service.is_empty()
|
||||||
|
&& service.len() <= 128
|
||||||
|
&& service.bytes().all(|byte| {
|
||||||
|
matches!(
|
||||||
|
byte,
|
||||||
|
b'a'..=b'z'
|
||||||
|
| b'A'..=b'Z'
|
||||||
|
| b'0'..=b'9'
|
||||||
|
| b'.'
|
||||||
|
| b'_'
|
||||||
|
| b'-'
|
||||||
|
| b'@'
|
||||||
|
| b':'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_service_action_script(service: &str, action: ServiceAction) -> String {
|
||||||
|
format!(
|
||||||
|
r#"set -eu
|
||||||
|
service='{service}'
|
||||||
|
action='{action}'
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
systemctl "$action" "$service"
|
||||||
|
else
|
||||||
|
sudo -n systemctl "$action" "$service"
|
||||||
|
fi
|
||||||
|
systemctl is-active "$service" || true
|
||||||
|
systemctl status "$service" --no-pager -l -n 12 || true
|
||||||
|
"#,
|
||||||
|
service = service,
|
||||||
|
action = action.as_systemctl_arg()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ssh_script(
|
||||||
|
alias: &str,
|
||||||
|
script: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<RemoteCommandResult, String> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut child = Command::new("ssh")
|
||||||
|
.arg("-o")
|
||||||
|
.arg("BatchMode=yes")
|
||||||
|
.arg("-o")
|
||||||
|
.arg("ConnectTimeout=8")
|
||||||
|
.arg(alias)
|
||||||
|
.arg("sh")
|
||||||
|
.arg("-s")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| format!("无法启动 ssh: {error}"))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
// 中文注释:写完脚本后必须关闭 stdin,让远端 `sh -s` 收到 EOF 并开始退出。
|
||||||
|
let Some(mut stdin) = child.stdin.take() else {
|
||||||
|
return Err("无法写入 ssh stdin".to_owned());
|
||||||
|
};
|
||||||
|
stdin
|
||||||
|
.write_all(script.as_bytes())
|
||||||
|
.map_err(|error| format!("写入远端脚本失败: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_status)) => {
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|error| format!("读取 ssh 输出失败: {error}"))?;
|
||||||
|
let success = output.status.success();
|
||||||
|
return Ok(RemoteCommandResult {
|
||||||
|
success,
|
||||||
|
summary: if success {
|
||||||
|
"执行成功".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("ssh 退出码 {:?}", output.status.code())
|
||||||
|
},
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(None) if started.elapsed() >= timeout => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(format!("ssh 执行超过 {} 秒", timeout.as_secs()));
|
||||||
|
}
|
||||||
|
Ok(None) => thread::sleep(Duration::from_millis(80)),
|
||||||
|
Err(error) => return Err(format!("等待 ssh 进程失败: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_remote_error(result: &RemoteCommandResult) -> String {
|
||||||
|
let stderr = result.stderr.trim();
|
||||||
|
let stdout = result.stdout.trim();
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
format!("{}: {}", result.summary, stderr)
|
||||||
|
} else if !stdout.is_empty() {
|
||||||
|
format!("{}: {}", result.summary, stdout)
|
||||||
|
} else {
|
||||||
|
result.summary.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_systemd_unit_names_only() {
|
||||||
|
assert!(is_safe_service_name("genarrative-api.service"));
|
||||||
|
assert!(is_safe_service_name("worker@1.service"));
|
||||||
|
assert!(!is_safe_service_name("api.service;rm -rf /"));
|
||||||
|
assert!(!is_safe_service_name(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
143
server-rs/crates/server-manager-panel/src/ssh_config.rs
Normal file
143
server-rs/crates/server-manager-panel/src/ssh_config.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SshAlias {
|
||||||
|
pub name: String,
|
||||||
|
pub source: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_ssh_aliases() -> Vec<SshAlias> {
|
||||||
|
let Some(home) = std::env::var_os("HOME") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let config_path = PathBuf::from(home).join(".ssh/config");
|
||||||
|
discover_from_file(&config_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_from_file(path: &Path) -> Vec<SshAlias> {
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
let mut aliases = Vec::new();
|
||||||
|
discover_inner(path, &mut visited, &mut aliases);
|
||||||
|
dedupe_aliases(aliases)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_inner(path: &Path, visited: &mut HashSet<PathBuf>, aliases: &mut Vec<SshAlias>) {
|
||||||
|
let Ok(canonical) = path.canonicalize() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !visited.insert(canonical.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(content) = fs::read_to_string(&canonical) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = trim_comment(line);
|
||||||
|
let mut parts = trimmed.split_whitespace();
|
||||||
|
let Some(keyword) = parts.next() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if keyword.eq_ignore_ascii_case("host") {
|
||||||
|
aliases.extend(parts.filter_map(|name| {
|
||||||
|
is_concrete_alias(name).then(|| SshAlias {
|
||||||
|
name: name.to_owned(),
|
||||||
|
source: canonical.clone(),
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
} else if keyword.eq_ignore_ascii_case("include") {
|
||||||
|
for include in parts {
|
||||||
|
for include_path in expand_include_path(include, canonical.parent()) {
|
||||||
|
discover_inner(&include_path, visited, aliases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dedupe_aliases(aliases: Vec<SshAlias>) -> Vec<SshAlias> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for alias in aliases {
|
||||||
|
if seen.insert(alias.name.clone()) {
|
||||||
|
deduped.push(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_comment(line: &str) -> &str {
|
||||||
|
line.split('#').next().unwrap_or("").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_concrete_alias(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& !value.starts_with('-')
|
||||||
|
&& !value.starts_with('!')
|
||||||
|
&& !value.contains('*')
|
||||||
|
&& !value.contains('?')
|
||||||
|
&& !value.contains('%')
|
||||||
|
&& !value.contains('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_include_path(raw: &str, parent: Option<&Path>) -> Vec<PathBuf> {
|
||||||
|
if raw.contains('*') || raw.contains('?') {
|
||||||
|
// 中文注释:SSH Include 支持复杂 glob;面板只解析普通文件,避免误扫过大目录。
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|home| home.join(rest))
|
||||||
|
} else {
|
||||||
|
let path = PathBuf::from(raw);
|
||||||
|
if path.is_absolute() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
parent.map(|base| base.join(path))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
expanded.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_parser_ignores_wildcards_and_negations() {
|
||||||
|
let mut aliases = Vec::new();
|
||||||
|
let source = PathBuf::from("/tmp/config");
|
||||||
|
for line in [
|
||||||
|
"Host dev release *.internal !blocked",
|
||||||
|
"Host github.com",
|
||||||
|
"Host ?pattern",
|
||||||
|
"Host -bad",
|
||||||
|
] {
|
||||||
|
let trimmed = trim_comment(line);
|
||||||
|
let mut parts = trimmed.split_whitespace();
|
||||||
|
let keyword = parts.next().unwrap();
|
||||||
|
if keyword.eq_ignore_ascii_case("host") {
|
||||||
|
aliases.extend(parts.filter_map(|name| {
|
||||||
|
is_concrete_alias(name).then(|| SshAlias {
|
||||||
|
name: name.to_owned(),
|
||||||
|
source: source.clone(),
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: Vec<_> = dedupe_aliases(aliases)
|
||||||
|
.into_iter()
|
||||||
|
.map(|alias| alias.name)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["dev", "release", "github.com"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_trimming_keeps_plain_aliases() {
|
||||||
|
assert_eq!(trim_comment(" Host dev # release host "), "Host dev");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,24 +10,39 @@ import {
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import * as clipboardService from '../../services/clipboard';
|
import * as clipboardService from '../../services/clipboard';
|
||||||
|
import * as shareGridService from '../../services/wechatMiniProgramShareGrid';
|
||||||
import { PublishShareModal } from './PublishShareModal';
|
import { PublishShareModal } from './PublishShareModal';
|
||||||
import {
|
import {
|
||||||
|
buildMiniProgramPublishSharePath,
|
||||||
|
buildPublishShareCardFileName,
|
||||||
|
buildPublishShareCopyUrl,
|
||||||
buildPublishShareText,
|
buildPublishShareText,
|
||||||
|
buildPublishShareUrl,
|
||||||
type PublishShareModalPayload,
|
type PublishShareModalPayload,
|
||||||
} from './publishShareModalModel';
|
} from './publishShareModalModel';
|
||||||
|
|
||||||
vi.mock('../../services/clipboard', () => ({
|
vi.mock('../../services/clipboard', () => ({
|
||||||
copyTextToClipboard: vi.fn(),
|
copyTextToClipboard: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../../services/wechatMiniProgramShareGrid', () => ({
|
||||||
|
canUseWechatMiniProgramShareGrid: vi.fn(() => false),
|
||||||
|
openWechatMiniProgramShareGridPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const payload: PublishShareModalPayload = {
|
const payload: PublishShareModalPayload = {
|
||||||
title: '暖灯猫街',
|
title: '暖灯猫街',
|
||||||
publicWorkCode: 'PZ-00000001',
|
publicWorkCode: 'PZ-00000001',
|
||||||
stage: 'puzzle-gallery-detail',
|
stage: 'puzzle-gallery-detail',
|
||||||
|
workTypeLabel: '拼图',
|
||||||
|
coverImageSrc: '/cover.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PublishShareModal', () => {
|
describe('PublishShareModal', () => {
|
||||||
@@ -39,7 +54,40 @@ describe('PublishShareModal', () => {
|
|||||||
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
|
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders share text and channel icons, then copies from main button', async () => {
|
test('builds the card file name without unsafe path characters', () => {
|
||||||
|
expect(
|
||||||
|
buildPublishShareCardFileName({
|
||||||
|
title: '暖灯:猫街',
|
||||||
|
publicWorkCode: 'PZ-00000001',
|
||||||
|
}),
|
||||||
|
).toBe('暖灯猫街-PZ-00000001.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds a mini program share path with public work detail params', () => {
|
||||||
|
const sharePath = buildMiniProgramPublishSharePath(payload);
|
||||||
|
const url = new URL(sharePath, 'https://mini.test');
|
||||||
|
|
||||||
|
expect(url.pathname).toBe('/pages/web-view/index');
|
||||||
|
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||||
|
expect(url.searchParams.get('work')).toBe('PZ-00000001');
|
||||||
|
expect(buildPublishShareCopyUrl(payload, { miniProgramRuntime: true })).toBe(
|
||||||
|
sharePath,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps existing mini program share params and fills missing detail params', () => {
|
||||||
|
const sharePath = buildMiniProgramPublishSharePath(
|
||||||
|
payload,
|
||||||
|
'/pages/web-view/index?scene=poster',
|
||||||
|
);
|
||||||
|
const url = new URL(sharePath, 'https://mini.test');
|
||||||
|
|
||||||
|
expect(url.searchParams.get('scene')).toBe('poster');
|
||||||
|
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||||
|
expect(url.searchParams.get('work')).toBe('PZ-00000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the share card and copies the public link', async () => {
|
||||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -52,26 +100,55 @@ describe('PublishShareModal', () => {
|
|||||||
expect(dialog.className).toContain('platform-modal-shell');
|
expect(dialog.className).toContain('platform-modal-shell');
|
||||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||||
expect(dialog.getAttribute('style')).toBeNull();
|
expect(dialog.getAttribute('style')).toBeNull();
|
||||||
expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy();
|
expect(within(dialog).getByRole('region', { name: '分享卡片' })).toBeTruthy();
|
||||||
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
|
expect(within(dialog).getByText('拼图')).toBeTruthy();
|
||||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
expect(within(dialog).getByText('暖灯猫街')).toBeTruthy();
|
||||||
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy();
|
||||||
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
expect(within(dialog).getByRole('button', { name: '下载卡片' })).toBeTruthy();
|
||||||
expect(
|
expect(within(dialog).queryByRole('button', { name: '九宫切图' })).toBeNull();
|
||||||
within(dialog).getByTestId('share-channel-logo-wechat'),
|
|
||||||
).toBeTruthy();
|
|
||||||
expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
within(dialog).getByTestId('share-channel-logo-douyin'),
|
|
||||||
).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
|
fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' }));
|
||||||
|
|
||||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('作品号:PZ-00000001'),
|
buildPublishShareUrl(payload),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
|
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('copies the mini program link inside mini program web-view', async () => {
|
||||||
|
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PublishShareModal open payload={payload} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' }));
|
||||||
|
|
||||||
|
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||||
|
buildMiniProgramPublishSharePath(payload),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the mini program grid action only inside mini program runtime', () => {
|
||||||
|
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PublishShareModal open payload={payload} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: '九宫切图' })).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Check, Copy } from 'lucide-react';
|
import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||||
|
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
|
||||||
import {
|
import {
|
||||||
buildPublishShareText,
|
canUseWechatMiniProgramShareGrid,
|
||||||
|
openWechatMiniProgramShareGridPage,
|
||||||
|
} from '../../services/wechatMiniProgramShareGrid';
|
||||||
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
import { downloadPublishShareCardImage } from './publishShareCardImage';
|
||||||
|
import {
|
||||||
|
buildPublishShareCopyUrl,
|
||||||
type PublishShareModalPayload,
|
type PublishShareModalPayload,
|
||||||
} from './publishShareModalModel';
|
} from './publishShareModalModel';
|
||||||
import { UnifiedModal } from './UnifiedModal';
|
import { UnifiedModal } from './UnifiedModal';
|
||||||
@@ -15,78 +23,27 @@ type PublishShareModalProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
|
type ActionState = 'idle' | 'success' | 'failed';
|
||||||
|
|
||||||
type ShareChannel = {
|
function normalizePayloadTitle(payload: PublishShareModalPayload | null) {
|
||||||
id: ShareChannelId;
|
return payload?.title.trim() || '我的作品';
|
||||||
label: string;
|
}
|
||||||
iconClassName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
|
|
||||||
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
|
|
||||||
wechat:
|
|
||||||
'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
|
|
||||||
qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
|
|
||||||
douyin:
|
|
||||||
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SHARE_CHANNELS = [
|
|
||||||
{
|
|
||||||
id: 'wechat',
|
|
||||||
label: '微信',
|
|
||||||
iconClassName: 'bg-[#07c160] text-white',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'qq',
|
|
||||||
label: 'QQ',
|
|
||||||
iconClassName: 'bg-[#12b7f5] text-white',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'douyin',
|
|
||||||
label: '抖音',
|
|
||||||
iconClassName: 'bg-black text-white',
|
|
||||||
},
|
|
||||||
] as const satisfies readonly ShareChannel[];
|
|
||||||
|
|
||||||
function ShareChannelLogo({ channel }: { channel: ShareChannel }) {
|
|
||||||
const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id];
|
|
||||||
|
|
||||||
if (channel.id === 'douyin') {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="-1 -1 26 26"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
className="h-6 w-6 overflow-visible"
|
|
||||||
data-share-channel-logo={channel.id}
|
|
||||||
data-testid={`share-channel-logo-${channel.id}`}
|
|
||||||
>
|
|
||||||
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
|
|
||||||
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
|
|
||||||
<path d={iconPath} fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) {
|
||||||
return (
|
return (
|
||||||
<svg
|
payload?.coverImageSrc?.trim() ||
|
||||||
viewBox="0 0 24 24"
|
payload?.fallbackCoverImageSrc?.trim() ||
|
||||||
aria-hidden="true"
|
''
|
||||||
focusable="false"
|
|
||||||
className="h-6 w-6"
|
|
||||||
data-share-channel-logo={channel.id}
|
|
||||||
data-testid={`share-channel-logo-${channel.id}`}
|
|
||||||
>
|
|
||||||
<path d={iconPath} fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
|
||||||
|
return payload?.workTypeLabel?.trim() || '互动作品';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布完成后的分享弹窗。
|
* 发布完成后的通用分享弹窗。
|
||||||
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK,可以只替换这里的渠道点击逻辑。
|
* 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。
|
||||||
*/
|
*/
|
||||||
export function PublishShareModal({
|
export function PublishShareModal({
|
||||||
open,
|
open,
|
||||||
@@ -94,14 +51,24 @@ export function PublishShareModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: PublishShareModalProps) {
|
}: PublishShareModalProps) {
|
||||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
const [copyState, setCopyState] = useState<ActionState>('idle');
|
||||||
'idle',
|
const [downloadState, setDownloadState] = useState<ActionState>('idle');
|
||||||
);
|
const [gridState, setGridState] = useState<ActionState>('idle');
|
||||||
const resetTimerRef = useRef<number | null>(null);
|
const resetTimerRef = useRef<number | null>(null);
|
||||||
const shareText = useMemo(
|
const shareCopyUrl = useMemo(
|
||||||
() => (payload ? buildPublishShareText(payload) : ''),
|
() =>
|
||||||
|
payload
|
||||||
|
? buildPublishShareCopyUrl(payload, {
|
||||||
|
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
|
||||||
|
})
|
||||||
|
: '',
|
||||||
[payload],
|
[payload],
|
||||||
);
|
);
|
||||||
|
const title = normalizePayloadTitle(payload);
|
||||||
|
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
|
||||||
|
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
|
||||||
|
const showMiniProgramGridButton =
|
||||||
|
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -114,25 +81,92 @@ export function PublishShareModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCopyState('idle');
|
setCopyState('idle');
|
||||||
|
setDownloadState('idle');
|
||||||
|
setGridState('idle');
|
||||||
}, [payload?.publicWorkCode]);
|
}, [payload?.publicWorkCode]);
|
||||||
|
|
||||||
const copyShareText = () => {
|
const scheduleStateReset = () => {
|
||||||
if (!shareText) {
|
if (resetTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(resetTimerRef.current);
|
||||||
|
}
|
||||||
|
resetTimerRef.current = window.setTimeout(() => {
|
||||||
|
resetTimerRef.current = null;
|
||||||
|
setCopyState('idle');
|
||||||
|
setDownloadState('idle');
|
||||||
|
setGridState('idle');
|
||||||
|
}, 1400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShareLink = () => {
|
||||||
|
if (!shareCopyUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void copyTextToClipboard(shareText).then((copied) => {
|
void copyTextToClipboard(shareCopyUrl).then((copied) => {
|
||||||
setCopyState(copied ? 'copied' : 'failed');
|
setCopyState(copied ? 'success' : 'failed');
|
||||||
if (resetTimerRef.current !== null) {
|
scheduleStateReset();
|
||||||
window.clearTimeout(resetTimerRef.current);
|
|
||||||
}
|
|
||||||
resetTimerRef.current = window.setTimeout(() => {
|
|
||||||
resetTimerRef.current = null;
|
|
||||||
setCopyState('idle');
|
|
||||||
}, 1400);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveMiniProgramGridCover = async () => {
|
||||||
|
if (!coverImageSrc) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resolveAssetReadUrl(coverImageSrc, {
|
||||||
|
expireSeconds: 600,
|
||||||
|
}).catch(() => coverImageSrc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadShareCard = () => {
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadState('idle');
|
||||||
|
void downloadPublishShareCardImage(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
title,
|
||||||
|
workTypeLabel,
|
||||||
|
coverImageSrc,
|
||||||
|
},
|
||||||
|
coverImageSrc,
|
||||||
|
)
|
||||||
|
.then((downloaded) => {
|
||||||
|
setDownloadState(downloaded ? 'success' : 'failed');
|
||||||
|
scheduleStateReset();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setDownloadState('failed');
|
||||||
|
scheduleStateReset();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMiniProgramGridDownload = () => {
|
||||||
|
if (!payload || !coverImageSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGridState('idle');
|
||||||
|
void resolveMiniProgramGridCover()
|
||||||
|
.then((resolvedCoverImageSrc) =>
|
||||||
|
openWechatMiniProgramShareGridPage({
|
||||||
|
imageUrl: resolvedCoverImageSrc,
|
||||||
|
title,
|
||||||
|
publicWorkCode: payload.publicWorkCode,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((opened) => {
|
||||||
|
setGridState(opened ? 'success' : 'failed');
|
||||||
|
scheduleStateReset();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setGridState('failed');
|
||||||
|
scheduleStateReset();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<UnifiedModal
|
||||||
open={open && Boolean(payload)}
|
open={open && Boolean(payload)}
|
||||||
@@ -142,53 +176,98 @@ export function PublishShareModal({
|
|||||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
||||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||||
footer={
|
footer={
|
||||||
<div className="grid w-full grid-cols-3 gap-3">
|
<div
|
||||||
{SHARE_CHANNELS.map((channel) => {
|
className={`grid w-full gap-3 ${
|
||||||
return (
|
showMiniProgramGridButton ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2'
|
||||||
<button
|
}`}
|
||||||
key={channel.id}
|
>
|
||||||
type="button"
|
<button
|
||||||
onClick={copyShareText}
|
type="button"
|
||||||
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
|
onClick={copyShareLink}
|
||||||
aria-label={`分享到${channel.label}`}
|
disabled={!shareCopyUrl}
|
||||||
title={channel.label}
|
className="platform-button platform-button--primary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
>
|
>
|
||||||
<span
|
{copyState === 'success' ? (
|
||||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
|
<Check className="h-4 w-4" />
|
||||||
>
|
) : (
|
||||||
<ShareChannelLogo channel={channel} />
|
<Link2 className="h-4 w-4" />
|
||||||
</span>
|
)}
|
||||||
<span>{channel.label}</span>
|
{copyState === 'success'
|
||||||
</button>
|
? '已复制'
|
||||||
);
|
: copyState === 'failed'
|
||||||
})}
|
? '复制失败'
|
||||||
|
: '复制链接'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadShareCard}
|
||||||
|
disabled={!payload}
|
||||||
|
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
>
|
||||||
|
{downloadState === 'success' ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{downloadState === 'success'
|
||||||
|
? '已下载'
|
||||||
|
: downloadState === 'failed'
|
||||||
|
? '下载失败'
|
||||||
|
: '下载卡片'}
|
||||||
|
</button>
|
||||||
|
{showMiniProgramGridButton ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openMiniProgramGridDownload}
|
||||||
|
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
{gridState === 'success' ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{gridState === 'success'
|
||||||
|
? '已打开'
|
||||||
|
: gridState === 'failed'
|
||||||
|
? '打开失败'
|
||||||
|
: '九宫切图'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
<section
|
||||||
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
className="overflow-hidden rounded-lg border border-[var(--platform-subpanel-border)] bg-white/78 shadow-[0_18px_42px_rgba(127,85,57,0.12)]"
|
||||||
{shareText}
|
aria-label="分享卡片"
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copyShareText}
|
|
||||||
disabled={!shareText}
|
|
||||||
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
|
|
||||||
>
|
>
|
||||||
{copyState === 'copied' ? (
|
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(135deg,#f4c38b,#e7b5b7_48%,#9bbfd1)]">
|
||||||
<Check className="h-4 w-4" />
|
{coverImageSrc ? (
|
||||||
) : (
|
<ResolvedAssetImage
|
||||||
<Copy className="h-4 w-4" />
|
src={coverImageSrc}
|
||||||
)}
|
alt={title}
|
||||||
{copyState === 'copied'
|
className="h-full w-full object-cover"
|
||||||
? '已复制'
|
/>
|
||||||
: copyState === 'failed'
|
) : (
|
||||||
? '复制失败'
|
<div className="flex h-full w-full items-center justify-center text-6xl font-black text-white/84">
|
||||||
: '分享'}
|
{Array.from(title)[0] ?? '陶'}
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 px-4 py-4">
|
||||||
|
<div className="inline-flex max-w-full items-center rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-accent-text)]">
|
||||||
|
<span className="truncate">{workTypeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="line-clamp-2 text-lg font-black leading-snug text-[var(--platform-text-strong)]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-xs font-bold text-[var(--platform-text-muted)]">
|
||||||
|
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{payload?.publicWorkCode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</UnifiedModal>
|
</UnifiedModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/components/common/publishShareCardImage.test.ts
Normal file
146
src/components/common/publishShareCardImage.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||||
|
import {
|
||||||
|
downloadPublishShareCardImage,
|
||||||
|
resolvePublishShareCardCanvasImageSource,
|
||||||
|
} from './publishShareCardImage';
|
||||||
|
|
||||||
|
vi.mock('../../services/assetReadUrlService', async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import('../../services/assetReadUrlService')>(
|
||||||
|
'../../services/assetReadUrlService',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readAssetBytes: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const createObjectUrl = vi.fn(() => 'blob:share-card-cover');
|
||||||
|
const revokeObjectUrl = vi.fn();
|
||||||
|
const fillTextCalls: string[] = [];
|
||||||
|
|
||||||
|
function installObjectUrlMocks() {
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
value: createObjectUrl,
|
||||||
|
});
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
value: revokeObjectUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installCanvasMocks() {
|
||||||
|
class MockImage {
|
||||||
|
crossOrigin = '';
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
naturalWidth = 900;
|
||||||
|
naturalHeight = 900;
|
||||||
|
width = 900;
|
||||||
|
height = 900;
|
||||||
|
|
||||||
|
set src(_value: string) {
|
||||||
|
this.onload?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('Image', MockImage);
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
||||||
|
vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
clearRect: vi.fn(),
|
||||||
|
clip: vi.fn(),
|
||||||
|
closePath: vi.fn(),
|
||||||
|
createLinearGradient: vi.fn(() => ({
|
||||||
|
addColorStop: vi.fn(),
|
||||||
|
})),
|
||||||
|
drawImage: vi.fn(),
|
||||||
|
fill: vi.fn(),
|
||||||
|
fillRect: vi.fn(),
|
||||||
|
fillText: vi.fn((text: string) => {
|
||||||
|
fillTextCalls.push(text);
|
||||||
|
}),
|
||||||
|
lineTo: vi.fn(),
|
||||||
|
measureText: vi.fn((text: string) => ({
|
||||||
|
width: Array.from(text).length * 32,
|
||||||
|
})),
|
||||||
|
moveTo: vi.fn(),
|
||||||
|
quadraticCurveTo: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
} as unknown as CanvasRenderingContext2D);
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||||
|
(callback: BlobCallback) => {
|
||||||
|
callback(new Blob(['share-card'], { type: 'image/png' }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
fillTextCalls.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publishShareCardImage', () => {
|
||||||
|
test('loads generated covers through same-origin bytes before drawing to canvas', async () => {
|
||||||
|
installObjectUrlMocks();
|
||||||
|
vi.mocked(readAssetBytes).mockResolvedValue(
|
||||||
|
new Response(new Blob(['cover-bytes'], { type: 'image/png' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageSource = await resolvePublishShareCardCanvasImageSource(
|
||||||
|
'/generated-puzzle-assets/session/profile/covers/main.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readAssetBytes).toHaveBeenCalledWith(
|
||||||
|
'/generated-puzzle-assets/session/profile/covers/main.png',
|
||||||
|
{ expireSeconds: 600 },
|
||||||
|
);
|
||||||
|
expect(imageSource.src).toBe('blob:share-card-cover');
|
||||||
|
|
||||||
|
imageSource.release();
|
||||||
|
|
||||||
|
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:share-card-cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps ordinary public covers as their original source', async () => {
|
||||||
|
const imageSource = await resolvePublishShareCardCanvasImageSource(
|
||||||
|
'/creation-type-references/puzzle.webp',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readAssetBytes).not.toHaveBeenCalled();
|
||||||
|
expect(imageSource.src).toBe('/creation-type-references/puzzle.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports the same card content as the modal instead of adding extra branding', async () => {
|
||||||
|
installObjectUrlMocks();
|
||||||
|
installCanvasMocks();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
downloadPublishShareCardImage(
|
||||||
|
{
|
||||||
|
title: '三叶草',
|
||||||
|
publicWorkCode: 'PZ-BE68CC73',
|
||||||
|
stage: 'puzzle-gallery-detail',
|
||||||
|
workTypeLabel: '拼图',
|
||||||
|
coverImageSrc: '/cover.png',
|
||||||
|
},
|
||||||
|
'/cover.png',
|
||||||
|
),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fillTextCalls).toContain('拼图');
|
||||||
|
expect(fillTextCalls).toContain('三叶草');
|
||||||
|
expect(fillTextCalls).toContain('PZ-BE68CC73');
|
||||||
|
expect(fillTextCalls).not.toContain('陶泥儿');
|
||||||
|
});
|
||||||
|
});
|
||||||
403
src/components/common/publishShareCardImage.ts
Normal file
403
src/components/common/publishShareCardImage.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import {
|
||||||
|
readAssetBytes,
|
||||||
|
shouldResolveAssetReadUrl,
|
||||||
|
} from '../../services/assetReadUrlService';
|
||||||
|
import {
|
||||||
|
buildPublishShareCardFileName,
|
||||||
|
type PublishShareModalPayload,
|
||||||
|
} from './publishShareModalModel';
|
||||||
|
|
||||||
|
const CARD_WIDTH = 1080;
|
||||||
|
const CARD_HEIGHT = 1440;
|
||||||
|
const CARD_RADIUS = 24;
|
||||||
|
const COVER_X = 0;
|
||||||
|
const COVER_Y = 0;
|
||||||
|
const COVER_SIZE = CARD_WIDTH;
|
||||||
|
const CONTENT_PADDING_X = 48;
|
||||||
|
const CONTENT_TOP = COVER_Y + COVER_SIZE + 48;
|
||||||
|
const TYPE_PILL_HEIGHT = 64;
|
||||||
|
|
||||||
|
type PublishShareCardTheme = {
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
neutralBackground: string;
|
||||||
|
accentText: string;
|
||||||
|
titleText: string;
|
||||||
|
mutedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveCssColor(variableName: string, fallback: string) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(variableName)
|
||||||
|
.trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublishShareCardTheme(): PublishShareCardTheme {
|
||||||
|
return {
|
||||||
|
background: '#fffaf4',
|
||||||
|
border: resolveCssColor('--platform-subpanel-border', '#ead9c7'),
|
||||||
|
neutralBackground: resolveCssColor('--platform-neutral-bg', '#f2e3d5'),
|
||||||
|
accentText: resolveCssColor('--platform-accent-text', '#7f5539'),
|
||||||
|
titleText: resolveCssColor('--platform-text-strong', '#332820'),
|
||||||
|
mutedText: resolveCssColor('--platform-text-muted', '#a88e7c'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoundedRect(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x + radius, y);
|
||||||
|
context.lineTo(x + width - radius, y);
|
||||||
|
context.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
context.lineTo(x + width, y + height - radius);
|
||||||
|
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
context.lineTo(x + radius, y + height);
|
||||||
|
context.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
context.lineTo(x, y + radius);
|
||||||
|
context.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWrappedText(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
maxWidth: number,
|
||||||
|
lineHeight: number,
|
||||||
|
maxLines: number,
|
||||||
|
) {
|
||||||
|
const chars = Array.from(text.trim() || '我的作品');
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
for (const char of chars) {
|
||||||
|
const nextLine = `${currentLine}${char}`;
|
||||||
|
if (currentLine && context.measureText(nextLine).width > maxWidth) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = char;
|
||||||
|
if (lines.length >= maxLines) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentLine = nextLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length < maxLines && currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.slice(0, maxLines).forEach((line, index) => {
|
||||||
|
const isLast = index === maxLines - 1 && lines.length >= maxLines;
|
||||||
|
let displayLine = line;
|
||||||
|
while (
|
||||||
|
isLast &&
|
||||||
|
displayLine.length > 1 &&
|
||||||
|
context.measureText(`${displayLine}...`).width > maxWidth
|
||||||
|
) {
|
||||||
|
displayLine = displayLine.slice(0, -1);
|
||||||
|
}
|
||||||
|
context.fillText(isLast ? `${displayLine}...` : displayLine, x, y + index * lineHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldLoadImageWithAnonymousCors(src: string) {
|
||||||
|
if (
|
||||||
|
src.startsWith('data:') ||
|
||||||
|
src.startsWith('blob:') ||
|
||||||
|
typeof window === 'undefined'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(src, window.location.origin);
|
||||||
|
return (
|
||||||
|
/^https?:$/u.test(parsedUrl.protocol) &&
|
||||||
|
parsedUrl.origin !== window.location.origin
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePublishShareCardCanvasImageSource(src: string) {
|
||||||
|
const normalizedSrc = src.trim();
|
||||||
|
if (!normalizedSrc) {
|
||||||
|
return {
|
||||||
|
src: '',
|
||||||
|
release() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldResolveAssetReadUrl(normalizedSrc)) {
|
||||||
|
return {
|
||||||
|
src: normalizedSrc,
|
||||||
|
release() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await readAssetBytes(normalizedSrc, {
|
||||||
|
expireSeconds: 600,
|
||||||
|
});
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
return {
|
||||||
|
src: objectUrl,
|
||||||
|
release() {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCanvasImage(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
if (shouldLoadImageWithAnonymousCors(src)) {
|
||||||
|
image.crossOrigin = 'anonymous';
|
||||||
|
}
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error('分享卡封面加载失败'));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawImageCover(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
image: HTMLImageElement,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
const sourceWidth = image.naturalWidth || image.width;
|
||||||
|
const sourceHeight = image.naturalHeight || image.height;
|
||||||
|
const sourceRatio = sourceWidth / Math.max(1, sourceHeight);
|
||||||
|
const targetRatio = width / Math.max(1, height);
|
||||||
|
const cropWidth = sourceRatio > targetRatio ? sourceHeight * targetRatio : sourceWidth;
|
||||||
|
const cropHeight = sourceRatio > targetRatio ? sourceHeight : sourceWidth / targetRatio;
|
||||||
|
const cropX = (sourceWidth - cropWidth) / 2;
|
||||||
|
const cropY = (sourceHeight - cropHeight) / 2;
|
||||||
|
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
cropX,
|
||||||
|
cropY,
|
||||||
|
cropWidth,
|
||||||
|
cropHeight,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCoverFallback(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
payload: PublishShareModalPayload,
|
||||||
|
) {
|
||||||
|
const gradient = context.createLinearGradient(
|
||||||
|
COVER_X,
|
||||||
|
COVER_Y,
|
||||||
|
COVER_X + COVER_SIZE,
|
||||||
|
COVER_Y + COVER_SIZE,
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, '#f6c58d');
|
||||||
|
gradient.addColorStop(0.48, '#e7b7b7');
|
||||||
|
gradient.addColorStop(1, '#9bbfd1');
|
||||||
|
context.fillStyle = gradient;
|
||||||
|
context.fillRect(COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
|
||||||
|
|
||||||
|
context.fillStyle = 'rgba(255, 255, 255, 0.82)';
|
||||||
|
context.font = '900 156px sans-serif';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
const initial = Array.from(payload.title.trim() || '陶')[0] ?? '陶';
|
||||||
|
context.fillText(initial, CARD_WIDTH / 2, COVER_Y + COVER_SIZE / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCopyIcon(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = 4;
|
||||||
|
drawRoundedRect(context, x + 10, y, 24, 30, 4);
|
||||||
|
context.stroke();
|
||||||
|
drawRoundedRect(context, x, y + 10, 24, 30, 4);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawShareCard(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
payload: PublishShareModalPayload,
|
||||||
|
coverImageSrc: string,
|
||||||
|
) {
|
||||||
|
const theme = resolvePublishShareCardTheme();
|
||||||
|
context.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
drawRoundedRect(context, 0, 0, CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS);
|
||||||
|
context.clip();
|
||||||
|
|
||||||
|
context.fillStyle = theme.background;
|
||||||
|
context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
||||||
|
|
||||||
|
if (coverImageSrc) {
|
||||||
|
const canvasImageSource =
|
||||||
|
await resolvePublishShareCardCanvasImageSource(coverImageSrc);
|
||||||
|
try {
|
||||||
|
const image = await loadCanvasImage(canvasImageSource.src);
|
||||||
|
drawImageCover(context, image, COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
|
||||||
|
} finally {
|
||||||
|
canvasImageSource.release();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
drawCoverFallback(context, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = payload.workTypeLabel?.trim() || '互动作品';
|
||||||
|
context.font = '800 36px sans-serif';
|
||||||
|
const pillWidth = Math.min(
|
||||||
|
CARD_WIDTH - CONTENT_PADDING_X * 2,
|
||||||
|
Math.max(180, context.measureText(typeLabel).width + 72),
|
||||||
|
);
|
||||||
|
const pillY = CONTENT_TOP;
|
||||||
|
context.fillStyle = theme.neutralBackground;
|
||||||
|
drawRoundedRect(
|
||||||
|
context,
|
||||||
|
CONTENT_PADDING_X,
|
||||||
|
pillY,
|
||||||
|
pillWidth,
|
||||||
|
TYPE_PILL_HEIGHT,
|
||||||
|
TYPE_PILL_HEIGHT / 2,
|
||||||
|
);
|
||||||
|
context.fill();
|
||||||
|
context.fillStyle = theme.accentText;
|
||||||
|
context.textAlign = 'left';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(typeLabel, CONTENT_PADDING_X + 36, pillY + TYPE_PILL_HEIGHT / 2);
|
||||||
|
|
||||||
|
context.fillStyle = theme.titleText;
|
||||||
|
context.font = '900 72px sans-serif';
|
||||||
|
context.textBaseline = 'top';
|
||||||
|
drawWrappedText(
|
||||||
|
context,
|
||||||
|
payload.title,
|
||||||
|
CONTENT_PADDING_X,
|
||||||
|
pillY + 92,
|
||||||
|
CARD_WIDTH - CONTENT_PADDING_X * 2,
|
||||||
|
84,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const code = payload.publicWorkCode.trim();
|
||||||
|
if (code) {
|
||||||
|
const codeY = CARD_HEIGHT - 74;
|
||||||
|
context.fillStyle = theme.mutedText;
|
||||||
|
context.font = '700 34px sans-serif';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
drawCopyIcon(context, CONTENT_PADDING_X, codeY - 20, theme.mutedText);
|
||||||
|
context.fillText(code, CONTENT_PADDING_X + 54, codeY);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
context.strokeStyle = theme.border;
|
||||||
|
context.lineWidth = 3;
|
||||||
|
drawRoundedRect(context, 1.5, 1.5, CARD_WIDTH - 3, CARD_HEIGHT - 3, CARD_RADIUS);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
if (typeof canvas.toBlob !== 'function') {
|
||||||
|
try {
|
||||||
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
|
const binary = atob(dataUrl.split(',')[1] ?? '');
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
resolve(new Blob([bytes], { type: 'image/png' }));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('分享卡导出失败'));
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(blob: Blob, fileName: string) {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadPublishShareCardImage(
|
||||||
|
payload: PublishShareModalPayload,
|
||||||
|
coverImageSrc: string,
|
||||||
|
) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = CARD_WIDTH;
|
||||||
|
canvas.height = CARD_HEIGHT;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await drawShareCard(context, payload, coverImageSrc);
|
||||||
|
triggerDownload(
|
||||||
|
await canvasToBlob(canvas),
|
||||||
|
buildPublishShareCardFileName(payload),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
const fallbackCanvas = document.createElement('canvas');
|
||||||
|
fallbackCanvas.width = CARD_WIDTH;
|
||||||
|
fallbackCanvas.height = CARD_HEIGHT;
|
||||||
|
const fallbackContext = fallbackCanvas.getContext('2d');
|
||||||
|
if (!fallbackContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await drawShareCard(fallbackContext, payload, '');
|
||||||
|
triggerDownload(
|
||||||
|
await canvasToBlob(fallbackCanvas),
|
||||||
|
buildPublishShareCardFileName(payload),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||||
|
|
||||||
|
const MINI_PROGRAM_WEB_VIEW_PAGE_PATH = '/pages/web-view/index';
|
||||||
|
const MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH = '/works/detail';
|
||||||
|
|
||||||
export type PublishShareModalPayload = {
|
export type PublishShareModalPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
publicWorkCode: string;
|
publicWorkCode: string;
|
||||||
stage: SelectionStage;
|
stage: SelectionStage;
|
||||||
|
workTypeLabel?: string | null;
|
||||||
|
coverImageSrc?: string | null;
|
||||||
|
fallbackCoverImageSrc?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildShareUrl(payload: PublishShareModalPayload) {
|
export function buildPublishShareUrl(payload: PublishShareModalPayload) {
|
||||||
const sharePath = buildPublicWorkStagePath(
|
const sharePath = buildPublicWorkStagePath(
|
||||||
payload.stage,
|
payload.stage,
|
||||||
payload.publicWorkCode,
|
payload.publicWorkCode,
|
||||||
@@ -18,13 +24,56 @@ function buildShareUrl(payload: PublishShareModalPayload) {
|
|||||||
: new URL(sharePath, window.location.origin).href;
|
: new URL(sharePath, window.location.origin).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMiniProgramPublishSharePath(
|
||||||
|
payload: PublishShareModalPayload,
|
||||||
|
basePath = MINI_PROGRAM_WEB_VIEW_PAGE_PATH,
|
||||||
|
) {
|
||||||
|
const [path = MINI_PROGRAM_WEB_VIEW_PAGE_PATH, rawSearch = ''] =
|
||||||
|
basePath.split('?');
|
||||||
|
const params = new URLSearchParams(rawSearch);
|
||||||
|
const publicWorkCode = payload.publicWorkCode.trim();
|
||||||
|
|
||||||
|
if (!params.has('targetPath')) {
|
||||||
|
params.set('targetPath', MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH);
|
||||||
|
}
|
||||||
|
if (publicWorkCode && !params.has('work')) {
|
||||||
|
params.set('work', publicWorkCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString ? `${path}?${queryString}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublishShareCopyUrl(
|
||||||
|
payload: PublishShareModalPayload,
|
||||||
|
options: { miniProgramRuntime?: boolean } = {},
|
||||||
|
) {
|
||||||
|
return options.miniProgramRuntime
|
||||||
|
? buildMiniProgramPublishSharePath(payload)
|
||||||
|
: buildPublishShareUrl(payload);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPublishShareText(payload: PublishShareModalPayload) {
|
export function buildPublishShareText(payload: PublishShareModalPayload) {
|
||||||
const publicWorkCode = payload.publicWorkCode.trim();
|
const publicWorkCode = payload.publicWorkCode.trim();
|
||||||
const title = payload.title.trim() || '我的作品';
|
const title = payload.title.trim() || '我的作品';
|
||||||
|
|
||||||
return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildShareUrl({
|
return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildPublishShareUrl({
|
||||||
...payload,
|
...payload,
|
||||||
publicWorkCode,
|
publicWorkCode,
|
||||||
title,
|
title,
|
||||||
})}`;
|
})}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildPublishShareCardFileName(
|
||||||
|
payload: Pick<PublishShareModalPayload, 'title' | 'publicWorkCode'>,
|
||||||
|
) {
|
||||||
|
const title = payload.title.trim() || '我的作品';
|
||||||
|
const publicWorkCode = payload.publicWorkCode.trim() || 'share';
|
||||||
|
const safeTitle = Array.from(title)
|
||||||
|
.filter((char) => !/[\\/:*?"<>|]/u.test(char))
|
||||||
|
.join('')
|
||||||
|
.replace(/\s+/gu, '-')
|
||||||
|
.slice(0, 28)
|
||||||
|
.trim();
|
||||||
|
return `${safeTitle || '我的作品'}-${publicWorkCode}.png`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1093,6 +1093,9 @@ test('creation hub published share icon opens unified share payload without open
|
|||||||
title: '沉钟拼图',
|
title: '沉钟拼图',
|
||||||
publicWorkCode: 'PZ-PROFILE1',
|
publicWorkCode: 'PZ-PROFILE1',
|
||||||
stage: 'puzzle-gallery-detail',
|
stage: 'puzzle-gallery-detail',
|
||||||
|
workTypeLabel: '拼图',
|
||||||
|
coverImageSrc: null,
|
||||||
|
fallbackCoverImageSrc: '/creation-type-references/puzzle.webp',
|
||||||
});
|
});
|
||||||
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
default as React,
|
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
default as React,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
type TouchEvent as ReactTouchEvent,
|
type TouchEvent as ReactTouchEvent,
|
||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
formatPlatformWorkDisplayTag,
|
formatPlatformWorkDisplayTag,
|
||||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||||
import {
|
import {
|
||||||
|
CREATION_WORK_KIND_FALLBACK_COVER,
|
||||||
type CreationWorkShelfBadgeTone,
|
type CreationWorkShelfBadgeTone,
|
||||||
type CreationWorkShelfItem,
|
type CreationWorkShelfItem,
|
||||||
type CreationWorkShelfKind,
|
|
||||||
type CreationWorkShelfMetric,
|
type CreationWorkShelfMetric,
|
||||||
type CreationWorkShelfMetricId,
|
type CreationWorkShelfMetricId,
|
||||||
formatCreationMetricCount,
|
formatCreationMetricCount,
|
||||||
@@ -55,21 +55,6 @@ const SWIPE_ACTION_WIDTH_PX = 76;
|
|||||||
const SWIPE_REVEAL_THRESHOLD_PX = 42;
|
const SWIPE_REVEAL_THRESHOLD_PX = 42;
|
||||||
const SWIPE_DIRECTION_LOCK_PX = 8;
|
const SWIPE_DIRECTION_LOCK_PX = 8;
|
||||||
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
|
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
|
||||||
const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
|
||||||
{
|
|
||||||
rpg: '/creation-type-references/rpg.webp',
|
|
||||||
'big-fish': '/creation-type-references/big-fish.webp',
|
|
||||||
match3d: '/creation-type-references/match3d.webp',
|
|
||||||
'square-hole': '/creation-type-references/square-hole.webp',
|
|
||||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
|
||||||
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
|
||||||
'puzzle-clear': '/creation-type-references/puzzle.webp',
|
|
||||||
puzzle: '/creation-type-references/puzzle.webp',
|
|
||||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
|
||||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
|
||||||
'visual-novel': '/creation-type-references/visual-novel.webp',
|
|
||||||
};
|
|
||||||
|
|
||||||
function easeOutCubic(progress: number) {
|
function easeOutCubic(progress: number) {
|
||||||
return 1 - (1 - progress) ** 3;
|
return 1 - (1 - progress) ** 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,50 @@ export type CreationWorkShelfKind =
|
|||||||
| 'baby-object-match'
|
| 'baby-object-match'
|
||||||
| 'bark-battle'
|
| 'bark-battle'
|
||||||
| 'visual-novel';
|
| 'visual-novel';
|
||||||
|
|
||||||
|
export const CREATION_WORK_KIND_FALLBACK_COVER: Record<
|
||||||
|
CreationWorkShelfKind,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
rpg: '/creation-type-references/rpg.webp',
|
||||||
|
'big-fish': '/creation-type-references/big-fish.webp',
|
||||||
|
match3d: '/creation-type-references/match3d.webp',
|
||||||
|
'square-hole': '/creation-type-references/square-hole.webp',
|
||||||
|
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||||
|
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
||||||
|
'puzzle-clear': '/creation-type-references/puzzle.webp',
|
||||||
|
puzzle: '/creation-type-references/puzzle.webp',
|
||||||
|
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||||
|
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||||
|
'visual-novel': '/creation-type-references/visual-novel.webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function describeCreationWorkShelfKind(kind: CreationWorkShelfKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'rpg':
|
||||||
|
return 'RPG世界';
|
||||||
|
case 'big-fish':
|
||||||
|
return '大鱼吃小鱼';
|
||||||
|
case 'match3d':
|
||||||
|
return '抓大鹅';
|
||||||
|
case 'square-hole':
|
||||||
|
return '方洞挑战';
|
||||||
|
case 'jump-hop':
|
||||||
|
return '跳一跳';
|
||||||
|
case 'wooden-fish':
|
||||||
|
return '敲木鱼';
|
||||||
|
case 'puzzle-clear':
|
||||||
|
return '拼消消';
|
||||||
|
case 'puzzle':
|
||||||
|
return '拼图';
|
||||||
|
case 'baby-object-match':
|
||||||
|
return '宝贝识物';
|
||||||
|
case 'bark-battle':
|
||||||
|
return '汪汪声浪';
|
||||||
|
case 'visual-novel':
|
||||||
|
return '视觉小说';
|
||||||
|
}
|
||||||
|
}
|
||||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||||
|
|
||||||
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ import {
|
|||||||
updateVisualNovelWork,
|
updateVisualNovelWork,
|
||||||
} from '../../services/visual-novel-works';
|
} from '../../services/visual-novel-works';
|
||||||
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
|
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
|
||||||
|
import { postWechatMiniProgramShareTarget } from '../../services/wechatMiniProgramShareTarget';
|
||||||
import {
|
import {
|
||||||
woodenFishClient,
|
woodenFishClient,
|
||||||
type WoodenFishGalleryCardResponse,
|
type WoodenFishGalleryCardResponse,
|
||||||
@@ -378,6 +379,7 @@ import {
|
|||||||
selectAdjacentPlatformRecommendEntry,
|
selectAdjacentPlatformRecommendEntry,
|
||||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||||
import {
|
import {
|
||||||
|
describePublicGalleryCardKind,
|
||||||
isBigFishGalleryEntry,
|
isBigFishGalleryEntry,
|
||||||
isEdutainmentGalleryEntry,
|
isEdutainmentGalleryEntry,
|
||||||
isJumpHopGalleryEntry,
|
isJumpHopGalleryEntry,
|
||||||
@@ -387,6 +389,8 @@ import {
|
|||||||
mapPuzzleWorkToPlatformGalleryCard,
|
mapPuzzleWorkToPlatformGalleryCard,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
resolvePlatformPublicWorkCode,
|
resolvePlatformPublicWorkCode,
|
||||||
|
resolvePlatformWorldCoverImage,
|
||||||
|
resolvePlatformWorldFallbackCoverImage,
|
||||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||||
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
|
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
|
||||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||||
@@ -844,6 +848,25 @@ function resolveRecommendEntryShareStage(
|
|||||||
return 'work-detail';
|
return 'work-detail';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function postRecommendEntryMiniProgramShareTarget(
|
||||||
|
entry: PlatformPublicGalleryCard | null | undefined,
|
||||||
|
) {
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||||
|
if (!publicWorkCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return postWechatMiniProgramShareTarget({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: publicWorkCode,
|
||||||
|
title: entry.worldName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function pushPuzzleResultHistoryEntry(
|
function pushPuzzleResultHistoryEntry(
|
||||||
session: PuzzleAgentSessionSnapshot | null,
|
session: PuzzleAgentSessionSnapshot | null,
|
||||||
) {
|
) {
|
||||||
@@ -2994,10 +3017,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postRecommendEntryMiniProgramShareTarget(entry);
|
||||||
openPublishShareModal({
|
openPublishShareModal({
|
||||||
title: entry.worldName,
|
title: entry.worldName,
|
||||||
publicWorkCode,
|
publicWorkCode,
|
||||||
stage: resolveRecommendEntryShareStage(entry),
|
stage: resolveRecommendEntryShareStage(entry),
|
||||||
|
workTypeLabel: describePublicGalleryCardKind(entry),
|
||||||
|
coverImageSrc: resolvePlatformWorldCoverImage(entry),
|
||||||
|
fallbackCoverImageSrc: resolvePlatformWorldFallbackCoverImage(entry),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[openPublishShareModal],
|
[openPublishShareModal],
|
||||||
@@ -13734,6 +13761,22 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
puzzleRun?.currentLevel?.profileId ?? null,
|
puzzleRun?.currentLevel?.profileId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectionStage !== 'platform' ||
|
||||||
|
platformBootstrap.platformTab !== 'home' ||
|
||||||
|
!activeRecommendEntry
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
postRecommendEntryMiniProgramShareTarget(activeRecommendEntry);
|
||||||
|
}, [
|
||||||
|
activeRecommendEntry,
|
||||||
|
platformBootstrap.platformTab,
|
||||||
|
selectionStage,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const decision = resolvePlatformRecommendRuntimeAutoStartDecision({
|
const decision = resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||||
isDesktopLayout,
|
isDesktopLayout,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type {
|
||||||
|
MiniGameDraftGenerationKind,
|
||||||
|
MiniGameDraftGenerationState,
|
||||||
|
} from '../../services/miniGameDraftGenerationProgress';
|
||||||
|
import type { SelectionStage } from './platformEntryTypes';
|
||||||
|
|
||||||
|
type MiniGameGenerationProgressTickStateMap = Partial<
|
||||||
|
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function resolveMiniGameGenerationProgressTickState(
|
||||||
|
selectionStage: SelectionStage,
|
||||||
|
states: MiniGameGenerationProgressTickStateMap,
|
||||||
|
) {
|
||||||
|
const stageKindMap: Partial<
|
||||||
|
Record<SelectionStage, MiniGameDraftGenerationKind>
|
||||||
|
> = {
|
||||||
|
'puzzle-generating': 'puzzle',
|
||||||
|
'big-fish-generating': 'big-fish',
|
||||||
|
'square-hole-generating': 'square-hole',
|
||||||
|
'match3d-generating': 'match3d',
|
||||||
|
'baby-object-match-generating': 'baby-object-match',
|
||||||
|
'jump-hop-generating': 'jump-hop',
|
||||||
|
'puzzle-clear-generating': 'puzzle-clear',
|
||||||
|
'wooden-fish-generating': 'wooden-fish',
|
||||||
|
};
|
||||||
|
const kind = stageKindMap[selectionStage];
|
||||||
|
|
||||||
|
return kind ? (states[kind] ?? null) : null;
|
||||||
|
}
|
||||||
@@ -7,23 +7,23 @@ import type {
|
|||||||
JumpHopGalleryCardResponse,
|
JumpHopGalleryCardResponse,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import type {
|
|
||||||
PuzzleClearGalleryCardResponse,
|
|
||||||
PuzzleClearWorkProfileResponse,
|
|
||||||
PuzzleClearWorkSummaryResponse,
|
|
||||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
|
||||||
import type {
|
import type {
|
||||||
Match3DGeneratedBackgroundAsset,
|
Match3DGeneratedBackgroundAsset,
|
||||||
Match3DGeneratedItemAsset,
|
Match3DGeneratedItemAsset,
|
||||||
Match3DWorkSummary,
|
Match3DWorkSummary,
|
||||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
|
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
|
||||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||||
|
import type {
|
||||||
|
PuzzleClearGalleryCardResponse,
|
||||||
|
PuzzleClearWorkProfileResponse,
|
||||||
|
PuzzleClearWorkSummaryResponse,
|
||||||
|
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type {
|
import type {
|
||||||
CustomWorldGalleryCard,
|
CustomWorldGalleryCard,
|
||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
|
|
||||||
import type {
|
import type {
|
||||||
SquareHoleHoleOption,
|
SquareHoleHoleOption,
|
||||||
SquareHoleShapeOption,
|
SquareHoleShapeOption,
|
||||||
@@ -1162,6 +1162,42 @@ export function buildPlatformWorldDisplayTags(
|
|||||||
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit);
|
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function describePublicGalleryCardKind(
|
||||||
|
entry: PlatformPublicGalleryCard,
|
||||||
|
) {
|
||||||
|
if (isBigFishGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
|
||||||
|
}
|
||||||
|
if (isPuzzleGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('拼图');
|
||||||
|
}
|
||||||
|
if (isPuzzleClearGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('拼消消');
|
||||||
|
}
|
||||||
|
if (isMatch3DGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('抓大鹅');
|
||||||
|
}
|
||||||
|
if (isSquareHoleGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('方洞挑战');
|
||||||
|
}
|
||||||
|
if (isJumpHopGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('跳一跳');
|
||||||
|
}
|
||||||
|
if (isWoodenFishGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('敲木鱼');
|
||||||
|
}
|
||||||
|
if (isVisualNovelGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('视觉小说');
|
||||||
|
}
|
||||||
|
if (isBarkBattleGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag('汪汪声浪');
|
||||||
|
}
|
||||||
|
if (isEdutainmentGalleryEntry(entry)) {
|
||||||
|
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||||
|
}
|
||||||
|
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||||
if (isBigFishGalleryEntry(entry)) {
|
if (isBigFishGalleryEntry(entry)) {
|
||||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
||||||
|
|||||||
@@ -5303,6 +5303,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
box-shadow: var(--platform-recommend-runtime-shadow);
|
box-shadow: var(--platform-recommend-runtime-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-wechat-mini-program-runtime='true']
|
||||||
|
.platform-recommend-runtime-panel {
|
||||||
|
max-height: min(76dvh, 42rem);
|
||||||
|
transform: scale(0.88);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-wechat-mini-program-runtime='true']
|
||||||
|
.platform-recommend-swipe-card__visual {
|
||||||
|
transform: scale(0.88);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
.platform-recommend-runtime-viewport {
|
.platform-recommend-runtime-viewport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -114,6 +114,18 @@ describe('index stylesheet unread dots', () => {
|
|||||||
expect(block).toContain('var(--platform-cool-bg)');
|
expect(block).toContain('var(--platform-cool-bg)');
|
||||||
expect(block).not.toContain('background: transparent;');
|
expect(block).not.toContain('background: transparent;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps mini program recommend runtime inside a share snapshot safe area', () => {
|
||||||
|
const css = readIndexCss();
|
||||||
|
const block = getCssBlock(
|
||||||
|
css,
|
||||||
|
"html[data-wechat-mini-program-runtime='true']\n .platform-recommend-runtime-panel",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(block).toContain('max-height: min(76dvh, 42rem);');
|
||||||
|
expect(block).toContain('transform: scale(0.88);');
|
||||||
|
expect(block).toContain('transform-origin: center;');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('index stylesheet draft mobile cards', () => {
|
describe('index stylesheet draft mobile cards', () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock';
|
|||||||
import {resolveAppRoute} from './routing/appRoutes';
|
import {resolveAppRoute} from './routing/appRoutes';
|
||||||
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
||||||
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
||||||
|
import {isWechatMiniProgramWebViewRuntime} from './services/authService';
|
||||||
|
|
||||||
type AppRoot = ReturnType<typeof createRoot>;
|
type AppRoot = ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
@@ -26,11 +27,18 @@ if (!rootElement) {
|
|||||||
throw new Error('Missing #root container');
|
throw new Error('Missing #root container');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markWechatMiniProgramRuntime() {
|
||||||
|
if (isWechatMiniProgramWebViewRuntime()) {
|
||||||
|
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement);
|
const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement);
|
||||||
const RouteComponent = route.Component;
|
const RouteComponent = route.Component;
|
||||||
|
|
||||||
lockMobileViewportZoom();
|
lockMobileViewportZoom();
|
||||||
stabilizeMobileViewportKeyboardFocus();
|
stabilizeMobileViewportKeyboardFocus();
|
||||||
|
markWechatMiniProgramRuntime();
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -415,4 +415,28 @@ describe('assetReadUrlService', () => {
|
|||||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await readAssetBytes(
|
||||||
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc',
|
||||||
|
{ expireSeconds: 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.headers.get('content-type')).toBe('image/png');
|
||||||
|
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||||
|
'/api/assets/read-bytes?',
|
||||||
|
);
|
||||||
|
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||||
|
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
} from '../../packages/shared/src/http';
|
} from '../../packages/shared/src/http';
|
||||||
import {
|
import {
|
||||||
ApiClientError,
|
ApiClientError,
|
||||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
|
||||||
type ApiRequestOptions,
|
type ApiRequestOptions,
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||||
fetchWithApiAuth,
|
fetchWithApiAuth,
|
||||||
requestJson,
|
requestJson,
|
||||||
} from './apiClient';
|
} from './apiClient';
|
||||||
@@ -376,7 +376,11 @@ export async function readAssetBytes(
|
|||||||
throw new Error('资源路径不能为空');
|
throw new Error('资源路径不能为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGeneratedLegacyPath(value)) {
|
const legacyPath = isGeneratedLegacyPath(value)
|
||||||
|
? value
|
||||||
|
: resolveGeneratedLegacyPathFromUrl(value);
|
||||||
|
|
||||||
|
if (!legacyPath) {
|
||||||
const response = await fetch(value, { signal: options.signal });
|
const response = await fetch(value, { signal: options.signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('读取资源内容失败');
|
throw new Error('读取资源内容失败');
|
||||||
@@ -386,7 +390,7 @@ export async function readAssetBytes(
|
|||||||
|
|
||||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||||
const searchParams = buildAssetReadSearchParams({
|
const searchParams = buildAssetReadSearchParams({
|
||||||
legacyPublicPath: value,
|
legacyPublicPath: legacyPath,
|
||||||
expireSeconds: options.expireSeconds,
|
expireSeconds: options.expireSeconds,
|
||||||
});
|
});
|
||||||
const response = await fetchWithApiAuth(
|
const response = await fetchWithApiAuth(
|
||||||
|
|||||||
96
src/services/wechatMiniProgramShareGrid.ts
Normal file
96
src/services/wechatMiniProgramShareGrid.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { isWechatMiniProgramWebViewRuntime } from './authService';
|
||||||
|
|
||||||
|
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
|
const SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
|
||||||
|
|
||||||
|
function loadWechatMiniProgramBridge() {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!isWechatMiniProgramWebViewRuntime()
|
||||||
|
) {
|
||||||
|
return Promise.reject(new Error('not_mini_program'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.wx?.miniProgram?.navigateTo) {
|
||||||
|
return Promise.resolve(window.wx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||||
|
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||||
|
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||||
|
);
|
||||||
|
const complete = () => {
|
||||||
|
if (window.wx?.miniProgram?.navigateTo) {
|
||||||
|
resolve(window.wx);
|
||||||
|
} else {
|
||||||
|
reject(new Error('wechat_js_sdk_unavailable'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.addEventListener('load', complete, { once: true });
|
||||||
|
existingScript.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error('wechat_js_sdk_load_failed')),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = WECHAT_JS_SDK_URL;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = complete;
|
||||||
|
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAbsoluteUrl(value: string) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(value, window.location.origin).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseWechatMiniProgramShareGrid() {
|
||||||
|
return isWechatMiniProgramWebViewRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWechatMiniProgramShareGridPage(params: {
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
publicWorkCode: string;
|
||||||
|
}) {
|
||||||
|
const imageUrl = params.imageUrl.trim();
|
||||||
|
if (!imageUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wxBridge = await loadWechatMiniProgramBridge();
|
||||||
|
const miniProgram = wxBridge.miniProgram;
|
||||||
|
if (!miniProgram?.navigateTo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
imageUrl: buildAbsoluteUrl(imageUrl),
|
||||||
|
title: params.title.trim() || '我的作品',
|
||||||
|
publicWorkCode: params.publicWorkCode.trim(),
|
||||||
|
});
|
||||||
|
const url = `${SHARE_GRID_PAGE_URL}?${searchParams.toString()}`;
|
||||||
|
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
miniProgram.navigateTo?.({
|
||||||
|
url,
|
||||||
|
success() {
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
66
src/services/wechatMiniProgramShareTarget.test.ts
Normal file
66
src/services/wechatMiniProgramShareTarget.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWechatMiniProgramShareTargetMessage,
|
||||||
|
postWechatMiniProgramShareTarget,
|
||||||
|
} from './wechatMiniProgramShareTarget';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Reflect.deleteProperty(window, 'wx');
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wechatMiniProgramShareTarget', () => {
|
||||||
|
test('builds a compact share target message for mini program native share', () => {
|
||||||
|
expect(
|
||||||
|
buildWechatMiniProgramShareTargetMessage({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: ' BB-12345678 ',
|
||||||
|
title: ' 汪汪声浪 ',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: 'genarrative:share-target',
|
||||||
|
payload: {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts the current recommended work to mini program web-view host', () => {
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
|
);
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
postMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
postWechatMiniProgramShareTarget({
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
type: 'genarrative:share-target',
|
||||||
|
payload: {
|
||||||
|
targetPath: '/works/detail',
|
||||||
|
work: 'BB-12345678',
|
||||||
|
title: '汪汪声浪',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/services/wechatMiniProgramShareTarget.ts
Normal file
60
src/services/wechatMiniProgramShareTarget.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { isWechatMiniProgramWebViewRuntime } from './authService';
|
||||||
|
|
||||||
|
const MESSAGE_TYPE = 'genarrative:share-target';
|
||||||
|
|
||||||
|
export type WechatMiniProgramShareTarget = {
|
||||||
|
targetPath: '/works/detail';
|
||||||
|
work: string;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeShareTarget(
|
||||||
|
target: WechatMiniProgramShareTarget | null | undefined,
|
||||||
|
) {
|
||||||
|
const work = target?.work?.trim() ?? '';
|
||||||
|
if (!work) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetPath: '/works/detail' as const,
|
||||||
|
work,
|
||||||
|
title: target?.title?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWechatMiniProgramShareTargetMessage(
|
||||||
|
target: WechatMiniProgramShareTarget | null | undefined,
|
||||||
|
) {
|
||||||
|
const normalizedTarget = normalizeShareTarget(target);
|
||||||
|
return normalizedTarget
|
||||||
|
? {
|
||||||
|
type: MESSAGE_TYPE,
|
||||||
|
payload: normalizedTarget,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postWechatMiniProgramShareTarget(
|
||||||
|
target: WechatMiniProgramShareTarget | null | undefined,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!isWechatMiniProgramWebViewRuntime() ||
|
||||||
|
typeof window.wx?.miniProgram?.postMessage !== 'function'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = buildWechatMiniProgramShareTargetMessage(target);
|
||||||
|
if (!message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页,
|
||||||
|
// 小程序页据此把右上角系统分享指向当前推荐作品。
|
||||||
|
window.wx.miniProgram.postMessage({
|
||||||
|
data: message,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user