重构作品分享链路
统一发布分享弹窗为作品分享卡片 支持下载分享卡与小程序九宫切图保存 小程序复制链接改为可直达作品详情的 web-view 路径 修复本地 dev Rust 构建绕过损坏 sccache 补充分享链路与 dev 启动文档和测试
This commit is contained in:
@@ -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,10 @@ const {
|
||||
WEB_VIEW_ENTRY_URL,
|
||||
WEB_VIEW_SOURCE_QUERY,
|
||||
} = require('../../config');
|
||||
const {
|
||||
appendHashParams,
|
||||
resolveWebViewUrlFromRuntimeConfig,
|
||||
} = require('./index.shared');
|
||||
|
||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||
@@ -59,50 +63,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 +193,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 +421,7 @@ Page({
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage: false,
|
||||
webViewUrl: resolveWebViewUrl(null),
|
||||
webViewUrl: resolveWebViewUrl(null, query),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -572,7 +526,7 @@ Page({
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
@@ -600,7 +554,7 @@ Page({
|
||||
loading: false,
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -674,7 +628,10 @@ Page({
|
||||
loading: false,
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(nextAuthResult),
|
||||
webViewUrl: resolveWebViewUrl(
|
||||
nextAuthResult,
|
||||
this._lastLaunchQuery || {},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
|
||||
129
miniprogram/pages/web-view/index.shared.js
Normal file
129
miniprogram/pages/web-view/index.shared.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
|
||||
|
||||
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 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,
|
||||
normalizeTargetPath,
|
||||
resolveLaunchTargetQuery,
|
||||
resolveWebViewUrlFromRuntimeConfig,
|
||||
};
|
||||
56
miniprogram/pages/web-view/index.test.js
Normal file
56
miniprogram/pages/web-view/index.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import webViewBridge from './index.shared.js';
|
||||
|
||||
const {
|
||||
appendLaunchTargetToEntryUrl,
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user