Merge remote-tracking branch 'origin/dev/1.0' into yuanlaide2
# Conflicts: # .local.config.js
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
.codebuddy
|
||||
.vscode
|
||||
.trae
|
||||
|
||||
# 鸿蒙元服务构建产物
|
||||
stub-app-projects/mp-harmony/ascf/ascf_src
|
||||
|
||||
@@ -138,6 +138,7 @@ const localDevConfig = ({
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
// })['2821']; // 离型纸硅油纸
|
||||
})['48']; // 离型纸硅油纸
|
||||
// })['2485']; // 验证电子表单前后端数据一致性
|
||||
// })['5g-11']; // 数码
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
33
common/js/quickapp-pull-refresh.js
Normal file
33
common/js/quickapp-pull-refresh.js
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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
623
common/js/util-navigate.js
Normal 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] 是否解码 &
|
||||
* @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
443
common/js/util-tabbar.js
Normal 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
@@ -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);
|
||||
|
||||
@@ -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 + ';';
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
10
docs/TODO.md
10
docs/TODO.md
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -132,6 +132,16 @@ export const lang = {
|
||||
domainNotInList: '{url}不在request 合法域名列表中',
|
||||
// 请求失败: {url} 为域名占位符
|
||||
requestFailed: '{url}请求失败. {err}',
|
||||
// 网络超时
|
||||
timeout: '网络请求超时,请稍后重试',
|
||||
// 网络连接失败
|
||||
networkError: '网络连接失败,请检查网络设置',
|
||||
// 服务器错误
|
||||
serverError: '服务暂时不可用,请稍后重试',
|
||||
// 请求被中断
|
||||
aborted: '请求已取消',
|
||||
// 未知错误
|
||||
unknownError: '请求失败,请稍后重试',
|
||||
},
|
||||
|
||||
// share 分享工具
|
||||
|
||||
8
main.js
8
main.js
@@ -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 的页面提供默认实现,
|
||||
|
||||
@@ -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",
|
||||
|
||||
247
pages.json
247
pages.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<style lang="scss">
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 30rpx;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
85
scripts/test-gate.js
Normal 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
@@ -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=
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
296
tests/clean-param-urls.test.js
Normal file
296
tests/clean-param-urls.test.js
Normal 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, '原对象不应被修改');
|
||||
});
|
||||
484
tests/cross-platform.test.js
Normal file
484
tests/cross-platform.test.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
285
tests/diy-redirect-to.test.js
Normal file
285
tests/diy-redirect-to.test.js
Normal 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);
|
||||
});
|
||||
253
tests/mp-share-default.test.js
Normal file
253
tests/mp-share-default.test.js
Normal 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();
|
||||
});
|
||||
400
tests/redirectTo-real.test.js
Normal file
400
tests/redirectTo-real.test.js
Normal 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
380
tests/util-navigate.test.js
Normal 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
698
tests/util.test.js
Normal 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: 解码 & 等 HTML 实体', () => {
|
||||
assert.equal(decodeEntity('a&b&c'), 'a&b&c');
|
||||
});
|
||||
|
||||
test('decodeEntity: 解码 中 这种十六进制', () => {
|
||||
assert.equal(decodeEntity('中'), '中');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 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');
|
||||
});
|
||||
Reference in New Issue
Block a user