重构作品分享链路

统一发布分享弹窗为作品分享卡片

支持下载分享卡与小程序九宫切图保存

小程序复制链接改为可直达作品详情的 web-view 路径

修复本地 dev Rust 构建绕过损坏 sccache

补充分享链路与 dev 启动文档和测试
This commit is contained in:
2026-06-11 21:32:29 +08:00
parent ccb5023197
commit c5763fdf25
37 changed files with 1958 additions and 305 deletions

View File

@@ -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({

View 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,
};

View 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();
});
});