build(scripts): add test gate and npm test scripts
1. 新增测试门禁脚本test-gate.js,统一管理tests目录下的测试用例执行 2. 在package.json中添加test、test:util、prebuild、prestart脚本,将测试环节前置到构建和启动流程 3. 重构normalizeUrl函数,新增更严格的URL规范化逻辑并补充完整注释 4. 优化代码格式,修正多处对象字面量的空格排版 5. 新增util.js的单元测试文件,覆盖核心工具函数的测试场景
This commit is contained in:
@@ -1353,6 +1353,7 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP
|
||||
console.log(`外部链接,跳转到webview`, {...link, src: url});
|
||||
this.redirectTo('/pages_tool/webview/webview', {
|
||||
...link,
|
||||
src: url
|
||||
@@ -1434,11 +1435,11 @@ export default {
|
||||
break;
|
||||
case 'niushop':
|
||||
// Niushop客服
|
||||
this.redirectTo('/pages_tool/chat/room', {...link});
|
||||
this.redirectTo('/pages_tool/chat/room', { ...link });
|
||||
break;
|
||||
case 'weapp':
|
||||
// 微信小程序,由于需要手动点击按钮触发,所以要跳转到中间页
|
||||
this.redirectTo(link.wap_url, {...link});
|
||||
this.redirectTo(link.wap_url, { ...link });
|
||||
break;
|
||||
default:
|
||||
// 拨打客服电话
|
||||
@@ -2029,109 +2030,147 @@ export default {
|
||||
/**
|
||||
* 适用于 UniApp 的全平台 URL 标准化函数
|
||||
* @param {string} urlString - 需要标准化的原始 URL 字符串
|
||||
* @returns {string} 标准化后的 URL
|
||||
* @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 ''
|
||||
*
|
||||
* 严格策略(只规范化已有部分,绝不自动补充):
|
||||
* 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底)
|
||||
* 2) 不补 https:// —— 如果原串没有协议,保持没有
|
||||
* 3) 不补 host —— 如果原串没有 host,保持没有
|
||||
* 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序
|
||||
* 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 ''
|
||||
*/
|
||||
normalizeUrl(urlString) {
|
||||
if (!urlString || typeof urlString !== 'string') return '';
|
||||
|
||||
let url = urlString.trim();
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
// 1) 预清理: 反引号 + 所有 ASCII 空白 + 中文全角空白 + BOM + 零宽字符
|
||||
let url = urlString
|
||||
.split('').filter(function (ch) {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code <= 0x20) return false; // ASCII 空白
|
||||
if (ch === '`') return false; // 反引号
|
||||
if (code === 0x3000) return false; // 全角空格
|
||||
if (code === 0xFEFF || code === 0x200B ||
|
||||
code === 0x200C || code === 0x200D ||
|
||||
code === 0x2060) return false; // BOM / 零宽
|
||||
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);
|
||||
// scheme 后不能紧跟 '/',即不允许 https:////host 这种畸形
|
||||
if (rest.charAt(0) === '/') {
|
||||
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
// 没有协议 —— 不自动补,只取 path 部分
|
||||
rest = url;
|
||||
}
|
||||
|
||||
// ==================== H5 端处理 (利用原生 URL API) ====================
|
||||
// #ifdef H5
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
urlObj.protocol = urlObj.protocol.toLowerCase();
|
||||
urlObj.hostname = urlObj.hostname.toLowerCase();
|
||||
|
||||
if ((urlObj.protocol === 'http:' && urlObj.port === '80') ||
|
||||
(urlObj.protocol === 'https:' && urlObj.port === '443')) {
|
||||
urlObj.port = '';
|
||||
}
|
||||
|
||||
const tempUrl = new URL(urlObj.pathname, urlObj.origin);
|
||||
urlObj.pathname = tempUrl.pathname;
|
||||
|
||||
if (urlObj.search) {
|
||||
const searchParams = new URLSearchParams(urlObj.search);
|
||||
const sortedParams = new URLSearchParams();
|
||||
Array.from(searchParams.keys()).sort().forEach(key => {
|
||||
sortedParams.append(key, searchParams.get(key));
|
||||
});
|
||||
urlObj.search = sortedParams.toString();
|
||||
}
|
||||
|
||||
if (urlObj.hash === '#') urlObj.hash = '';
|
||||
|
||||
return urlObj.toString();
|
||||
} catch (e) {
|
||||
console.error('H5端 URL 解析失败:', e);
|
||||
return url;
|
||||
// 解析 hash(可选,允许缺失)
|
||||
const hashIndex = rest.indexOf('#');
|
||||
let hash = '';
|
||||
let mainPart = rest;
|
||||
if (hashIndex !== -1) {
|
||||
hash = rest.substring(hashIndex);
|
||||
mainPart = rest.substring(0, hashIndex);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ==================== 非 H5 端处理 (小程序 / App) ====================
|
||||
// #ifndef H5
|
||||
try {
|
||||
const protocolMatch = url.match(/^(https?):\/\//i);
|
||||
const protocol = protocolMatch ? protocolMatch[1].toLowerCase() + '://' : 'https://';
|
||||
const withoutProtocol = url.replace(/^https?:\/\//i, '');
|
||||
// 解析 query(可选,允许缺失)
|
||||
const queryIndex = mainPart.indexOf('?');
|
||||
let query = '';
|
||||
let pathPart = mainPart;
|
||||
if (queryIndex !== -1) {
|
||||
query = mainPart.substring(queryIndex); // 包含 '?'
|
||||
pathPart = mainPart.substring(0, queryIndex);
|
||||
}
|
||||
|
||||
const hashIndex = withoutProtocol.indexOf('#');
|
||||
let hash = '';
|
||||
let mainPart = withoutProtocol;
|
||||
if (hashIndex !== -1) {
|
||||
hash = withoutProtocol.substring(hashIndex);
|
||||
mainPart = withoutProtocol.substring(0, hashIndex);
|
||||
}
|
||||
|
||||
const pathQuerySplit = mainPart.split('/');
|
||||
const hostPort = pathQuerySplit.shift();
|
||||
let pathname = '/' + pathQuerySplit.join('/');
|
||||
|
||||
const [hostname, port] = hostPort.split(':');
|
||||
const lowerHostname = hostname.toLowerCase();
|
||||
|
||||
let finalPort = port;
|
||||
if ((protocol === 'http://' && port === '80') || (protocol === 'https://' && port === '443')) {
|
||||
finalPort = '';
|
||||
}
|
||||
|
||||
let pathParts = pathname.split('/').filter(p => p !== '' && p !== '.');
|
||||
const resolvedPath = [];
|
||||
for (const part of pathParts) {
|
||||
if (part === '..') {
|
||||
resolvedPath.pop();
|
||||
// 解析 host(可选,允许缺失)
|
||||
let hostname = '';
|
||||
let port = '';
|
||||
let pathname = pathPart;
|
||||
if (protocol) {
|
||||
// 有协议,前一段视为 host(可能为 'host'、'host:port'、'' 三种)
|
||||
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 {
|
||||
resolvedPath.push(part);
|
||||
hostname = hostPort;
|
||||
}
|
||||
}
|
||||
pathname = '/' + resolvedPath.join('/');
|
||||
|
||||
const queryIndex = pathname.indexOf('?');
|
||||
let query = '';
|
||||
if (queryIndex !== -1) {
|
||||
const queryString = pathname.substring(queryIndex + 1);
|
||||
pathname = pathname.substring(0, queryIndex);
|
||||
|
||||
const params = queryString.split('&').map(pair => {
|
||||
const [k, v = ''] = pair.split('=');
|
||||
return { k, v };
|
||||
});
|
||||
params.sort((a, b) => a.k.localeCompare(b.k));
|
||||
query = '?' + params.map(p => `${p.k}=${p.v}`).join('&');
|
||||
}
|
||||
|
||||
const portStr = finalPort ? `:${finalPort}` : '';
|
||||
return `${protocol}//${lowerHostname}${portStr}${pathname}${query}${hash}`;
|
||||
} catch (e) {
|
||||
console.error('非H5端 URL 解析失败:', e);
|
||||
return url;
|
||||
}
|
||||
// #endif
|
||||
// 无协议时,pathPart 整体视为 pathname,host 保持空
|
||||
|
||||
// 规范化 host
|
||||
let lowerHostname = '';
|
||||
if (hostname) {
|
||||
lowerHostname = hostname.toLowerCase();
|
||||
// 去默认端口
|
||||
if ((protocol === 'http://' && port === '80') ||
|
||||
(protocol === 'https://' && port === '443')) {
|
||||
port = '';
|
||||
}
|
||||
// host 内不允许出现 '/' 或空白(若出现,视为脏数据)
|
||||
if (/[\/\s]/.test(lowerHostname)) {
|
||||
console.warn('[normalizeUrl] host 含非法字符:', urlString);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 规范化 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);
|
||||
}
|
||||
// 合并连续 '/' 已经在 split/join 中自然完成
|
||||
normalizedPath = resolved.join('/') || '/';
|
||||
}
|
||||
|
||||
// 规范化 query: 按 key 字母序排序
|
||||
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('&');
|
||||
}
|
||||
}
|
||||
|
||||
// 拼回最终 URL(只包含真实存在的部分)
|
||||
const portStr = port ? ':' + port : '';
|
||||
const hostPart = lowerHostname ? lowerHostname + portStr : '';
|
||||
const schemeAndHost = protocol + hostPart; // protocol 形如 'https://',hostPart 可能为空
|
||||
return schemeAndHost + normalizedPath + normalizedQuery + hash;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2139,7 +2178,7 @@ export default {
|
||||
* @param {string} url - 原始 URL 字符串
|
||||
* @param {object} params - 需要追加的参数对象,例如 { name: '张三', city: '苏州' }
|
||||
* @returns {string} 追加参数后的完整 URL
|
||||
*/
|
||||
*/
|
||||
appendQueryParams(url, params) {
|
||||
if (!url || typeof url !== 'string') return '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user