合并分享链路重构到主分支
合入通用作品分享卡片与小程序直达路径 合入推荐页当前作品系统分享参数同步 合入小程序九宫切图与相关测试 # Conflicts: # .hermes/shared-memory/decision-log.md # docs/【开发运维】本地开发验证与生产运维-2026-05-15.md # docs/【玩法创作】平台入口与玩法链路-2026-05-15.md # src/components/custom-world-home/CustomWorldCreationHub.tsx # src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
@@ -32,6 +32,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`。
|
||||
- 关联文档:`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 微信能力按领域收口
|
||||
|
||||
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `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 仍沿用原有端口探测与漂移逻辑。
|
||||
|
||||
本地 `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
|
||||
|
||||
@@ -1280,8 +1280,8 @@
|
||||
|
||||
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
||||
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 由 `scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper,自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
||||
|
||||
@@ -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 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
||||
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 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` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。
|
||||
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `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 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/web-view/index",
|
||||
"pages/share-grid/index",
|
||||
"pages/wechat-pay/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_SOURCE_QUERY,
|
||||
} = require('../../config');
|
||||
const {
|
||||
appendHashParams,
|
||||
buildWebViewSharePath,
|
||||
buildWebViewShareTimelineQuery,
|
||||
resolveShareTargetFromWebViewMessage,
|
||||
resolveWebViewUrlFromRuntimeConfig,
|
||||
} = require('./index.shared');
|
||||
|
||||
const MINI_PROGRAM_CLIENT_TYPE = '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 PAY_RESULT_RECHECK_DELAY_MS = 120;
|
||||
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
|
||||
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
||||
|
||||
function showWebViewShareMenu() {
|
||||
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 {
|
||||
title: WEB_VIEW_SHARE_TITLE,
|
||||
path: WEB_VIEW_SHARE_PATH,
|
||||
path: buildWebViewSharePath(query),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebViewShareTimeline() {
|
||||
function buildWebViewShareTimeline(query = {}) {
|
||||
return {
|
||||
title: WEB_VIEW_SHARE_TITLE,
|
||||
query: '',
|
||||
query: buildWebViewShareTimelineQuery(query),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,50 +73,6 @@ function isConfiguredApiBaseUrl(value) {
|
||||
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function appendQuery(url, query) {
|
||||
const pairs = Object.keys(query)
|
||||
.filter((key) => query[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||
);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
||||
}
|
||||
|
||||
function appendHashParams(url, params) {
|
||||
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
||||
const pairs = Object.keys(params)
|
||||
.filter((key) => params[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||
);
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const hashIndex = url.indexOf('#');
|
||||
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||
const keptHashParts = rawHash.split('&').filter((part) => {
|
||||
if (!part) {
|
||||
return false;
|
||||
}
|
||||
const [rawKey = ''] = part.split('=');
|
||||
try {
|
||||
return !nextKeys.has(decodeURIComponent(rawKey));
|
||||
} catch (_error) {
|
||||
return !nextKeys.has(rawKey);
|
||||
}
|
||||
});
|
||||
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
||||
}
|
||||
|
||||
function parseBooleanQueryFlag(value) {
|
||||
return value === true || value === '1' || value === 'true' || value === 'yes';
|
||||
}
|
||||
@@ -233,22 +203,16 @@ function shouldReturnToPreviousPage(query) {
|
||||
return String((query && query.returnTo) || '').trim() === 'previous';
|
||||
}
|
||||
|
||||
function resolveWebViewUrl(authResult) {
|
||||
function resolveWebViewUrl(authResult, launchQuery = {}) {
|
||||
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
||||
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
|
||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
|
||||
if (!authResult || !authResult.token) {
|
||||
return sourcedUrl;
|
||||
}
|
||||
|
||||
return appendHashParams(sourcedUrl, {
|
||||
auth_provider: 'wechat',
|
||||
auth_token: authResult.token,
|
||||
auth_binding_status: authResult.bindingStatus,
|
||||
return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
|
||||
...runtimeConfig,
|
||||
webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,7 +431,7 @@ Page({
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage: false,
|
||||
webViewUrl: resolveWebViewUrl(null),
|
||||
webViewUrl: resolveWebViewUrl(null, query),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -572,7 +536,7 @@ Page({
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
@@ -600,7 +564,7 @@ Page({
|
||||
loading: false,
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -674,7 +638,10 @@ Page({
|
||||
loading: false,
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(nextAuthResult),
|
||||
webViewUrl: resolveWebViewUrl(
|
||||
nextAuthResult,
|
||||
this._lastLaunchQuery || {},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
@@ -712,15 +679,19 @@ Page({
|
||||
},
|
||||
|
||||
handleWebViewMessage(event) {
|
||||
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
|
||||
if (shareTarget) {
|
||||
this._currentShareTarget = shareTarget;
|
||||
}
|
||||
// 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。
|
||||
console.info('[web-view] message', event.detail);
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return buildWebViewShareAppMessage();
|
||||
return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
|
||||
},
|
||||
|
||||
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: '汪汪声浪',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
|
||||
|
||||
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
||||
const SERVICE_ALIASES = new Map([
|
||||
@@ -399,6 +400,39 @@ function requireCommand(command) {
|
||||
}
|
||||
}
|
||||
|
||||
function isSccacheRustcWrapper(value) {
|
||||
const wrapper = String(value ?? '').trim();
|
||||
if (!wrapper) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
|
||||
return command === 'sccache' || command === 'sccache.exe';
|
||||
}
|
||||
|
||||
function buildLocalRustProcessEnv(env, options = {}) {
|
||||
const mergedEnv = {...env};
|
||||
const wrappers = [
|
||||
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
|
||||
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
|
||||
].filter(Boolean);
|
||||
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
|
||||
if (customWrapper) {
|
||||
mergedEnv.RUSTC_WRAPPER = customWrapper;
|
||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
|
||||
return mergedEnv;
|
||||
}
|
||||
|
||||
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
||||
if (options.log !== false) {
|
||||
console.warn(
|
||||
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
|
||||
);
|
||||
}
|
||||
return mergedEnv;
|
||||
}
|
||||
|
||||
function readWorkspaceSpacetimeVersion() {
|
||||
const manifestText = readFileSync(manifestPath, 'utf8');
|
||||
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
|
||||
@@ -776,7 +810,7 @@ class DevRunner {
|
||||
this.writeDevStackState();
|
||||
}
|
||||
|
||||
async prepareLinuxPortRange(command) {
|
||||
async prepareLinuxPortRange() {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
@@ -1228,7 +1262,7 @@ class DevRunner {
|
||||
}
|
||||
|
||||
async publishSpacetimeModule() {
|
||||
const env = {...this.baseEnv};
|
||||
const env = buildLocalRustProcessEnv(this.baseEnv);
|
||||
this.prepareMigrationBootstrapSecret(env);
|
||||
|
||||
const args = buildSpacetimePublishArgs({
|
||||
@@ -1291,7 +1325,7 @@ class DevRunner {
|
||||
await this.ensureApiServerSpacetimeToken();
|
||||
|
||||
const mergedEnv = buildApiServerProcessEnv({
|
||||
baseEnv: this.baseEnv,
|
||||
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
|
||||
options: this.options,
|
||||
state: this.state,
|
||||
});
|
||||
@@ -2124,19 +2158,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
|
||||
}
|
||||
|
||||
export {
|
||||
DevRunner,
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildLocalRustProcessEnv,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
isSpacetimePublishPermissionError,
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
};
|
||||
|
||||
@@ -5,19 +5,20 @@ import {join} from 'node:path';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
DevRunner,
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildLocalRustProcessEnv,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
@@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler Rust build env', () => {
|
||||
test('local dev Rust env bypasses project sccache wrapper', () => {
|
||||
const env = buildLocalRustProcessEnv(
|
||||
{
|
||||
RUSTC_WRAPPER: '/usr/bin/sccache',
|
||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||
},
|
||||
{log: false},
|
||||
);
|
||||
|
||||
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
|
||||
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
|
||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
||||
});
|
||||
|
||||
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
|
||||
const env = buildLocalRustProcessEnv(
|
||||
{
|
||||
RUSTC_WRAPPER: 'custom-wrapper',
|
||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||
},
|
||||
{log: false},
|
||||
);
|
||||
|
||||
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler stack state file', () => {
|
||||
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
||||
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
||||
|
||||
@@ -10,24 +10,39 @@ import {
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as clipboardService from '../../services/clipboard';
|
||||
import * as shareGridService from '../../services/wechatMiniProgramShareGrid';
|
||||
import { PublishShareModal } from './PublishShareModal';
|
||||
import {
|
||||
buildMiniProgramPublishSharePath,
|
||||
buildPublishShareCardFileName,
|
||||
buildPublishShareCopyUrl,
|
||||
buildPublishShareText,
|
||||
buildPublishShareUrl,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
|
||||
vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/wechatMiniProgramShareGrid', () => ({
|
||||
canUseWechatMiniProgramShareGrid: vi.fn(() => false),
|
||||
openWechatMiniProgramShareGridPage: vi.fn(),
|
||||
}));
|
||||
|
||||
const payload: PublishShareModalPayload = {
|
||||
title: '暖灯猫街',
|
||||
publicWorkCode: 'PZ-00000001',
|
||||
stage: 'puzzle-gallery-detail',
|
||||
workTypeLabel: '拼图',
|
||||
coverImageSrc: '/cover.png',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('PublishShareModal', () => {
|
||||
@@ -39,7 +54,40 @@ describe('PublishShareModal', () => {
|
||||
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
|
||||
});
|
||||
|
||||
test('renders share text and channel icons, then copies from main button', async () => {
|
||||
test('builds the card file name without unsafe path characters', () => {
|
||||
expect(
|
||||
buildPublishShareCardFileName({
|
||||
title: '暖灯:猫街',
|
||||
publicWorkCode: 'PZ-00000001',
|
||||
}),
|
||||
).toBe('暖灯猫街-PZ-00000001.png');
|
||||
});
|
||||
|
||||
test('builds a mini program share path with public work detail params', () => {
|
||||
const sharePath = buildMiniProgramPublishSharePath(payload);
|
||||
const url = new URL(sharePath, 'https://mini.test');
|
||||
|
||||
expect(url.pathname).toBe('/pages/web-view/index');
|
||||
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||
expect(url.searchParams.get('work')).toBe('PZ-00000001');
|
||||
expect(buildPublishShareCopyUrl(payload, { miniProgramRuntime: true })).toBe(
|
||||
sharePath,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps existing mini program share params and fills missing detail params', () => {
|
||||
const sharePath = buildMiniProgramPublishSharePath(
|
||||
payload,
|
||||
'/pages/web-view/index?scene=poster',
|
||||
);
|
||||
const url = new URL(sharePath, 'https://mini.test');
|
||||
|
||||
expect(url.searchParams.get('scene')).toBe('poster');
|
||||
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||
expect(url.searchParams.get('work')).toBe('PZ-00000001');
|
||||
});
|
||||
|
||||
test('renders the share card and copies the public link', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
@@ -52,26 +100,55 @@ describe('PublishShareModal', () => {
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(dialog.getAttribute('style')).toBeNull();
|
||||
expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByTestId('share-channel-logo-wechat'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByTestId('share-channel-logo-douyin'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByRole('region', { name: '分享卡片' })).toBeTruthy();
|
||||
expect(within(dialog).getByText('拼图')).toBeTruthy();
|
||||
expect(within(dialog).getByText('暖灯猫街')).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '下载卡片' })).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('button', { name: '九宫切图' })).toBeNull();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' }));
|
||||
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-00000001'),
|
||||
buildPublishShareUrl(payload),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('copies the mini program link inside mini program web-view', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
|
||||
render(
|
||||
<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 { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
canUseWechatMiniProgramShareGrid,
|
||||
openWechatMiniProgramShareGridPage,
|
||||
} from '../../services/wechatMiniProgramShareGrid';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { downloadPublishShareCardImage } from './publishShareCardImage';
|
||||
import {
|
||||
buildPublishShareCopyUrl,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
@@ -15,78 +23,27 @@ type PublishShareModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
|
||||
type ActionState = 'idle' | 'success' | 'failed';
|
||||
|
||||
type ShareChannel = {
|
||||
id: ShareChannelId;
|
||||
label: string;
|
||||
iconClassName: string;
|
||||
};
|
||||
|
||||
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
|
||||
const SHARE_CHANNEL_ICON_PATHS: Record<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 normalizePayloadTitle(payload: PublishShareModalPayload | null) {
|
||||
return payload?.title.trim() || '我的作品';
|
||||
}
|
||||
|
||||
function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className="h-6 w-6"
|
||||
data-share-channel-logo={channel.id}
|
||||
data-testid={`share-channel-logo-${channel.id}`}
|
||||
>
|
||||
<path d={iconPath} fill="currentColor" />
|
||||
</svg>
|
||||
payload?.coverImageSrc?.trim() ||
|
||||
payload?.fallbackCoverImageSrc?.trim() ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
|
||||
return payload?.workTypeLabel?.trim() || '互动作品';
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布完成后的分享弹窗。
|
||||
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK,可以只替换这里的渠道点击逻辑。
|
||||
* 发布完成后的通用分享弹窗。
|
||||
* 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。
|
||||
*/
|
||||
export function PublishShareModal({
|
||||
open,
|
||||
@@ -94,14 +51,24 @@ export function PublishShareModal({
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const [copyState, setCopyState] = useState<ActionState>('idle');
|
||||
const [downloadState, setDownloadState] = useState<ActionState>('idle');
|
||||
const [gridState, setGridState] = useState<ActionState>('idle');
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const shareText = useMemo(
|
||||
() => (payload ? buildPublishShareText(payload) : ''),
|
||||
const shareCopyUrl = useMemo(
|
||||
() =>
|
||||
payload
|
||||
? buildPublishShareCopyUrl(payload, {
|
||||
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
|
||||
})
|
||||
: '',
|
||||
[payload],
|
||||
);
|
||||
const title = normalizePayloadTitle(payload);
|
||||
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
|
||||
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
|
||||
const showMiniProgramGridButton =
|
||||
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -114,25 +81,92 @@ export function PublishShareModal({
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
setDownloadState('idle');
|
||||
setGridState('idle');
|
||||
}, [payload?.publicWorkCode]);
|
||||
|
||||
const copyShareText = () => {
|
||||
if (!shareText) {
|
||||
const scheduleStateReset = () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
setDownloadState('idle');
|
||||
setGridState('idle');
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
const copyShareLink = () => {
|
||||
if (!shareCopyUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, 1400);
|
||||
void copyTextToClipboard(shareCopyUrl).then((copied) => {
|
||||
setCopyState(copied ? 'success' : 'failed');
|
||||
scheduleStateReset();
|
||||
});
|
||||
};
|
||||
|
||||
const resolveMiniProgramGridCover = async () => {
|
||||
if (!coverImageSrc) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return await resolveAssetReadUrl(coverImageSrc, {
|
||||
expireSeconds: 600,
|
||||
}).catch(() => coverImageSrc);
|
||||
};
|
||||
|
||||
const downloadShareCard = () => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadState('idle');
|
||||
void downloadPublishShareCardImage(
|
||||
{
|
||||
...payload,
|
||||
title,
|
||||
workTypeLabel,
|
||||
coverImageSrc,
|
||||
},
|
||||
coverImageSrc,
|
||||
)
|
||||
.then((downloaded) => {
|
||||
setDownloadState(downloaded ? 'success' : 'failed');
|
||||
scheduleStateReset();
|
||||
})
|
||||
.catch(() => {
|
||||
setDownloadState('failed');
|
||||
scheduleStateReset();
|
||||
});
|
||||
};
|
||||
|
||||
const openMiniProgramGridDownload = () => {
|
||||
if (!payload || !coverImageSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGridState('idle');
|
||||
void resolveMiniProgramGridCover()
|
||||
.then((resolvedCoverImageSrc) =>
|
||||
openWechatMiniProgramShareGridPage({
|
||||
imageUrl: resolvedCoverImageSrc,
|
||||
title,
|
||||
publicWorkCode: payload.publicWorkCode,
|
||||
}),
|
||||
)
|
||||
.then((opened) => {
|
||||
setGridState(opened ? 'success' : 'failed');
|
||||
scheduleStateReset();
|
||||
})
|
||||
.catch(() => {
|
||||
setGridState('failed');
|
||||
scheduleStateReset();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open && Boolean(payload)}
|
||||
@@ -142,53 +176,98 @@ export function PublishShareModal({
|
||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
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={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{SHARE_CHANNELS.map((channel) => {
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
|
||||
aria-label={`分享到${channel.label}`}
|
||||
title={channel.label}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
|
||||
>
|
||||
<ShareChannelLogo channel={channel} />
|
||||
</span>
|
||||
<span>{channel.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={`grid w-full gap-3 ${
|
||||
showMiniProgramGridButton ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareLink}
|
||||
disabled={!shareCopyUrl}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{copyState === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Link2 className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'success'
|
||||
? '已复制'
|
||||
: 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 className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{shareText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
<section
|
||||
className="overflow-hidden rounded-lg border border-[var(--platform-subpanel-border)] bg-white/78 shadow-[0_18px_42px_rgba(127,85,57,0.12)]"
|
||||
aria-label="分享卡片"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享'}
|
||||
</button>
|
||||
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(135deg,#f4c38b,#e7b5b7_48%,#9bbfd1)]">
|
||||
{coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={coverImageSrc}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-6xl font-black text-white/84">
|
||||
{Array.from(title)[0] ?? '陶'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div className="inline-flex max-w-full items-center rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-accent-text)]">
|
||||
<span className="truncate">{workTypeLabel}</span>
|
||||
</div>
|
||||
<h3 className="line-clamp-2 text-lg font-black leading-snug text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-bold text-[var(--platform-text-muted)]">
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{payload?.publicWorkCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
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 type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||
|
||||
const MINI_PROGRAM_WEB_VIEW_PAGE_PATH = '/pages/web-view/index';
|
||||
const MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH = '/works/detail';
|
||||
|
||||
export type PublishShareModalPayload = {
|
||||
title: string;
|
||||
publicWorkCode: string;
|
||||
stage: SelectionStage;
|
||||
workTypeLabel?: string | null;
|
||||
coverImageSrc?: string | null;
|
||||
fallbackCoverImageSrc?: string | null;
|
||||
};
|
||||
|
||||
function buildShareUrl(payload: PublishShareModalPayload) {
|
||||
export function buildPublishShareUrl(payload: PublishShareModalPayload) {
|
||||
const sharePath = buildPublicWorkStagePath(
|
||||
payload.stage,
|
||||
payload.publicWorkCode,
|
||||
@@ -18,13 +24,56 @@ function buildShareUrl(payload: PublishShareModalPayload) {
|
||||
: new URL(sharePath, window.location.origin).href;
|
||||
}
|
||||
|
||||
export function buildMiniProgramPublishSharePath(
|
||||
payload: PublishShareModalPayload,
|
||||
basePath = MINI_PROGRAM_WEB_VIEW_PAGE_PATH,
|
||||
) {
|
||||
const [path = MINI_PROGRAM_WEB_VIEW_PAGE_PATH, rawSearch = ''] =
|
||||
basePath.split('?');
|
||||
const params = new URLSearchParams(rawSearch);
|
||||
const publicWorkCode = payload.publicWorkCode.trim();
|
||||
|
||||
if (!params.has('targetPath')) {
|
||||
params.set('targetPath', MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH);
|
||||
}
|
||||
if (publicWorkCode && !params.has('work')) {
|
||||
params.set('work', publicWorkCode);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${path}?${queryString}` : path;
|
||||
}
|
||||
|
||||
export function buildPublishShareCopyUrl(
|
||||
payload: PublishShareModalPayload,
|
||||
options: { miniProgramRuntime?: boolean } = {},
|
||||
) {
|
||||
return options.miniProgramRuntime
|
||||
? buildMiniProgramPublishSharePath(payload)
|
||||
: buildPublishShareUrl(payload);
|
||||
}
|
||||
|
||||
export function buildPublishShareText(payload: PublishShareModalPayload) {
|
||||
const publicWorkCode = payload.publicWorkCode.trim();
|
||||
const title = payload.title.trim() || '我的作品';
|
||||
|
||||
return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildShareUrl({
|
||||
return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildPublishShareUrl({
|
||||
...payload,
|
||||
publicWorkCode,
|
||||
title,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function buildPublishShareCardFileName(
|
||||
payload: Pick<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: '沉钟拼图',
|
||||
publicWorkCode: 'PZ-PROFILE1',
|
||||
stage: 'puzzle-gallery-detail',
|
||||
workTypeLabel: '拼图',
|
||||
coverImageSrc: null,
|
||||
fallbackCoverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
});
|
||||
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
default as React,
|
||||
type CSSProperties,
|
||||
default as React,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
formatPlatformWorkDisplayTag,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
CREATION_WORK_KIND_FALLBACK_COVER,
|
||||
type CreationWorkShelfBadgeTone,
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfKind,
|
||||
type CreationWorkShelfMetric,
|
||||
type CreationWorkShelfMetricId,
|
||||
formatCreationMetricCount,
|
||||
@@ -55,21 +55,6 @@ const SWIPE_ACTION_WIDTH_PX = 76;
|
||||
const SWIPE_REVEAL_THRESHOLD_PX = 42;
|
||||
const SWIPE_DIRECTION_LOCK_PX = 8;
|
||||
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
|
||||
const CREATION_WORK_KIND_FALLBACK_COVER: Record<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) {
|
||||
return 1 - (1 - progress) ** 3;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,50 @@ export type CreationWorkShelfKind =
|
||||
| 'baby-object-match'
|
||||
| 'bark-battle'
|
||||
| 'visual-novel';
|
||||
|
||||
export const CREATION_WORK_KIND_FALLBACK_COVER: Record<
|
||||
CreationWorkShelfKind,
|
||||
string
|
||||
> = {
|
||||
rpg: '/creation-type-references/rpg.webp',
|
||||
'big-fish': '/creation-type-references/big-fish.webp',
|
||||
match3d: '/creation-type-references/match3d.webp',
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
||||
'puzzle-clear': '/creation-type-references/puzzle.webp',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||
'visual-novel': '/creation-type-references/visual-novel.webp',
|
||||
};
|
||||
|
||||
export function describeCreationWorkShelfKind(kind: CreationWorkShelfKind) {
|
||||
switch (kind) {
|
||||
case 'rpg':
|
||||
return 'RPG世界';
|
||||
case 'big-fish':
|
||||
return '大鱼吃小鱼';
|
||||
case 'match3d':
|
||||
return '抓大鹅';
|
||||
case 'square-hole':
|
||||
return '方洞挑战';
|
||||
case 'jump-hop':
|
||||
return '跳一跳';
|
||||
case 'wooden-fish':
|
||||
return '敲木鱼';
|
||||
case 'puzzle-clear':
|
||||
return '拼消消';
|
||||
case 'puzzle':
|
||||
return '拼图';
|
||||
case 'baby-object-match':
|
||||
return '宝贝识物';
|
||||
case 'bark-battle':
|
||||
return '汪汪声浪';
|
||||
case 'visual-novel':
|
||||
return '视觉小说';
|
||||
}
|
||||
}
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
||||
|
||||
@@ -353,6 +353,7 @@ import {
|
||||
updateVisualNovelWork,
|
||||
} from '../../services/visual-novel-works';
|
||||
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
|
||||
import { postWechatMiniProgramShareTarget } from '../../services/wechatMiniProgramShareTarget';
|
||||
import {
|
||||
woodenFishClient,
|
||||
type WoodenFishGalleryCardResponse,
|
||||
@@ -378,6 +379,7 @@ import {
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import {
|
||||
describePublicGalleryCardKind,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
@@ -387,6 +389,8 @@ import {
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
@@ -844,6 +848,25 @@ function resolveRecommendEntryShareStage(
|
||||
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(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
@@ -2858,10 +2881,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
postRecommendEntryMiniProgramShareTarget(entry);
|
||||
openPublishShareModal({
|
||||
title: entry.worldName,
|
||||
publicWorkCode,
|
||||
stage: resolveRecommendEntryShareStage(entry),
|
||||
workTypeLabel: describePublicGalleryCardKind(entry),
|
||||
coverImageSrc: resolvePlatformWorldCoverImage(entry),
|
||||
fallbackCoverImageSrc: resolvePlatformWorldFallbackCoverImage(entry),
|
||||
});
|
||||
},
|
||||
[openPublishShareModal],
|
||||
@@ -13455,6 +13482,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleRun?.currentLevel?.profileId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage !== 'platform' ||
|
||||
platformBootstrap.platformTab !== 'home' ||
|
||||
!activeRecommendEntry
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
postRecommendEntryMiniProgramShareTarget(activeRecommendEntry);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
platformBootstrap.platformTab,
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const decision = resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
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,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
PuzzleClearGalleryCardResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
PuzzleClearWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
PuzzleClearGalleryCardResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
PuzzleClearWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
|
||||
import type {
|
||||
SquareHoleHoleOption,
|
||||
SquareHoleShapeOption,
|
||||
@@ -1162,6 +1162,42 @@ export function buildPlatformWorldDisplayTags(
|
||||
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit);
|
||||
}
|
||||
|
||||
export function describePublicGalleryCardKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
|
||||
}
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼图');
|
||||
}
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼消消');
|
||||
}
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('抓大鹅');
|
||||
}
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('方洞挑战');
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('跳一跳');
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('敲木鱼');
|
||||
}
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('视觉小说');
|
||||
}
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('汪汪声浪');
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||
}
|
||||
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
||||
|
||||
@@ -5303,6 +5303,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
box-shadow: var(--platform-recommend-runtime-shadow);
|
||||
}
|
||||
|
||||
html[data-wechat-mini-program-runtime='true']
|
||||
.platform-recommend-runtime-panel {
|
||||
max-height: min(76dvh, 42rem);
|
||||
transform: scale(0.88);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
html[data-wechat-mini-program-runtime='true']
|
||||
.platform-recommend-swipe-card__visual {
|
||||
transform: scale(0.88);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -114,6 +114,18 @@ describe('index stylesheet unread dots', () => {
|
||||
expect(block).toContain('var(--platform-cool-bg)');
|
||||
expect(block).not.toContain('background: transparent;');
|
||||
});
|
||||
|
||||
it('keeps mini program recommend runtime inside a share snapshot safe area', () => {
|
||||
const css = readIndexCss();
|
||||
const block = getCssBlock(
|
||||
css,
|
||||
"html[data-wechat-mini-program-runtime='true']\n .platform-recommend-runtime-panel",
|
||||
);
|
||||
|
||||
expect(block).toContain('max-height: min(76dvh, 42rem);');
|
||||
expect(block).toContain('transform: scale(0.88);');
|
||||
expect(block).toContain('transform-origin: center;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('index stylesheet draft mobile cards', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock';
|
||||
import {resolveAppRoute} from './routing/appRoutes';
|
||||
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
||||
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
||||
import {isWechatMiniProgramWebViewRuntime} from './services/authService';
|
||||
|
||||
type AppRoot = ReturnType<typeof createRoot>;
|
||||
|
||||
@@ -26,11 +27,18 @@ if (!rootElement) {
|
||||
throw new Error('Missing #root container');
|
||||
}
|
||||
|
||||
function markWechatMiniProgramRuntime() {
|
||||
if (isWechatMiniProgramWebViewRuntime()) {
|
||||
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement);
|
||||
const RouteComponent = route.Component;
|
||||
|
||||
lockMobileViewportZoom();
|
||||
stabilizeMobileViewportKeyboardFocus();
|
||||
markWechatMiniProgramRuntime();
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -415,4 +415,28 @@ describe('assetReadUrlService', () => {
|
||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc',
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
} from '../../packages/shared/src/http';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
type ApiRequestOptions,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from './apiClient';
|
||||
@@ -376,7 +376,11 @@ export async function readAssetBytes(
|
||||
throw new Error('资源路径不能为空');
|
||||
}
|
||||
|
||||
if (!isGeneratedLegacyPath(value)) {
|
||||
const legacyPath = isGeneratedLegacyPath(value)
|
||||
? value
|
||||
: resolveGeneratedLegacyPathFromUrl(value);
|
||||
|
||||
if (!legacyPath) {
|
||||
const response = await fetch(value, { signal: options.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error('读取资源内容失败');
|
||||
@@ -386,7 +390,7 @@ export async function readAssetBytes(
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
legacyPublicPath: value,
|
||||
legacyPublicPath: legacyPath,
|
||||
expireSeconds: options.expireSeconds,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
|
||||
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