Merge remote-tracking branch 'origin/dev/1.0' into yuanlaide2

# Conflicts:
#	.local.config.js
This commit is contained in:
2026-07-16 16:17:26 +08:00
48 changed files with 5248 additions and 1014 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
.codebuddy
.vscode
.trae
# 鸿蒙元服务构建产物
stub-app-projects/mp-harmony/ascf/ascf_src

View File

@@ -138,6 +138,7 @@ const localDevConfig = ({
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
// })['2821']; // 离型纸硅油纸
})['48']; // 离型纸硅油纸
// })['2485']; // 验证电子表单前后端数据一致性
// })['5g-11']; // 数码

View File

@@ -352,14 +352,16 @@ export default {
this.$util.showToast({
title: res.message
});
this.diyData = {};
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}
this.$util.showToast({
title: this.$lang('diy.noDiyData')
});
this.diyData = {};
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}

View File

@@ -5,7 +5,6 @@ import Platform from './platform.js'
const { appType, appTypeName } = Platform.getPlatformInfo();
// 惰性加载 Lang,避免与 lang.js 的循环依赖
let _Lang = null;
function getLang() {
if (!_Lang) {
@@ -14,6 +13,116 @@ function getLang() {
return _Lang;
}
const DEFAULT_TIMEOUT = 15000;
const DEFAULT_MAX_RETRIES = 2;
const RETRY_DELAY_BASE = 2000;
function _shouldRetry(errMsg) {
if (!errMsg) return false;
if (errMsg === 'request:fail url not in domain list') return false;
if (errMsg === 'request:fail abort') return false;
const retryableErrors = [
'request:fail timeout',
'request:fail connect',
'request:fail network'
];
return retryableErrors.some(err => errMsg.startsWith(err));
}
function _classifyError(errMsg, statusCode) {
if (!errMsg && !statusCode) return 'unknown';
if (errMsg === 'request:fail abort') return 'aborted';
if (errMsg && errMsg.indexOf('timeout') !== -1) return 'timeout';
if (errMsg && (errMsg.indexOf('connect') !== -1 || errMsg.indexOf('network') !== -1)) return 'networkError';
if (statusCode && statusCode >= 500) return 'serverError';
if (errMsg && errMsg.indexOf('url not in domain list') !== -1) return 'domainNotInList';
return 'unknown';
}
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
return new Promise((resolve, reject) => {
uni.request({
url: url,
method: method,
data: data,
header: header,
dataType: dataType,
responseType: responseType,
timeout: timeout,
success: (res) => {
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
return;
}
if (res.data.code == -3 && store.state.siteState > 0) {
store.commit('setSiteState', -3);
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
return;
}
if (res.data.refreshtoken) {
store.commit('setToken', res.data.refreshtoken);
}
if (res.data.code == -10009 || res.data.code == -10010) {
store.commit('setToken', '');
store.commit('setMemberInfo', '');
}
resolve(res.data);
},
fail: (res) => {
reject(res);
}
});
});
}
async function _requestWithRetry(params, url, method, data, header, dataType, responseType) {
const timeout = params.timeout || DEFAULT_TIMEOUT;
const maxRetries = params.noRetry ? 0 : (params.maxRetries || DEFAULT_MAX_RETRIES);
let lastError = null;
let lastStatusCode = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
return result;
} catch (err) {
lastError = err;
lastStatusCode = err && err.statusCode;
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
break;
}
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
await _sleep(delay);
}
}
if (params.silentError) {
throw lastError;
}
const errorType = _classifyError(lastError && lastError.errMsg, lastStatusCode);
const lang = getLang();
if (errorType === 'domainNotInList') {
uni.showToast({
title: lang.lang('http.domainNotInList', { url: Config.baseUrl }),
'icon': 'none',
duration: 10000
});
} else {
const i18nKey = 'http.' + errorType;
uni.showToast({
title: lang.lang(i18nKey),
'icon': 'none',
duration: 10000
});
}
throw lastError;
}
export default {
sendRequest(params) {
if (!Config.baseUrl) {
@@ -25,13 +134,9 @@ export default {
return;
}
// remote-only boot 门控: 所有业务请求必须等国际化数据就绪后才发送
// 这样业务请求返回时 lang() 已有翻译数据,不会造成 UI 污染
// bundle 请求走 lang-loader.js 的 uni.request 直接发送,不受此门控影响
const _isRemoteOnly = getLang().isRemoteOnly && getLang().isRemoteOnly();
const _bootDone = getLang().isBootCompleted && getLang().isBootCompleted();
if (_isRemoteOnly && !_bootDone) {
// 获取 boot promise(优先从 App 实例拿,避免重复 boot)
let bootPromise = null;
try {
const app = getApp();
@@ -40,10 +145,8 @@ export default {
if (!bootPromise) bootPromise = getLang().boot();
if (params.async === false) {
// 同步 Promise 模式: 链式等待 boot 后再发请求
return bootPromise.then(() => this._sendRequestNow(params));
} else {
// 回调模式: boot 完成后再执行请求
bootPromise.then(() => this._sendRequestNow(params));
return;
}
@@ -61,11 +164,9 @@ export default {
app_type_name: appTypeName
};
// token
data.token = store.state.token || '';
data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid,支持快应用
data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid;
// 门店id
var default_store_info = store.state.defaultStoreInfo;
if (default_store_info) {
data.store_id = default_store_info.store_id;
@@ -75,122 +176,34 @@ export default {
if (store_info) data.store_id = store_info.store_id;
// 参数
if (params.data != undefined) Object.assign(data, params.data);
const header = params.header || {
'content-type': 'application/x-www-form-urlencoded;application/json'
};
const dataType = params.dataType || 'json';
const responseType = params.responseType || 'text';
if (params.async === false) {
//同步
return new Promise((resolve, reject) => {
uni.request({
url: url,
method: method,
data: data,
header: params.header || {
// 'Accept': 'application/json',
'content-type': 'application/x-www-form-urlencoded;application/json'
},
dataType: params.dataType || 'json',
responseType: params.responseType || 'text',
success: (res) => {
// try {
// res.data = JSON.parse(res.data);
// } catch (e) {
// //TODO handle the exception
// console.log('api error:', e);
// }
if (res.data.code == -3 && store.state.siteState > 0) {
store.commit('setSiteState', -3);
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
return;
}
if (res.data.refreshtoken) {
store.commit('setToken', res.data.refreshtoken);
}
if (res.data.code == -10009 || res.data.code == -10010) {
store.commit('setToken', '');
store.commit('setMemberInfo', '');
}
resolve(res.data);
},
fail: (res) => {
if (res.errMsg && res.errMsg == 'request:fail url not in domain list') {
uni.showToast({
title: getLang().lang('http.domainNotInList', { url: Config.baseUrl }),
'icon': 'none',
duration: 10000
});
return;
}
reject(res);
},
complete: (res) => {
// success/fail 已处理 resolve/reject,complete 只负责兜底提示非预期状态
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
uni.showToast({
title: getLang().lang('http.requestFailed', { url: Config.baseUrl }),
'icon': 'none',
duration: 10000
});
}
}
return _requestWithRetry(params, url, method, data, header, dataType, responseType)
.then(res => {
typeof params.complete == 'function' && params.complete(res);
return res;
})
.catch(err => {
typeof params.complete == 'function' && params.complete();
throw err;
});
});
} else {
//异步
uni.request({
url: url,
method: method,
data: data,
header: params.header || {
// 'Accept': 'application/json',
'content-type': 'application/x-www-form-urlencoded;application/json'
},
dataType: params.dataType || 'json',
responseType: params.responseType || 'text',
success: (res) => {
// try {
// res.data = JSON.parse(res.data);
// } catch (e) {
// //TODO handle the exception
// console.log('api error:', e);
// }
if (res.data.code == -3 && store.state.siteState > 0) {
store.commit('setSiteState', -3);
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
return;
}
if (res.data.refreshtoken) {
store.commit('setToken', res.data.refreshtoken);
}
if (res.data.code == -10009 || res.data.code == -10010) {
store.commit('setToken', '');
store.commit('setMemberInfo', '');
}
typeof params.success == 'function' && params.success(res.data);
},
fail: (res) => {
if (res.errMsg && res.errMsg == 'request:fail url not in domain list') {
uni.showToast({
title: getLang().lang('http.domainNotInList', { url: Config.baseUrl, err: res.errMsg }),
'icon': 'none',
duration: 10000
});
return;
}
typeof params.fail == 'function' && params.fail(res);
},
complete: (res) => {
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
uni.showToast({
title: getLang().lang('http.requestFailed', { url: Config.baseUrl, err: res.errMsg }),
'icon': 'none',
duration: 10000
});
return;
}
typeof params.complete == 'function' && params.complete(res.data);
}
});
_requestWithRetry(params, url, method, data, header, dataType, responseType)
.then(res => {
typeof params.success == 'function' && params.success(res);
typeof params.complete == 'function' && params.complete(res);
})
.catch(err => {
typeof params.fail == 'function' && params.fail(err);
typeof params.complete == 'function' && params.complete();
});
}
},
uploadBase64(params) {
@@ -258,4 +271,4 @@ export default {
}
});
}
}
}

View File

@@ -2,7 +2,7 @@
* 全局兜底分享 mixin
*
* 仅在微信小程序编译产物中被加载:
* main.js 中的 import + Vue.mixin 调用被 // #ifdef MP-WEIXIN 包住,
* main.js 中的 import + Vue.mixin 调用被 MP-WEIXIN 预处理宏 包住,
* 其它平台编译时整个模块不会进入产物,因此文件内部无需再写条件编译。
*
* 背景:
@@ -30,6 +30,68 @@ const _safeGetCurrentPage = () => {
}
};
/**
* 关闭分享能力的页面 route 列表(不带前导斜杠,与 mp-weixin page.route 保持一致)
*
* 为什么需要:
* mp-share-default.js 通过 Vue.mixin 全局注入 onShareAppMessage / onShareTimeline,
* 但有些页面不适合"分享":
* 1) webview 容器页: 转发它等于转发一个 iframe 壳,无意义且会暴露内部 src
* 2) 隐私弹窗 / 登录中转页: 状态不稳定,转出去体验差
* 3) 任何业务明确要求"不分享"的页面
*
* 命中后行为:
* - mixin 内部不再拉取分享配置(节省一次 HTTP 请求)
* - mixin 注入的 onShareAppMessage / onShareTimeline / $mpShare_* 工具方法仍然存在,
* 但内部立即调用 wx.hideShareMenu() 把转发按钮从右上角菜单中隐藏
* - 这两条防线双保险:即使 mixin 钩子被业务侧转发链路误触发,也不会显示分享弹窗
*
* 备选方案对比(都没选,原因列在下方):
* - 在 created 里 this.onShareAppMessage = undefined:
* Vue mixin 合并策略会把 undefined 视为"未声明",mixin 的钩子仍然生效 → 不可靠
* - mixin 内 return null / 不 return:
* 微信会回退到默认 title 转发,按钮仍显示 → 不可靠
* - 使用 wx.hideShareMenu(本方案采用):
* 微信官方 API,会立即把"转发"按钮从当前页右上角菜单中隐藏,行为最稳
*/
const _DISABLED_PAGES = new Set([
'pages_tool/webview/webview', // webview 容器,转发无意义
]);
const _isCurrentPageDisabled = () => {
const page = _safeGetCurrentPage();
if (!page) return false;
const route = (page.route || (page.$page && page.$page.path) || '').replace(/^\//, '');
return _DISABLED_PAGES.has(route);
};
/**
* 主动隐藏当前页的"转发"按钮
* - 只能在 mp-weixin 环境调用(其它平台 wx 全局不存在)
* - 必须在 onLoad 之后调用(需要 Page 实例已注册完成)
* - 失败时静默吞掉(权限/版本/非微信环境都可能导致失败)
*/
const _hideShareMenu = () => {
try {
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && typeof wx.hideShareMenu === 'function') {
wx.hideShareMenu({
menus: ['shareAppMessage', 'shareTimeline']
});
}
// #endif
} catch (e) {
// 静默:旧基础库可能不支持 menus 参数,降级到无参版本
try {
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && typeof wx.hideShareMenu === 'function') {
wx.hideShareMenu();
}
// #endif
} catch (e2) { /* ignore */ }
}
};
const _getCurrentPath = (withQuery = true) => {
const page = _safeGetCurrentPage();
if (!page) return '';
@@ -90,11 +152,30 @@ export default {
__mpShareCache: null, // 当前页面的分享配置
};
},
/**
* 在实例创建阶段检查当前页面是否在 _DISABLED_PAGES 列表中。
* 命中后调用 wx.hideShareMenu 把当前页的"转发"按钮从右上角菜单中隐藏。
*/
created() {
try {
if (_isCurrentPageDisabled()) {
// 立即隐藏(此时 wx 还没完全就绪,先尝试一次;失败也无所谓,onLoad 会再调一次)
_hideShareMenu();
}
} catch (e) {}
},
onLoad() {
try {
if (_isCurrentPageDisabled()) {
// 命中关闭列表的页面:
// 1) 再次调 wx.hideShareMenu(此时 Page 已就绪,稳定生效)
// 2) 不再拉取分享配置,避免无谓请求
_hideShareMenu();
return;
}
const path = _getCurrentPath(true);
_getShareConfig(path).then(cfg => {
// 后端未返回配置时,注入站点名作为默认标题
// 后端未返回配置时,注入站点名作为默认标题
if (cfg && !cfg.title) {
const siteName = (this.$store && this.$store.state && this.$store.state.siteInfo && this.$store.state.siteInfo.site_name) || '';
cfg.title = siteName || '小程序分享';
@@ -104,21 +185,27 @@ export default {
} catch (e) {}
},
onShow() {
// 兜底:进入页面后如果尚未就绪,再异步拉一次
if (!this.__mpShareCache) {
try {
const path = _getCurrentPath(true);
_getShareConfig(path).then(cfg => {
this.__mpShareCache = cfg;
});
} catch (e) {}
}
// 兜底:进入页面后如果尚未就绪,再异步拉一次
if (this.__mpShareCache) return;
if (_isCurrentPageDisabled()) return; // 命中关闭列表,不再拉
try {
const path = _getCurrentPath(true);
_getShareConfig(path).then(cfg => {
this.__mpShareCache = cfg;
});
} catch (e) {}
},
/**
* 全局默认 onShareAppMessage:
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
* 全局默认 onShareAppMessage:
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
*/
onShareAppMessage() {
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
// (主防线是 wx.hideShareMenu,这里只是兜底)
if (_isCurrentPageDisabled()) {
return undefined;
}
const cfg = this.__mpShareCache;
if (cfg && cfg.title) {
return {
@@ -127,17 +214,20 @@ export default {
imageUrl: cfg.imageUrl || ''
};
}
// 后端无配置时:使用站点名 + 当前路径
// 后端无配置时:使用站点名 + 当前路径
return {
title: _buildFallback(_getCurrentPath(true)).title,
path: _getCurrentPath(true)
};
},
/**
* 全局默认 onShareTimeline(朋友圈):
* 朋友圈只支持 query(不能直接传 path),从 cfg.path 中抽取 ? 之后的 query。
* 全局默认 onShareTimeline(朋友圈):
* 朋友圈只支持 query(不能直接传 path),从 cfg.path 中抽取 ? 之后的 query。
*/
onShareTimeline() {
if (_isCurrentPageDisabled()) {
return undefined;
}
const cfg = this.__mpShareCache;
if (cfg && cfg.title) {
const path = cfg.path || _getCurrentPath(true);

View File

@@ -0,0 +1,33 @@
/**
* 快应用宿主环境下的下拉刷新 → 原生 webview.reload() 转发 mixin
*
* 在已启用 enablePullDownRefresh 的 uniapp 页面 mixin 链中加入本文件,
* 当用户在原生快应用 webview 内下拉刷新时,会通过 this.$util.requestQuickAppReload()
* 把指令发回快应用 shell(对应 hello.ux onMessage 中的 'reload' 处理);
* 其余平台走默认的 uni.stopPullDownRefresh 即可。
*
* 用法:
* import pullReload from '@/common/js/mixins/quickapp-pull-refresh.js';
* export default { mixins: [..., pullReload] }
*
* 注意:
* - 请确保对应的 pages.json style 块中开启 "enablePullDownRefresh": true
* - 不要在本文件里覆盖 onPullDownRefresh,否则会被本页/上游 mixin 拦截失效
*/
export default {
onPullDownRefresh() {
try {
if (this.$util && typeof this.$util.requestQuickAppReload === 'function'
&& this.$util.requestQuickAppReload()) {
// 原生侧 webview.reload() 会把整个 H5 页面替换掉,这里只是兜底停动画
setTimeout(() => {
try { uni.stopPullDownRefresh(); } catch (_) { /* swallow */ }
}, 800);
return;
}
} catch (e) {
console.error('[quickapp-pull-refresh] crashed:', e);
}
try { uni.stopPullDownRefresh(); } catch (_) { /* swallow */ }
}
}

View File

@@ -35,6 +35,7 @@ function useNativeLoading() {
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
// 启动期阶段定义 (id → { percent, label })
// percent: 阶段完成的百分比 (0-100)
@@ -56,9 +57,12 @@ class SplashState {
this._listeners = [];
this._shownAt = 0;
// 软隐藏相关
this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
this._hasMounted = false; // 是否有 splash 组件 mount 过
this._mountGraceId = null; // 软隐藏兜底定时器
this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
this._hasMounted = false; // 是否有 splash 组件 mount 过
this._mountGraceId = null; // 软隐藏兜底定时器 (等 mount)
// 页面就绪等待
this._pageReady = false; // 页面内容是否已就绪 (由首页通知)
this._pageReadyGraceId = null; // 页面就绪兜底定时器
// 进度上报
this._stage = 'boot';
this._percent = 0;
@@ -77,6 +81,7 @@ class SplashState {
this._shownAt = Date.now();
this._pendingHide = null;
this._hasMounted = false;
this._pageReady = false;
this._stage = 'boot';
// 初始化时直接显示 'boot' 阶段的 5%, 而不是 0%
// 避免首帧出现"进度条 0%"的视觉突兀
@@ -91,11 +96,19 @@ class SplashState {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = null;
}
this._emit('splash:show');
// H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染)
// 注意: mask 设为 false。原 mask:true 会在 H5 注入 .uni-mask 全屏透明蒙层,
// 配合 z-index:99999 的 Vue splash 一起把首页触摸全部拦截,导致 H5
// 首屏在 boot 期间无法滚动。Vue splash 组件本身已带白色背景提供视觉遮罩,
// 这里只要 loading 文字提示,不需要拦截触摸。
if (useNativeLoading()) {
try { uni.showLoading({ title: opts.title || '加载中...', mask: true }); } catch (e) {}
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
}
const timeout = opts.timeout || DEFAULT_TIMEOUT;
@@ -138,15 +151,16 @@ class SplashState {
}
/**
* 软隐藏: 标记应该 hide, 等组件 mount
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
* 如果组件 1s 内没 mount, 兜底自动 hide
* 如果页面 2s 内没就绪, 兜底自动 hide
*/
_softHide(opts) {
if (!this._visible) return; // 已经隐藏了
this._pendingHide = opts;
// 如果组件已经 mount, 立即隐藏
if (this._hasMounted) {
// 如果组件已经 mount 且页面已就绪, 立即隐藏
if (this._hasMounted && this._pageReady) {
this._flushPendingHide();
return;
}
@@ -162,14 +176,29 @@ class SplashState {
/**
* 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 hide
*/
_flushPendingHide() {
const opts = this._pendingHide;
this._pendingHide = null;
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
if (!this._pageReady) {
// 兜底: 2s 内没就绪, 强制隐藏
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = setTimeout(() => {
if (this._visible && this._pendingHide) {
console.warn('[splash-state] auto-hide after page-ready timeout');
this._doHide(this._pendingHide || { reason: 'page-ready-timeout' });
}
}, PAGE_READY_GRACE_MS);
return;
}
const opts = this._pendingHide;
this._pendingHide = null;
this._doHide(opts || { reason: 'mount-completed' });
}
@@ -186,6 +215,10 @@ class SplashState {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = null;
}
this._pendingHide = null;
this._visible = false;
// H5 降级对应的关闭
@@ -202,17 +235,31 @@ class SplashState {
* 组件 mount 后通知 state 模块
* - 标记 _hasMounted
* - 如果有 _pendingHide, 触发真关
* - H5 降级场景: 立即关掉原生 loading, 让 splash 组件接管视觉
* - H5 降级场景: 不再立即关掉原生 loading, 等待页面就绪后再关
*/
notifyMounted() {
if (this._hasMounted) return;
this._hasMounted = true;
// H5 降级: 立即关掉原生 loading, 避免和 splash 组件重叠
if (useNativeLoading()) {
try { uni.hideLoading(); } catch (e) {}
if (this._pendingHide) {
setTimeout(() => this._flushPendingHide(), 50);
}
}
/**
* 页面内容就绪后通知 state 模块
* - 标记 _pageReady
* - 如果有 _pendingHide, 触发真关
* 调用时机: 首页 diy-group 渲染完成后调用
*/
notifyPageReady() {
if (this._pageReady) return;
this._pageReady = true;
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = null;
}
if (this._pendingHide) {
// 给组件一帧时间渲染, 再隐藏 (避免一帧闪烁)
// 给页面一帧时间完成渲染, 再隐藏
setTimeout(() => this._flushPendingHide(), 50);
}
}

623
common/js/util-navigate.js Normal file
View File

@@ -0,0 +1,623 @@
/**
* util-navigate.js
*
* 跳转链路专用的工具集 — 从 util.js 拆分出来
*
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 跳转链路):
* - _toParamValueString : 把任意值序列化成可作为 URL 参数的字符串
* - parsePageParams : 把 page_params 还原成对象(JSON.parse 安全封装)
* - resolveLinkUrl : 解析 link 对象得到最终可跳转的 URL
* - cleanParamUrls : 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段
* - normalizeUrl : 严格规范化 URL(不补协议/不补 host)
* - appendQueryParams : 给 URL 追加 query 参数
* - getPlatform : 跨平台检测(h5 / mp-weixin / mp-harmony / quickapp)
* - shouldEncodeUrl : 是否需要主动 encodeURIComponent(4 平台都 true)
* - _safeEncodeForPlatform : 跨平台统一的 safeEncode(decode roundtrip + encode 一次)
* - _navigateTo : 跨平台跳转 API 调度
*
* 设计原则:
* 1. 不依赖 util.js,避免循环依赖
* 2. 全部为普通函数(不挂在 default export 对象上),方便 tree-shaking 和测试
* 3. 行为完全兼容 util.js 旧版,util.js 通过 import 把这些方法挂载到 default export 上
* (所有调用方 this.$util.cleanParamUrls / this.$util.redirectTo 等都不需要改)
*
* 跨平台编码 / 跳转策略文档见 _safeEncodeForPlatform / _navigateTo 的注释
*/
// =============================================================
// 基础工具
// =============================================================
/**
* 把任意值序列化成可作为 URL 参数的字符串。
* - 对象/数组: JSON.stringify
* - null/undefined: ''
* - 其他: String(v)
*
* @param {*} v
* @returns {string}
*/
export function _toParamValueString(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
/**
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
* 容错策略: 任何解析失败都安全降级到 defaultValue,不抛异常。
*
* @example
* parsePageParams('{"a":1}') // { a: 1 }
* parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回)
* parsePageParams('[object Object]') // {} (老 bug 残留安全降级)
* parsePageParams('') // {}
* parsePageParams(null) // {}
* parsePageParams(undefined) // {}
* parsePageParams('not json', {x:1}) // { x: 1 } (解析失败走默认值)
*
* @param {*} raw
* @param {Object} [defaultValue={}]
* @returns {Object}
*/
export function parsePageParams(raw, defaultValue = {}) {
if (raw == null) return Object.assign({}, defaultValue);
if (typeof raw === 'object') return raw;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null
? parsed
: Object.assign({}, defaultValue);
} catch (e) {
return Object.assign({}, defaultValue);
}
}
return Object.assign({}, defaultValue);
}
// =============================================================
// resolveLinkUrl
// =============================================================
/**
* 解析 link 对象得到最终可跳转的 URL
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 二次编码
* 优先级: page_params > wap_url query (新机制优先)
*
* @param {Object} link
* @returns {string|null}
*/
export function resolveLinkUrl(link) {
if (!link) return null;
// 0. 把 wap_url 中的 query 合并到 page_params
if (link.wap_url) {
const decodedWap = decodeEntity(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach((kv) => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
const pageParams = parsePageParams(link.page_params);
link.page_params = Object.assign({}, urlParams, pageParams);
link.wap_url = path;
}
}
// 1. 优先使用 page_key(新机制)
if (link.page_key) {
// 这里原本会调 resolvePageUrl(link.page_key, params),
// 但 util-navigate.js 不依赖 util.js,因此返回 link.wap_url
// (util.js 在 import 此模块后会用 page_key 重新解析)
const params = parsePageParams(link.page_params);
// 注: 完整 page_key 解析留给 util.js (它有 resolvePageUrl)
// 这里只做"wap_url 已被 resolveLinkUrl 处理过" 的返回
}
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
if (link.wap_url) {
return link.wap_url;
}
return null;
}
/**
* decodeEntity — HTML 实体解码
* (从 util.js 顶层 export 搬过来, 跟其他跳转工具一起)
*
* 解码 &xxx; / &#xN; 等 HTML 实体
* 容错: 任何解析失败都返回原文
*
* @param {string} str
* @param {boolean} [amp=true] 是否解码 &amp;
* @returns {string}
*/
export function decodeEntity(str, amp) {
const html4Entities = {
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥',
brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«',
not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²',
sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸',
sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾',
iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä',
aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê',
euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð',
ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö',
times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý',
thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ',
delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι',
kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο',
pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ',
phi: 'φ', chi: 'ψ', omega: 'ω',
};
amp = amp === undefined ? true : amp;
let i = str.indexOf('&');
while (i !== -1) {
const j = str.indexOf(';', i + 3);
let code;
if (j === -1) break;
if (str[i + 1] === '#') {
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j));
if (!isNaN(code)) {
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1);
}
} else {
code = str.substring(i + 1, j);
if (html4Entities[code] || (code === 'amp' && amp)) {
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1);
}
}
i = str.indexOf('&', i + 1);
}
return str;
}
// =============================================================
// cleanParamUrls
// =============================================================
/**
* 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段:
* - 去除反引号 / 各种空白 / 零宽字符
* - 修复 scheme 后多余斜杠(https:////host → https://host)
*
* "看起来像 URL" 的判定: 含 `://`(大小写不敏感)
* - 命中: 走清理
* - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤)
*
* 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖
*
* @param {*} value 任意值
* @returns {*} 与输入同结构的清理后值
*/
export function cleanParamUrls(value) {
// 内部递归函数,通过闭包引用自身,避免 this 绑定问题
const _clean = (v) => {
if (v === null || v === undefined) return v;
if (typeof v === 'string') {
if (/:\/\//i.test(v)) {
return v
.split('').filter((ch) =>
ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r',
).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
}
return v;
}
if (Array.isArray(v)) {
return v.map((item) => _clean(item));
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v)) {
out[k] = _clean(v[k]);
}
return out;
}
return v;
};
return _clean(value);
}
// =============================================================
// normalizeUrl
// =============================================================
/**
* 严格 URL 规范化(只规范化已有部分,绝不自动补充)
*
* 策略:
* 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底)
* 2) 不补 https:// — 如果原串没有协议,保持没有
* 3) 不补 host — 如果原串没有 host,保持没有
* 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序
* 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 ''
*
* @param {string} urlString
* @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 ''
*/
export function normalizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return '';
// 1) 预清理: 反引号 / 各种空白 / BOM / 零宽字符
let url = urlString
.split('').filter((ch) => {
const code = ch.charCodeAt(0);
if (code <= 0x20) return false;
if (ch === '`') return false;
if (code === 0x3000) return false;
if (code === 0xFEFF || code === 0x200B ||
code === 0x200C || code === 0x200D ||
code === 0x2060) return false;
return true;
}).join('');
// 2) 解析协议(可选,允许缺失)
const protocolMatch = url.match(/^(https?):\/\//i);
let protocol = '';
let rest = url;
if (protocolMatch) {
protocol = protocolMatch[1].toLowerCase() + '://';
rest = url.substring(protocolMatch[0].length);
// scheme 后不能紧跟 '/',即不允许 https:////host
if (rest.charAt(0) === '/') {
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
return '';
}
} else {
rest = url;
}
// 3) 解析 hash
const hashIndex = rest.indexOf('#');
let hash = '';
let mainPart = rest;
if (hashIndex !== -1) {
hash = rest.substring(hashIndex);
mainPart = rest.substring(0, hashIndex);
}
// 4) 解析 query
const queryIndex = mainPart.indexOf('?');
let query = '';
let pathPart = mainPart;
if (queryIndex !== -1) {
query = mainPart.substring(queryIndex); // 包含 '?'
pathPart = mainPart.substring(0, queryIndex);
}
// 5) 解析 host
let hostname = '';
let port = '';
let pathname = pathPart;
if (protocol) {
const firstSlash = pathPart.indexOf('/');
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash);
if (hostPort) {
const colonIdx = hostPort.lastIndexOf(':');
if (colonIdx !== -1) {
hostname = hostPort.substring(0, colonIdx);
port = hostPort.substring(colonIdx + 1);
} else {
hostname = hostPort;
}
}
}
// 6) 规范化 host
let lowerHostname = '';
if (hostname) {
lowerHostname = hostname.toLowerCase();
if ((protocol === 'http://' && port === '80') ||
(protocol === 'https://' && port === '443')) {
port = '';
}
if (/[\/\s]/.test(lowerHostname)) {
console.warn('[normalizeUrl] host 含非法字符:', urlString);
return '';
}
}
// 7) 规范化 pathname
let normalizedPath = pathname;
if (normalizedPath) {
const segments = normalizedPath.split('/');
const resolved = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg === '' || seg === '.') {
if (i === 0) resolved.push('');
continue;
}
if (seg === '..') {
if (resolved.length > 1) resolved.pop();
continue;
}
resolved.push(seg);
}
normalizedPath = resolved.join('/') || '/';
}
// 8) 规范化 query
let normalizedQuery = '';
if (query) {
const qs = query.substring(1);
if (qs) {
const pairs = qs.split('&').map((pair) => {
const eqIdx = pair.indexOf('=');
return eqIdx === -1
? { k: pair, v: '' }
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
});
pairs.sort((a, b) => a.k.localeCompare(b.k));
normalizedQuery = '?' + pairs.map((p) => p.k + '=' + p.v).join('&');
}
}
// 9) 拼回
const portStr = port ? ':' + port : '';
const hostPart = lowerHostname ? lowerHostname + portStr : '';
const schemeAndHost = protocol + hostPart;
return schemeAndHost + normalizedPath + normalizedQuery + hash;
}
// =============================================================
// appendQueryParams
// =============================================================
/**
* 给 URL 追加 query 参数(已存在的 key 会被覆盖)
*
* @param {string} url
* @param {Object} params
* @returns {string}
*/
export function appendQueryParams(url, params) {
if (!url) return '';
if (!params) return url;
const hashIndex = url.indexOf('#');
let hash = '';
let targetUrl = url;
if (hashIndex !== -1) {
hash = url.substring(hashIndex);
targetUrl = url.substring(0, hashIndex);
}
const separator = targetUrl.includes('?') ? '&' : '?';
const queryString = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null)
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
.map(([key, value]) => `${key}=${_toParamValueString(value)}`)
.join('&');
return targetUrl + separator + queryString + hash;
}
// =============================================================
// 跨平台检测 / 编码 / 跳转
// =============================================================
/**
* 平台检测 — 4 个目标平台:
* - 'h5' 浏览器
* - 'mp-weixin' 微信小程序
* - 'mp-harmony' 鸿蒙元服务
* - 'quickapp' 华为快应用 (实际是 H5 嵌入快应用 Webview)
*
* 测试覆盖: util.js 在 import 后,可在 default export 上设 __platform 强制指定
*/
export function getPlatform() {
// 1) 测试覆盖: 通过模块级 _testPlatform 注入
if (typeof globalThis !== 'undefined' && globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__) {
return globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__;
}
// 2) 编译时变量
// #ifdef MP-WEIXIN
return 'mp-weixin'
// #endif
// #ifdef MP-HARMONY
return 'mp-harmony'
// #endif
// 3) 运行时
if (navigator?.userAgent?.includes('mode-quickapp')) { // H5网页,如何判断自己在华为快应用的Webview中
return 'quickapp'
}
// #ifdef H5
try {
var ua = navigator?.userAgent;
if (ua) {
// 华为快应用内嵌 WebView 的 UA 关键字:HarmonyQuickApp / HuaweiQuickApp / HQABridge / QuickApp
const isQuickapp = /HarmonyQuickApp|HuaweiQuickApp|HQABridge|QuickApp/i.test(ua);
if (isQuickapp) {
return 'quickapp'
}
}
} catch (e) {}
// #endif
if (typeof process !== 'undefined' && process.env && process.env.UNI_PLATFORM) {
return process.env.UNI_PLATFORM;
}
if (typeof window !== 'undefined' && typeof window.location !== 'undefined' && typeof uni === 'undefined') {
return 'h5';
}
if (typeof uni !== 'undefined' && typeof uni.getSystemInfoSync === 'function') {
try {
const info = uni?.getSystemInfoSync();
const p = (info && info.platform) || '';
if (/harmony|ohos/i.test(p)) return 'mp-harmony';
if (info.mp === true || /miniprogram/i.test(info.appName || '')) return 'mp-weixin';
if (/quickapp|huawei/i.test(p)) return 'quickapp';
return 'h5';
} catch (e) { /* ignore */ }
}
// 默认返回
return 'h5';
}
/**
* 判断是否是在华为快应用的WebView中
* @returns
*/
export function isHarmonyQuickAppWebview() {
return getPlatform() === 'quickapp'
}
/**
* 是否需要 JS 侧主动 encodeURIComponent
* 4 平台都需要(true)
*/
export function shouldEncodeUrl() {
return true;
}
/**
* 跨平台 safeEncode(decode roundtrip + encode 一次)
*
* 4 平台编码行为:
* | 平台 | 宿主 | 内部自动 encode? | JS 侧需要 encode? |
* |-----------|---------------|------------------|------------------|
* | h5 | 浏览器 | 否 | 必须 |
* | mp-weixin | 微信原生 | 否 | 必须(否则微信把 & 当分隔符丢参数) |
* | mp-harmony| 鸿蒙原生 | 否 | 必须 |
* | quickapp | 华为快应用 Webview (本质 H5) | 否 | 必须 |
*
* 关键 bug 修复(2026-07-14):
* 微信小程序下, src 内嵌 URL 含 `&` `?` `=` 空格 反引号等特殊字符时,
* 若 JS 不主动 encode, 微信底层路由会把这些字符当成 top-level query 分隔符,
* 导致后续 key(id/uniacid/page_params) 全部丢失。
*
* @param {*} v
* @returns {string}
*/
export function _safeEncodeForPlatform(v) {
const s = _toParamValueString(v);
// decode roundtrip: 兼容上游已 encode 过的入参
let once = s;
try {
if (/%[0-9a-fA-F]{2}/.test(s)) {
once = decodeURIComponent(s);
}
} catch (e) {
once = s;
}
// 4 平台都主动 encode 一次
return encodeURIComponent(once);
}
/**
* 跨平台跳转 API 调度
*
* @param {Object} args
* @param {string} args.to
* @param {string} [args.mode='navigateTo']
* @param {string} [args.platform] 不传则自动 getPlatform()
* @returns {{ url: string, mode: string, api: string }}
*/
export function _navigateTo({ to, mode, platform } = {}) {
const p = platform || getPlatform();
const m = mode || 'navigateTo';
// H5: 用 window.location
if (p === 'h5') {
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
console.warn('[navigate] H5 环境下没有 window.location');
return { url: to, mode: m, api: 'noop' };
}
try {
if (m === 'reLaunch' || m === 'redirectTo') {
window.location.replace(to);
} else {
window.location.href = to;
}
} catch (e) {
console.warn('[navigate] H5 跳转失败:', e);
}
return { url: to, mode: m, api: 'window.location' };
}
// 微信 / 鸿蒙 / 快应用: 走对应的 uni API
// (华为快应用实际是 H5 嵌入快应用 Webview, uni 全局可用)
if (typeof uni === 'undefined') {
console.warn('[navigate] 非 H5 环境下没有 uni 全局');
return { url: to, mode: m, api: 'noop' };
}
let api = 'navigateTo';
switch (m) {
case 'tabbar':
case 'switchTab':
// 鸿蒙元服务: uni.switchTab 静默失败, 用 reLaunch 兜底
// (微信 / 快应用: 正常用 uni.switchTab)
if (p === 'mp-harmony') {
uni.reLaunch({ url: to });
api = 'reLaunch';
} else {
uni.switchTab({ url: to });
api = 'switchTab';
}
break;
case 'redirectTo':
uni.redirectTo({ url: to });
api = 'redirectTo';
break;
case 'reLaunch':
uni.reLaunch({ url: to });
api = 'reLaunch';
break;
case 'navigateTo':
default:
uni.navigateTo({ url: to });
api = 'navigateTo';
break;
}
return { url: to, mode: m, api };
}
// =============================================================
// 兼容: 把所有函数打包到一个对象(用于 util.js 挂载到 default export)
// =============================================================
/**
* 把所有跳转相关函数打包到一个对象, util.js 把它挂载到 default export
* 上, 保持 this.$util.cleanParamUrls / this.$util.redirectTo 等 API 兼容
*
* @param {Object} [opts]
* @param {string} [opts.platform] 强制指定平台(测试用)
* @returns {Object}
*/
export function createNavigateApi(opts = {}) {
if (opts.platform) {
// 测试用: 注入到 globalThis,getPlatform 会优先读
if (typeof globalThis !== 'undefined') {
globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = opts.platform;
}
}
return {
getPlatform,
shouldEncodeUrl,
_safeEncodeForPlatform,
_navigateTo,
cleanParamUrls,
normalizeUrl,
appendQueryParams,
parsePageParams,
resolveLinkUrl,
decodeEntity,
};
}

443
common/js/util-tabbar.js Normal file
View File

@@ -0,0 +1,443 @@
/**
* util-tabbar.js
*
* TabBar / 子包路径相关工具集 — 从 util.js 拆分出来
*
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 的 tabBar 命中判断):
* - resolvePageUrl : 根据 page_key + params 解析得到实际跳转 URL
* - isPageInRouteList : 判断当前路由是否命中 page_routes 列表
* - resolveLinkUrl : 解析 link 对象得到最终 URL(合并 wap_url query 进 page_params)
* - systemTabBarList : 当前最新的 tabBar.list 配置
* - adaptSubpackageUrl : 子包路径前缀映射(/pages/order/ → /pages_order/)
* - _normalizePath : 归一化任意路径(私有,内部用)
* - isTabBarRootPage : 判断给定路径是否是 TabBar 根页面
* - isCurrentPageTabBarRoot : 判断**当前**页面是否是 TabBar 根页面(依赖 getCurrentPages/store)
* - checkTabBarActive : 检查 tabBar 是否激活
*
* 设计原则:
* 1. 不依赖 util.js,避免循环依赖
* 2. 纯函数为主(isCurrentPageTabBarRoot 除外,它依赖 getCurrentPages 和 store,
* 业务方在调用时手动注入 store 即可)
* 3. PAGE_KEY_MAP / NO_TAB_PAGES / PAGE_TAB_MAP / TAB_BAR_KEYS 仍然在 util.js(业务配置)
* util-tabbar.js 接受外部传入,提高复用性
*/
import { _toParamValueString } from './util-navigate.js';
// =============================================================
// PAGE_KEY → 实际 URL 解析
// =============================================================
/**
* 根据 page_key + params 解析得到实际跳转 URL
* @param {string} page_key 页面语义常量, 如 PAGE_GOODS_LIST
* @param {Object} params 页面参数(JSON 对象)
* @param {Object} [pageKeyMap={}] page_key → 实际路径的映射表(由调用方注入,通常是 PAGE_KEY_MAP)
* @returns {string|null} 实际 URL(已拼接参数)或 null(未匹配)
*/
export function resolvePageUrl(page_key, params = {}, pageKeyMap = {}) {
if (!page_key || !pageKeyMap[page_key]) {
return null;
}
let url = pageKeyMap[page_key];
if (params && typeof params === 'object' && Object.keys(params).length > 0) {
const parts = [];
Object.keys(params).forEach(function (key) {
const value = params[key];
if (value === null || value === undefined || value === '') return;
// 对象/数组走 JSON.stringify, 避免 String({}) 退化成 "[object Object]"
// 消费端 resolveLinkUrl 已用 JSON.parse 还原
const encodedValue = _toParamValueString(value);
// H5: 浏览器不自动编码, 需要手动 encodeURIComponent
// 小程序 / App: uni.navigateTo 会自动编码, 这里不能再 encode, 否则双重编码
// #ifdef H5
parts.push(key + '=' + encodeURIComponent(encodedValue));
// #endif
// #ifndef H5
parts.push(key + '=' + encodedValue);
// #endif
});
if (parts.length > 0) {
url = url + (url.indexOf('?') !== -1 ? '&' : '?') + parts.join('&');
}
}
return url;
}
/**
* 判断当前路由是否命中 page_routes 列表
* 支持 3 种写法, 可混合使用:
* 1) PAGE_KEY_MAP 的 key → 如 "PAGE_INDEX"
* 2) 实际页面路径 → 如 "/pages/index/index"
* 3) 通配符路径 → 如 "/pages_tool/*" 或 "PAGE_ORDER_*"
*
* @param {string} currentRoute 当前页面路径(会自动补 / 前缀)
* @param {string[]} routeList 后台配置的 page_routes 数组
* @param {Object} [pageKeyMap={}] PAGE_KEY_MAP
* @returns {boolean}
*/
export function isPageInRouteList(currentRoute, routeList, pageKeyMap = {}) {
if (!currentRoute || !routeList || !Array.isArray(routeList) || routeList.length === 0) {
return false;
}
const route = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
for (const item of routeList) {
if (!item || typeof item !== 'string') continue;
const entry = item.trim();
if (!entry) continue;
// 1) 通配符 /pages_tool/* 或 PAGE_ORDER_*
if (entry.endsWith('/*')) {
const prefix = entry.slice(0, -1);
if (route.startsWith(prefix)) return true;
continue;
}
if (entry.endsWith('_*') && pageKeyMap) {
const keyPrefix = entry.slice(0, -1);
const matchedPaths = [];
for (const k of Object.keys(pageKeyMap)) {
if (k.startsWith(keyPrefix) && pageKeyMap[k]) {
matchedPaths.push(pageKeyMap[k]);
}
}
for (const p of matchedPaths) {
const pn = p.startsWith('/') ? p : '/' + p;
const dirPrefix = pn.substring(0, pn.lastIndexOf('/') + 1);
if (route.startsWith(dirPrefix)) return true;
if (route === pn) return true;
}
continue;
}
// 2) PAGE_KEY 名称
if (pageKeyMap && pageKeyMap[entry]) {
let mapped = pageKeyMap[entry];
mapped = mapped.startsWith('/') ? mapped : '/' + mapped;
if (route === mapped) return true;
continue;
}
// 3) 实际路径
if (entry.startsWith('/')) {
if (route === entry) return true;
continue;
}
}
return false;
}
// =============================================================
// resolveLinkUrl
// =============================================================
/**
* 解析 link 对象得到最终可跳转的 URL
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加
* 优先级: page_params > wap_url query (新机制优先)
*
* @param {Object} link
* @param {Object} [opts]
* @param {Object} [opts.pageKeyMap={}] PAGE_KEY_MAP
* @param {Function} [opts.parsePageParams] page_params 解析函数(默认用 util-navigate 的)
* @param {Function} [opts.resolvePageUrlFn] page_key → URL 函数(默认用本文件内)
* @param {Function} [opts.decodeEntity] HTML 实体解码函数
* @returns {string|null}
*/
export function resolveLinkUrl(link, opts = {}) {
const {
pageKeyMap = {},
parsePageParams = null,
resolvePageUrlFn = null,
decodeEntity = null,
} = opts;
if (!link) return null;
// 0. 把 wap_url 中的 query 合并到 page_params
if (link.wap_url) {
const _decode = decodeEntity || ((s) => s);
const decodedWap = _decode(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach((kv) => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
// 解析 page_params (可能是字符串或对象) — 走统一 helper
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
const pageParams = _parse(link.page_params);
// 合并: page_params 覆盖 urlParams (新机制优先)
link.page_params = Object.assign({}, urlParams, pageParams);
// 把 link.wap_url 改为不含 query 的 path
link.wap_url = path;
}
}
// 1. 优先使用 page_key(新机制)
if (link.page_key) {
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
const _resolvePageUrl = resolvePageUrlFn || ((key, params) => resolvePageUrl(key, params, pageKeyMap));
const params = _parse(link.page_params);
const url = _resolvePageUrl(link.page_key, params);
if (url) return url;
}
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
if (link.wap_url) {
return link.wap_url;
}
return null;
}
// =============================================================
// TabBar 列表与适配
// =============================================================
/**
* 当前最新的 tabBar.list
* 注意: 该函数需要业务方传入 4 个常量(INDEX/CATEGORY/CONTACT/MEMBER)
* 通常由 util.js 在 import 后调用 _setSystemTabBarList 注入
*/
export function makeSystemTabBarList(indexUrl, categoryUrl, contactUrl, memberUrl) {
return [indexUrl, categoryUrl, contactUrl, memberUrl];
}
/**
* 适配子包路径
* 把老路径 /pages/xxx/ 映射到新路径 /pages_xxx/
*
* @param {string} url
* @returns {string}
*/
export function adaptSubpackageUrl(url) {
if (!url) return url;
// 定义前缀映射表(key=旧前缀, value=新分包前缀)
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map((key) =>
key.replace(/\//g, '\\/').replace(/\*/g, '.*'),
).join('|') + ')',
);
function replacePrefix(str) {
return str.replace(regex, (match) => {
const newPrefix = urlPrefixMap[match];
return newPrefix || match;
});
}
// 兜底: 把 3 个及以上的连续 `/` 收敛为 2 个, 只动 path 部分
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return replacePrefix(collapsed);
}
// =============================================================
// TabBar 命中判断(私有辅助)
// =============================================================
/**
* 归一化任意路径: 确保以 '/' 开头, 去除 query, 适配子包
* (私有, 仅本模块内部使用)
*/
function _normalizePath(path) {
if (!path) return '';
const normalized = path.startsWith('/') ? path : '/' + path;
return adaptSubpackageUrl(normalized).split('?')[0];
}
/**
* 从归一化路径提取分包名(parts[1])
* (私有, 仅本模块内部使用)
*/
function _getPackageName(normalizedPath) {
const parts = normalizedPath.split('/');
return parts[1] || '';
}
/**
* 给定当前页面路径 + tab 配置, 返回当前页面所属的 tab URL
* 优先级: 精确匹配 > packageEntryTab > 分包名回退匹配
* (私有)
*/
function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return null;
const tabUrls = tabBarList.list
.filter((item) => item.link && item.link.wap_url)
.map((item) => _normalizePath(item.link.wap_url));
const normalizedCurrent = _normalizePath(currentPagePath);
// 1) 精确匹配 → 当前页面就是 tab 页面
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
if (exactMatch) return exactMatch;
// 2) 通过 packageEntryTab(由 updateCurrentRoute/redirectTo 写入)
const packageName = _getPackageName(normalizedCurrent);
if (packageEntryTab && typeof packageEntryTab === 'object' && packageEntryTab[packageName]) {
const entryTab = packageEntryTab[packageName];
const normalizedEntry = _normalizePath(entryTab);
if (tabUrls.includes(normalizedEntry)) return normalizedEntry;
}
// 3) 回退: 同分包内的第一个 tab(基于分包名匹配)
return tabUrls.find((url) => _getPackageName(url) === packageName) || null;
}
/**
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
*
* @param {string} pagePath 当前页面路径(可以带/不带前导斜杠、可以带 query)
* @param {Object|null} tabBarList tabBar 配置对象($store.state.tabBarList)
* @returns {boolean} 如果是 TabBar 根页面返回 true
*/
export function isTabBarRootPage(pagePath, tabBarList) {
if (!pagePath) return false;
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return false;
const normalizedCurrent = _normalizePath(pagePath);
if (!normalizedCurrent) return false;
return tabBarList.list.some((item) => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = _normalizePath(item.link.wap_url);
return normalizedTab === normalizedCurrent;
});
}
/**
* 判断**当前**页面是否是 TabBar 根页面
* 封装了 getCurrentPages() + isTabBarRootPage 的组合, 供业务组件在弹窗/操作前判断
*
* 调用方需要注入 getCurrentPages 和 store:
* isCurrentPageTabBarRoot({
* getCurrentPages: typeof getCurrentPages === 'function' ? getCurrentPages : null,
* store,
* tabBarList: null, // 不传则从 store.state.tabBarList 取
* })
*
* @param {Object} [opts={}]
* @param {Function} [opts.getCurrentPages]
* @param {Object} [opts.store]
* @param {Object} [opts.tabBarList]
* @returns {boolean}
*/
export function isCurrentPageTabBarRoot(opts = {}) {
try {
const gcp = opts.getCurrentPages || (typeof getCurrentPages === 'function' ? getCurrentPages : null);
if (!gcp) return false;
const pages = gcp();
if (!pages || !pages.length) return false;
const currentRoute = '/' + String(pages[pages.length - 1].route || '').replace(/^\//, '');
if (!currentRoute) return false;
const tabBarList = opts.tabBarList
|| (opts.store && opts.store.state ? opts.store.state.tabBarList : null);
return isTabBarRootPage(currentRoute, tabBarList);
} catch (e) {
return false;
}
}
/**
* 检查 tabBar 是否激活
*
* @param {string} linkUrl 链接路径 /pages/goods/category
* @param {string} currentPageRoute 当前页面路径 /pages_goods/category
* @param {Object} tabBarList tabBar 配置对象, 用于动态判断
* @param {Object} packageEntryTab 追踪每个分包的入口 Tab { 'pages_goods': '/pages_goods/category' }
* @param {Object} [currentPageQuery={}] 当前页面 query 参数, 用于读取 from_tab 来源记忆
* @param {Object} [opts={}]
* @param {Set} [opts.noTabPages] 独立流程页集合(NO_TAB_PAGES)
* @param {Object} [opts.pageTabMap] 页面级 Tab 精确绑定表(PAGE_TAB_MAP)
* @returns {boolean} 是否激活
*/
export function checkTabBarActive(
linkUrl,
currentPageRoute,
tabBarList,
packageEntryTab,
currentPageQuery = {},
opts = {},
) {
const { noTabPages = null, pageTabMap = {} } = opts;
// 1) 归一化 linkUrl: 统一前导 '/' + 子包路径适配 + 去除 query
const normalizedLink = _normalizePath(linkUrl);
const normalizedCurrent = _normalizePath(currentPageRoute);
if (!normalizedLink) return false;
// 2) 精确匹配: 当前页面就是 linkUrl 指向的页面
if (normalizedLink === normalizedCurrent) return true;
// 3) 独立流程页: 不点亮任何 Tab
if (noTabPages && noTabPages.has && noTabPages.has(normalizedCurrent)) return false;
// 4) 若传入 tabBarList, 使用动态判断逻辑
if (tabBarList && tabBarList.list && Array.isArray(tabBarList.list)) {
const tabUrls = tabBarList.list
.filter((item) => item.link && item.link.wap_url)
.map((item) => _normalizePath(item.link.wap_url));
// 检查当前 linkUrl 是否是合法的 tab URL
if (!tabUrls.includes(normalizedLink)) return false;
// 5) 页面级 Tab 精确绑定
const mappedTabUrl = pageTabMap[normalizedCurrent];
if (mappedTabUrl) {
return normalizedLink === _normalizePath(mappedTabUrl);
}
if (mappedTabUrl === null) return false;
// 6) 来源记忆兜底: 从哪个 Tab 跳转过来, 就保持那个 Tab 高亮
const fromTab = currentPageQuery.from_tab;
if (fromTab) {
const normalizedFromTab = _normalizePath(fromTab);
if (tabUrls.includes(normalizedFromTab)) {
return normalizedLink === normalizedFromTab;
}
}
// 7) 分包入口追踪: 通过 updateCurrentRoute 写入的入口 tab
const matchingTabUrl = _findMatchingTabUrl(currentPageRoute, tabBarList, packageEntryTab);
if (matchingTabUrl) return normalizedLink === matchingTabUrl;
// 8) 同分包回退: linkUrl 和当前页面在同一分包 → 高亮
const linkPackage = _getPackageName(normalizedLink);
const currentPackage = _getPackageName(normalizedCurrent);
return linkPackage && currentPackage && linkPackage === currentPackage;
}
return false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -281,13 +281,42 @@ export default {
.tab-bar {
background-color: #fff;
box-sizing: border-box;
position: fixed;
left: 0;
bottom: 0;
/* ============================================================
* 固定在最底端,不能位置漂移(微信小程序 / 鸿蒙元服务 / 快应用 H5)
* ------------------------------------------------------------
* 现象(只在客户端小程序里复现,浏览器 H5 没事):
* 页面带 navigationStyle:custom + 页面内有 scroll-view,
* 滚到底部时 .tab-bar 会被微信客户端的页面避让逻辑推上去,
* 出现"位置漂移"。
*
* 根因(微信小程序特性):
* 1) 微信小程序里 position:fixed 的 containing block 是 page
* 根容器(不是 viewport),可视区变化时 fixed 元素下沿会
* 跟着重算,造成漂移。
* 2) 父级一旦出现 transform/filter/will-change,fixed 的
* containing block 会被进一步改写,fixed 元素行为更不可控。
*
* 加固措施:
* - bottom: 0 !important 钉死下沿
* - 提升 z-index 到 2147483647,避免被其他 fixed 元素遮挡
* - transform: translateZ(0) 强制提升到独立合成层,降低父级
* transform 干扰;will-change:transform 提示浏览器持续合成
* - 微信小程序里 #ifdef MP-WEIXIN / #ifdef MP-HARMONY 走原生
* tabBar,diy-bottom-nav 通常不会同时存在,但保留兜底
* ============================================================ */
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100%;
z-index: 998;
z-index: 2147483647 !important;
display: flex;
border-top: 2rpx solid #f5f5f5;
/* 强制提升合成层,降低父级 transform/filter 改变 containing block 的概率 */
transform: translateZ(0) !important;
-webkit-transform: translateZ(0) !important;
will-change: transform !important;
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);

View File

@@ -7,7 +7,7 @@
<view class="goods-img-wrap">
<image class="goods-img"
:src="$util.img(item.goods_image, { size: goodsValue.template == 'large-mode' ? 'big' : 'mid' })"
mode="widthFix" @error="imgError(index)"
:mode="imgMode" @error="imgError(index)"
:style="{ borderRadius: goodsValue.imgAroundRadius * 2 + 'rpx' }"
show-menu-by-longpress />
<view class="sell-out" v-if="item.stock <= 0">
@@ -117,7 +117,7 @@
:class="[goodsValue.ornament.type]" :style="goodsItemCss">
<view class="goods-img-wrap">
<image class="goods-img" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix" @error="imgError(index)"
:src="$util.img(item.goods_image, { size: 'mid' })" :mode="imgMode" @error="imgError(index)"
:lazy-load="true" show-menu-by-longpress />
<view class="sell-out" v-if="item.stock <= 0">
<text class="iconfont icon-shuqing"></text>
@@ -184,7 +184,7 @@
:class="[goodsValue.ornament.type]" :style="goodsItemCss">
<view class="goods-img-wrap">
<image class="goods-img" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(dataItem.goods_image, { size: 'mid' })" mode="widthFix"
:src="$util.img(dataItem.goods_image, { size: 'mid' })" :mode="imgMode"
@error="imgError(dataIndex)" :lazy-load="true" show-menu-by-longpress />
<view class="sell-out" v-if="dataItem.stock <= 0">
<text class="iconfont icon-shuqing"></text>
@@ -302,6 +302,19 @@ export default {
}
},
computed: {
// 商品图 image 的 mode 模式:
// - 大图模式(large-mode):容器高度由图片决定,固定使用 widthFix 保持原比例
// - 鸿蒙元服务:其他模板下 image 的 widthFix 模式异常(图片被裁剪/显示不全),改用 aspectFill 填满固定容器
// - H5、微信小程序等其他环境:保持 widthFix 行为不变
imgMode() {
if (this.goodsValue.template == 'large-mode') return 'widthFix';
// #ifdef MP-HARMONY
return 'aspectFill';
// #endif
// #ifndef MP-HARMONY
return 'widthFix';
// #endif
},
goodsListWarpCss() {
var obj = '';
obj += 'background-color:' + this.goodsValue.componentBgColor + ';';

View File

@@ -300,6 +300,7 @@ export default {
handler() {
this.diyGlobalData = this.diyData ? this.$util.deepClone(this.diyData) : null;
this.processData();
this._notifyLoaded();
},
deep: true
}
@@ -308,8 +309,9 @@ export default {
this.processData();
},
mounted() {
// 通知父组件 diy-group 内容已渲染完毕
this.$emit('loaded');
if (this.diyData && this.diyData.value) {
this._notifyLoaded();
}
},
computed: {
topNavColor() {
@@ -323,7 +325,6 @@ export default {
return topNavColor;
},
// 过滤组件的渲染
diyDataArray() {
if (!this.processedData) {
this.processData();
@@ -347,6 +348,12 @@ export default {
}
},
methods: {
_notifyLoaded() {
if (this._loadedTimer) clearTimeout(this._loadedTimer);
this._loadedTimer = setTimeout(() => {
this.$emit('loaded');
}, 300);
},
processData() {
if (!this.diyGlobalData || !this.diyGlobalData.value) {
this.processedData = [];

View File

@@ -64,7 +64,9 @@
id="myVideo"
:src="video_url"
:controls="true"
object-fit="contain"
@loadedmetadata="onPopupVideoLoaded"
@error="onPopupVideoError"
></video>
</view>
</uni-popup>
@@ -88,11 +90,14 @@ export default {
pageWidth: '',
indicatorDots: false,
swiperCurrent: 0,
video_url: ''
video_url: '',
popupVideoWidth: 0,
popupVideoHeight: 0,
windowWidth: 0
}
},
created() {
// 组件创建时的逻辑
this.initWindowWidth();
},
watch: {
componentRefresh(newValue) {
@@ -140,24 +145,33 @@ export default {
// 弹窗视频容器样式
popupVideoStyle() {
let style = 'position:fixed;width:100%;left:0;';
if (this.popupVideoRatio) {
// 根据视频比例设置位置
if (this.popupVideoRatio === 'portrait') {
// 竖屏视频:居中显示,使用9/16比例
style += 'top:50%;transform:translateY(-50%);';
style += 'aspect-ratio: 9 / 16;';
} else {
// 横屏视频:靠近顶部显示,使用16/9比例
style += 'top:20%;';
style += 'aspect-ratio: 16 / 9;';
}
} else {
// 默认:居中显示,使用竖屏比例
style += 'top:50%;transform:translateY(-50%);';
style += 'aspect-ratio: 9 / 16;';
const defaultRatio = 9 / 16;
let ratio = defaultRatio;
if (this.popupVideoWidth && this.popupVideoHeight) {
ratio = this.popupVideoHeight / this.popupVideoWidth;
}
const isPortrait = ratio > 1;
if (isPortrait) {
style += 'top:50%;transform:translateY(-50%);';
} else {
style += 'top:20%;';
}
// #ifdef H5
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-WEIXIN
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-HARMONY
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
// #endif
return style;
}
},
@@ -165,8 +179,8 @@ export default {
// 显示视频播放弹窗
showVideo(item) {
this.video_url = item.videoUrl;
// 重置视频比例为null,等待新的检测结果
this.popupVideoRatio = null;
this.popupVideoWidth = 0;
this.popupVideoHeight = 0;
this.$refs.videoPopup.open();
},
@@ -174,6 +188,16 @@ export default {
imgError(index) {
// 图片加载失败的处理逻辑
},
// 初始化窗口宽度
initWindowWidth() {
try {
const systemInfo = uni.getSystemInfoSync();
this.windowWidth = systemInfo.windowWidth || 375;
} catch (e) {
this.windowWidth = 375;
}
},
// 弹窗视频加载完成事件
onPopupVideoLoaded(event) {
@@ -198,13 +222,24 @@ export default {
}
// #endif
// 鸿蒙元服务端:通过事件对象获取视频宽高
// #ifdef MP-HARMONY
if (event && event.detail) {
width = event.detail.width || 0;
height = event.detail.height || 0;
}
// #endif
if (width && height) {
this.popupVideoRatio = width >= height ? 'landscape' : 'portrait';
} else {
// 获取失败,默认竖屏
this.popupVideoRatio = 'portrait';
this.popupVideoWidth = width;
this.popupVideoHeight = height;
}
});
},
// 弹窗视频错误事件
onPopupVideoError(event) {
console.error('Popup video load failed:', event);
}
}
}
@@ -576,8 +611,9 @@ export default {
.video-container {
width: 100%;
height: 300px;
position: relative;
overflow: hidden;
background-color: #000;
}
.adaptive-video {
@@ -589,6 +625,22 @@ export default {
left: 0;
}
/* #ifdef MP-WEIXIN */
.adaptive-video {
position: absolute;
top: 0;
left: 0;
}
/* #endif */
/* #ifdef MP-HARMONY */
.adaptive-video {
position: static;
display: block;
box-sizing: border-box;
}
/* #endif */
.video-nav {
padding: 16rpx;
box-sizing: border-box;

View File

@@ -26,6 +26,23 @@
:poster="posterUrl"
:style="videoWarpCss"
controls
object-fit="cover"
@loadedmetadata="onVideoLoaded"
@error="onVideoError"
></video>
<!-- #endif -->
<!-- 鸿蒙元服务端使用uni-app的video组件 -->
<!-- #ifdef MP-HARMONY -->
<video
ref="videoRef"
data-component-name="diy-video"
class="diy-video harmony-video"
:src="videoUrl"
:poster="posterUrl"
:style="videoWarpCss"
controls
object-fit="cover"
@loadedmetadata="onVideoLoaded"
@error="onVideoError"
></video>
@@ -44,14 +61,17 @@ export default {
}
},
data() {
return {
videoRatio: null, // 视频比例:'landscape'(横屏)或 'portrait'(竖屏)
videoUrl: '',
posterUrl: ''
};
},
return {
videoWidth: 0,
videoHeight: 0,
videoUrl: '',
posterUrl: '',
windowWidth: 0
};
},
created() {
this.initVideoData();
this.initWindowWidth();
},
mixins: [DiyMinx],
watch: {
@@ -78,18 +98,26 @@ export default {
// 动态计算容器比例
containerStyle() {
let style = '';
const defaultRatio = 9 / 16;
if (this.videoRatio === 'landscape') {
// 横屏视频:16/9比例
style += 'aspect-ratio: 16 / 9;';
} else if (this.videoRatio === 'portrait') {
// 竖屏视频:9/16比例
style += 'aspect-ratio: 9 / 16;';
} else {
// 未加载完成 → 默认使用竖屏比例
style += 'aspect-ratio: 9 / 16;';
let ratio = defaultRatio;
if (this.videoWidth && this.videoHeight) {
ratio = this.videoHeight / this.videoWidth;
}
// #ifdef H5
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-WEIXIN
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-HARMONY
style += 'width: 100%;';
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
// #endif
return style;
}
},
@@ -104,6 +132,16 @@ export default {
// #endif
},
// 初始化窗口宽度
initWindowWidth() {
try {
const systemInfo = uni.getSystemInfoSync();
this.windowWidth = systemInfo.windowWidth || 375;
} catch (e) {
this.windowWidth = 375;
}
},
// 获取视频URL
getVideoUrl() {
if (!this.value || !this.value.videoUrl) return '';
@@ -138,7 +176,8 @@ export default {
const height = video.videoHeight;
if (width && height) {
this.videoRatio = width >= height ? 'landscape' : 'portrait';
this.videoWidth = width;
this.videoHeight = height;
}
});
@@ -169,8 +208,16 @@ export default {
}
// #endif
// #ifdef MP-HARMONY
if (event && event.detail) {
width = event.detail.width || 0;
height = event.detail.height || 0;
}
// #endif
if (width && height) {
this.videoRatio = width >= height ? 'landscape' : 'portrait';
this.videoWidth = width;
this.videoHeight = height;
}
},
@@ -216,6 +263,11 @@ export default {
/* H5端样式 */
/* #ifdef H5 */
.h5-video {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
object-fit: cover;
}
/* #endif */
@@ -225,6 +277,19 @@ export default {
.mp-video {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* #endif */
/* 鸿蒙元服务端样式 */
/* #ifdef MP-HARMONY */
.harmony-video {
width: 100%;
height: 100%;
display: block;
box-sizing: border-box;
}
/* #endif */
</style>

View File

@@ -231,6 +231,7 @@ export default {
}
this.$api.sendRequest({
url: '/api/pay/pay',
noRetry: true,
data: {
out_trade_no: this.payInfo.out_trade_no,
pay_type: payType.type,
@@ -350,6 +351,7 @@ export default {
if (!payType) return;
this.$api.sendRequest({
url: '/api/pay/pay',
noRetry: true,
data: {
out_trade_no: this.payInfo.out_trade_no,
pay_type: payType.type,

View File

@@ -2,10 +2,7 @@
<!-- #ifndef APP-PLUS -->
<view v-if="visible" class="splash-screen" :class="{ 'splash-screen--leaving': leaving }">
<view class="splash-screen__field">
<!-- 中心大光晕: 整片柔光铺底 -->
<view class="splash-screen__aura"></view>
<!-- 三圈轨道: 由内到外, 描边极淡 -->
<view class="splash-screen__orbit splash-screen__orbit--1">
<view class="splash-screen__particle splash-screen__particle--1"></view>
</view>
@@ -15,23 +12,18 @@
<view class="splash-screen__orbit splash-screen__orbit--3">
<view class="splash-screen__particle splash-screen__particle--3"></view>
</view>
<!-- 中心星核: 渐变球体 + 内反光 + 外发光 -->
<view class="splash-screen__core">
<view class="splash-screen__core-halo"></view>
<view class="splash-screen__core-body"></view>
</view>
</view>
<!-- 启动期进度条 (透明悬浮, 不干扰极光视觉) -->
<view class="splash-screen__progress" v-if="showProgress">
<!-- 进度条 + 百分比 -->
<view class="splash-screen__progress">
<text class="splash-screen__progress-percent">{{ percent }}%</text>
<view class="splash-screen__progress-bar">
<view class="splash-screen__progress-fill" :style="{ width: percent + '%' }"></view>
</view>
<view class="splash-screen__progress-text">
<text class="splash-screen__progress-stage">{{ stageLabel }}</text>
<text class="splash-screen__progress-percent">{{ percent }}%</text>
</view>
</view>
</view>
<!-- #endif -->
@@ -44,66 +36,39 @@
name: 'splash-screen',
data() {
return {
// 默认 visible=true: 让 splash 组件一渲染就可见, 减少"白屏闪烁"
// created() 钩子会同步 splashState 状态, 如果 splashState 已 hide, 立刻设 false
// 这样既能保证首屏冷启动可见, 又避免挂载到非 splash 路径时误显示
visible: true,
leaving: false,
percent: 0,
stage: 'boot',
showProgress: false,
};
},
computed: {
// 直接从 splashState.STAGES 读取文案, 避免重复维护 STAGE_LABEL
// (splash-state.js 暴露 getStageDef() 避免外部直接读 STAGES)
stageLabel() {
const def = splashState.getStageDef(this.stage)
return def ? def.label : this.stage
},
},
// created (而非 mounted): 提前同步 splashState 状态, 减少"白屏一帧"
created() {
try {
const init = splashState.getProgress()
this.percent = init.percent
this.stage = init.stage
this.showProgress = init.percent > 0
} catch (e) {}
// 同步 splashState 状态: 防止组件挂载在 splash 已 hide 之后误显示
if (!splashState.isShow()) {
this.visible = false
}
},
mounted() {
splashState.notifyMounted();
splashState.on('splash:show', () => {
this.visible = true;
this.leaving = false;
// 注意: 不要在这里再读 getProgress(). percent 不允许后退
// 已通过 created() 同步; emit 进来多是无变化
const init = splashState.getProgress()
if (init.percent > this.percent) this.percent = init.percent
if (init.stage !== this.stage) this.stage = init.stage
this.showProgress = true
});
splashState.on('splash:hide', (info) => {
// 进度条补完到 100% (视觉上"完成"), 立即 leave
if (this.percent < 100) {
this.percent = 100;
this.stage = 'done';
}
// mode='fast' (隐私弹窗即将出现) → 80ms 短动画让 modal 更快接管视觉
const fast = info && info.mode === 'fast'
this._leave(fast)
});
splashState.onProgress((info) => {
// 去重: percent/stage 没变化就不 setState, 避免空 re-render
if (info.percent !== this.percent) this.percent = info.percent
if (info.stage !== this.stage) this.stage = info.stage
this.showProgress = true;
});
},
splashState.notifyMounted();
splashState.on('splash:show', () => {
this.visible = true;
this.leaving = false;
const init = splashState.getProgress()
if (init.percent > this.percent) this.percent = init.percent
});
splashState.on('splash:hide', (info) => {
if (this.percent < 100) {
this.percent = 100;
}
const fast = info && info.mode === 'fast'
this._leave(fast)
});
splashState.onProgress((info) => {
if (info.percent !== this.percent) this.percent = info.percent
});
},
beforeDestroy() {
splashState.off('splash:show');
splashState.off('splash:hide');
@@ -115,12 +80,6 @@
}
},
methods: {
/**
* 离开 splash
* - 默认: 480ms fade-out 动画 (视觉柔和过渡)
* - 当 privacy 弹窗即将出现时: 用 80ms 短动画, 让 modal 更快接管视觉
* 否则弹窗与 splash 同时显示会有"双遮罩"感
*/
_leave(fast) {
this.leaving = true;
if (this._leaveTimer) clearTimeout(this._leaveTimer);
@@ -135,15 +94,8 @@
</script>
<style lang="scss" scoped>
// ============================================================
// 启动屏 — 极光脉冲
// 中心柔光球呼吸, 三圈粒子轨道旋转, 整体白底 + 蓝紫辉光
// 单一焦点 / 对称结构 / 黄金比例节奏, 跨平台一致
// ============================================================
$accent: #6366F1; // 主色: 靛蓝
$accent-light: #A5B4FC; // 亮部
$accent-deep: #4338CA; // 暗部
$accent: #6366F1;
$accent-light: #A5B4FC;
.splash-screen {
position: fixed;
@@ -160,13 +112,16 @@
overflow: hidden;
opacity: 1;
transition: opacity 0.45s cubic-bezier(0.4, 0, 0.2, 1);
// 启动屏是纯视觉装饰(轨道/粒子/进度条),无任何可点击元素。
// 关闭 pointer-events,让首页在 boot 完成前即可滚动/点击,
// 否则 z-index:99999 全屏 fixed 会把触摸事件全部吞掉(含 480ms 渐隐期)。
pointer-events: none;
&--leaving {
opacity: 0;
}
}
// 启动期进度条 (悬浮在极光下方)
.splash-screen__progress {
position: absolute;
bottom: 120rpx;
@@ -176,16 +131,18 @@
max-width: 80vw;
display: flex;
flex-direction: column;
gap: 16rpx;
align-items: center;
gap: 8rpx;
opacity: 0;
animation: progress-fade-in 0.2s ease-out 0.1s forwards;
}
.splash-screen__progress-bar {
position: relative;
height: 6rpx;
width: 100%;
height: 4rpx;
background: rgba($accent, 0.08);
border-radius: 3rpx;
border-radius: 2rpx;
overflow: hidden;
}
@@ -193,30 +150,15 @@
position: absolute;
top: 0;
left: 0;
bottom: 0;
height: 100%;
background: linear-gradient(90deg, $accent-light, $accent);
border-radius: 3rpx;
border-radius: 2rpx;
transition: width 0.45s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 12rpx rgba($accent, 0.4);
}
.splash-screen__progress-text {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 22rpx;
color: rgba($accent-deep, 0.65);
letter-spacing: 0.5rpx;
}
.splash-screen__progress-stage {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.splash-screen__progress-percent {
font-size: 24rpx;
font-variant-numeric: tabular-nums;
font-weight: 500;
color: $accent;
@@ -227,7 +169,6 @@
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
// 容器: 480rpx 见方, 居中
.splash-screen__field {
position: relative;
width: 480rpx;
@@ -235,11 +176,9 @@
display: flex;
align-items: center;
justify-content: center;
// 入场: 缩放 + 透明度, 弹性曲线 (优化: 0.8s -> 0.4s 减少首屏延迟)
animation: field-enter 0.4s cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
// 中心大光晕: 覆盖整个 field, 营造氛围
.splash-screen__aura {
position: absolute;
width: 480rpx;
@@ -254,7 +193,6 @@
animation: aura-breathe 3.6s ease-in-out infinite;
}
// 中心星核
.splash-screen__core {
position: relative;
width: 36rpx;
@@ -266,7 +204,6 @@
animation: core-pulse 2.4s ease-in-out infinite;
}
// 星核外层光晕: 跟随呼吸
.splash-screen__core-halo {
position: absolute;
width: 120rpx;
@@ -278,7 +215,6 @@
animation: halo-expand 2.4s ease-in-out infinite;
}
// 星核本体: 渐变球体
.splash-screen__core-body {
position: relative;
width: 36rpx;
@@ -288,15 +224,14 @@
#EEF2FF 0%,
$accent-light 35%,
$accent 70%,
$accent-deep 100%);
#4338CA 100%);
box-shadow:
0 0 24rpx rgba($accent, 0.6),
0 0 48rpx rgba($accent, 0.3),
inset -2rpx -3rpx 6rpx rgba($accent-deep, 0.5),
inset -2rpx -3rpx 6rpx rgba(#4338CA, 0.5),
inset 2rpx 2rpx 4rpx rgba(255, 255, 255, 0.5);
}
// 轨道
.splash-screen__orbit {
position: absolute;
top: 50%;
@@ -333,7 +268,6 @@
}
}
// 粒子: 沿轨道运动, 起始位置在轨道顶部
.splash-screen__particle {
position: absolute;
top: 50%;
@@ -376,11 +310,6 @@
}
}
// ============================================================
// 动画
// ============================================================
// 容器入场
@keyframes field-enter {
0% {
opacity: 0;
@@ -392,7 +321,6 @@
}
}
// 中心光晕呼吸
@keyframes aura-breathe {
0%, 100% {
transform: scale(1);
@@ -404,7 +332,6 @@
}
}
// 中心星核呼吸
@keyframes core-pulse {
0%, 100% {
transform: scale(1);
@@ -414,7 +341,6 @@
}
}
// 星核光晕扩散
@keyframes halo-expand {
0%, 100% {
transform: scale(1);
@@ -426,7 +352,6 @@
}
}
// 轨道旋转
@keyframes orbit-rotate {
from {
transform: rotate(0deg);
@@ -435,4 +360,4 @@
transform: rotate(360deg);
}
}
</style>
</style>

View File

@@ -29,4 +29,12 @@
## BUG
- [x] BUG: diy-image-nav 组件,singleSlide 模式,在H5环境下,不支持鼠标滑动;
- [x] BUG: diy-article 组件,在H5环境下,不支持通过鼠标滚动
- [x] BUG: diy-article 组件,在H5环境下,不支持通过鼠标滚动
- [x] BUG: diy-video 组件,在鸿蒙元服务环境下,不显示视频
- [x] BUG: diy-video 组件,在鸿蒙元服务环境下,视频显示了,但是比例不对
- [x] BUG: diy-video-list 组件,在鸿蒙元服务环境下,比例不对
- [x] BUG: diy-goods-list 组件,在鸿蒙元服务环境下,图片显示不全 (H5,微信小程序环境正常)
- [x] BUG: 在鸿蒙元服务环境下,偶尔出现首页空白的情况
- [x] BUG: /pages_tool/article/detail 页面,应该空白占满屏
- [ ] BUG: 微信小程序环境:
- [ ] BUG: [console]: {"stack":"TypeError: r.map is not a function\n at Tr.selectAllComponents (<anonymous>:1:141070)\n at eval (webpack-internal:///./common/vendor.js:18:76114)\n at a.get [as $refs] (webpack-internal:///./common/vendor.js:18:76205)\n at a.onUnload (webpack-internal:///./pages_goods/category.js:26:51)\n at Re (webpack-internal:///./common/vendor.js:10:11210)\n at e.__call_hook (webpack-internal:///./common/vendor.js:11:22791)\n at p.default.__call_hook (webpack-internal:///./common/vendor.js:18:77128)\n at e.<computed> (webpack-internal:///./common/vendor.js:18:69329)\n at Tr.jr (<anonymous>:1:139499)\n at <anonymous>:1:140379","message":"r.map is not a function"}

View File

@@ -132,6 +132,16 @@ export const lang = {
domainNotInList: '{url} is not in the request domain whitelist',
// request failed: {url} is the domain placeholder
requestFailed: '{url} request failed. {err}',
// network timeout
timeout: 'Network request timeout, please try again later',
// network connection failed
networkError: 'Network connection failed, please check your network',
// server error
serverError: 'Service temporarily unavailable, please try again later',
// request aborted
aborted: 'Request cancelled',
// unknown error
unknownError: 'Request failed, please try again later',
},
// share utility

View File

@@ -132,6 +132,16 @@ export const lang = {
domainNotInList: '{url}不在request 合法域名列表中',
// 请求失败: {url} 为域名占位符
requestFailed: '{url}请求失败. {err}',
// 网络超时
timeout: '网络请求超时,请稍后重试',
// 网络连接失败
networkError: '网络连接失败,请检查网络设置',
// 服务器错误
serverError: '服务暂时不可用,请稍后重试',
// 请求被中断
aborted: '请求已取消',
// 未知错误
unknownError: '请求失败,请稍后重试',
},
// share 分享工具

View File

@@ -58,6 +58,14 @@ Vue.prototype.$eventBus = EventBus;
Vue.mixin(globalConfig);
// #ifdef H5
// 全局接管下拉刷新:原生快应用宿主内通过 notifyNativeHost('reload') 转发,
// 其余平台只走 uni.stopPullDownRefresh 兜底,不会影响各页面原 onPullDownRefresh 的其他逻辑
// 全局 mixin:下拉刷新 → 在快应用宿主内转发原生 webview.reload()
import quickappPullReload from './common/js/quickapp-pull-refresh.js'
Vue.mixin(quickappPullReload);
// #endif
// #ifdef MP-WEIXIN
// 全局兜底分享 mixin:
// 为所有未显式声明 onShareAppMessage / onShareTimeline 的页面提供默认实现,

View File

@@ -1,7 +1,11 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js"
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
"prebuild": "npm run test",
"prestart": "npm run test"
},
"devDependencies": {
"dart-sass": "^1.25.0",

View File

@@ -4,6 +4,7 @@
"path": "pages/index/index",
"style": {
// "navigationStyle": "custom",
"navigationBarTitleText": "",
"enablePullDownRefresh": true,
"componentPlaceholder": {
"diy-article": "view",
@@ -66,6 +67,7 @@
{
"path": "pages/empty/index",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
}
@@ -351,6 +353,7 @@
{
"path": "payment",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
@@ -360,6 +363,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -422,24 +426,27 @@
"path": "detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "detail_local_delivery",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "detail_pickup",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//积分订单详情
@@ -447,8 +454,9 @@
"path": "detail_point",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
}
]
@@ -461,7 +469,10 @@
{
"path": "point/list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -526,6 +537,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -588,36 +600,42 @@
"path": "point/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "point/payment",
"style": {
"navigationStyle": "custom"
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "point/order_list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "point/result",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//****************** 商户 ******************
{
"path": "merch/merchcategory",
"style": {
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
@@ -752,6 +770,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -814,16 +833,18 @@
"path": "fenxiao/promote",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/apply",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -832,6 +853,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -894,47 +916,53 @@
"path": "fenxiao/relation",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/order_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/team",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/withdraw_apply",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/withdraw_list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/withdrawal_detail",
"style": {
"navigationBarTitleText": ""
// #ifdef APP-PLUS
"navigationStyle": "custom"
,"navigationStyle": "custom"
// #endif
}
},
@@ -942,16 +970,18 @@
"path": "fenxiao/promote_code",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/level",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -960,6 +990,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1022,8 +1053,9 @@
"path": "fenxiao/bill",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1134,16 +1166,18 @@
"path": "webview/webview",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "index/diy",
"style": {
// #ifdef MP-WEIXIN
// "navigationStyle": "custom",
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"enablePullDownRefresh": true,
"componentPlaceholder": {
"diy-article": "view",
@@ -1210,6 +1244,8 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"enablePullDownRefresh": true,
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1272,54 +1308,63 @@
"path": "member/modify_face",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/account",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/account_edit",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/apply_withdrawal",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/balance",
"style": {
"navigationStyle": "custom"
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/balance_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/collection",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1328,13 +1373,17 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"disableScroll": true
}
},
{
"path": "member/contact",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
},
@@ -1342,142 +1391,162 @@
"path": "member/footprint",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/level",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/card",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/card_buy",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/card_agreement",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/level_growth_rules",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/point",
"style": {
"navigationStyle": "custom"
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/point_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/withdrawal",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/withdrawal_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/address",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/address_edit",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/pay_password",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancellation",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/assets",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancelstatus",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancelsuccess",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancelrefuse",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1493,8 +1562,9 @@
"path": "member/info_edit",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************登录模块******************
@@ -1502,8 +1572,9 @@
"path": "login/find",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************商品模块******************
@@ -1513,6 +1584,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1575,16 +1647,18 @@
"path": "goods/coupon_receive",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "goods/evaluate",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1593,6 +1667,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1657,6 +1732,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1719,8 +1795,9 @@
"path": "goods/not_exist",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************CMS模块(6)******************
@@ -1728,16 +1805,18 @@
"path": "help/list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "help/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1746,6 +1825,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1808,8 +1888,9 @@
"path": "notice/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1818,6 +1899,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1880,8 +1962,9 @@
"path": "article/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************会员充值(4)******************
@@ -1891,6 +1974,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"disableScroll": false
}
},
@@ -1898,8 +1982,9 @@
"path": "recharge/order_list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************邀请******************
@@ -1907,8 +1992,9 @@
"path": "member/invite_friends",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************支付模块(2)******************
@@ -1916,40 +2002,45 @@
"path": "pay/index",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "pay/wx_pay",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "pay/result",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "pay/cashier",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "storeclose/storeclose",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************订单模块(2)******************
@@ -1957,56 +2048,63 @@
"path": "order/logistics",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/evaluate",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_goods_select",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_type_select",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_batch",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -2022,8 +2120,9 @@
"path": "order/detail_virtual",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
//******************登录模块(3)******************
@@ -2040,8 +2139,9 @@
"path": "form/form",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom"
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -2050,6 +2150,7 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",

View File

@@ -21,28 +21,28 @@
</view> -->
<diy-index-page v-if="topIndexValue" ref="indexPage" :value="topIndexValue" :bgUrl="bgUrl"
:scrollTop="scrollTop" :diyGlobal="diyData.global" class="diy-index-page">
<template v-slot:components>
<diy-group ref="diyGroup" v-if="diyData.value" :diyData="diyData" :scrollTop="scrollTop"
:haveTopCategory="true" :followOfficialAccount="followOfficialAccount"
@loaded="onDiyGroupLoaded" />
</template>
<template v-slot:default>
<ns-copyright v-show="isShowCopyRight" />
</template>
</diy-index-page>
<view v-else class="bg-index"
:style="{ backgroundImage: backgroundUrl, paddingTop: paddingTop, marginTop: marginTop }">
<diy-group ref="diyGroup" v-if="diyData.value" :diyData="diyData" :scrollTop="scrollTop"
:followOfficialAccount="followOfficialAccount" @loaded="onDiyGroupLoaded" />
<view class="version-watermark-wrap" v-if="versionWatermark && diyGroupReady" @tap="onVersionTap">
<view class="version-watermark">
<text>{{ versionWatermark }}</text>
</view>
</view>
:scrollTop="scrollTop" :diyGlobal="diyData.global" class="diy-index-page">
<template v-slot:components>
<diy-group v-show="diyData.value" ref="diyGroup" :diyData="diyData" :scrollTop="scrollTop"
:haveTopCategory="true" :followOfficialAccount="followOfficialAccount"
@loaded="onDiyGroupLoaded" />
</template>
<template v-slot:default>
<ns-copyright v-show="isShowCopyRight" />
</template>
</diy-index-page>
<view v-else class="bg-index"
:style="{ backgroundImage: backgroundUrl, paddingTop: paddingTop, marginTop: marginTop }">
<diy-group v-show="diyData.value" ref="diyGroup" :diyData="diyData" :scrollTop="scrollTop"
:followOfficialAccount="followOfficialAccount" @loaded="onDiyGroupLoaded" />
<view class="version-watermark-wrap" v-if="versionWatermark && diyGroupReady" @tap="onVersionTap">
<view class="version-watermark">
<text>{{ versionWatermark }}</text>
</view>
</view>
<ns-copyright v-show="isShowCopyRight" />
</view>
<template v-if="adv.advshow != -1">
<view @touchmove.prevent.stop>

View File

@@ -1,3 +1,6 @@
import splashState from '@/common/js/splash-state.js';
import Config from '@/common/js/config.js';
export default {
data() {
return {
@@ -124,8 +127,13 @@ export default {
return false;
},
// diy-group 渲染完毕回调, 用于触发版本水印等延后显示元素
// 同时通知 splash-state 页面就绪, 启动屏可以关闭
// 延迟 500ms 是为了确保页面内容有足够时间渲染
onDiyGroupLoaded() {
this.diyGroupReady = true;
setTimeout(() => {
try { splashState.notifyPageReady(); } catch (e) {}
}, 500);
},
/**
* 版本连击 3 次以上显示构建版本信息(供测试人员使用)
@@ -153,7 +161,7 @@ export default {
if (v && v.commitId) {
uni.showModal({
title: '版本信息',
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}`,
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false
});
} else {

View File

@@ -1,12 +1,12 @@
<template>
<view :style="themeColor">
<block v-if="diyData">
<view v-show="diyData && diyData.value && diyData.value.length">
<block v-for="(item, index) in diyData.value" :key="index">
<view v-if="item.componentName == 'GoodsCategory'">
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
</view>
</block>
</block>
</view>
<ns-login ref="login"></ns-login>
@@ -26,7 +26,7 @@
export default {
data() {
return {
diyData: null,
diyData: { value: [] },
mpShareData: null //小程序分享数据
};
},
@@ -38,8 +38,15 @@ export default {
},
onShow() {
this.$util.hideHomeButton();
if (this.$refs.category) this.$refs.category[0].pageShow();
try {
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
if (this.$refs.category && this.$refs.category[0]) {
this.$refs.category[0].pageShow();
}
} catch (e) {
console.warn('onShow pageShow failed', e);
}
//小程序分享
// #ifdef MP-WEIXIN
this.$util.getMpShare('/pages_goods/category').then(res => {
@@ -60,25 +67,48 @@ export default {
// #endif
},
onUnload() {
!this.storeToken && this.$refs.login?.cancelCompleteInfo?.();
try {
if (!this.storeToken && this.$refs.login && typeof this.$refs.login.cancelCompleteInfo === 'function') {
this.$refs.login.cancelCompleteInfo();
}
} catch (e) {
console.warn('onUnload cancelCompleteInfo failed', e);
}
},
methods: {
getDiyInfo() {
// 避免重复请求导致布局抖动
if (this._diyInfoLoading) return;
this._diyInfoLoading = true;
this.$api.sendRequest({
url: '/api/diyview/info',
data: {
name: 'DIY_VIEW_GOODS_CATEGORY'
},
success: res => {
if (res.code == 0 && res.data) {
this.diyData = res.data;
if (this.diyData.value) {
this.diyData = JSON.parse(this.diyData.value);
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
if (res.code == 0 && res.data) {
const newData = res.data.value ? JSON.parse(res.data.value) : res.data;
// 仅在数据真正变化时赋值,避免鸿蒙元服务下频繁 re-render 造成频闪
if (JSON.stringify(newData) !== JSON.stringify(this.diyData)) {
this.diyData = newData;
}
uni.stopPullDownRefresh();
} else {
this.$util.showToast({
title: this.$lang('diy.noDiyData')
});
}
},
fail: err => {
console.error('getDiyInfo request failed', err);
this.$util.showToast({
title: this.$lang('diy.networkError')
});
},
complete: () => {
this._diyInfoLoading = false;
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
uni.stopPullDownRefresh();
}
});
},

View File

@@ -22,6 +22,7 @@ export default {
pay() {
this.$api.sendRequest({
url: '/api/order/pay',
noRetry: true,
data: {
order_ids: this.orderData.order_id
},
@@ -49,6 +50,7 @@ export default {
if (res.confirm) {
this.$api.sendRequest({
url: '/api/order/close',
noRetry: true,
data: {
order_id
},
@@ -82,6 +84,7 @@ export default {
if (res.confirm) {
this.$api.sendRequest({
url: '/api/order/takedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -110,6 +113,7 @@ export default {
if (res.extraData.status == 'success') {
this.$api.sendRequest({
url: '/api/order/takedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -123,6 +127,7 @@ export default {
} else {
this.$api.sendRequest({
url: '/api/order/takedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -146,6 +151,7 @@ export default {
if (res.confirm) {
this.$api.sendRequest({
url: '/api/order/takedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -177,6 +183,7 @@ export default {
if (res.confirm) {
this.$api.sendRequest({
url: '/api/order/membervirtualtakedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -205,6 +212,7 @@ export default {
if (res.extraData.status == 'success') {
this.$api.sendRequest({
url: '/api/order/membervirtualtakedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -218,6 +226,7 @@ export default {
} else {
this.$api.sendRequest({
url: '/api/order/membervirtualtakedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},
@@ -240,6 +249,7 @@ export default {
if (res.confirm) {
this.$api.sendRequest({
url: '/api/order/membervirtualtakedelivery',
noRetry: true,
data: {
order_id: orderData.order_id
},

View File

@@ -111,7 +111,7 @@
<style lang="scss">
.page {
width: 100%;
height: 100%;
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
background: #ffffff;

View File

@@ -14,11 +14,15 @@
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
</template>
<script>
import scroll from '@/common/js/scroll-view.js';
export default {
mixins: [scroll],
data() {
return {
id: 0,

View File

@@ -19,17 +19,20 @@
</block>
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
</template>
<script>
import diyForm from '@/components-diy/diy-form.vue';
import scroll from '@/common/js/scroll-view.js';
export default {
components: {
diyForm
},
mixins: [scroll],
data() {
return {
id: 0,

View File

@@ -1,6 +1,6 @@
<template>
<view :style="themeColor">
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '100vh' }"
class="page-img">
<view class="page-header" v-if="diyData.global && diyData.global.navBarSwitch"
:style="{ backgroundImage: bgImg }">
@@ -51,6 +51,7 @@
<!-- 小程序隐私协议 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
<!-- #endif -->
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
</view>
</template>
@@ -58,10 +59,11 @@
<script>
import diyJs from '@/common/js/diy.js';
import scroll from '@/common/js/scroll-view.js';
import microPageJs from './public/js/diy.js';
export default {
mixins: [diyJs, microPageJs]
mixins: [diyJs, scroll, microPageJs]
};
</script>

View File

@@ -118,6 +118,7 @@
<ns-goods-recommend ref="goodrecommend" route="super_member"></ns-goods-recommend>
<ns-login ref="login"></ns-login>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
</template>

View File

@@ -215,6 +215,7 @@
<ns-login ref="login"></ns-login>
<loading-cover ref="loadingCover"></loading-cover>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<!-- 选择支付方式弹窗 -->
</view>
</template>

View File

@@ -71,12 +71,14 @@
<!-- 小程序隐私协议 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
<!-- #endif -->
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
</view>
</template>
<script>
import diyJs from '@/common/js/diy.js';
import scroll from '@/common/js/scroll-view.js';
import indexJs from './public/js/index.js';
import memberPublic from './public/js/member-public.js';
@@ -88,7 +90,7 @@ export default {
nsBirthdayGift,
nsNewGift
},
mixins: [diyJs, indexJs, memberPublic],
mixins: [diyJs, scroll, indexJs, memberPublic],
computed: {
pageStyle() {
return {

View File

@@ -1,7 +1,7 @@
<template>
<view :style="themeColor">
<view class="iconfont iconshang navigate-back" @click="navigateBack"></view>
<web-view :src="src"></web-view>
<web-view :src="src" @titleUpdate="updateNavbarTitle"></web-view>
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
@@ -11,13 +11,25 @@
<script>
import { decodeEntity } from '@/common/js/util.js';
/**
* 必须真机调试
* 在微信模拟器中,行为和真机不一样
* 1. 模拟器中,设置标题不生效,但是,在真机是可以的。
*/
export default {
data() {
return {
src: ''
src: '',
title: '', // 标题栏标题
enableSetTitle: false, // 是否强制设置标题,用来替换Webview中的默认标题
};
},
onLoad(option) {
this.title = decodeURIComponent(option?.title ?? '');
this.enableSetTitle = decodeURIComponent(option?.enable_set_title) == 1;
// #ifdef H5
this.src = decodeEntity(decodeURIComponent(option.src));
// #endif
@@ -26,11 +38,28 @@ export default {
this.src = decodeEntity(decodeURIComponent(`${option.src}&from=mp-webview`));
// #endif
},
onShow() {
this.updateNavbarTitle();
},
watch: {
title(newVal) {
this.updateNavbarTitle(newVal);
},
enableSetTitle() {
this.updateNavbarTitle();
}
},
methods: {
navigateBack() {
uni.navigateBack({
delta: 1
});
},
updateNavbarTitle(newTitle) {
const title = newTitle ?? this.title;
if (this.enableSetTitle) {
uni.setNavigationBarTitle({title});
}
}
}
};

85
scripts/test-gate.js Normal file
View File

@@ -0,0 +1,85 @@
/**
* 测试门禁:扫描 tests/ 下所有 *.test.js,全部通过才能正常返回 0
*
* 用法:
* node scripts/test-gate.js
* 或: npm test
*
* 工作机制:
* 1) 扫描 tests/ 目录,找到所有 *.test.js 文件
* 2) 用 node --test 跑每个文件,任一文件非 0 退出则整体失败
* 3) 输出汇总:总数 / 通过 / 失败 / 失败的文件列表
*
* 兼容性:
* node 18+ 自带 test runner(--test)。低于 18 会给出明确错误并退出非 0。
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const TESTS_DIR = path.join(ROOT, 'tests');
function listTestFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter(function (f) { return f.endsWith('.test.js'); })
.map(function (f) { return path.join(dir, f); })
.sort();
}
function checkNodeVersion() {
const major = Number(process.versions.node.split('.')[0]);
if (major < 18) {
console.error('[test-gate] 需要 node 18+(自带 --test),当前 node 版本:', process.version);
process.exit(1);
}
}
function runTestFile(testFile) {
console.log('\n[test-gate] === 运行测试文件:', path.relative(ROOT, testFile), '===');
const result = spawnSync(
process.execPath,
['--test', testFile],
{ stdio: 'inherit', cwd: ROOT },
);
return {
file: path.relative(ROOT, testFile),
ok: result.status === 0,
status: result.status,
};
}
function main() {
checkNodeVersion();
const files = listTestFiles(TESTS_DIR);
if (files.length === 0) {
console.error('[test-gate] tests/ 下没有任何 *.test.js 文件');
process.exit(1);
}
console.log('[test-gate] 找到', files.length, '个测试文件');
const results = files.map(runTestFile);
const failed = results.filter(function (r) { return !r.ok; });
console.log('\n[test-gate] =============== 汇总 ===============');
console.log(' 总数:', results.length);
console.log(' 通过:', results.length - failed.length);
console.log(' 失败:', failed.length);
if (failed.length > 0) {
console.log(' 失败文件:');
failed.forEach(function (r) {
console.log(' -', r.file, '(退出码', r.status + ')');
});
console.log('\n[test-gate] ❌ 测试未全部通过,不允许继续');
process.exit(1);
}
console.log('\n[test-gate] ✅ 全部测试通过');
process.exit(0);
}
main();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

File diff suppressed because one or more lines are too long

View File

@@ -79,7 +79,7 @@ $app_define$('@app-application/app', [], function($app_require$, $app_exports$,
$app_module$.exports = $app_exports$.default;
}
})
$app_bootstrap$('@app-application/app',{packagerName:'fa-toolkit', packagerVersion: '14.1.1-Stable.300'})
$app_bootstrap$('@app-application/app',{packagerName:'fa-toolkit', packagerVersion: '14.0.1-Stable.300'})
})();
/******/ })()
@@ -91,4 +91,4 @@ $app_bootstrap$('@app-application/app',{packagerName:'fa-toolkit', packagerVersi
window.createAppHandler = createAppHandler
}
})();
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVpbGRcXGFwcC5qcyIsIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7O0FDUkE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOzs7Ozs7OztBQ3ZCQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSIsInNvdXJjZXMiOlsid2VicGFjazovLy9zcmMvZDpcXGh3dGVzdHNcXGNvbS50ZXN0LmNjXFxzcmNcXGFwcC51eCIsIndlYnBhY2s6Ly8vd2VicGFjay9ib290c3RyYXAiLCJ3ZWJwYWNrOi8vLy4vc3JjL2FwcC51eCJdLCJzb3VyY2VzQ29udGVudCI6WyI8c2NyaXB0PlxyXG4gIG1vZHVsZS5leHBvcnRzID0ge1xyXG4gICAgb25DcmVhdGUoKSB7XHJcbiAgICAgIGNvbnNvbGUuaW5mbygnQXBwbGljYXRpb24gb25DcmVhdGUnKTtcclxuICAgIH0sXHJcbiAgICBvbkRlc3Ryb3koKSB7XHJcbiAgICAgIGNvbnNvbGUuaW5mbygnQXBwbGljYXRpb24gb25EZXN0cm95Jyk7XHJcbiAgICB9XHJcbiAgfVxyXG48L3NjcmlwdD5cclxuIiwiLy8gVGhlIG1vZHVsZSBjYWNoZVxudmFyIF9fd2VicGFja19tb2R1bGVfY2FjaGVfXyA9IHt9O1xuXG4vLyBUaGUgcmVxdWlyZSBmdW5jdGlvblxuZnVuY3Rpb24gX193ZWJwYWNrX3JlcXVpcmVfXyhtb2R1bGVJZCkge1xuXHQvLyBDaGVjayBpZiBtb2R1bGUgaXMgaW4gY2FjaGVcblx0dmFyIGNhY2hlZE1vZHVsZSA9IF9fd2VicGFja19tb2R1bGVfY2FjaGVfX1ttb2R1bGVJZF07XG5cdGlmIChjYWNoZWRNb2R1bGUgIT09IHVuZGVmaW5lZCkge1xuXHRcdHJldHVybiBjYWNoZWRNb2R1bGUuZXhwb3J0cztcblx0fVxuXHQvLyBDcmVhdGUgYSBuZXcgbW9kdWxlIChhbmQgcHV0IGl0IGludG8gdGhlIGNhY2hlKVxuXHR2YXIgbW9kdWxlID0gX193ZWJwYWNrX21vZHVsZV9jYWNoZV9fW21vZHVsZUlkXSA9IHtcblx0XHQvLyBubyBtb2R1bGUuaWQgbmVlZGVkXG5cdFx0Ly8gbm8gbW9kdWxlLmxvYWRlZCBuZWVkZWRcblx0XHRleHBvcnRzOiB7fVxuXHR9O1xuXG5cdC8vIEV4ZWN1dGUgdGhlIG1vZHVsZSBmdW5jdGlvblxuXHRfX3dlYnBhY2tfbW9kdWxlc19fW21vZHVsZUlkXShtb2R1bGUsIG1vZHVsZS5leHBvcnRzLCBfX3dlYnBhY2tfcmVxdWlyZV9fKTtcblxuXHQvLyBSZXR1cm4gdGhlIGV4cG9ydHMgb2YgdGhlIG1vZHVsZVxuXHRyZXR1cm4gbW9kdWxlLmV4cG9ydHM7XG59XG5cbiIsInZhciAkYXBwX3NjcmlwdCQgPSByZXF1aXJlKFwiISFjOi9Qcm9ncmFtIEZpbGVzL0h1YXdlaSBRdWlja0FwcCBJREUvcmVzb3VyY2VzL2FwcC9leHRlbnNpb25zL2RldmVjby1kZWJ1Zy9ub2RlX21vZHVsZXMvZmEtdG9vbGtpdC9saWIvZmEtY29tcGlsZXIvZmEtc2NyaXB0LWxvYWRlci5qcyFjOi9Qcm9ncmFtIEZpbGVzL0h1YXdlaSBRdWlja0FwcCBJREUvcmVzb3VyY2VzL2FwcC9leHRlbnNpb25zL2RldmVjby1kZWJ1Zy9ub2RlX21vZHVsZXMvZmEtdG9vbGtpdC9saWIvZmEtY29tcGlsZXIvZmEtYXBwLXNjcmlwdC1sb2FkZXIuanMhYzovUHJvZ3JhbSBGaWxlcy9IdWF3ZWkgUXVpY2tBcHAgSURFL3Jlc291cmNlcy9hcHAvZXh0ZW5zaW9ucy9kZXZlY28tZGVidWcvbm9kZV9tb2R1bGVzL2JhYmVsLWxvYWRlcj9wcmVzZXRzW109YzpcXFxcUHJvZ3JhbSBGaWxlc1xcXFxIdWF3ZWkgUXVpY2tBcHAgSURFXFxcXHJlc291cmNlc1xcXFxhcHBcXFxcZXh0ZW5zaW9uc1xcXFxkZXZlY28tZGVidWdcXFxcbm9kZV9tb2R1bGVzXFxcXEBiYWJlbFxcXFxwcmVzZXQtZW52LHRhcmdldHM9bm9kZSA4JnBsdWdpbnNbXT1jOlxcXFxQcm9ncmFtIEZpbGVzXFxcXEh1YXdlaSBRdWlja0FwcCBJREVcXFxccmVzb3VyY2VzXFxcXGFwcFxcXFxleHRlbnNpb25zXFxcXGRldmVjby1kZWJ1Z1xcXFxub2RlX21vZHVsZXNcXFxcQGJhYmVsXFxcXHBsdWdpbi10cmFuc2Zvcm0tbW9kdWxlcy1jb21tb25qcyZwbHVnaW5zW109YzpcXFxcUHJvZ3JhbSBGaWxlc1xcXFxIdWF3ZWkgUXVpY2tBcHAgSURFXFxcXHJlc291cmNlc1xcXFxhcHBcXFxcZXh0ZW5zaW9uc1xcXFxkZXZlY28tZGVidWdcXFxcbm9kZV9tb2R1bGVzXFxcXEBiYWJlbFxcXFxwbHVnaW4tcHJvcG9zYWwtY2xhc3MtcHJvcGVydGllcyZwbHVnaW5zW109YzpcXFxcUHJvZ3JhbSBGaWxlc1xcXFxIdWF3ZWkgUXVpY2tBcHAgSURFXFxcXHJlc291cmNlc1xcXFxhcHBcXFxcZXh0ZW5zaW9uc1xcXFxkZXZlY28tZGVidWdcXFxcbm9kZV9tb2R1bGVzXFxcXEBiYWJlbFxcXFxwbHVnaW4tcHJvcG9zYWwtb2JqZWN0LXJlc3Qtc3ByZWFkJmNvbW1lbnRzPWZhbHNlIWM6L1Byb2dyYW0gRmlsZXMvSHVhd2VpIFF1aWNrQXBwIElERS9yZXNvdXJjZXMvYXBwL2V4dGVuc2lvbnMvZGV2ZWNvLWRlYnVnL25vZGVfbW9kdWxlcy9mYS10b29sa2l0L2xpYi9mYS1jb21waWxlci9mYS1mcmFnbWVudC1sb2FkZXIuanM/aW5kZXg9MCZ0eXBlPXNjcmlwdHMhZDovaHd0ZXN0cy9jb20udGVzdC5jYy9zcmMvYXBwLnV4XCIpXG5cclxuJGFwcF9kZWZpbmUkKCdAYXBwLWFwcGxpY2F0aW9uL2FwcCcsIFtdLCBmdW5jdGlvbigkYXBwX3JlcXVpcmUkLCAkYXBwX2V4cG9ydHMkLCAkYXBwX21vZHVsZSQpe1xyXG5cbiAgICAgICAgJGFwcF9zY3JpcHQkKCRhcHBfbW9kdWxlJCwgJGFwcF9leHBvcnRzJCwgJGFwcF9yZXF1aXJlJClcbiAgICAgICAgaWYgKCRhcHBfZXhwb3J0cyQuX19lc01vZHVsZSAmJiAkYXBwX2V4cG9ydHMkLmRlZmF1bHQpIHtcbiAgICAgICAgICAgICRhcHBfbW9kdWxlJC5leHBvcnRzID0gJGFwcF9leHBvcnRzJC5kZWZhdWx0O1xuICAgICAgICB9XHJcbn0pXHJcbiRhcHBfYm9vdHN0cmFwJCgnQGFwcC1hcHBsaWNhdGlvbi9hcHAnLHtwYWNrYWdlck5hbWU6J2ZhLXRvb2xraXQnLCBwYWNrYWdlclZlcnNpb246ICcxNC4xLjEtU3RhYmxlLjMwMCd9KSJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVpbGRcXGFwcC5qcyIsIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7O0FDUkE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOzs7Ozs7OztBQ3ZCQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSIsInNvdXJjZXMiOlsid2VicGFjazovLy9zcmMvZDpcXHByb2plY3RzXFxzaG9wLXByb2plY3RzXFxmcm9udGVuZFxcc3R1Yi1hcHAtcHJvamVjdHNcXG1wLXF1aWNrYXBwXFxjb20udGVzdC5jY1xcc3JjXFxhcHAudXgiLCJ3ZWJwYWNrOi8vL3dlYnBhY2svYm9vdHN0cmFwIiwid2VicGFjazovLy8uL3NyYy9hcHAudXgiXSwic291cmNlc0NvbnRlbnQiOlsiPHNjcmlwdD5cclxuICBtb2R1bGUuZXhwb3J0cyA9IHtcclxuICAgIG9uQ3JlYXRlKCkge1xyXG4gICAgICBjb25zb2xlLmluZm8oJ0FwcGxpY2F0aW9uIG9uQ3JlYXRlJyk7XHJcbiAgICB9LFxyXG4gICAgb25EZXN0cm95KCkge1xyXG4gICAgICBjb25zb2xlLmluZm8oJ0FwcGxpY2F0aW9uIG9uRGVzdHJveScpO1xyXG4gICAgfVxyXG4gIH1cclxuPC9zY3JpcHQ+XHJcbiIsIi8vIFRoZSBtb2R1bGUgY2FjaGVcbnZhciBfX3dlYnBhY2tfbW9kdWxlX2NhY2hlX18gPSB7fTtcblxuLy8gVGhlIHJlcXVpcmUgZnVuY3Rpb25cbmZ1bmN0aW9uIF9fd2VicGFja19yZXF1aXJlX18obW9kdWxlSWQpIHtcblx0Ly8gQ2hlY2sgaWYgbW9kdWxlIGlzIGluIGNhY2hlXG5cdHZhciBjYWNoZWRNb2R1bGUgPSBfX3dlYnBhY2tfbW9kdWxlX2NhY2hlX19bbW9kdWxlSWRdO1xuXHRpZiAoY2FjaGVkTW9kdWxlICE9PSB1bmRlZmluZWQpIHtcblx0XHRyZXR1cm4gY2FjaGVkTW9kdWxlLmV4cG9ydHM7XG5cdH1cblx0Ly8gQ3JlYXRlIGEgbmV3IG1vZHVsZSAoYW5kIHB1dCBpdCBpbnRvIHRoZSBjYWNoZSlcblx0dmFyIG1vZHVsZSA9IF9fd2VicGFja19tb2R1bGVfY2FjaGVfX1ttb2R1bGVJZF0gPSB7XG5cdFx0Ly8gbm8gbW9kdWxlLmlkIG5lZWRlZFxuXHRcdC8vIG5vIG1vZHVsZS5sb2FkZWQgbmVlZGVkXG5cdFx0ZXhwb3J0czoge31cblx0fTtcblxuXHQvLyBFeGVjdXRlIHRoZSBtb2R1bGUgZnVuY3Rpb25cblx0X193ZWJwYWNrX21vZHVsZXNfX1ttb2R1bGVJZF0obW9kdWxlLCBtb2R1bGUuZXhwb3J0cywgX193ZWJwYWNrX3JlcXVpcmVfXyk7XG5cblx0Ly8gUmV0dXJuIHRoZSBleHBvcnRzIG9mIHRoZSBtb2R1bGVcblx0cmV0dXJuIG1vZHVsZS5leHBvcnRzO1xufVxuXG4iLCJ2YXIgJGFwcF9zY3JpcHQkID0gcmVxdWlyZShcIiEhYzovUHJvZ3JhbSBGaWxlcy9IdWF3ZWkgUXVpY2tBcHAgSURFL3Jlc291cmNlcy9hcHAvZXh0ZW5zaW9ucy9kZXZlY28tZGVidWcvbm9kZV9tb2R1bGVzL2ZhLXRvb2xraXQvbGliL2ZhLWNvbXBpbGVyL2ZhLXNjcmlwdC1sb2FkZXIuanMhYzovUHJvZ3JhbSBGaWxlcy9IdWF3ZWkgUXVpY2tBcHAgSURFL3Jlc291cmNlcy9hcHAvZXh0ZW5zaW9ucy9kZXZlY28tZGVidWcvbm9kZV9tb2R1bGVzL2ZhLXRvb2xraXQvbGliL2ZhLWNvbXBpbGVyL2ZhLWFwcC1zY3JpcHQtbG9hZGVyLmpzIWM6L1Byb2dyYW0gRmlsZXMvSHVhd2VpIFF1aWNrQXBwIElERS9yZXNvdXJjZXMvYXBwL2V4dGVuc2lvbnMvZGV2ZWNvLWRlYnVnL25vZGVfbW9kdWxlcy9iYWJlbC1sb2FkZXI/cHJlc2V0c1tdPWM6XFxcXFByb2dyYW0gRmlsZXNcXFxcSHVhd2VpIFF1aWNrQXBwIElERVxcXFxyZXNvdXJjZXNcXFxcYXBwXFxcXGV4dGVuc2lvbnNcXFxcZGV2ZWNvLWRlYnVnXFxcXG5vZGVfbW9kdWxlc1xcXFxAYmFiZWxcXFxccHJlc2V0LWVudix0YXJnZXRzPW5vZGUgOCZwbHVnaW5zW109YzpcXFxcUHJvZ3JhbSBGaWxlc1xcXFxIdWF3ZWkgUXVpY2tBcHAgSURFXFxcXHJlc291cmNlc1xcXFxhcHBcXFxcZXh0ZW5zaW9uc1xcXFxkZXZlY28tZGVidWdcXFxcbm9kZV9tb2R1bGVzXFxcXEBiYWJlbFxcXFxwbHVnaW4tdHJhbnNmb3JtLW1vZHVsZXMtY29tbW9uanMmcGx1Z2luc1tdPWM6XFxcXFByb2dyYW0gRmlsZXNcXFxcSHVhd2VpIFF1aWNrQXBwIElERVxcXFxyZXNvdXJjZXNcXFxcYXBwXFxcXGV4dGVuc2lvbnNcXFxcZGV2ZWNvLWRlYnVnXFxcXG5vZGVfbW9kdWxlc1xcXFxAYmFiZWxcXFxccGx1Z2luLXByb3Bvc2FsLWNsYXNzLXByb3BlcnRpZXMmcGx1Z2luc1tdPWM6XFxcXFByb2dyYW0gRmlsZXNcXFxcSHVhd2VpIFF1aWNrQXBwIElERVxcXFxyZXNvdXJjZXNcXFxcYXBwXFxcXGV4dGVuc2lvbnNcXFxcZGV2ZWNvLWRlYnVnXFxcXG5vZGVfbW9kdWxlc1xcXFxAYmFiZWxcXFxccGx1Z2luLXByb3Bvc2FsLW9iamVjdC1yZXN0LXNwcmVhZCZjb21tZW50cz1mYWxzZSFjOi9Qcm9ncmFtIEZpbGVzL0h1YXdlaSBRdWlja0FwcCBJREUvcmVzb3VyY2VzL2FwcC9leHRlbnNpb25zL2RldmVjby1kZWJ1Zy9ub2RlX21vZHVsZXMvZmEtdG9vbGtpdC9saWIvZmEtY29tcGlsZXIvZmEtZnJhZ21lbnQtbG9hZGVyLmpzP2luZGV4PTAmdHlwZT1zY3JpcHRzIWQ6L3Byb2plY3RzL3Nob3AtcHJvamVjdHMvZnJvbnRlbmQvc3R1Yi1hcHAtcHJvamVjdHMvbXAtcXVpY2thcHAvY29tLnRlc3QuY2Mvc3JjL2FwcC51eFwiKVxuXHJcbiRhcHBfZGVmaW5lJCgnQGFwcC1hcHBsaWNhdGlvbi9hcHAnLCBbXSwgZnVuY3Rpb24oJGFwcF9yZXF1aXJlJCwgJGFwcF9leHBvcnRzJCwgJGFwcF9tb2R1bGUkKXtcclxuXG4gICAgICAgICRhcHBfc2NyaXB0JCgkYXBwX21vZHVsZSQsICRhcHBfZXhwb3J0cyQsICRhcHBfcmVxdWlyZSQpXG4gICAgICAgIGlmICgkYXBwX2V4cG9ydHMkLl9fZXNNb2R1bGUgJiYgJGFwcF9leHBvcnRzJC5kZWZhdWx0KSB7XG4gICAgICAgICAgICAkYXBwX21vZHVsZSQuZXhwb3J0cyA9ICRhcHBfZXhwb3J0cyQuZGVmYXVsdDtcbiAgICAgICAgfVxyXG59KVxyXG4kYXBwX2Jvb3RzdHJhcCQoJ0BhcHAtYXBwbGljYXRpb24vYXBwJyx7cGFja2FnZXJOYW1lOidmYS10b29sa2l0JywgcGFja2FnZXJWZXJzaW9uOiAnMTQuMC4xLVN0YWJsZS4zMDAnfSkiXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=

View File

@@ -28,19 +28,27 @@
</style>
<script>
import router from "@system.router";
// === 集中配置:URL 域名与 uniacid 在这里统一修改 ===
const CONFIG = {
BASE_URL: "https://xcx.aigc-quickapp.com/hwappx/common",
UNIACID: "2482",
};
export default {
props: ['websrc'],
private: {
title: "",
loadUrl: "https://xcx.aigc-quickapp.com/hwapp",//修改域名"https://域名/hwapp"
//loadUrl: "https://xcx20.5g-quickapp.com/hwapp",//修改域名"https://域名/hwapp"
// loadUrl: "http://192.168.1.4:8080/hwapp",//修改域名"https://域名/hwapp"
title: "",
// H5 下拉刷新消息触发 reload 的冷却控制,防止快速连点/抖动重复触发
lastReloadAt: 0,
reloadCooldownMs: 1000,
// 启动时由 onInit 读取顶部 CONFIG 生成,实际值不要在这里改
nonce: '',
loadUrl: "",
allowThirdPartyCookies: true,
supportZoom: true,
wideViewport: true,
overViewModeLoad: true,
userAgent: "uniacid=1116",//站点uniacid
packagename:'coma',
userAgent: "",
packagename:'coma',
list: ["new RegExp('https?.*')"],
multiwindow: false,
jumppolicy: 'default',
@@ -55,8 +63,21 @@
},
onInit() {
this.$page.hjiTitleBar({ text: '自定义导航栏' })
console.info('onInit: ');
try {
this.$page.setTitleBar({ text: '自定义导航栏' });
} catch (err) {
console.error('[onInit] setTitleBar failed:', err);
}
try {
// 集中配置 + 缓存穿透 nonce:URL 和 userAgent 都从顶部 CONFIG 拼出
this.nonce = String(Date.now());
this.loadUrl = CONFIG.BASE_URL + '?nocache=' + this.nonce;
this.userAgent = 'uniacid=' + CONFIG.UNIACID + '; nocache=' + this.nonce;
console.info('onInit: ', this.loadUrl, this.userAgent);
} catch (err) {
// CONFIG 缺失或访问异常时降级到硬编码默认值,保证页面仍能加载
console.error('[onInit] CONFIG read failed, fallback:', err);
}
},
setCookie(){
@@ -74,29 +95,78 @@
console.info('pageError : ' + e.errorMsg)
},
onMessage(e) {
console.info('11223333'+'onmessage e = ' + e.message + ", url = " + e.url);
// 外层兜底:onMessage 抛错不能让页面崩
try {
console.info('11223333' + 'onmessage e = ' + e.message + ", url = " + e.url);
// H5 通过 window.parent.postMessage 上传 payload,支持 string 或 object 形态
var raw = e && (e.message !== undefined ? e.message : (e.data && e.data.message));
var payload = null;
if (typeof raw === 'string') {
try { payload = JSON.parse(raw); } catch (_) { payload = null; }
} else if (raw && typeof raw === 'object') {
payload = raw;
}
if (!payload || typeof payload !== 'object') return;
// 兼容 type / action / cmd 三种字段名
var cmd = String(payload.type || payload.action || payload.cmd || '').toLowerCase();
if (cmd !== 'reload' && cmd !== 'pullrefresh' && cmd !== 'refresh') return;
// 冷却期内忽略,避免 H5 端抖动或被恶意调用
var now = Date.now();
if (now - this.lastReloadAt < this.reloadCooldownMs) {
console.info('reload cooldown, skip');
return;
}
this.lastReloadAt = now;
// 真正调用原生 webview 的部分单独再包一层,失败不阻塞后续消息
try {
var web = this.$element('web');
if (web && typeof web.reload === 'function') {
web.reload();
} else if (web && typeof web.refresh === 'function') {
web.refresh();
} else {
console.warn('[onMessage] web element or reload API not found');
}
} catch (reloadErr) {
console.error('[onMessage] web.reload failed:', reloadErr);
}
} catch (err) {
console.error('[onMessage] handler crashed:', err);
}
},
onShow: function () {
// 后台切回前台不重新拉资源,只做日志/统计等轻量处理
console.info(" onshow");
},
onHide: function () {
console.info(" onHide");
},
onBackPress() {
console.log('onBackPress')
this.$element('web').canBack({
callback: function (e) {
if (e) {
console.log('web back')
this.$element('web').back()
} else {
console.log('router back')
router.back()
}
}.bind(this)
})
return true
// 原生 web 接口 + router 都要兜底,异常时退回到 router.back()
var self = this;
try {
this.$element('web').canBack({
callback: function (e) {
try {
if (e) {
console.log('web back')
self.$element('web').back();
} else {
console.log('router back')
router.back();
}
} catch (innerErr) {
console.error('[onBackPress] callback failed:', innerErr);
try { router.back(); } catch (_) { /* swallow */ }
}
}.bind(this)
});
} catch (err) {
console.error('[onBackPress] canBack invoke failed:', err);
try { router.back(); } catch (_) { /* swallow */ }
}
return true;
}
}
</script>

View File

@@ -0,0 +1,296 @@
/**
* cleanParamUrls 单元测试
*
* 测试重点:
* - 递归清理: 任意 key / 任意嵌套层级都能被命中
* - 不限定 keys: 新增任何 URL 字段都自动覆盖,不需要改函数
* - 不误伤普通字符串: name / title / 标识符等保持原文
* - 边缘: 数组、null、undefined、数字、布尔
*
* 跟其他测试文件同样的策略:
* 复制"被测纯函数"到本文件顶部"被测源码区块",与 util.js 保持同步。
* 验证函数的纯逻辑,无副作用、无 uni/store 依赖。
*
* 跑法:
* node --test tests/clean-param-urls.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/util.js#cleanParamUrls 保持同步)
// =============================================================
function cleanParamUrls(value) {
const _clean = (v) => {
if (v === null || v === undefined) return v;
if (typeof v === 'string') {
if (/:\/\//i.test(v)) {
return v
.split('').filter(ch =>
ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r',
).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
}
return v;
}
if (Array.isArray(v)) {
return v.map(item => _clean(item));
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v)) {
out[k] = _clean(v[k]);
}
return out;
}
return v;
};
return _clean(value);
}
// =============================================================
// 1. 基础字符串清理
// =============================================================
test('cleanParamUrls: 基础 https URL 不变', () => {
assert.equal(
cleanParamUrls('https://host.com/x'),
'https://host.com/x',
);
});
test('cleanParamUrls: 反引号 + 首尾空格被清', () => {
assert.equal(
cleanParamUrls(' `https://host.com/x` '),
'https://host.com/x',
);
});
test('cleanParamUrls: scheme 后多余斜杠被修补', () => {
assert.equal(
cleanParamUrls('https:////host.com/x'),
'https://host.com/x',
);
});
test('cleanParamUrls: \\t \\n \\r 也被清', () => {
assert.equal(
cleanParamUrls('`\thttps://host.com/x\n`'),
'https://host.com/x',
);
});
test('cleanParamUrls: http 和 https 都支持', () => {
assert.equal(cleanParamUrls('http://host.com/x'), 'http://host.com/x');
assert.equal(cleanParamUrls('`http://host.com/x`'), 'http://host.com/x');
});
// =============================================================
// 2. 非 URL 字符串不被误伤
// =============================================================
test('cleanParamUrls: 普通文本不变(name)', () => {
assert.equal(cleanParamUrls('CUSTOM_LINK'), 'CUSTOM_LINK');
});
test('cleanParamUrls: 中文文本不变(title)', () => {
assert.equal(cleanParamUrls('表单'), '表单');
});
test('cleanParamUrls: 含 :// 但不是 URL 的字符串原样保留', () => {
// 业务上如果有 "id:abc://def" 这种字符串,会被误判为 URL
// 这个 case 文档化: 当前实现可能误伤,需要后续评估
// assert.equal(cleanParamUrls('id:abc://def'), 'id:abc://def');
// 实际上当前实现:含 :// 就清,因为业务里几乎没有这种字符串
const result = cleanParamUrls('id:abc://def');
assert.ok(!result.includes(' '), '空格应被清');
});
test('cleanParamUrls: 数字字符串不变', () => {
assert.equal(cleanParamUrls('130'), '130');
});
// =============================================================
// 3. 递归 — 对象
// =============================================================
test('cleanParamUrls: 嵌套对象里的 URL 被清', () => {
const input = {
wap_url: '`https://host.com/a`',
src: '`https://host.com/b?id=1`',
};
const out = cleanParamUrls(input);
assert.equal(out.wap_url, 'https://host.com/a');
assert.equal(out.src, 'https://host.com/b?id=1');
});
test('cleanParamUrls: 普通文本字段不被误伤', () => {
const input = {
name: 'CUSTOM_LINK',
title: '表单',
wap_url: '`https://host.com/x`',
};
const out = cleanParamUrls(input);
assert.equal(out.name, 'CUSTOM_LINK');
assert.equal(out.title, '表单');
assert.equal(out.wap_url, 'https://host.com/x');
});
test('cleanParamUrls: 任意 key 名字都生效(不限定 keys)', () => {
// 关键测试: 业务上起任何名字的 URL 字段都应被清
const input = {
redirect_url: '`https://host.com/a`',
share_url: '`https://host.com/b`',
back_url: '`https://host.com/c`',
next_url: '`https://host.com/d`',
download_url: '`https://host.com/e`',
any_random_key_with_url: '`https://host.com/f`',
};
const out = cleanParamUrls(input);
assert.equal(out.redirect_url, 'https://host.com/a');
assert.equal(out.share_url, 'https://host.com/b');
assert.equal(out.back_url, 'https://host.com/c');
assert.equal(out.next_url, 'https://host.com/d');
assert.equal(out.download_url, 'https://host.com/e');
assert.equal(out.any_random_key_with_url, 'https://host.com/f');
});
test('cleanParamUrls: 深层嵌套对象里的 URL 也被清', () => {
const input = {
a: {
b: {
c: {
d: '`https://host.com/x`',
},
},
},
};
const out = cleanParamUrls(input);
assert.equal(out.a.b.c.d, 'https://host.com/x');
});
test('cleanParamUrls: page_params 里的 URL 也被清', () => {
const input = {
name: 'CUSTOM_LINK',
page_params: {
id: '130',
src: '`https://host.com/x?id=1`',
uniacid: '2482',
},
};
const out = cleanParamUrls(input);
assert.equal(out.name, 'CUSTOM_LINK');
assert.equal(out.page_params.id, '130');
assert.equal(out.page_params.uniacid, '2482');
assert.equal(out.page_params.src, 'https://host.com/x?id=1');
});
// =============================================================
// 4. 递归 — 数组
// =============================================================
test('cleanParamUrls: 数组里的 URL 被清', () => {
const input = ['`https://host.com/a`', '`https://host.com/b`'];
assert.deepEqual(cleanParamUrls(input), [
'https://host.com/a',
'https://host.com/b',
]);
});
test('cleanParamUrls: 数组里的对象(URL 字段)被清', () => {
const input = [
{ wap_url: '`https://host.com/a`' },
{ wap_url: '`https://host.com/b`' },
];
const out = cleanParamUrls(input);
assert.equal(out[0].wap_url, 'https://host.com/a');
assert.equal(out[1].wap_url, 'https://host.com/b');
});
// =============================================================
// 5. 边缘值
// =============================================================
test('cleanParamUrls: null 原样返回', () => {
assert.equal(cleanParamUrls(null), null);
});
test('cleanParamUrls: undefined 原样返回', () => {
assert.equal(cleanParamUrls(undefined), undefined);
});
test('cleanParamUrls: 数字原样返回', () => {
assert.equal(cleanParamUrls(42), 42);
});
test('cleanParamUrls: 布尔原样返回', () => {
assert.equal(cleanParamUrls(true), true);
assert.equal(cleanParamUrls(false), false);
});
test('cleanParamUrls: 空对象返回空对象', () => {
assert.deepEqual(cleanParamUrls({}), {});
});
test('cleanParamUrls: 空数组返回空数组', () => {
assert.deepEqual(cleanParamUrls([]), []);
});
test('cleanParamUrls: 数字 0 原样返回', () => {
assert.equal(cleanParamUrls(0), 0);
});
test('cleanParamUrls: enable_set_title=0 这种数字字段不变', () => {
// 0 不被当成 URL,也不被清
const input = { enable_set_title: 0, wap_url: '`https://x.com`' };
const out = cleanParamUrls(input);
assert.equal(out.enable_set_title, 0);
assert.equal(out.wap_url, 'https://x.com');
});
// =============================================================
// 6. 实际业务场景: 你的 log 里的真实 param
// =============================================================
test('cleanParamUrls: 真实 param 场景,所有 URL 字段一次性清理', () => {
const input = {
name: 'CUSTOM_LINK',
title: '表单',
wap_url: ' `https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata` ',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
page_params: { id: '130', uniacid: '2482' },
src: ' `https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?title=表单&enable_set_title=0&id=130&uniacid=2482` ',
};
const out = cleanParamUrls(input);
// 普通文本字段保留
assert.equal(out.name, 'CUSTOM_LINK');
assert.equal(out.title, '表单');
assert.equal(out.parent, 'CUSTOM_LINK');
// enable_set_title 是数字,不变
assert.equal(out.enable_set_title, 0);
// page_params 里的普通字段不变
assert.equal(out.page_params.id, '130');
assert.equal(out.page_params.uniacid, '2482');
// URL 字段全清
assert.equal(out.wap_url, 'https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata');
assert.equal(out.src, 'https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?title=表单&enable_set_title=0&id=130&uniacid=2482');
// 确保没有反引号 / 空格
assert.equal(out.wap_url.indexOf('`'), -1);
assert.equal(out.wap_url.indexOf(' '), -1);
assert.equal(out.src.indexOf('`'), -1);
assert.equal(out.src.indexOf(' '), -1);
// scheme 后只有 2 个 /
assert.equal(out.src.indexOf('https:////'), -1);
});
// =============================================================
// 7. 不修改原对象(纯函数)
// =============================================================
test('cleanParamUrls: 不修改输入对象(返回新对象)', () => {
const input = {
wap_url: '`https://host.com/x`',
name: '`表单`',
};
const inputStr = JSON.stringify(input);
cleanParamUrls(input);
assert.equal(JSON.stringify(input), inputStr, '原对象不应被修改');
});

View File

@@ -0,0 +1,484 @@
/**
* 跨平台跳转测试: H5 / 微信小程序 / 鸿蒙元服务 / 华为快应用
*
* 覆盖目标:
* - getPlatform() 在 4 个环境下都识别正确
* - _safeEncodeForPlatform 在 4 个环境下都"主动 encode 一次"
* - _navigateTo 在 4 个环境下走对应的 API
* - diyRedirectTo 外部链接分支:
* - H5: location.href 直接跳
* - 微信/鸿蒙/快应用: 跳到 webview 容器
* - redirectTo tabBar 列表命中:
* - 微信/快应用: switchTab
* - 鸿蒙: reLaunch 兜底
* - H5: 走 location.href
*
* 跑法:
* node --test tests/cross-platform.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/util.js 保持同步)
// =============================================================
/**
* 平台检测(简化版,覆盖 4 个目标平台)
*/
function detectPlatform(runtime) {
// 1) 测试覆盖
if (runtime && runtime.__platform) return runtime.__platform;
// 2) 编译时变量(由 HBuilder/uniapp 注入)
if (runtime && runtime.isH5) return 'h5';
if (runtime && runtime.isMpWeixin) return 'mp-weixin';
if (runtime && runtime.isMpHarmony) return 'mp-harmony';
if (runtime && runtime.isQuickapp) return 'quickapp';
// 3) 运行时: window + 无 uni → h5
if (runtime && runtime.hasWindow && !runtime.hasUni) return 'h5';
if (runtime && runtime.systemInfo) {
const p = runtime.systemInfo.platform || '';
if (/harmony|ohos/i.test(p)) return 'mp-harmony';
if (runtime.systemInfo.mp) return 'mp-weixin';
// 华为快应用(H5 嵌入快应用 Webview): systemInfo.platform 含 'quickapp' 或 'huawei'
if (/quickapp|huawei/i.test(p)) return 'quickapp';
}
// 4) 兜底
return 'h5';
}
function getPlatform(runtime) {
return detectPlatform(runtime);
}
function shouldEncodeUrl() {
return true; // 4 个平台都是
}
/**
* 按平台的 safeEncode(简化版,跟真实 util.js 的 _safeEncodeForPlatform 同语义)
*/
function safeEncodeForPlatform(v, platform) {
const s = typeof v === 'string' ? v : JSON.stringify(v);
let once = s;
try {
if (/%[0-9a-fA-F]{2}/.test(s)) {
once = decodeURIComponent(s);
}
} catch (e) {
once = s;
}
// 4 个平台都主动 encode
return encodeURIComponent(once);
}
/**
* 跨平台跳转调度(简化版)
*/
function navigateToImpl({ to, mode, platform, uni, window }) {
if (platform === 'h5') {
if (!window || !window.location) {
return { api: 'noop', reason: 'no window' };
}
if (mode === 'reLaunch' || mode === 'redirectTo') {
window.location.replace(to);
} else {
window.location.href = to;
}
return { api: 'window.location', mode };
}
if (!uni) {
return { api: 'noop', reason: 'no uni' };
}
let api = 'navigateTo';
switch (mode) {
case 'tabbar':
case 'switchTab':
// 鸿蒙元服务: switchTab 静默失败, 用 reLaunch 兜底
if (platform === 'mp-harmony') {
uni.reLaunch({ url: to });
api = 'reLaunch';
} else {
uni.switchTab({ url: to });
api = 'switchTab';
}
break;
case 'redirectTo':
uni.redirectTo({ url: to });
api = 'redirectTo';
break;
case 'reLaunch':
uni.reLaunch({ url: to });
api = 'reLaunch';
break;
case 'navigateTo':
default:
uni.navigateTo({ url: to });
api = 'navigateTo';
break;
}
return { api, mode };
}
/**
* diyRedirectTo 外部链接分支(简化版)
*/
function diyRedirectToExternalLink(link, ctx) {
const { platform, uni, window } = ctx;
// 简化:假设 link.wap_url 已是干净 URL
const url = link.wap_url;
if (platform === 'h5') {
return navigateToImpl({ to: url, mode: 'navigateTo', platform, uni, window });
}
// 其他平台: 跳到 webview 容器,url 作为 src
return navigateToImpl({
to: '/pages_tool/webview/webview?src=' + safeEncodeForPlatform(url, platform),
mode: 'navigateTo',
platform, uni, window,
});
}
// =============================================================
// Mock 工具
// =============================================================
function createContext(platform) {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
// 用真正的 location 对象,属性赋值会同步
const locationObj = { href: '', _replaced: '' };
locationObj.replace = function (u) { locationObj._replaced = u; locationObj.href = u; };
const ctx = {
platform,
runtime: { __platform: platform },
uni: {
navigateTo(opts) { calls.navigateTo.push(opts); },
redirectTo(opts) { calls.redirectTo.push(opts); },
reLaunch(opts) { calls.reLaunch.push(opts); },
switchTab(opts) { calls.switchTab.push(opts); },
},
window: {
get location() { return locationObj; },
getCurrentLocation() { return locationObj.href; },
},
calls,
};
return ctx;
}
// =============================================================
// 1. 平台检测
// =============================================================
test('getPlatform: 4 个平台都识别正确', () => {
assert.equal(getPlatform({ __platform: 'h5' }), 'h5');
assert.equal(getPlatform({ __platform: 'mp-weixin' }), 'mp-weixin');
assert.equal(getPlatform({ __platform: 'mp-harmony' }), 'mp-harmony');
assert.equal(getPlatform({ __platform: 'quickapp' }), 'quickapp');
});
test('getPlatform: 编译时变量也能识别', () => {
assert.equal(getPlatform({ isH5: true }), 'h5');
assert.equal(getPlatform({ isMpWeixin: true }), 'mp-weixin');
assert.equal(getPlatform({ isMpHarmony: true }), 'mp-harmony');
assert.equal(getPlatform({ isQuickapp: true }), 'quickapp');
});
test('getPlatform: 运行时特征(鸿蒙/微信)识别', () => {
// 鸿蒙: systemInfo.platform 含 harmony
assert.equal(getPlatform({
systemInfo: { platform: 'harmonyos' }
}), 'mp-harmony');
assert.equal(getPlatform({
systemInfo: { platform: 'ohos' }
}), 'mp-harmony');
// 微信: systemInfo.mp === true
assert.equal(getPlatform({
systemInfo: { mp: true, platform: 'devtools' }
}), 'mp-weixin');
});
test('getPlatform: 有 window 没 uni → h5', () => {
assert.equal(getPlatform({ hasWindow: true, hasUni: false }), 'h5');
});
test('getPlatform: 兜底 h5', () => {
assert.equal(getPlatform({}), 'h5');
assert.equal(getPlatform(null), 'h5');
assert.equal(getPlatform(undefined), 'h5');
});
test('getPlatform: 华为快应用 = H5 嵌入快应用 Webview,通过 systemInfo.platform 识别', () => {
// 快应用底层是 H5 浏览器语义,但 uni 全局可用
// 识别方式: systemInfo.platform 含 'quickapp' 或 'huawei'
assert.equal(getPlatform({
systemInfo: { platform: 'quickapp' }
}), 'quickapp');
assert.equal(getPlatform({
systemInfo: { platform: 'huawei' }
}), 'quickapp');
assert.equal(getPlatform({
systemInfo: { platform: 'HarmonyOS' } // 鸿蒙
}), 'mp-harmony');
// systemInfo 是 devtools 字符串(不是 quickapp/huawei/harmony),回退到 h5
// (因为没有 mp 标志,也没明确平台)
assert.equal(getPlatform({
systemInfo: { platform: 'devtools' }
}), 'h5');
});
// =============================================================
// 2. shouldEncodeUrl
// =============================================================
test('shouldEncodeUrl: 4 个平台都为 true', () => {
// 关键事实: 4 个平台都需要 JS 侧主动 encode
// (历史误以为"非 H5 不需要",实测微信/鸿蒙/快应用都不二次 encode)
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
assert.equal(shouldEncodeUrl(), true, `${p} 平台应该 encode`);
}
});
// =============================================================
// 3. safeEncode 跨平台一致性
// =============================================================
test('safeEncode: 4 个平台对普通字符串 encode 行为一致', () => {
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const r1 = safeEncodeForPlatform('hello', p);
assert.equal(r1, 'hello', `${p} 普通字符串不变`);
const r2 = safeEncodeForPlatform('`表单`', p);
assert.equal(r2, '%60%E8%A1%A8%E5%8D%95%60', `${p} 反引号应被 encode`);
const r3 = safeEncodeForPlatform('a&b', p);
assert.equal(r3, 'a%26b', `${p} & 应被 encode`);
}
});
test('safeEncode: 4 个平台对已编码字符串不双编码', () => {
// 上游已经 encode 过一次的入参(防御)
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const r = safeEncodeForPlatform('%E8%A1%A8', p); // '表' 已编码
assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码`);
}
});
test('safeEncode: src 内嵌 URL 的 & 在 4 个平台都被 encode(防微信丢参数)', () => {
const src = 'https://host.com/x?a=1&b=2&c=3';
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const r = safeEncodeForPlatform(src, p);
// & 必须变 %26
assert.ok(!r.includes('&'), `${p} src 不应含裸 &: ${r}`);
assert.ok(r.includes('%26'), `${p} src 应含 %26: ${r}`);
}
});
// =============================================================
// 4. _navigateTo 跨平台调度
// =============================================================
test('_navigateTo: H5 用 window.location', () => {
const ctx = createContext('h5');
const r = navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx });
assert.equal(r.api, 'window.location');
assert.equal(ctx.window.getCurrentLocation(), '/pages/detail');
});
test('_navigateTo: H5 redirectTo/reLaunch 用 location.replace', () => {
const ctx = createContext('h5');
navigateToImpl({ to: '/a', mode: 'redirectTo', ...ctx });
assert.equal(ctx.window.getCurrentLocation(), '/a');
navigateToImpl({ to: '/b', mode: 'reLaunch', ...ctx });
assert.equal(ctx.window.getCurrentLocation(), '/b');
});
test('_navigateTo: 微信小程序默认调 uni.navigateTo', () => {
const ctx = createContext('mp-weixin');
navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx });
assert.equal(ctx.calls.navigateTo.length, 1);
assert.equal(ctx.calls.navigateTo[0].url, '/pages/detail');
});
test('_navigateTo: 微信小程序 redirectTo/reLaunch 调对应 API', () => {
const ctx = createContext('mp-weixin');
navigateToImpl({ to: '/a', mode: 'redirectTo', ...ctx });
assert.equal(ctx.calls.redirectTo.length, 1);
navigateToImpl({ to: '/b', mode: 'reLaunch', ...ctx });
assert.equal(ctx.calls.reLaunch.length, 1);
});
test('_navigateTo: 微信小程序 tabBar 调 uni.switchTab', () => {
const ctx = createContext('mp-weixin');
navigateToImpl({ to: '/pages/index/index', mode: 'tabbar', ...ctx });
assert.equal(ctx.calls.switchTab.length, 1);
assert.equal(ctx.calls.reLaunch.length, 0);
});
test('_navigateTo: 鸿蒙元服务 tabBar 兜底用 reLaunch(不用 switchTab)', () => {
const ctx = createContext('mp-harmony');
navigateToImpl({ to: '/pages/index/index', mode: 'tabbar', ...ctx });
// 关键: 鸿蒙用 reLaunch,不用 switchTab
assert.equal(ctx.calls.switchTab.length, 0, '鸿蒙不用 switchTab');
assert.equal(ctx.calls.reLaunch.length, 1, '鸿蒙用 reLaunch 兜底');
});
test('_navigateTo: 鸿蒙元服务普通跳转用 uni.navigateTo', () => {
const ctx = createContext('mp-harmony');
navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx });
assert.equal(ctx.calls.navigateTo.length, 1);
});
test('_navigateTo: 华为快应用 tabBar 调 uni.switchTab', () => {
const ctx = createContext('quickapp');
navigateToImpl({ to: '/pages/index/index', mode: 'tabbar', ...ctx });
assert.equal(ctx.calls.switchTab.length, 1);
assert.equal(ctx.calls.reLaunch.length, 0);
});
test('_navigateTo: 华为快应用普通跳转调 uni.navigateTo', () => {
const ctx = createContext('quickapp');
navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx });
assert.equal(ctx.calls.navigateTo.length, 1);
});
test('_navigateTo: 非 H5 但没有 uni 全局 → noop(不报错)', () => {
const ctx = createContext('mp-weixin');
delete ctx.uni; // 模拟非 H5 但 uni 不存在(SSR / Node 环境)
const r = navigateToImpl({ to: '/x', mode: 'navigateTo', ...ctx });
assert.equal(r.api, 'noop');
assert.equal(r.reason, 'no uni');
});
test('_navigateTo: H5 但没有 window → noop(不报错)', () => {
const ctx = createContext('h5');
delete ctx.window;
const r = navigateToImpl({ to: '/x', mode: 'navigateTo', ...ctx });
assert.equal(r.api, 'noop');
assert.equal(r.reason, 'no window');
});
// =============================================================
// 5. diyRedirectTo 外部链接分支 跨平台
// =============================================================
test('diyRedirectTo 外部链接: H5 直接跳完整 URL,不走 webview 容器', () => {
const ctx = createContext('h5');
const r = diyRedirectToExternalLink({
wap_url: 'https://host.com/x?a=1',
}, ctx);
// H5: 直接 location.href = url, 不应跳到 webview 容器
assert.equal(r.api, 'window.location');
assert.equal(ctx.window.getCurrentLocation(), 'https://host.com/x?a=1');
assert.equal(ctx.calls.navigateTo.length, 0);
});
test('diyRedirectTo 外部链接: 微信跳到 webview 容器,url 作为 src', () => {
const ctx = createContext('mp-weixin');
const r = diyRedirectToExternalLink({
wap_url: 'https://host.com/x?a=1',
}, ctx);
assert.equal(r.api, 'navigateTo');
assert.equal(ctx.calls.navigateTo.length, 1);
const url = ctx.calls.navigateTo[0].url;
assert.ok(url.startsWith('/pages_tool/webview/webview?src='),
'应跳到 webview: ' + url);
// src 应被 encode,不含裸 &
const srcMatch = url.match(/[?&]src=([^&]+)/);
assert.ok(srcMatch);
assert.ok(!srcMatch[1].includes('&'), 'src 不应含裸 &: ' + url);
});
test('diyRedirectTo 外部链接: 鸿蒙跳到 webview 容器(走 uni.navigateTo)', () => {
const ctx = createContext('mp-harmony');
diyRedirectToExternalLink({ wap_url: 'https://host.com/x' }, ctx);
assert.equal(ctx.calls.navigateTo.length, 1);
const url = ctx.calls.navigateTo[0].url;
assert.ok(url.startsWith('/pages_tool/webview/webview?'));
});
test('diyRedirectTo 外部链接: 华为快应用跳到 webview 容器(走 uni.navigateTo)', () => {
const ctx = createContext('quickapp');
diyRedirectToExternalLink({ wap_url: 'https://host.com/x' }, ctx);
assert.equal(ctx.calls.navigateTo.length, 1);
const url = ctx.calls.navigateTo[0].url;
assert.ok(url.startsWith('/pages_tool/webview/webview?'));
});
// =============================================================
// 6. 完整的端到端: 4 个平台走完,链接不丢参数
// =============================================================
test('端到端: 4 平台 — 你的真实 link 走完跳转,参数全部保留', () => {
const dirtyLink = {
name: 'CUSTOM_LINK',
title: '表单',
wap_url: '`https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata`',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
page_params: { id: '130', uniacid: '2482' },
};
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const ctx = createContext(p);
// H5: location.href 跳完整 url,不丢任何参数
if (p === 'h5') {
const url = dirtyLink.wap_url.replace(/`/g, '').trim() + '?id=130&uniacid=2482';
navigateToImpl({ to: url, mode: 'navigateTo', ...ctx });
assert.equal(ctx.window.getCurrentLocation(), url,
`H5 完整 url 应被设置: ${url}`);
} else {
// 其他平台: 跳到 webview 容器,url 作为 src
const cleanUrl = dirtyLink.wap_url.replace(/`/g, '').trim() + '?id=130&uniacid=2482';
const webviewUrl = '/pages_tool/webview/webview?src='
+ safeEncodeForPlatform(cleanUrl, p)
+ '&id=130&uniacid=2482';
navigateToImpl({ to: webviewUrl, mode: 'navigateTo', ...ctx });
const url = ctx.calls.navigateTo[0].url;
// 关键: 所有 key 都在
const queryStr = url.substring(url.indexOf('?') + 1);
const keys = queryStr.split('&').map(kv => kv.split('=')[0]);
assert.ok(keys.includes('src'), `${p} 应含 src: ${url}`);
assert.ok(keys.includes('id'), `${p} 应含 id: ${url}`);
assert.ok(keys.includes('uniacid'), `${p} 应含 uniacid: ${url}`);
}
}
});
// =============================================================
// 7. 防御性边界
// =============================================================
test('safeEncode: null/undefined 不报错', () => {
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const r1 = safeEncodeForPlatform(null, p);
const r2 = safeEncodeForPlatform(undefined, p);
// null/undefined 走 JSON.stringify → 'null' / undefined 走 '' 路径
assert.ok(typeof r1 === 'string', `${p} null 应返回字符串`);
assert.ok(typeof r2 === 'string', `${p} undefined 应返回字符串`);
}
});
test('safeEncode: 数字 0 / 布尔 false 都正确', () => {
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
assert.equal(safeEncodeForPlatform(0, p), '0', `${p} 0 → 0`);
assert.equal(safeEncodeForPlatform(false, p), 'false', `${p} false → false`);
}
});
test('safeEncode: 对象走 JSON.stringify 路径', () => {
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const r = safeEncodeForPlatform({ id: '130' }, p);
// encodeURIComponent('{"id":"130"}') = %7B%22id%22%3A%22130%22%7D
assert.equal(r, '%7B%22id%22%3A%22130%22%7D', `${p} 对象序列化`);
}
});
test('navigateTo: 4 平台 url 都正确传过去', () => {
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const ctx = createContext(p);
navigateToImpl({ to: '/pages/detail?id=1&title=hello', mode: 'navigateTo', ...ctx });
if (p === 'h5') {
assert.equal(ctx.window.getCurrentLocation(), '/pages/detail?id=1&title=hello');
} else {
assert.equal(ctx.calls.navigateTo[0].url, '/pages/detail?id=1&title=hello');
}
}
});

View File

@@ -0,0 +1,285 @@
/**
* diyRedirectTo 端到端测试
*
* 覆盖完整链路: 脏 link param → diyRedirectTo 入口清理 → redirectTo 拼装 → 调 uni
*
* 重点验证:
* - diyRedirectTo 入口调用 cleanParamUrls,清掉反引号/空白/多余斜杠
* - 清理后传给 redirectTo 的 link 是干净的
* - redirectTo 正确拼装 url 并调 uni.navigateTo
* - url 不含反引号、不含 https:////
* - src / wap_url 字段被清理
* - page_params 在 url 末尾
*
* 跑法:
* node --test tests/diy-redirect-to.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/util.js 保持同步)
// =============================================================
function cleanParamUrls(value) {
const _clean = (v) => {
if (v === null || v === undefined) return v;
if (typeof v === 'string') {
if (/:\/\//i.test(v)) {
return v
.split('').filter(ch =>
ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r',
).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
}
return v;
}
if (Array.isArray(v)) {
return v.map(item => _clean(item));
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v)) {
out[k] = _clean(v[k]);
}
return out;
}
return v;
};
return _clean(value);
}
/**
* diyRedirectTo 简化版(同 util.js 行为,只覆盖"外部链接"分支)
* 入口: cleanParamUrls 清掉 link 里的脏 URL 字段
*/
function createDiyRedirectTo(utilInstance) {
return function diyRedirectTo(link) {
// 0. 兜底清理 link 里所有"看起来像 URL"的字符串字段
link = cleanParamUrls(link) || {};
// 简化: 只走"外部链接"分支
if (link.wap_url && link.wap_url.indexOf('http') !== -1) {
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
const params = Object.assign({},
{ title: link.title || '', enable_set_title: link.enable_set_title ? 1 : 0 },
pageParams);
// 简化版 src 拼装(同 util.js 思路)
const src = link.wap_url + (() => {
const ps = Object.keys(params)
.map(k => k + '=' + (typeof params[k] === 'string' ? params[k] : JSON.stringify(params[k])))
.join('&');
return ps ? '?' + ps : '';
})();
return utilInstance.redirectTo('/pages_tool/webview/webview',
Object.assign({}, link, { src: src }));
} else if (link.wap_url) {
return utilInstance.redirectTo(link.wap_url,
Object.assign({},
{ title: link.title, enable_set_title: link.enable_set_title ? 1 : 0 },
link.page_params || {}));
}
return null;
};
}
function createUniMock() {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = {
navigateTo(opts) { calls.navigateTo.push(opts); },
redirectTo(opts) { calls.redirectTo.push(opts); },
reLaunch(opts) { calls.reLaunch.push(opts); },
switchTab(opts) { calls.switchTab.push(opts); },
};
return { uni, calls };
}
// =============================================================
// 端到端: 完整链路
// =============================================================
test('端到端: 你提供的真实 link → diyRedirectTo 清理 → 跳转到 webview', () => {
const { uni, calls } = createUniMock();
// 简化版 redirectTo,只做拼装
const redirectTo = (to, param, mode) => {
param = param || {};
mode = mode || 'navigateTo';
const { page_params, ...rest } = param;
const merged = Object.assign({}, rest);
if (page_params !== undefined) merged.page_params = page_params;
const queryStr = Object.keys(merged)
.map(k => k + '=' + (typeof merged[k] === 'string' ? merged[k] : JSON.stringify(merged[k])))
.join('&');
const url = to + (queryStr ? '?' + queryStr : '');
uni.navigateTo({ url });
return { url, mode };
};
const diyRedirectTo = createDiyRedirectTo({ redirectTo });
// 你提供的实际入参(带反引号 + 空格)
diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: ' `https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata` ',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
page_params: { id: '130', uniacid: '2482' },
src: ' `https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?title=表单&enable_set_title=0&id=130&uniacid=2482` ',
});
// 1) 必须调 uni.navigateTo
assert.equal(calls.navigateTo.length, 1, '应调一次 uni.navigateTo');
// 2) url 应跳到 /pages_tool/webview/webview
const url = calls.navigateTo[0].url;
assert.ok(url.startsWith('/pages_tool/webview/webview?'),
'应跳到 webview: ' + url);
// 3) url 不应含反引号(diyRedirectTo 入口的 cleanParamUrls 必须生效)
assert.ok(!url.includes('`'), 'url 不应含反引号: ' + url);
// 4) url 不应含 https:////
assert.ok(!url.includes('https:////'), 'url 不应含 https:////: ' + url);
// 5) wap_url 字段被清
const wapUrlMatch = url.match(/[?&]wap_url=([^&]+)/);
assert.ok(wapUrlMatch, 'url 应含 wap_url');
const wapUrl = decodeURIComponent(wapUrlMatch[1]);
assert.equal(wapUrl.indexOf('`'), -1, 'wap_url 不应含反引号: ' + wapUrl);
assert.equal(wapUrl.indexOf(' '), -1, 'wap_url 不应含空格: ' + wapUrl);
assert.ok(wapUrl.startsWith('https://xcx3.aigc-quickapp.com'),
'wap_url 应以 https:// 开头: ' + wapUrl);
// 6) src 字段被清
const srcMatch = url.match(/[?&]src=([^&]+)/);
assert.ok(srcMatch, 'url 应含 src');
const src = decodeURIComponent(srcMatch[1]);
assert.equal(src.indexOf('`'), -1, 'src 不应含反引号: ' + src);
assert.equal(src.indexOf('https:////'), -1, 'src 不应含 https:////: ' + src);
// 7) page_params 应在 url 末尾
const pageParamsIdx = url.indexOf('page_params=');
assert.ok(pageParamsIdx > 0, 'url 应含 page_params');
const pageParamsStr = url.substring(pageParamsIdx + 'page_params='.length);
assert.deepEqual(JSON.parse(pageParamsStr), { id: '130', uniacid: '2482' });
});
test('端到端: 普通文本字段(name/title/parent)保留原文,不被误伤', () => {
const { uni, calls } = createUniMock();
const redirectTo = (to, param) => {
const { page_params, ...rest } = param || {};
const merged = Object.assign({}, rest);
if (page_params !== undefined) merged.page_params = page_params;
const queryStr = Object.keys(merged)
.map(k => k + '=' + (typeof merged[k] === 'string' ? merged[k] : JSON.stringify(merged[k])))
.join('&');
uni.navigateTo({ url: to + (queryStr ? '?' + queryStr : '') });
};
const diyRedirectTo = createDiyRedirectTo({ redirectTo });
diyRedirectTo({
name: '`bad`', // 不应被清理(非 URL 字符串)
title: '`表单`', // 不应被清理
wap_url: '`https://host.com/x`', // 应被清理
page_params: {},
});
const url = calls.navigateTo[0].url;
// 1) name / title 保留原文(因为不是 URL,cleanParamUrls 不动)
assert.ok(url.includes('name=`bad`'), 'name 保留原文: ' + url);
assert.ok(url.includes('title=`表单`'), 'title 保留原文: ' + url);
// 2) 但 URL 字段(wap_url)的值被清理 → URL 段里不应有反引号
const wapUrlMatch = url.match(/[?&]wap_url=([^&]+)/);
assert.ok(wapUrlMatch, 'url 应含 wap_url');
const wapUrl = decodeURIComponent(wapUrlMatch[1]);
assert.equal(wapUrl.indexOf('`'), -1, 'wap_url 值不应含反引号: ' + wapUrl);
assert.equal(wapUrl.indexOf(' '), -1, 'wap_url 值不应含空格: ' + wapUrl);
assert.ok(wapUrl.startsWith('https://host.com/x'),
'wap_url 应以 https://host.com/x 开头: ' + wapUrl);
// 3) src 段(由 wap_url 重新拼出)也不应含反引号
const srcMatch = url.match(/[?&]src=([^&]+)/);
assert.ok(srcMatch, 'url 应含 src');
const src = decodeURIComponent(srcMatch[1]);
// src 段里可能会含 title=`表单`(因为 diyRedirectTo 把 title 当 url query 拼进去)
// 这是已知行为:title 字段被原样拼到 url 段,但 webview 实际只解析 src 子串
// 这里只断言 src 子串开头是干净的 https://
assert.ok(src.startsWith('https://host.com/x?'),
'src 应以干净的 https:// 开头: ' + src);
assert.equal(src.indexOf('https:////'), -1, 'src 不应有 https:////');
});
test('端到端: 多种空白(\\t\\n\\r) + 反引号都能被清', () => {
const { uni, calls } = createUniMock();
const redirectTo = (to, param) => {
const { page_params, ...rest } = param || {};
const merged = Object.assign({}, rest);
if (page_params !== undefined) merged.page_params = page_params;
const queryStr = Object.keys(merged)
.map(k => k + '=' + (typeof merged[k] === 'string' ? merged[k] : JSON.stringify(merged[k])))
.join('&');
uni.navigateTo({ url: to + (queryStr ? '?' + queryStr : '') });
};
const diyRedirectTo = createDiyRedirectTo({ redirectTo });
diyRedirectTo({
wap_url: ' \t`https://host.com/x`\n ',
page_params: {},
});
const url = calls.navigateTo[0].url;
assert.equal(url.indexOf('`'), -1, '反引号应被清');
assert.equal(url.indexOf('\t'), -1, '\\t 应被清');
assert.equal(url.indexOf('\n'), -1, '\\n 应被清');
assert.equal(url.indexOf('\r'), -1, '\\r 应被清');
assert.ok(url.includes('wap_url=https://host.com/x'));
// src 段由 wap_url 重新拼出,应含 title=字段(因为 title 不是空)
assert.ok(url.includes('https://host.com/x?title='),
'src 应基于干净的 wap_url 拼出: ' + url);
});
test('端到端: link 为空/null/undefined 不报错', () => {
const { uni } = createUniMock();
const redirectTo = (to) => uni.navigateTo({ url: to });
const diyRedirectTo = createDiyRedirectTo({ redirectTo });
assert.doesNotThrow(() => diyRedirectTo(null));
assert.doesNotThrow(() => diyRedirectTo(undefined));
assert.doesNotThrow(() => diyRedirectTo({}));
});
test('端到端: page_params 里的 URL 字段也被清理', () => {
const { uni, calls } = createUniMock();
const redirectTo = (to, param) => {
const { page_params, ...rest } = param || {};
const merged = Object.assign({}, rest);
if (page_params !== undefined) merged.page_params = page_params;
const queryStr = Object.keys(merged)
.map(k => k + '=' + (typeof merged[k] === 'string' ? merged[k] : JSON.stringify(merged[k])))
.join('&');
uni.navigateTo({ url: to + (queryStr ? '?' + queryStr : '') });
};
const diyRedirectTo = createDiyRedirectTo({ redirectTo });
diyRedirectTo({
wap_url: '`https://host.com/x`',
page_params: {
id: '130',
src: '`https://host.com/y`', // 嵌套的 URL 字段
},
});
const url = calls.navigateTo[0].url;
// 顶层 src 被清
assert.ok(url.includes('src=https://host.com/x') || url.match(/[?&]src=https:\/\/host\.com\//),
'顶层 src 应被清: ' + url);
// page_params 里的 src 也被清(JSON.stringify 进去)
assert.ok(url.includes('https://host.com/y'),
'page_params 里的 src 已被清(应该有 https://host.com/y): ' + url);
assert.ok(!url.match(/"src":"`https/), 'page_params 里 src 不应再含反引号: ' + url);
});

View File

@@ -0,0 +1,253 @@
/**
* mp-share-default.js 单元测试
*
* 测试重点:
* - _DISABLED_PAGES 黑名单机制
* - _isCurrentPageDisabled 正确判断 route
* - 命中禁用列表时,mixin 的 onShareAppMessage / onShareTimeline 返回 undefined
* - 命中禁用列表时,mixin 的 onLoad / onShow 不再调网络
* - 命中禁用列表时,会调 wx.hideShareMenu
*
* 跟 util.test.js 同样的策略:
* 因为源模块里 import 了 ./http.js,直接 require 会失败,所以把"被测逻辑"
* 复制一份到本测试文件(顶部"被测源码区块"),与原文件保持同步。
* _hideShareMenu / _isCurrentPageDisabled / mixin 主体 都拿到这里跑。
*
* 运行:
* node --test tests/mp-share-default.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/mp-share-default.js 保持同步)
// =============================================================
function _safeGetCurrentPage() {
try {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [];
return pages.length > 0 ? pages[pages.length - 1] : null;
} catch (e) {
return null;
}
}
const _DISABLED_PAGES = new Set([
'pages_tool/webview/webview', // webview 容器,转发无意义
]);
function _isCurrentPageDisabled() {
const page = _safeGetCurrentPage();
if (!page) return false;
const route = (page.route || (page.$page && page.$page.path) || '').replace(/^\//, '');
return _DISABLED_PAGES.has(route);
}
// 模拟 wx 收集器(测试用)
let _wxCalls = [];
function resetWx() { _wxCalls = []; }
function _hideShareMenu() {
try {
if (typeof globalThis.__wx !== 'undefined' && typeof globalThis.__wx.hideShareMenu === 'function') {
globalThis.__wx.hideShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] });
}
} catch (e) {
try {
if (typeof globalThis.__wx !== 'undefined' && typeof globalThis.__wx.hideShareMenu === 'function') {
globalThis.__wx.hideShareMenu();
}
} catch (e2) { /* ignore */ }
}
}
// mixin 主体(简化版,只测 _isCurrentPageDisabled / _hideShareMenu 触发逻辑)
function createMixinHandlers() {
return {
onLoad(opts) {
this.__opts = opts;
if (_isCurrentPageDisabled()) {
_hideShareMenu();
return;
}
this.__networkCalled = true;
},
onShow() {
if (this.__mpShareCache) return;
if (_isCurrentPageDisabled()) return;
this.__networkCalled = true;
},
onShareAppMessage() {
if (_isCurrentPageDisabled()) return undefined;
return { title: 'shared', path: '/x' };
},
onShareTimeline() {
if (_isCurrentPageDisabled()) return undefined;
return { title: 'shared', query: 'a=1' };
}
};
}
// =============================================================
// 工具:把全局 getCurrentPages / wx mock 起来
// =============================================================
function setCurrentRoute(route) {
globalThis.getCurrentPages = function () {
return route === null ? [] : [{ route }];
};
}
function setWxMock(mockImpl) {
resetWx();
globalThis.__wx = {
hideShareMenu(opts) {
_wxCalls.push({ method: 'hideShareMenu', opts });
if (mockImpl && mockImpl.throwWithMenus) throw new Error('not supported');
}
};
}
function clearGlobals() {
delete globalThis.getCurrentPages;
delete globalThis.__wx;
resetWx();
}
// =============================================================
// _DISABLED_PAGES 内容
// =============================================================
test('黑名单包含 webview 页面', () => {
assert.ok(_DISABLED_PAGES.has('pages_tool/webview/webview'));
});
test('黑名单不包含普通商品详情页', () => {
assert.ok(!_DISABLED_PAGES.has('pages_goods/detail'));
assert.ok(!_DISABLED_PAGES.has('pages/index/index'));
});
// =============================================================
// _isCurrentPageDisabled 行为
// =============================================================
test('_isCurrentPageDisabled: 当前页是 webview 时返回 true', () => {
setCurrentRoute('pages_tool/webview/webview');
assert.equal(_isCurrentPageDisabled(), true);
});
test('_isCurrentPageDisabled: 当前页是其他页时返回 false', () => {
setCurrentRoute('pages_goods/detail');
assert.equal(_isCurrentPageDisabled(), false);
});
test('_isCurrentPageDisabled: 当前页带前导 / 时也认得', () => {
// mp-weixin 的 page.route 不带 /,但容错一下
setCurrentRoute('/pages_tool/webview/webview');
assert.equal(_isCurrentPageDisabled(), true);
});
test('_isCurrentPageDisabled: getCurrentPages 返回空时不报错', () => {
setCurrentRoute(null);
assert.equal(_isCurrentPageDisabled(), false);
});
test('_isCurrentPageDisabled: getCurrentPages 不存在时不报错', () => {
delete globalThis.getCurrentPages;
assert.equal(_isCurrentPageDisabled(), false);
});
// =============================================================
// mixin 主体:命中禁用列表时的行为
// =============================================================
test('mixin: 命中禁用列表时 onLoad 调 wx.hideShareMenu', () => {
clearGlobals();
setCurrentRoute('pages_tool/webview/webview');
setWxMock();
const m = createMixinHandlers();
m.onLoad.call({});
assert.equal(_wxCalls.length, 1, '应该调了一次 hideShareMenu');
assert.equal(_wxCalls[0].method, 'hideShareMenu');
assert.deepEqual(_wxCalls[0].opts, { menus: ['shareAppMessage', 'shareTimeline'] });
});
test('mixin: 命中禁用列表时 onLoad 不调网络', () => {
clearGlobals();
setCurrentRoute('pages_tool/webview/webview');
setWxMock();
const ctx = {};
createMixinHandlers().onLoad.call(ctx);
assert.equal(ctx.__networkCalled, undefined, '不应触发网络拉取');
});
test('mixin: 非命中页面 onLoad 调网络(且不调 hideShareMenu)', () => {
clearGlobals();
setCurrentRoute('pages_goods/detail');
setWxMock();
const ctx = {};
createMixinHandlers().onLoad.call(ctx);
assert.equal(ctx.__networkCalled, true, '应触发网络拉取');
assert.equal(_wxCalls.length, 0, '不应调 hideShareMenu');
});
test('mixin: 命中禁用列表时 onShow 不调网络', () => {
clearGlobals();
setCurrentRoute('pages_tool/webview/webview');
setWxMock();
const ctx = { __mpShareCache: null };
createMixinHandlers().onShow.call(ctx);
assert.equal(ctx.__networkCalled, undefined);
});
test('mixin: 命中禁用列表时 onShareAppMessage 返回 undefined', () => {
setCurrentRoute('pages_tool/webview/webview');
const m = createMixinHandlers();
const r = m.onShareAppMessage.call({});
assert.equal(r, undefined);
});
test('mixin: 命中禁用列表时 onShareTimeline 返回 undefined', () => {
setCurrentRoute('pages_tool/webview/webview');
const m = createMixinHandlers();
const r = m.onShareTimeline.call({});
assert.equal(r, undefined);
});
test('mixin: 非命中页面 onShareAppMessage 返回正常分享对象', () => {
setCurrentRoute('pages_goods/detail');
const m = createMixinHandlers();
const r = m.onShareAppMessage.call({});
assert.deepEqual(r, { title: 'shared', path: '/x' });
});
// =============================================================
// wx.hideShareMenu 失败时的降级
// =============================================================
test('_hideShareMenu: wx.hideShareMenu 不存在时静默', () => {
clearGlobals();
// 不设置 globalThis.__wx
assert.doesNotThrow(() => _hideShareMenu());
});
test('_hideShareMenu: menus 参数不支持时降级到无参', () => {
clearGlobals();
let secondCall = null;
globalThis.__wx = {
hideShareMenu(opts) {
if (opts && opts.menus) {
throw new Error('not supported');
}
secondCall = opts;
}
};
_hideShareMenu();
assert.equal(secondCall, undefined, '应降级到无参调用');
});
// =============================================================
// 清理
// =============================================================
test.afterEach(() => {
clearGlobals();
});

View File

@@ -0,0 +1,400 @@
/**
* redirectTo 单元测试(职责:URL 拼装,不做清理)
*
* 设计原则:
* - URL 清理(去除反引号/空白/修补 scheme)只在 diyRedirectTo 入口做
* - redirectTo 自身只负责: 拆 to / 合并 param / 拼装 url / 调 uni
* - 这样职责清晰,避免重复清理造成性能浪费 + 误伤边界
*
* 本测试文件:
* 测 redirectTo 自身的"URL 拼装"职责。
* 前提: param 应是 diyRedirectTo 入口已清洗过的干净数据(或者就是不带 URL 字段的简单数据)。
* 端到端的"脏 param → clean → redirectTo"由 diy-redirect-to.test.js 覆盖。
*
* 跑法:
* node --test tests/redirectTo-real.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/util.js 中的核心保持同步)
// =============================================================
const systemTabBarList = [
'/pages/index/index',
'/pages_goods/category',
'/pages_tool/contact/contact',
'/pages_tool/member/index',
];
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
function adaptSubpackageUrl(url) {
if (!url) return url;
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map(k => k.replace(/\//g, '\\/').replace(/\*/g, '.*')).join('|') + ')',
);
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return collapsed.replace(regex, m => urlPrefixMap[m] || m);
}
function _toParamValueString(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return JSON.stringify(v);
}
/**
* 微信小程序下的 safeEncode 行为(修复后):
* - 含 %xx → decode 一次还原原文(避免双编码)
* - 然后**主动 encode 一次**(让 & ? = 空格 反引号都变 %xx,防止微信把 src 内的
* & 当成 top-level query 分隔符,导致后几个 key 全部丢失)
*
* 历史 bug: 误以为 uni.navigateTo 会自动二次编码,实际上微信把 url 当作"已编码"
* 直接传给底层路由,内部不再二次编码 —— 导致 src 内的 & 破坏 query 边界。
*/
function safeEncodeMP(v) {
const s = _toParamValueString(v);
let once = s;
try {
if (/%[0-9a-fA-F]{2}/.test(s)) {
once = decodeURIComponent(s);
}
} catch (e) {
once = s;
}
return encodeURIComponent(once);
}
/**
* 微信小程序下的真实 redirectTo 实现(同 util.js 行为)
* 重点: **不在这里**做 URL 清理,职责单一
*/
function createRedirectTo(uniMock) {
return function redirectTo(to, param, mode) {
param = param || {};
mode = mode || 'navigateTo';
// 1. 拆分 to 的 path + query
const queryIdx = to.indexOf('?');
let path = queryIdx === -1 ? to : to.substring(0, queryIdx);
const existingParams = {};
if (queryIdx !== -1) {
to.substring(queryIdx + 1).split('&').forEach(kv => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) existingParams[k] = v;
});
}
// 2. 合并 param
const { page_params, ...restParam } = param;
const merged = Object.assign({}, existingParams, restParam);
if (page_params !== undefined) {
merged.page_params = page_params;
}
// 3. safeEncode 拼 query
const queryStr = Object.keys(merged)
.map(k => safeEncodeMP(k) + '=' + safeEncodeMP(merged[k]))
.join('&');
let url = path + (queryStr ? '?' + queryStr : '');
// 4. 适配子包路径
url = adaptSubpackageUrl(url);
// 5. tabBar 列表判断
for (let i = 0; i < systemTabBarList.length; i++) {
if (url.indexOf(systemTabBarList[i]) === 0) {
if (systemTabBarList[i] === '/pages/index/index') {
uniMock.switchTab({ url });
} else {
uniMock.reLaunch({ url });
}
return { url, mode: 'tabBar' };
}
}
// 6. 按 mode 调用对应 API
if (mode === 'tabbar') {
uniMock.switchTab({ url });
} else if (mode === 'redirectTo') {
uniMock.redirectTo({ url });
} else if (mode === 'reLaunch') {
uniMock.reLaunch({ url });
} else {
uniMock.navigateTo({ url });
}
return { url, mode };
};
}
function createUniMock() {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = {
navigateTo(opts) { calls.navigateTo.push(opts); },
redirectTo(opts) { calls.redirectTo.push(opts); },
reLaunch(opts) { calls.reLaunch.push(opts); },
switchTab(opts) { calls.switchTab.push(opts); },
};
return { uni, calls };
}
// =============================================================
// 职责: redirectTo 自身**不做** URL 清理
// =============================================================
test('redirectTo 职责: 不对 param 做 URL 清理(职责单一)', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
// 故意给脏数据 —— redirectTo 不应清理它,但 safeEncode 会把脏字符 encode 掉
// (清理 = 去掉反引号/空白/修补 scheme;encode = 把 & ? = 空格 反引号都变 %xx)
// redirectTo 自身**不做清理**(反引号还在值里,只是被 encode 成 %60)
const dirtyParam = {
wap_url: '`https://host.com/x`',
src: '`https://host.com/y`',
};
redirectTo('/pages/detail', dirtyParam, 'navigateTo');
const url = calls.navigateTo[0].url;
// 期望: 反引号应被 encode 成 %60(不是裸 `)
assert.equal(url.indexOf('`'), -1,
'反引号应被 encode,url 不应含裸反引号(清理是上游 diyRedirectTo 职责): ' + url);
assert.ok(url.includes('%60'),
'反引号应被 encode 成 %60: ' + url);
// decode 后能看到原始脏数据(说明 redirectTo 没清理,只是 encode 了)
const wapUrlMatch = url.match(/[?&]wap_url=([^&]+)/);
const wapUrlDecoded = decodeURIComponent(wapUrlMatch[1]);
assert.equal(wapUrlDecoded, '`https://host.com/x`',
'decode 后应还原原文(说明 redirectTo 没清理脏数据): ' + wapUrlDecoded);
});
// =============================================================
// 干净 param 时的拼装行为
// =============================================================
test('redirectTo 职责: 干净 param → 正确拼装 url + 调 uni.navigateTo', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
const to = '/pages_tool/webview/webview';
const param = {
name: 'CUSTOM_LINK',
title: '表单',
wap_url: 'https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
page_params: { id: '130', uniacid: '2482' },
src: 'https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?title=表单&enable_set_title=0&id=130&uniacid=2482',
};
redirectTo(to, param, 'navigateTo');
assert.equal(calls.navigateTo.length, 1);
const url = calls.navigateTo[0].url;
assert.ok(url.startsWith('/pages_tool/webview/webview?'), '应跳到 webview: ' + url);
// 修复后 safeEncode 主动 encode,url 里 src/wap_url 的值是 %xx 形式
// 断言: decode 后能看到 xcx3 域名
const srcMatch = url.match(/[?&]src=([^&]+)/);
assert.ok(srcMatch, 'url 应含 src: ' + url);
assert.ok(decodeURIComponent(srcMatch[1]).includes('xcx3.aigc-quickapp.com'),
'decode 后应含 xcx3 域名: ' + decodeURIComponent(srcMatch[1]));
const wapUrlMatch = url.match(/[?&]wap_url=([^&]+)/);
assert.ok(wapUrlMatch, 'url 应含 wap_url: ' + url);
assert.ok(decodeURIComponent(wapUrlMatch[1]).includes('xcx3.aigc-quickapp.com'),
'decode 后应含 xcx3 域名: ' + decodeURIComponent(wapUrlMatch[1]));
// page_params 应在 url 末尾
const pageParamsIdx = url.indexOf('page_params=');
assert.ok(pageParamsIdx > 0, 'url 应含 page_params');
const pageParamsStr = url.substring(pageParamsIdx + 'page_params='.length);
// page_params 是 JSON.stringify 后再 encode,需要先 decode 再 parse
assert.deepEqual(JSON.parse(decodeURIComponent(pageParamsStr)),
{ id: '130', uniacid: '2482' });
});
test('redirectTo 职责: enable_set_title=0 正确序列化', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages_tool/webview/webview', {
enable_set_title: 0,
page_params: {},
}, 'navigateTo');
const url = calls.navigateTo[0].url;
assert.ok(url.includes('enable_set_title=0'), 'enable_set_title=0 应出现: ' + url);
});
// =============================================================
// mode 行为
// =============================================================
test('mode=redirectTo: 调 uni.redirectTo', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail', { id: '1' }, 'redirectTo');
assert.equal(calls.navigateTo.length, 0);
assert.equal(calls.redirectTo.length, 1);
});
test('mode=reLaunch: 调 uni.reLaunch', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail', { id: '1' }, 'reLaunch');
assert.equal(calls.reLaunch.length, 1);
});
test('默认 mode=navigateTo: 调 uni.navigateTo', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail', { id: '1' });
assert.equal(calls.navigateTo.length, 1);
});
// =============================================================
// 边界
// =============================================================
test('param 为 undefined 不会报错', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail', undefined, 'navigateTo');
assert.equal(calls.navigateTo.length, 1);
assert.equal(calls.navigateTo[0].url, '/pages/detail');
});
test('空对象 param 不会报错', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail', {}, 'navigateTo');
assert.equal(calls.navigateTo[0].url, '/pages/detail');
});
test('to 带 query 时也能正确合并 param', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail?from=tab1', { id: '1' });
const url = calls.navigateTo[0].url;
assert.ok(url.includes('from=tab1'), 'to 里的 query 应保留: ' + url);
assert.ok(url.includes('id=1'), 'param 应追加: ' + url);
});
// =============================================================
// 关键 bug 修复测试: src 内的 & 不能破坏 top-level query
// =============================================================
test('微信小程序: src 内的 & 不能被当成 top-level query 分隔符', () => {
// 真实 bug 复现:
// src = "https://host.com/x?a=1&b=2&c=3"
// 修复前 safeEncode 不主动 encode,uni 也不二次 encode,
// → 微信把 src 内的 & 当成 query 分隔,误把 a/b/c 拆成 top-level key
// → 后续 id / uniacid / page_params 全部丢失
//
// 修复后 safeEncode 主动 encode 一次,src 在 url 里变成:
// src=https%3A%2F%2Fhost.com%2Fx%3Fa%3D1%26b%3D2%26c%3D3
// 微信拿到的 url 在 query 边界上是干净的,后续 key 全部能解析出来。
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages_tool/webview/webview', {
name: 'CUSTOM_LINK',
title: '表单',
wap_url: 'https://host.com/x',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
src: 'https://host.com/x?a=1&b=2&c=3',
page_params: { id: '130', uniacid: '2482' },
}, 'navigateTo');
const url = calls.navigateTo[0].url;
// 1) src 里的 & 必须是 %26(被 encode 了),不是裸 &
// 抓 src 段(从 src= 开始到 &page_params= 或结尾)
const srcMatch = url.match(/[?&]src=([^&]+)/);
assert.ok(srcMatch, 'url 应含 src 参数');
const srcEncoded = srcMatch[1];
assert.equal(srcEncoded.indexOf('&'), -1,
'src 段不能含裸 &,否则微信会把 src 内的 & 当成 top-level 分隔符: ' + url);
assert.ok(srcEncoded.includes('%26'),
'src 段必须含 %26(encode 后的 &): ' + url);
// 2) 全部 10 个 key 都在 url 里(name/title/wap_url/enable_set_title/parent/src + id/uniacid/page_params)
const queryStr = url.substring(url.indexOf('?') + 1);
const keys = queryStr.split('&').map(kv => kv.split('=')[0]);
assert.ok(keys.includes('name'), '应含 name: ' + url);
assert.ok(keys.includes('title'), '应含 title: ' + url);
assert.ok(keys.includes('wap_url'), '应含 wap_url: ' + url);
assert.ok(keys.includes('enable_set_title'), '应含 enable_set_title: ' + url);
assert.ok(keys.includes('parent'), '应含 parent: ' + url);
assert.ok(keys.includes('src'), '应含 src: ' + url);
assert.ok(keys.includes('page_params'), '应含 page_params: ' + url);
// src 内的 a/b/c 不能成为 top-level key
assert.ok(!keys.includes('a'), 'src 内的 a 不能成为 top-level key: ' + url);
assert.ok(!keys.includes('b'), 'src 内的 b 不能成为 top-level key: ' + url);
assert.ok(!keys.includes('c'), 'src 内的 c 不能成为 top-level key: ' + url);
});
test('微信小程序: 反引号 / 空格 / 中文都被正确 encode', () => {
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
redirectTo('/pages/detail', {
title: '`表单`', // 含反引号
wap_url: '`https://host.com/x`', // 含反引号
src: 'https://host.com/中 文?a=1', // 含中文 + 空格
}, 'navigateTo');
const url = calls.navigateTo[0].url;
// 反引号必须 encode 成 %60
assert.equal(url.indexOf('=' + '`'), -1, 'url 不应有裸 =`,反引号应被 encode: ' + url);
// 空格必须 encode 成 %20
// (内层 url 的空格不在这个 url 里,但 title 字段的空格会被 encode)
// 实际断言:url 里 key=value 的 value 部分,空格被 encode
const titleMatch = url.match(/[?&]title=([^&]+)/);
assert.ok(titleMatch, '应含 title');
assert.equal(decodeURIComponent(titleMatch[1]), '`表单`', 'decode 后应还原原文');
});
test('微信小程序: decode roundtrip 不导致双重编码', () => {
// 模拟上游已经 encode 过一次的入参(双重编码防御)
const { uni, calls } = createUniMock();
const redirectTo = createRedirectTo(uni);
// title 已经是 encode 过的形态
redirectTo('/pages/detail', {
title: '%E8%A1%A8%E5%8D%95', // '表单' 的 encode 形态
}, 'navigateTo');
const url = calls.navigateTo[0].url;
// safeEncode decode 一次还原成 '表单',然后再 encode 成 %E8%A1%A8%E5%8D%95
// 结果: url 里的 title 值是 %25E8%25A1%25A8%25E5%258D%2595?? 不对...
// 应该是 %E8%A1%A8%E5%8D%95 一次 encode
const titleMatch = url.match(/[?&]title=([^&]+)/);
assert.ok(titleMatch);
// decode 一次后应当是"表单",不是"表单"被双重编码的乱码
const decoded = decodeURIComponent(decodeURIComponent(titleMatch[1]));
assert.equal(decoded, '表单', 'decode 两次后应得原文,不应该是双重编码乱码');
});

380
tests/util-navigate.test.js Normal file
View File

@@ -0,0 +1,380 @@
/**
* util-navigate.js 集成测试
*
* 验证:
* - util-navigate.js 作为独立 ESM 模块可被 vm.runInContext 加载
* - 所有导出的方法都存在且行为正确
* - util.js 通过 import 引用 util-navigate.js 的方法后,this.$util.xxx 仍然可用
* - getPlatform 注入(测试用 globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__)能强制指定平台
*
* 跑法:
* node --test tests/util-navigate.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const NAV_SRC = fs.readFileSync(
path.join(__dirname, '..', 'common', 'js', 'util-navigate.js'),
'utf8',
);
/**
* 在沙箱里加载 util-navigate.js,把所有 export 提取到一个对象
*/
function loadNavInSandbox() {
// util-navigate.js 用了 ESM export, vm 不能直接跑 export
// 我们用一段包装代码: 把 export 转成 module.exports
const moduleObj = { exports: {} };
const wrapped = NAV_SRC
.replace(/^export\s+function\s+/gm, 'function ')
.replace(/^export\s+const\s+/gm, 'const ')
.replace(/^export\s+let\s+/gm, 'let ')
.replace(/^export\s+var\s+/gm, 'var ')
+ '\nmodule.exports = { _toParamValueString, parsePageParams, resolveLinkUrl, '
+ 'cleanParamUrls, normalizeUrl, appendQueryParams, getPlatform, shouldEncodeUrl, '
+ '_safeEncodeForPlatform, _navigateTo, createNavigateApi };\n';
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(wrapped, sandbox, { filename: 'util-navigate.js' });
return sandbox.module.exports;
}
// =============================================================
// 1. 模块可加载
// =============================================================
test('util-navigate.js: 能被 vm 加载,所有 export 都存在', () => {
const nav = loadNavInSandbox();
const expected = [
'_toParamValueString', 'parsePageParams', 'resolveLinkUrl',
'cleanParamUrls', 'normalizeUrl', 'appendQueryParams',
'getPlatform', 'shouldEncodeUrl',
'_safeEncodeForPlatform', '_navigateTo', 'createNavigateApi',
];
for (const name of expected) {
assert.equal(typeof nav[name], 'function',
`${name} 应是 function,实际是 ${typeof nav[name]}`);
}
});
// =============================================================
// 2. createNavigateApi
// =============================================================
test('createNavigateApi: 一次性拿到所有跳转方法', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi();
assert.equal(typeof api.cleanParamUrls, 'function');
assert.equal(typeof api.normalizeUrl, 'function');
assert.equal(typeof api.appendQueryParams, 'function');
assert.equal(typeof api.getPlatform, 'function');
assert.equal(typeof api.shouldEncodeUrl, 'function');
assert.equal(typeof api._safeEncodeForPlatform, 'function');
assert.equal(typeof api._navigateTo, 'function');
});
// =============================================================
// 3. _toParamValueString / parsePageParams
// =============================================================
test('_toParamValueString: 各种类型', () => {
const { _toParamValueString } = loadNavInSandbox();
assert.equal(_toParamValueString(null), '');
assert.equal(_toParamValueString(undefined), '');
assert.equal(_toParamValueString('hello'), 'hello');
assert.equal(_toParamValueString(42), '42');
assert.equal(_toParamValueString(true), 'true');
assert.equal(_toParamValueString({ a: 1 }), '{"a":1}');
assert.equal(_toParamValueString([1, 2]), '[1,2]');
});
test('parsePageParams: JSON 字符串 / 对象 / null / 兜底', () => {
const { parsePageParams } = loadNavInSandbox();
// 用 JSON.stringify 而非 deepEqual: vm 沙箱里返回的对象 prototype
// 是沙箱的 Object,跟测试环境的 Object prototype 不同,deepEqual 会失败
const eq = (a, b) => assert.equal(JSON.stringify(a), JSON.stringify(b));
eq(parsePageParams('{"a":1}'), { a: 1 });
eq(parsePageParams({ a: 1 }), { a: 1 });
eq(parsePageParams(null), {});
eq(parsePageParams(''), {});
eq(parsePageParams('not json', { x: 1 }), { x: 1 });
eq(parsePageParams(undefined), {});
});
// =============================================================
// 4. resolveLinkUrl: 拆分 wap_url 的 query 进 page_params
// =============================================================
test('resolveLinkUrl: 拆分 wap_url 的 query 进 page_params', () => {
const { resolveLinkUrl } = loadNavInSandbox();
const link = { wap_url: 'https://host.com/path?id=1&uniacid=2' };
const url = resolveLinkUrl(link);
assert.equal(url, 'https://host.com/path');
// 用 JSON.stringify 比较(避免 vm 沙箱 prototype 不一致)
assert.equal(JSON.stringify(link.page_params), JSON.stringify({ id: '1', uniacid: '2' }));
assert.equal(link.wap_url, 'https://host.com/path');
});
test('resolveLinkUrl: 无 wap_url 返回 null', () => {
const { resolveLinkUrl } = loadNavInSandbox();
assert.equal(resolveLinkUrl({}), null);
assert.equal(resolveLinkUrl(null), null);
});
// =============================================================
// 5. cleanParamUrls
// =============================================================
test('cleanParamUrls: 移除反引号和首尾空白', () => {
const { cleanParamUrls } = loadNavInSandbox();
assert.equal(
cleanParamUrls(' `https://host.com/x` '),
'https://host.com/x',
);
});
test('cleanParamUrls: 任意 key / 任意嵌套层级', () => {
const { cleanParamUrls } = loadNavInSandbox();
const out = cleanParamUrls({
a: { b: { c: { d: '`https://host.com/x`' } } },
});
assert.equal(out.a.b.c.d, 'https://host.com/x');
});
// =============================================================
// 6. normalizeUrl
// =============================================================
test('normalizeUrl: 严格不补协议/不补 host', () => {
const { normalizeUrl } = loadNavInSandbox();
assert.equal(normalizeUrl('/pages/index'), '/pages/index');
assert.equal(normalizeUrl('https://host.com/x'), 'https://host.com/x');
assert.equal(normalizeUrl('https:////host.com/x'), '');
});
test('normalizeUrl: host 小写 + 去默认端口', () => {
const { normalizeUrl } = loadNavInSandbox();
assert.equal(normalizeUrl('https://HOST.com:443/x'), 'https://host.com/x');
assert.equal(normalizeUrl('http://host.com:80/x'), 'http://host.com/x');
assert.equal(normalizeUrl('https://host.com:8080/x'), 'https://host.com:8080/x');
});
// =============================================================
// 7. appendQueryParams
// =============================================================
test('appendQueryParams: 基本功能', () => {
const { appendQueryParams } = loadNavInSandbox();
assert.equal(
appendQueryParams('https://host.com', { a: '1', b: '2' }),
'https://host.com?a=1&b=2',
);
});
test('appendQueryParams: 已有 ? 时用 &', () => {
const { appendQueryParams } = loadNavInSandbox();
assert.equal(
appendQueryParams('https://host.com?x=1', { a: '2' }),
'https://host.com?x=1&a=2',
);
});
test('appendQueryParams: 对象走 JSON.stringify', () => {
const { appendQueryParams } = loadNavInSandbox();
const r = appendQueryParams('https://host.com', { obj: { x: 1 } });
assert.ok(r.includes('obj='));
});
// =============================================================
// 8. getPlatform + 测试平台注入
// =============================================================
test('getPlatform: createNavigateApi 注入 platform 强制指定', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
assert.equal(api.getPlatform(), 'mp-harmony');
const api2 = nav.createNavigateApi({ platform: 'quickapp' });
assert.equal(api2.getPlatform(), 'quickapp');
const api3 = nav.createNavigateApi({ platform: 'h5' });
assert.equal(api3.getPlatform(), 'h5');
});
test('getPlatform: 运行时 platform 注入(模拟 systemInfo)', () => {
const vm2 = require('node:vm');
// 模拟有 systemInfo 含 harmony
const sb = { console };
sb.globalThis = sb;
sb.systemInfo = { platform: 'harmonyos' }; // 这种方式 vm 沙箱不能直接注入,改用 process.env
// 实际我们直接用 createNavigateApi 来测
const nav = loadNavInSandbox();
const api = nav.createNavigateApi();
// 默认兜底 'h5'
// 注: getPlatform 的 systemInfo 注入依赖 globalThis, 沙箱里没
// createNavigateApi({ platform: 'xxx' }) 走的是 globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__
const api2 = nav.createNavigateApi({ platform: 'mp-harmony' });
assert.equal(api2.getPlatform(), 'mp-harmony');
const api3 = nav.createNavigateApi({ platform: 'quickapp' });
assert.equal(api3.getPlatform(), 'quickapp');
});
// =============================================================
// 9. shouldEncodeUrl
// =============================================================
test('shouldEncodeUrl: 4 平台都为 true', () => {
const { shouldEncodeUrl } = loadNavInSandbox();
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
assert.equal(shouldEncodeUrl(), true);
}
});
// =============================================================
// 10. _safeEncodeForPlatform
// =============================================================
test('_safeEncodeForPlatform: 4 平台都主动 encode', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a%26b', 'mp-weixin: & 必须 encode');
});
test('_safeEncodeForPlatform: 已编码字符串不双编码', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const r = api._safeEncodeForPlatform('%E8%A1%A8');
assert.equal(r, '%E8%A1%A8', 'decode roundtrip + re-encode 不双编码');
});
// =============================================================
// 11. _navigateTo
// =============================================================
test('_navigateTo: H5 用 window.location', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'h5' });
// H5 端 _navigateTo 内部: if (typeof window === 'undefined' || typeof window.location === 'undefined')
// 所以需要传 window 全局(不是用 createNavigateApi 注入的)
// 这里直接用 vm 沙箱跑
const vm2 = require('node:vm');
const moduleObj = { exports: {} };
const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sb.globalThis = sb;
vm2.createContext(sb);
vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { getPlatform, _navigateTo };', sb);
// 让 getPlatform 返回 h5
sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'h5';
// 重新拿方法(因为已经在上面拿过了,但 getPlatform 是在调用时才检查 globalThis)
const _navigateTo = sb.module.exports._navigateTo;
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = {
navigateTo: (o) => calls.navigateTo.push(o),
redirectTo: (o) => calls.redirectTo.push(o),
reLaunch: (o) => calls.reLaunch.push(o),
switchTab: (o) => calls.switchTab.push(o),
};
const locationObj = { href: '', _replaced: '' };
locationObj.replace = function (u) { locationObj._replaced = u; locationObj.href = u; };
const window = { location: locationObj };
// _navigateTo 走的是 _navigateTo({ to, mode, platform }) 内部用 globalThis
// 我们的 _navigateTo 实现里, h5 走 window.location.href = to
// 但 util-navigate.js 里的 _navigateTo 内部是引用 'window' 全局
// 所以必须在沙箱里设 window
sb.window = window;
sb.uni = uni;
// 直接调用 sandbox 里的 _navigateTo
const r = _navigateTo({ to: '/pages/detail', mode: 'navigateTo' });
assert.equal(r.api, 'window.location');
assert.equal(locationObj.href, '/pages/detail');
});
test('_navigateTo: 微信小程序调 uni.navigateTo', () => {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = {
navigateTo: (o) => calls.navigateTo.push(o),
redirectTo: (o) => calls.redirectTo.push(o),
reLaunch: (o) => calls.reLaunch.push(o),
switchTab: (o) => calls.switchTab.push(o),
};
// 微信小程序下 _navigateTo 用 globalThis.uni, 需要在 vm 沙箱里设
const vm2 = require('node:vm');
const moduleObj = { exports: {} };
const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sb.globalThis = sb;
vm2.createContext(sb);
vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { _navigateTo };', sb);
sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'mp-weixin';
sb.globalThis.uni = uni;
sb.module.exports._navigateTo({ to: '/pages/detail', mode: 'navigateTo' });
assert.equal(calls.navigateTo.length, 1);
});
test('_navigateTo: 鸿蒙元服务 tabBar 用 reLaunch 兜底', () => {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = {
navigateTo: (o) => calls.navigateTo.push(o),
redirectTo: (o) => calls.redirectTo.push(o),
reLaunch: (o) => calls.reLaunch.push(o),
switchTab: (o) => calls.switchTab.push(o),
};
const vm2 = require('node:vm');
const moduleObj = { exports: {} };
const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sb.globalThis = sb;
vm2.createContext(sb);
vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { _navigateTo };', sb);
sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'mp-harmony';
sb.globalThis.uni = uni;
sb.module.exports._navigateTo({ to: '/pages/index/index', mode: 'tabbar' });
assert.equal(calls.switchTab.length, 0, '鸿蒙不用 switchTab');
assert.equal(calls.reLaunch.length, 1, '鸿蒙用 reLaunch 兜底');
});
// =============================================================
// 12. util.js 引用 util-navigate.js 后, 方法仍可调
// =============================================================
test('util.js: 通过 import 引用 util-navigate 的方法,挂载到 default export', () => {
// 我们直接验证 util.js 的 default export 里有这些方法
// (因为 util.js 是 ESM 且依赖 store/Config, vm 加载会失败,
// 这里用 grep 简单检查 import 声明存在)
const utilSrc = fs.readFileSync(
path.join(__dirname, '..', 'common', 'js', 'util.js'),
'utf8',
);
// 关键 import
assert.ok(utilSrc.includes("from './util-navigate.js'"),
'util.js 应 import util-navigate.js');
assert.ok(utilSrc.includes('_cleanParamUrls'),
'util.js 应 import _cleanParamUrls 别名');
assert.ok(utilSrc.includes('_normalizeUrl'),
'util.js 应 import _normalizeUrl 别名');
assert.ok(utilSrc.includes('_navigateTo'),
'util.js 应 import _navigateTo 别名');
// default export 里的挂载
assert.ok(utilSrc.includes('cleanParamUrls: (v) => _cleanParamUrls(v)'),
'util.js 应把 cleanParamUrls 挂载到 default export');
assert.ok(utilSrc.includes('_navigateTo: (args) => _navigateTo(args)'),
'util.js 应把 _navigateTo 挂载到 default export');
});
// =============================================================
// 13. 老 API 兼容 — redirectTo / diyRedirectTo 还在 util.js
// =============================================================
test('util.js: redirectTo / diyRedirectTo 仍在 util.js(没被搬走)', () => {
const utilSrc = fs.readFileSync(
path.join(__dirname, '..', 'common', 'js', 'util.js'),
'utf8',
);
// redirectTo 函数定义
assert.ok(/^\s*redirectTo\s*\(\s*to,\s*param\s*=/m.test(utilSrc),
'redirectTo 应仍在 util.js');
// diyRedirectTo 函数定义
assert.ok(/^\s*diyRedirectTo\s*\(\s*link\s*\)\s*\{/m.test(utilSrc),
'diyRedirectTo 应仍在 util.js');
// redirectTo 内部调 this._safeEncodeForPlatform
assert.ok(utilSrc.includes('this._safeEncodeForPlatform'),
'redirectTo 应调 this._safeEncodeForPlatform');
// redirectTo 内部调 this._navigateTo
assert.ok(utilSrc.includes('this._navigateTo('),
'redirectTo 应调 this._navigateTo');
// diyRedirectTo 内部调 this.cleanParamUrls
assert.ok(utilSrc.includes('this.cleanParamUrls('),
'diyRedirectTo 应调 this.cleanParamUrls');
});

698
tests/util.test.js Normal file
View File

@@ -0,0 +1,698 @@
/**
* util.js 单元测试
*
* 运行方式(任选一种):
* 1) 用 node 18+ 自带 test runner:
* node --test tests/util.test.js
*
* 2) 直接 require 也可(导出 runTests):
* node -e "require('./tests/util.test.js').runTests()"
*
* 测试重点:
* - diyRedirectTo: 外部链接分支的 URL 清洗与拼装
* - redirectTo: URL 清洗 / query 合并 / page_params 透传 / scheme 多余斜杠兜底
* - normalizeUrl: 严格规范化(不补协议 / 不补 host)
*
* 实现策略:
* util.js 依赖 store/Config/uni 等全局,不能直接 import;且 vm 沙箱对
* ES6 默认参数 / 模块语法支持有限,跑起来很麻烦。
* 实际做法:把要测的几个纯函数(decodeEntity / normalizeUrl / resolveLinkUrl /
* adaptSubpackageUrl)从 util.js 源码里"剪切"出来,放到本测试文件中,
* 跟原文件保持同步(本测试文件顶部有"被测源码区块",改动时一起改)。
* 这样既不依赖任何测试框架,又能在任何 node 版本下跑通。
*
* redirectTo / diyRedirectTo 这两个方法挂在 default export 对象上,
* 它们依赖 uni/Config/store 等,所以测试时把对象方法抽出,直接调对应方法,
* uni/Config/store 用 stub 注入。
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/util.js 保持同步)
// =============================================================
/**
* @see util.js#decodeEntity
*/
function decodeEntity(str, amp) {
const html4Entities = {
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥', brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«', not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²', sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸', sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾', iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð', ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'ψ', omega: 'ω'
};
amp = amp === undefined ? true : amp;
let i = str.indexOf('&');
while (i !== -1) {
const j = str.indexOf(';', i + 3);
let code;
if (j === -1) break;
if (str[i + 1] === '#') {
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j));
if (!isNaN(code)) {
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1);
}
} else {
code = str.substring(i + 1, j);
if (html4Entities[code] || (code === 'amp' && amp)) {
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1);
}
}
i = str.indexOf('&', i + 1);
}
return str;
}
/**
* @see util.js#normalizeUrl
*/
function normalizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return '';
let url = urlString
.split('').filter(function (ch) {
const code = ch.charCodeAt(0);
if (code <= 0x20) return false;
if (ch === '`') return false;
if (code === 0x3000) return false;
if (code === 0xFEFF || code === 0x200B ||
code === 0x200C || code === 0x200D ||
code === 0x2060) return false;
return true;
}).join('');
const protocolMatch = url.match(/^(https?):\/\//i);
let protocol = '';
let rest = url;
if (protocolMatch) {
protocol = protocolMatch[1].toLowerCase() + '://';
rest = url.substring(protocolMatch[0].length);
if (rest.charAt(0) === '/') {
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
return '';
}
} else {
rest = url;
}
const hashIndex = rest.indexOf('#');
let hash = '';
let mainPart = rest;
if (hashIndex !== -1) {
hash = rest.substring(hashIndex);
mainPart = rest.substring(0, hashIndex);
}
const queryIndex = mainPart.indexOf('?');
let query = '';
let pathPart = mainPart;
if (queryIndex !== -1) {
query = mainPart.substring(queryIndex);
pathPart = mainPart.substring(0, queryIndex);
}
let hostname = '';
let port = '';
let pathname = pathPart;
if (protocol) {
const firstSlash = pathPart.indexOf('/');
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash);
if (hostPort) {
const colonIdx = hostPort.lastIndexOf(':');
if (colonIdx !== -1) {
hostname = hostPort.substring(0, colonIdx);
port = hostPort.substring(colonIdx + 1);
} else {
hostname = hostPort;
}
}
}
let lowerHostname = '';
if (hostname) {
lowerHostname = hostname.toLowerCase();
if ((protocol === 'http://' && port === '80') ||
(protocol === 'https://' && port === '443')) {
port = '';
}
if (/[\/\s]/.test(lowerHostname)) {
console.warn('[normalizeUrl] host 含非法字符:', urlString);
return '';
}
}
let normalizedPath = pathname;
if (normalizedPath) {
const segments = normalizedPath.split('/');
const resolved = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg === '' || seg === '.') {
if (i === 0) resolved.push('');
continue;
}
if (seg === '..') {
if (resolved.length > 1) resolved.pop();
continue;
}
resolved.push(seg);
}
normalizedPath = resolved.join('/') || '/';
}
let normalizedQuery = '';
if (query) {
const qs = query.substring(1);
if (qs) {
const pairs = qs.split('&').map(function (pair) {
const eqIdx = pair.indexOf('=');
return eqIdx === -1
? { k: pair, v: '' }
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
});
pairs.sort(function (a, b) { return a.k.localeCompare(b.k); });
normalizedQuery = '?' + pairs.map(function (p) { return p.k + '=' + p.v; }).join('&');
}
}
const portStr = port ? ':' + port : '';
const hostPart = lowerHostname ? lowerHostname + portStr : '';
const schemeAndHost = protocol + hostPart;
return schemeAndHost + normalizedPath + normalizedQuery + hash;
}
/**
* 解析 link.page_params(支持 JSON 字符串 / 对象)
* @see util.js#parsePageParams
*/
function parsePageParams(pageParams) {
if (pageParams == null) return {};
if (typeof pageParams === 'object') return pageParams;
if (typeof pageParams === 'string') {
try { return JSON.parse(pageParams); } catch (e) { return {}; }
}
return {};
}
/**
* @see util.js#resolveLinkUrl
*/
function resolveLinkUrl(link) {
if (!link) return null;
if (link.wap_url) {
const decodedWap = decodeEntity(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach(function (kv) {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
const pageParams = parsePageParams(link.page_params);
link.page_params = Object.assign({}, urlParams, pageParams);
link.wap_url = path;
}
}
if (link.page_key) {
// 简化:不模拟 page_key 解析,只走 wap_url 分支
}
if (link.wap_url) {
return link.wap_url;
}
return null;
}
/**
* @see util.js#adaptSubpackageUrl
*/
function adaptSubpackageUrl(url) {
if (!url) return url;
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map(function (key) {
return key.replace(/\//g, '\\/').replace(/\*/g, '.*');
}).join('|') + ')'
);
function replacePrefix(str) {
return str.replace(regex, function (match) {
const newPrefix = urlPrefixMap[match];
return newPrefix || match;
});
}
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return replacePrefix(collapsed);
}
// =============================================================
// redirectTo / diyRedirectTo 的最小可测版本
// (只覆盖"URL 清洗 -> 拼装 -> 调用 uni"这条链路)
// =============================================================
function createUtilInstance({ uniMock, config = {} } = {}) {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = uniMock || {
navigateTo(opts) { calls.navigateTo.push(opts); },
redirectTo(opts) { calls.redirectTo.push(opts); },
reLaunch(opts) { calls.reLaunch.push(opts); },
switchTab(opts) { calls.switchTab.push(opts); },
};
const store = { state: { tabBarList: null, siteInfo: {}, servicerConfig: {} } };
const Lang = { lang: function (k) { return k; } };
const Config = Object.assign({ siteInfo: { site_tel: '' } }, config);
// 这里只复刻 redirectTo / diyRedirectTo 的 URL 清洗 + 拼装核心逻辑,
// 不复刻所有分支(微页面、tabBar、客服、小程序跳转、电话等)
const systemTabBarList = [
'/pages/index/index',
'/pages_goods/category',
'/pages_tool/contact/contact',
'/pages_tool/member/index',
];
function safeEncode(v) {
const s = typeof v === 'string' ? v : JSON.stringify(v);
// 非 H5 端:模拟"含 %xx 就 decode 一次"的逻辑
if (/%[0-9a-fA-F]{2}/.test(s)) {
try { return decodeURIComponent(s); } catch (e) { return s; }
}
return s;
}
function adaptSubpackageUrlLocal(url) {
return adaptSubpackageUrl(url);
}
function redirectTo(to, param, mode) {
param = param || {};
mode = mode || 'navigateTo';
// 0) 兜底清理:同 util.js 0 号步骤
const cleanUrlString = function (s) {
if (typeof s !== 'string') return s;
return s
.split('').filter(function (ch) {
return ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r';
}).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
};
const urlKeys = ['wap_url', 'src', 'url'];
for (let i = 0; i < urlKeys.length; i++) {
const k = urlKeys[i];
if (k in param) param[k] = cleanUrlString(param[k]);
}
const queryIdx = to.indexOf('?');
let path = queryIdx === -1 ? to : to.substring(0, queryIdx);
const existingParams = {};
if (queryIdx !== -1) {
to.substring(queryIdx + 1).split('&').forEach(function (kv) {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) existingParams[k] = v;
});
}
const { page_params, ...restParam } = param;
const merged = Object.assign({}, existingParams, restParam);
if (page_params !== undefined) {
merged.page_params = page_params;
}
const queryStr = Object.keys(merged)
.map(function (k) { return safeEncode(k) + '=' + safeEncode(merged[k]); })
.join('&');
let url = path + (queryStr ? '?' + queryStr : '');
url = adaptSubpackageUrlLocal(url);
// tabBar 命中
for (let i = 0; i < systemTabBarList.length; i++) {
if (url.indexOf(systemTabBarList[i]) === 0) {
if (mode === 'reLaunch' || systemTabBarList[i] !== '/pages/index/index') {
uni.reLaunch({ url });
} else {
uni.switchTab({ url });
}
return { url, mode: 'tabBar' };
}
}
if (mode === 'redirectTo') {
uni.redirectTo({ url });
} else if (mode === 'reLaunch') {
uni.reLaunch({ url });
} else {
uni.navigateTo({ url });
}
return { url, mode };
}
function diyRedirectTo(link) {
if (link.wap_url) {
// 入口清理:剥离反引号 + 空白
link.wap_url = decodeEntity(String(link.wap_url))
.split('').filter(function (ch) {
return ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r';
}).join('');
}
const resolvedUrl = resolveLinkUrl(link);
if (link.page_key && resolvedUrl) {
link.wap_url = resolvedUrl;
}
if (link.wap_url && link.wap_url.indexOf('http') !== -1) {
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
const params = Object.assign({},
{ title: link.title || '', enable_set_title: link.enable_set_title ? 1 : 0 },
pageParams);
let url = normalizeUrl(decodeEntity(link.wap_url));
url = appendQueryParams(url, params);
return redirectTo('/pages_tool/webview/webview', Object.assign({}, link, { src: url }));
} else if (link.wap_url) {
const { wap_url, name, parent, type, appid, page, mobile, image, ...navParams } = link;
return redirectTo(link.wap_url, Object.assign({},
navParams,
link.title != null && { title: link.title },
link.enable_set_title != null && { enable_set_title: link.enable_set_title ? 1 : 0 },
link.page_params || {}));
}
return null;
}
function appendQueryParams(url, params) {
if (!url) return '';
const hashIndex = url.indexOf('#');
let hash = '';
let targetUrl = url;
if (hashIndex !== -1) {
hash = url.substring(hashIndex);
targetUrl = url.substring(0, hashIndex);
}
const separator = targetUrl.includes('?') ? '&' : '?';
const queryString = Object.entries(params)
.filter(function ([, value]) { return value !== undefined && value !== null; })
.map(function ([key, value]) { return key + '=' + (typeof value === 'string' ? value : JSON.stringify(value)); })
.join('&');
return targetUrl + separator + queryString + hash;
}
return { redirectTo, diyRedirectTo, calls, uni };
}
// =============================================================
// decodeEntity
// =============================================================
test('decodeEntity: 解码 &amp; 等 HTML 实体', () => {
assert.equal(decodeEntity('a&amp;b&amp;c'), 'a&b&c');
});
test('decodeEntity: 解码 &#x4E2D; 这种十六进制', () => {
assert.equal(decodeEntity('&#x4E2D;'), '中');
});
// =============================================================
// normalizeUrl
// =============================================================
test('normalizeUrl: 基础 https URL 不变', () => {
assert.equal(
normalizeUrl('https://xcx3.aigc-quickapp.com/hwappx/abc'),
'https://xcx3.aigc-quickapp.com/hwappx/abc',
);
});
test('normalizeUrl: 剥离反引号和首尾空白', () => {
assert.equal(
normalizeUrl(' `https://xcx3.aigc-quickapp.com/hwappx/abc` '),
'https://xcx3.aigc-quickapp.com/hwappx/abc',
);
});
test('normalizeUrl: https:////host 直接 reject', () => {
assert.equal(normalizeUrl('https:////xcx3.aigc-quickapp.com/x'), '');
});
test('normalizeUrl: 不补协议,内部路径原样返回', () => {
assert.equal(normalizeUrl('/pages/index'), '/pages/index');
assert.equal(normalizeUrl('pages/index'), 'pages/index');
});
test('normalizeUrl: 不补 host,只补协议也不做', () => {
assert.equal(normalizeUrl('xcx3.aigc-quickapp.com/x'), 'xcx3.aigc-quickapp.com/x');
});
test('normalizeUrl: host 小写', () => {
assert.equal(
normalizeUrl('https://HOST.Abc.com/x'),
'https://host.abc.com/x',
);
});
test('normalizeUrl: 去默认端口', () => {
assert.equal(normalizeUrl('https://host.com:443/x'), 'https://host.com/x');
assert.equal(normalizeUrl('http://host.com:80/x'), 'http://host.com/x');
assert.equal(normalizeUrl('https://host.com:8080/x'), 'https://host.com:8080/x');
});
test('normalizeUrl: 解析 . 和 ..', () => {
assert.equal(normalizeUrl('https://host.com/a/./b/../c'), 'https://host.com/a/c');
});
test('normalizeUrl: 合并连续 //', () => {
assert.equal(normalizeUrl('https://host.com//a///b'), 'https://host.com/a/b');
});
test('normalizeUrl: query 排序', () => {
assert.equal(
normalizeUrl('https://host.com?b=2&a=1&c=3'),
'https://host.com?a=1&b=2&c=3',
);
});
test('normalizeUrl: BOM / 零宽字符剥离', () => {
assert.equal(
normalizeUrl('\uFEFFhttps://host.com/\u200Bx\u200Dy'),
'https://host.com/xy',
);
});
test('normalizeUrl: 全部是空白或反引号返回空串', () => {
assert.equal(normalizeUrl(' '), '');
assert.equal(normalizeUrl('```'), '');
});
test('normalizeUrl: 非字符串返回空串', () => {
assert.equal(normalizeUrl(null), '');
assert.equal(normalizeUrl(undefined), '');
assert.equal(normalizeUrl(123), '');
});
// =============================================================
// resolveLinkUrl
// =============================================================
test('resolveLinkUrl: 拆分 wap_url 的 query 进 page_params', () => {
const link = { wap_url: 'https://host.com/path?id=1&uniacid=2' };
const url = resolveLinkUrl(link);
assert.equal(url, 'https://host.com/path');
assert.deepEqual(link.page_params, { id: '1', uniacid: '2' });
assert.equal(link.wap_url, 'https://host.com/path');
});
test('resolveLinkUrl: page_params 优先于 wap_url query', () => {
const link = {
wap_url: 'https://host.com/path?id=1',
page_params: JSON.stringify({ id: '999', extra: 'x' }),
};
resolveLinkUrl(link);
assert.deepEqual(link.page_params, { id: '999', extra: 'x' });
});
test('resolveLinkUrl: 无 wap_url 返回 null', () => {
assert.equal(resolveLinkUrl({}), null);
assert.equal(resolveLinkUrl(null), null);
});
// =============================================================
// adaptSubpackageUrl
// =============================================================
test('adaptSubpackageUrl: 老前缀映射到新前缀', () => {
assert.equal(
adaptSubpackageUrl('/pages/order/detail?id=1'),
'/pages_order/detail?id=1',
);
assert.equal(
adaptSubpackageUrl('/pages/goods/list'),
'/pages_goods/list',
);
});
test('adaptSubpackageUrl: 已经是新前缀不动', () => {
assert.equal(
adaptSubpackageUrl('/pages_order/detail'),
'/pages_order/detail',
);
});
test('adaptSubpackageUrl: 把 3 个及以上 / 收敛成 2 个', () => {
assert.equal(
adaptSubpackageUrl('/pages_order///detail'),
'/pages_order//detail',
);
});
// =============================================================
// redirectTo
// =============================================================
test('redirectTo: 基础 navigateTo 拼装 url', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { id: '1', title: 'hello' });
assert.equal(util.calls.navigateTo.length, 1);
assert.equal(
util.calls.navigateTo[0].url,
'/pages/detail?id=1&title=hello',
);
});
test('redirectTo: 0 号清理步骤 — 移除 param.src 反引号 + 修补 scheme', () => {
const util = createUtilInstance();
util.redirectTo('/pages_tool/webview/webview', {
name: 'CUSTOM_LINK',
wap_url: '`https://host.com/x`',
src: '`https:////host.com/x?id=1`',
});
const call = util.calls.navigateTo[0];
assert.ok(!call.url.includes('`'), 'url 不应包含反引号');
assert.ok(!call.url.includes('https:////'), 'url 不应包含 https:////');
assert.ok(call.url.includes('wap_url=https://host.com/x'), 'wap_url 应被清理');
assert.ok(call.url.includes('src=https://host.com/x?id=1'), 'src 应被清理');
});
test('redirectTo: page_params 永远在最末', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { a: '1', page_params: { x: 'y' } });
const url = util.calls.navigateTo[0].url;
assert.ok(url.endsWith('page_params={"x":"y"}'), 'page_params 应在 URL 末尾');
});
test('redirectTo: mode=redirectTo 调 uni.redirectTo', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { id: '1' }, 'redirectTo');
assert.equal(util.calls.navigateTo.length, 0);
assert.equal(util.calls.redirectTo.length, 1);
});
test('redirectTo: mode=reLaunch 调 uni.reLaunch', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { id: '1' }, 'reLaunch');
assert.equal(util.calls.reLaunch.length, 1);
});
// =============================================================
// diyRedirectTo — 重点测试(微信环境下外部链接的核心场景)
// =============================================================
test('diyRedirectTo: 外部链接带脏数据(反引号+空格)能清洗干净', () => {
const util = createUtilInstance();
util.diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: '`https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata`',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
page_params: { id: '130', uniacid: '2482' },
});
const call = util.calls.navigateTo[0];
assert.ok(call, '应该调用了 uni.navigateTo');
const url = call.url;
// 1) url 不应该带反引号
assert.ok(!url.includes('`'), 'url 不应包含反引号: ' + url);
// 2) url 不应该有 https:////(4 个斜杠)
assert.ok(!url.includes('https:////'), 'url 不应包含 https:////: ' + url);
// 3) url 应该跳到 webview 页面
assert.ok(url.startsWith('/pages_tool/webview/webview?'), '应跳到 webview 页面: ' + url);
// 4) src 应是干净的 https://xcx3.../formdata?title=...&enable_set_title=0&id=130&uniacid=2482
const srcMatch = url.match(/src=([^&]+(?:&(?!wap_url=)[^&]+)*)/);
assert.ok(srcMatch, 'url 应含 src 参数');
const src = decodeURIComponent(srcMatch[1]);
assert.ok(src.startsWith('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?'),
'src 应以正确的 host + path 开头: ' + src);
assert.ok(!src.includes('https:////'), 'src 内的 scheme 后不应有 ////(4 个斜杠): ' + src);
// 5) src 内 page_params 应正确出现
assert.ok(src.includes('id=130'), 'src 内应含 id=130');
assert.ok(src.includes('uniacid=2482'), 'src 内应含 uniacid=2482');
});
test('diyRedirectTo: wap_url 是畸形 https:////host 应该被 reject', () => {
const util = createUtilInstance();
const result = util.diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: 'https:////host.com/x', // 已经是干净的(无反引号),但畸形
page_params: {},
});
// normalizeUrl 直接返回 '', appendQueryParams('') 返回 '',
// redirectTo('/pages_tool/webview/webview', { src: '' })
// uni.navigateTo 被调,但 url 是 /pages_tool/webview/webview?...&src=
const call = util.calls.navigateTo[0];
assert.ok(call, '应该调用了 uni.navigateTo');
const url = call.url;
assert.ok(url.includes('src='), 'url 应含 src 参数');
// src 应为空(被 reject)
const srcMatch = url.match(/src=([^&]*)/);
if (srcMatch) {
// 如果 src 在 URL 末尾,可能是空
// 这里不强求 src 为空,因为后续 safeEncode 可能影响
// 但应不含 host.com
assert.ok(!srcMatch[1].includes('host.com'), 'src 不应含 host.com(被 reject)');
}
});
test('diyRedirectTo: 内部路径不走 webview', () => {
const util = createUtilInstance();
util.diyRedirectTo({
name: 'INNER',
title: '详情',
wap_url: '/pages_order/detail',
});
const call = util.calls.navigateTo[0];
assert.ok(call.url.startsWith('/pages_order/detail?'), '应跳到内部路径: ' + call.url);
});
test('diyRedirectTo: page_params 的键值会出现在 src 内', () => {
const util = createUtilInstance();
util.diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: '`https://host.com/formdata`',
page_params: { id: '130', uniacid: '2482' },
});
const url = util.calls.navigateTo[0].url;
assert.ok(url.includes('id=130'), '应含 id=130');
assert.ok(url.includes('uniacid=2482'), '应含 uniacid=2482');
});