Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide3
This commit is contained in:
@@ -139,19 +139,12 @@ const localDevConfig = ({
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'72': { // 测试平台
|
||||
uniacid: 72,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'54': { // 测试平台
|
||||
uniacid: 54,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
// })['2821']; // 离型纸硅油纸
|
||||
})['54']; // 离型纸硅油纸
|
||||
})['2482']; // 低空经济
|
||||
// })['48']; // 离型纸硅油纸
|
||||
// })['2485']; // 验证电子表单前后端数据一致性
|
||||
// })['5g-11']; // 数码
|
||||
// })['5g-213']; // 选择要使用的环境配置
|
||||
|
||||
105
common/js/form-platform-mixin.js
Normal file
105
common/js/form-platform-mixin.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* form-platform-mixin.js
|
||||
*
|
||||
* 表单组件共用的平台检测 mixin
|
||||
*
|
||||
* 集中维护 3 个 computed:
|
||||
* - platform 当前运行平台 key(与后端 formConfig.terminal 键名对齐)
|
||||
* - platformAliases 后端键名 → 内部 platform 名 兼容层
|
||||
* - platformConfigKeys 当前平台的有效配置键集合(用于从 formConfig.terminal 查找)
|
||||
*
|
||||
* 消费方:
|
||||
* - components/ns-form/ns-form.vue
|
||||
* - components/ns-newform/ns-newform.vue
|
||||
* - 任何需要按端读取 formConfig.terminal.{h5|weapp|harmony|app} 的表单组件
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 单一真值:避免 ns-form / ns-newform 各自维护导致漂移
|
||||
* 2. 纯 computed mixin:不依赖 props/data/methods,便于跨组件复用
|
||||
* 3. 不依赖 util.js 等其它模块,避免循环依赖
|
||||
* 4. 通过 mixins: [...] 引入,组件侧不再写平台判断代码
|
||||
*
|
||||
* 用法:
|
||||
* import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
* export default {
|
||||
* mixins: [formPlatformMixin],
|
||||
* computed: {
|
||||
* config() {
|
||||
* const cur = this.platform;
|
||||
* const keys = this.platformConfigKeys;
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
/**
|
||||
* 当前运行平台标识(与后端 formConfig.terminal 键名对齐)
|
||||
* 后端在 formConfig.terminal 下挂的差异化配置键:h5 / weapp / mp-harmony / app
|
||||
* 这里返回的 key 必须与后端设计器输出的 terminal 字段名一致
|
||||
*/
|
||||
platform() {
|
||||
// #ifdef H5
|
||||
return 'h5';
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'weapp';
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
return 'mp-alipay';
|
||||
// #endif
|
||||
// #ifdef MP-BAIDU
|
||||
return 'mp-baidu';
|
||||
// #endif
|
||||
// #ifdef MP-TOUTIAO
|
||||
return 'mp-toutiao';
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
return 'app';
|
||||
// #endif
|
||||
// #ifdef MP-HARMONY
|
||||
return 'harmony';
|
||||
// #endif
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
return 'quickapp';
|
||||
// #endif
|
||||
return 'h5';
|
||||
},
|
||||
/**
|
||||
* 后端键名 → 内部 platform 名 兼容层
|
||||
* 不同后端序列化路径可能用 mp-weixin / wechat / mp-harmony 等不同的 key,
|
||||
* 在这里统一映射到内部 platform 名,避免 config() 里到处写 if/else。
|
||||
*/
|
||||
platformAliases() {
|
||||
return {
|
||||
'h5': 'h5',
|
||||
'weapp': 'weapp',
|
||||
'mp-weixin': 'weapp',
|
||||
'wechat': 'weapp',
|
||||
'harmony': 'harmony',
|
||||
'app': 'app',
|
||||
'app-plus': 'app'
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 当前平台的有效配置键集合
|
||||
* 用于 config() 中查找 formConfig.terminal 下的平台差异化配置:
|
||||
* - 后端格式:formConfig.terminal = { h5: {...}, weapp: {...}, harmony: {...} }
|
||||
* - 旧式扁平:formConfig[platformKey] = { ... }
|
||||
* 返回所有映射到当前 platform 的后端键名,方便两种结构都能命中。
|
||||
*/
|
||||
platformConfigKeys() {
|
||||
const cur = this.platform;
|
||||
const aliases = this.platformAliases;
|
||||
const keys = [];
|
||||
Object.keys(aliases).forEach(k => {
|
||||
if (aliases[k] === cur) keys.push(k);
|
||||
});
|
||||
// 加入当前平台名(兼容 formConfig[platform] 这种扁平写法)
|
||||
if (keys.indexOf(cur) === -1) keys.push(cur);
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -476,6 +476,78 @@ export function resolveNavMode(link) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl
|
||||
// =============================================================
|
||||
// pickCurrentUniacid
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 从两个候选 uniacid 源中选出"系统当前 uniacid"。
|
||||
*
|
||||
* 用法(当前 diyRedirectTo 中的实际调用, 必须与 http.js 行 168 保持一致):
|
||||
* pickCurrentUniacid(
|
||||
* uni.getStorageSync('uniacid'), // 持久化, 登录/初始化写入
|
||||
* Config.uniacid, // 编译期兜底
|
||||
* )
|
||||
*
|
||||
* 优先级: 第一个参数非 0 → 选第一个; 否则选第二个非 0 值; 都没有 → 0。
|
||||
* 0 视为"无 uniacid": 业务上 uniacid 不会是 0, parseInt 后为 0 也算无效。
|
||||
*
|
||||
* ★ 注意: 故意【不读 store.state.uniacid】。当前代码库里 store.state 上从未设置
|
||||
* uniacid 字段(http.js 只用 storage/Config), 若引入 store 分支会出现
|
||||
* "守卫用 A 真值, http 请求用 B 真值"的不一致。
|
||||
*
|
||||
* @param {*} primaryValue 第一候选(运行时持久化值)
|
||||
* @param {*} secondaryValue 第二候选(编译期兜底)
|
||||
* @returns {number} 选出的 uniacid; 都无效时返回 0
|
||||
*/
|
||||
export function pickCurrentUniacid(primaryValue, secondaryValue) {
|
||||
const a = parseInt(primaryValue, 10);
|
||||
if (a) return a; // 非 0 真值
|
||||
const b = parseInt(secondaryValue, 10);
|
||||
if (b) return b;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 检测 link.wap_url 中【URL 原生】的 uniacid query 与系统当前 uniacid 是否一致。
|
||||
*
|
||||
* 用途: uniacid 是平台租户 ID, 会随租户迁移 / 平台调整而变化。
|
||||
* - 如果数据里的 wap_url 自带的 uniacid 与当前系统不一致,
|
||||
* 说明这份装修数据可能是历史遗留或别的租户的(脏数据),
|
||||
* 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
|
||||
* - 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
|
||||
*
|
||||
* 关键: 必须【在 resolveLinkUrl 抽走 query 之前】判断, 否则 URL 里的 uniacid 已经
|
||||
* 被搬到 page_params 里, 分不清"URL 原生"还是"外加的"。
|
||||
*
|
||||
* 比较规则: 两侧 parseInt 后比较, 兼容 '2482' vs 2482、URL 编码、空值。
|
||||
*
|
||||
* @param {string} wap_url link.wap_url (resolveLinkUrl 之前的原始值)
|
||||
* @param {string|number|null} currentUniacid 系统当前的 uniacid
|
||||
* (0 / null / '' / undefined 一律视为"无当前 uniacid", 返回 false — 让上游决定怎么做)
|
||||
* @returns {boolean} true = URL 原生 uniacid 与当前不一致(脏数据, 该强制走线上)
|
||||
*/
|
||||
export function hasStaleUniacidInUrl(wap_url, currentUniacid) {
|
||||
if (!wap_url || typeof wap_url !== 'string') return false;
|
||||
if (currentUniacid == null || currentUniacid === '' || currentUniacid === 0) return false;
|
||||
const m = wap_url.match(/[?&]uniacid=([^&#]*)/i);
|
||||
if (!m) return false;
|
||||
let urlUniacid;
|
||||
try { urlUniacid = decodeURIComponent(m[1]); } catch (e) { urlUniacid = m[1]; }
|
||||
const cur = String(currentUniacid);
|
||||
const a = parseInt(urlUniacid, 10);
|
||||
const b = parseInt(cur, 10);
|
||||
// 两侧都能 parse 成数字时按数字比; 否则按字符串比
|
||||
if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b;
|
||||
return urlUniacid !== cur;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// appendQueryParams
|
||||
// =============================================================
|
||||
@@ -592,29 +664,30 @@ export function shouldEncodeUrl() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 跨平台 safeEncode(decode roundtrip + encode 一次)
|
||||
* 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样
|
||||
*
|
||||
* 4 平台编码行为:
|
||||
* | 平台 | 宿主 | 内部自动 encode? | JS 侧需要 encode? |
|
||||
* |-----------|---------------|------------------|------------------|
|
||||
* | h5 | 浏览器 | 否 | 必须 |
|
||||
* | mp-weixin | 微信原生 | 否 | 必须(否则微信把 & 当分隔符丢参数) |
|
||||
* | mp-harmony| 鸿蒙原生 | 否 | 必须 |
|
||||
* | quickapp | 华为快应用 Webview (本质 H5) | 否 | 必须 |
|
||||
* 解决问题:
|
||||
* - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值
|
||||
* 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏),
|
||||
* 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。
|
||||
* - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。
|
||||
* 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。
|
||||
* - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。
|
||||
*
|
||||
* 关键 bug 修复(2026-07-14):
|
||||
* 微信小程序下, src 内嵌 URL 含 `&` `?` `=` 空格 反引号等特殊字符时,
|
||||
* 若 JS 不主动 encode, 微信底层路由会把这些字符当成 top-level query 分隔符,
|
||||
* 导致后续 key(id/uniacid/page_params) 全部丢失。
|
||||
*
|
||||
* @param {*} v
|
||||
* @param {string} s 已 decode roundtrip 后的原文
|
||||
* @returns {string}
|
||||
*/
|
||||
function _encodeUrlDelimiters(s) {
|
||||
// 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样
|
||||
return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c));
|
||||
}
|
||||
|
||||
export function _safeEncodeForPlatform(v) {
|
||||
const s = _toParamValueString(v);
|
||||
|
||||
// decode roundtrip: 兼容上游已 encode 过的入参
|
||||
// decode roundtrip: 兼容上游已 encode 过的入参(无论平台都做,避免双编码)
|
||||
let once = s;
|
||||
try {
|
||||
if (/%[0-9a-fA-F]{2}/.test(s)) {
|
||||
@@ -624,7 +697,22 @@ export function _safeEncodeForPlatform(v) {
|
||||
once = s;
|
||||
}
|
||||
|
||||
// 4 平台都主动 encode 一次
|
||||
const platform = getPlatform();
|
||||
|
||||
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
|
||||
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断)
|
||||
if (platform === 'mp-weixin') {
|
||||
return _encodeUrlDelimiters(once);
|
||||
}
|
||||
|
||||
// 鸿蒙元服务: 整体 encode url(包括中文和已编码的 %xx)。
|
||||
// JS 侧完全不 encode,直接返回 decode roundtrip 后的原文,让鸿蒙原生做唯一一次 encode。
|
||||
if (platform === 'mp-harmony') {
|
||||
return once;
|
||||
}
|
||||
|
||||
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
|
||||
return encodeURIComponent(once);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
appendQueryParams as _appendQueryParamsBase,
|
||||
extractInternalAppPath as _extractInternalAppPath,
|
||||
resolveNavMode as _resolveNavMode,
|
||||
hasStaleUniacidInUrl as _hasStaleUniacidInUrl,
|
||||
pickCurrentUniacid as _pickCurrentUniacid,
|
||||
_shouldEncodeUrl,
|
||||
_safeEncodeForPlatform,
|
||||
_navigateTo,
|
||||
@@ -44,6 +46,36 @@ import {
|
||||
|
||||
export { decodeEntity, cleanParamUrls, _toParamValueString, adaptSubpackageUrl };
|
||||
|
||||
/**
|
||||
* 获取系统当前的 uniacid(运行时单点真值)。
|
||||
*
|
||||
* 必须【与 common/js/http.js 发送请求时使用的 uniacid 完全一致】(行 168):
|
||||
* data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid
|
||||
* 否则 uniacid 守卫(脏数据判断)和实际网络请求会基于不同的真值,
|
||||
* 产生不一致行为。
|
||||
*
|
||||
* 真值源:
|
||||
* 1) uni.getStorageSync('uniacid') — 持久化, 由登录 / 初始化写入
|
||||
* 2) Config.uniacid — 编译期兜底(从 .local.config.js / vue.config.js 注入)
|
||||
*
|
||||
* 0 视为"无 uniacid": hasStaleUniacidInUrl 收到 0 时直接返回 false (不强行判断)。
|
||||
*
|
||||
* @returns {number} 当前 uniacid; 无法获取时返回 0
|
||||
*/
|
||||
function _getCurrentUniacid() {
|
||||
let storageVal;
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && uni && typeof uni.getStorageSync === 'function') {
|
||||
storageVal = uni.getStorageSync('uniacid');
|
||||
}
|
||||
} catch (e) {}
|
||||
let configVal;
|
||||
try {
|
||||
if (Config && Config.uniacid) configVal = Config.uniacid;
|
||||
} catch (e) {}
|
||||
return _pickCurrentUniacid(storageVal, configVal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
|
||||
* 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。
|
||||
@@ -507,12 +539,17 @@ export default {
|
||||
}
|
||||
|
||||
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
|
||||
// - 小程序/App: uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
||||
// 若在 JS 侧再 encodeURIComponent 一次,会被平台再编码一层,目标页 onLoad(options)
|
||||
// 只 decode 一次后仍是编码形态(如 %25E6%25B2%2599%25E5%258F%2591 -> %E6%B2%99%E5%8F%91)
|
||||
// => 这里必须保持原始值,完全交给平台去编码
|
||||
// - H5: 浏览器不会自动编码 url,若参数含 & ? = 等特殊字符会被 queryString 错位解析
|
||||
// => 这里仍需手动 encodeURIComponent,decode->encode roundtrip 兼容调用方已编码的入参
|
||||
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
||||
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
|
||||
// 是已编码字符串(典型表现:商品分类 → 商品列表,
|
||||
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
|
||||
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
|
||||
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
|
||||
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
|
||||
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
|
||||
// 成 top-level 分隔符丢参数)
|
||||
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
|
||||
// 做 decode roundtrip + encode 一次)
|
||||
const queryStr = Object.keys(merged)
|
||||
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
|
||||
.join('&');
|
||||
@@ -988,14 +1025,14 @@ export default {
|
||||
*/
|
||||
isIPhoneX() {
|
||||
let res = this.getDeviceInfo();
|
||||
if (res.model.search('iPhone X') != -1) {
|
||||
if (res?.model?.search('iPhone X') != -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
//判断安卓还是iOS
|
||||
isAndroid() {
|
||||
let platform = this.getDeviceInfo().platform;
|
||||
let platform = this.getDeviceInfo()?.platform;
|
||||
if (platform == 'ios') {
|
||||
return false;
|
||||
} else if (platform == 'android') {
|
||||
@@ -1006,21 +1043,21 @@ export default {
|
||||
* 获取设备信息(包含降级处理)
|
||||
*/
|
||||
getDeviceInfo() {
|
||||
try {
|
||||
return uni.getDeviceInfo();
|
||||
} catch (e) {
|
||||
return uni.getSystemInfoSync();
|
||||
let info = {};
|
||||
try { info = uni.getDeviceInfo(); } catch (e) {
|
||||
try { info = uni.getSystemInfoSync(); } catch (e) {}
|
||||
}
|
||||
return info;
|
||||
},
|
||||
/**
|
||||
* 获取窗口信息(包含降级处理)
|
||||
*/
|
||||
getWindowInfo() {
|
||||
try {
|
||||
return uni.getWindowInfo();
|
||||
} catch (e) {
|
||||
return uni.getSystemInfoSync();
|
||||
let info;
|
||||
try {info = uni.getWindowInfo();} catch (e) {
|
||||
try {info = uni.getSystemInfoSync();} catch (e) {}
|
||||
}
|
||||
return info;
|
||||
},
|
||||
/**
|
||||
* 深度拷贝对象
|
||||
@@ -1088,6 +1125,19 @@ export default {
|
||||
link.wap_url = decodeEntity(link.wap_url);
|
||||
}
|
||||
|
||||
// 修复: wap_url 自带的 uniacid query 必须与系统当前 uniacid 一致
|
||||
// 原因: uniacid 是本平台租户 ID,会随租户迁移 / 平台调整而变化。
|
||||
// 装修数据里的 wap_url?uniacid=2482 一旦与系统当前 uniacid(比如 2483)不一致,
|
||||
// 说明这份数据可能是历史遗留或别的租户的(脏数据),
|
||||
// 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
|
||||
// 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
|
||||
// ★ 必须在 resolveLinkUrl 之前判断 —— 否则 URL 的 query 被抽走就分不清了。
|
||||
// ★ open_mode=internal 时不覆盖 —— 用户显式强制内部, 表示"我知道我在做什么"。
|
||||
if (_hasStaleUniacidInUrl(link.wap_url, _getCurrentUniacid())
|
||||
&& _resolveNavMode(link) !== 'internal') {
|
||||
link.open_mode = 'online';
|
||||
}
|
||||
|
||||
// 优先用 page_key 解析实际URL(新机制,向后兼容)
|
||||
const resolvedUrl = resolveLinkUrl(link);
|
||||
if (link.page_key && resolvedUrl) {
|
||||
@@ -1118,10 +1168,10 @@ export default {
|
||||
// auto: host 完全一致才还原
|
||||
const appHosts = [];
|
||||
// #ifdef H5
|
||||
try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) {}
|
||||
try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) { }
|
||||
// #endif
|
||||
try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) {}
|
||||
try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) {}
|
||||
try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) { }
|
||||
try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) { }
|
||||
internalPath = _extractInternalAppPath(link.wap_url, appHosts);
|
||||
}
|
||||
if (internalPath) {
|
||||
@@ -1312,7 +1362,7 @@ export default {
|
||||
*/
|
||||
uniappIsIPhone11() {
|
||||
let isIphone11 = false;
|
||||
let systemInfo = this.getDeviceInfo();
|
||||
let systemInfo = this?.getDeviceInfo();
|
||||
// #ifdef MP
|
||||
if (systemInfo.model.search('iPhone 11') != -1) {
|
||||
isIphone11 = true;
|
||||
@@ -1323,8 +1373,7 @@ export default {
|
||||
// #ifdef H5
|
||||
//判断该浏览器是否为safaria浏览器
|
||||
isSafari() {
|
||||
let res = this.getDeviceInfo();
|
||||
var ua = navigator.userAgent.toLowerCase();
|
||||
var ua = navigator?.userAgent?.toLowerCase();
|
||||
if (ua.indexOf('applewebkit') > -1 && ua.indexOf('mobile') > -1 && ua.indexOf('safari') > -1 &&
|
||||
ua.indexOf('linux') === -1 && ua.indexOf('android') === -1 && ua.indexOf('chrome') === -1 &&
|
||||
ua.indexOf('ios') === -1 && ua.indexOf('browser') === -1) {
|
||||
@@ -1883,4 +1932,67 @@ export default {
|
||||
return newObj;
|
||||
},
|
||||
|
||||
/**
|
||||
* rpx 单位按端适配转换(uni-app 标准约定)
|
||||
*
|
||||
* 后端 formConfig / diyview 等接口返回的数值字段(label_font_size / input_height /
|
||||
* row_min_height 等)一律以 rpx 传递。前端处理:
|
||||
* - 微信小程序 / App / 鸿蒙元服务:原生支持 rpx,直接返回 `${val}rpx`
|
||||
* - H5(WebView):浏览器不认 rpx,通过 uni.upx2px 转 px 返回 `${uni.upx2px(val)}px`
|
||||
*
|
||||
* 主要用于 JS 动态生成的 CSS 值(computed 里的 inline style、CSS 变量赋值)。
|
||||
* Vue 的 :style binding 对普通 CSS 属性会自动转 rpx→px,但对 CSS 自定义属性
|
||||
* (以 -- 开头的变量)不会做单位转换,因此必须经此方法处理。
|
||||
*
|
||||
* @param {number|string} val 后端返回的 rpx 数值
|
||||
* 可以是数字(如 60)、字符串数字('60')、带 px 后缀的字符串('30px',原样返回)
|
||||
* 或带 rpx 后缀的字符串('60rpx',先剥后缀再处理)
|
||||
* @returns {string} 带单位的 CSS 值(H5 返回 'Npx',其它端返回 'Nrpx');
|
||||
* val 为空/undefined 时返回 ''
|
||||
*/
|
||||
rpx(val) {
|
||||
if (val === undefined || val === null || val === '') return '';
|
||||
// 已带 px 后缀的,原样返回
|
||||
if (typeof val === 'string' && /px$/i.test(val.trim())) return val.trim();
|
||||
// 已带 rpx 后缀的,剥掉(避免 '60rpx' + 'rpx' → '60rpxrpx' 的双重后缀 bug)
|
||||
if (typeof val === 'string' && /rpx$/i.test(val.trim())) {
|
||||
val = parseFloat(val);
|
||||
}
|
||||
// #ifdef H5
|
||||
if (typeof uni !== 'undefined' && typeof uni.upx2px === 'function') {
|
||||
return uni.upx2px(Number(val) || 0) + 'px';
|
||||
}
|
||||
return (Number(val) || 0) / 2 + 'px';
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return val + 'rpx';
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算最长 label 宽度(rpx 单位)
|
||||
*
|
||||
* 与后端 backend/src/app/component/view/form/js/design.js calculateMaxLabelWidth
|
||||
* 算法对齐:取所有 title 中最长字符数(必填 +1),按 fontSize(rpx)近似换算宽度。
|
||||
* 后端在 H5 中按 1rpx = 0.5px 计算宽度,再加 12 边距。
|
||||
*
|
||||
* @param {Array} formData 表单数据数组(item.value.title 存在即参与计算)
|
||||
* @param {number|string} labelFontSize 标签字号(rpx)
|
||||
* @returns {number} 最长 label 宽度,单位 rpx
|
||||
*/
|
||||
calculateMaxLabelWidth(formData, labelFontSize) {
|
||||
if (!formData || formData.length === 0) return 0;
|
||||
const fontSize = parseInt(labelFontSize) || 28;
|
||||
let maxChars = 0;
|
||||
formData.forEach(item => {
|
||||
if (!item.value || !item.value.title) return;
|
||||
let len = (item.value.title || '').length;
|
||||
if (item.value.required) len += 1;
|
||||
if (len > maxChars) maxChars = len;
|
||||
});
|
||||
// chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2)
|
||||
const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
|
||||
return Math.round(widthPx * 2);
|
||||
},
|
||||
|
||||
}
|
||||
@@ -1,36 +1,59 @@
|
||||
<template>
|
||||
<view class="form-wrap form-component">
|
||||
<view class="form-wrap form-component" :style="formWrapStyle">
|
||||
<block v-for="(item, index) in formData">
|
||||
<view class="order-wrap" v-if="item.controller == 'Text'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<input type="text" :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val" />
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Textarea'">
|
||||
<view class="order-cell flex-box textarea">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
||||
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<textarea :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val"></textarea>
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
|
||||
<textarea
|
||||
:rows="item.value.rows || 2"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-HARMONY -->
|
||||
<textarea
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<picker mode="selector" :range="item.value.options" v-if="item.controller == 'Select'" @change="pickerChange($event, index)">
|
||||
<view class="order-wrap">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
@@ -41,18 +64,18 @@
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Checkbox'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box check-group-box">
|
||||
<checkbox-group @change="checkboxChange($event, index)">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<checkbox :value="v.value" :checked="v.checked" />
|
||||
<view class="checkbox">
|
||||
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }"></text>
|
||||
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }" :style="{ color: v.checked ? checkedColor : '' }"></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -61,18 +84,18 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Radio'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box radio-group-box">
|
||||
<radio-group @change="radioChange($event, index)">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<radio :value="v.value" :checked="item.val == v.value" />
|
||||
<view class="radio-box">
|
||||
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }"></text>
|
||||
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }" :style="{ color: item.val == v.value ? checkedColor : '' }"></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -81,11 +104,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Img'">
|
||||
<view class="order-cell flex-box">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box img-boxs">
|
||||
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
||||
@@ -97,11 +120,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Date'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
@@ -112,11 +135,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Datelimit'">
|
||||
<view class="order-cell flex-box">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -138,11 +161,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Time'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
@@ -153,11 +176,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Timelimit'">
|
||||
<view class="order-cell flex-box">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -179,11 +202,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
@@ -200,37 +223,224 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-form',
|
||||
mixins: [formPlatformMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
default: {}
|
||||
default: () => []
|
||||
},
|
||||
customAttr: {
|
||||
type: Object,
|
||||
default: function() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 表单样式配置(由后端 designer 通过 API 传入)。
|
||||
* 字段名同时支持 camelCase(如 formPaddingX / inputHeight / labelColor)
|
||||
* 与 snake_case(如 form_padding_x / input_height / label_color),camelCase 优先。
|
||||
* 不传则使用组件内置默认(与重构前硬编码值一致,无视觉回归)。
|
||||
*
|
||||
* 支持的键:
|
||||
* formPaddingX, formBgColor, rowPaddingY, rowMinHeight, labelGap,
|
||||
* inputHeight, inputRadius, labelWidthMode ('auto'|'fixed'), labelFixedWidth,
|
||||
* labelFontSize, labelColor, requiredColor, checkedColor,
|
||||
* placeholderColor, showRowDivider, rowDividerColor
|
||||
*
|
||||
* 平台差异化:formConfig.terminal.{h5|weapp|harmony|app|quickapp} 只放差异字段
|
||||
*/
|
||||
formConfig: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formData: this.data
|
||||
formData: [],
|
||||
maxLabelWidth: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 合并配置:内置默认 < 全局 formConfig < 当前平台 formConfig.terminal[platform]
|
||||
// 同时兼容 snake_case 与 camelCase 字段名
|
||||
config() {
|
||||
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
||||
const defaults = {
|
||||
formPaddingX: 0,
|
||||
formBgColor: '#ffffff',
|
||||
rowPaddingY: 20,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 16,
|
||||
inputHeight: 60,
|
||||
inputRadius: 0,
|
||||
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelColor: '#1f2937',
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#909399',
|
||||
placeholderFontSize: 28,
|
||||
showRowDivider: true,
|
||||
rowDividerColor: '#f0f0f0'
|
||||
};
|
||||
const fc = this.formConfig || {};
|
||||
|
||||
// 同时读 camelCase 与 snake_case,camelCase 优先
|
||||
const pick = (camel, snake) => {
|
||||
const v1 = fc[camel];
|
||||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||||
return fc[snake];
|
||||
};
|
||||
const base = {
|
||||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||||
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
|
||||
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
||||
labelGap: pick('labelGap', 'label_gap'),
|
||||
inputHeight: pick('inputHeight', 'input_height'),
|
||||
inputRadius: pick('inputRadius', 'input_radius'),
|
||||
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
|
||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||
labelColor: pick('labelColor', 'label_color'),
|
||||
requiredColor: pick('requiredColor', 'required_color'),
|
||||
checkedColor: pick('checkedColor', 'checked_color'),
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
showRowDivider: pick('showRowDivider', 'show_row_divider'),
|
||||
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
|
||||
};
|
||||
// 移除 undefined / 空字符串(保留 0 / false)
|
||||
Object.keys(base).forEach(k => {
|
||||
if (base[k] === undefined || base[k] === null || base[k] === '') delete base[k];
|
||||
});
|
||||
// showRowDivider 在前后端可能传数字 0/1,统一转 boolean
|
||||
if ('showRowDivider' in base) {
|
||||
base.showRowDivider = base.showRowDivider === true || base.showRowDivider === 1 || base.showRowDivider === '1';
|
||||
}
|
||||
|
||||
// 2) 当前平台差异化配置(最高优先级),从 formConfig.terminal 取出当前平台 + 所有别名键,合并
|
||||
const termBlock = fc.terminal || {};
|
||||
const platformKeys = this.platformConfigKeys || [];
|
||||
const platformCfg = Object.assign(
|
||||
{},
|
||||
...platformKeys.map(k => termBlock[k] || fc[k] || {})
|
||||
);
|
||||
// 平台块可能也是 snake_case,统一转 camelCase
|
||||
const platformCfgCamel = {};
|
||||
if (this.$util && typeof this.$util.snakeToCamel === 'function') {
|
||||
Object.keys(platformCfg).forEach(k => {
|
||||
platformCfgCamel[this.$util.snakeToCamel(k)] = platformCfg[k];
|
||||
});
|
||||
} else {
|
||||
Object.assign(platformCfgCamel, platformCfg);
|
||||
}
|
||||
|
||||
// 3) 三层合并:defaults < base < platformCfgCamel
|
||||
return Object.assign({}, defaults, base, platformCfgCamel);
|
||||
},
|
||||
// 容器样式:内边距 + 背景 + 16 个 CSS 自定义属性
|
||||
formWrapStyle() {
|
||||
const cfg = this.config;
|
||||
const num = (v, fb) => (typeof v === 'number' ? v : parseInt(v) || fb);
|
||||
// 后端 formConfig 一律以 rpx 传递;
|
||||
// _rpx() 按端分支:H5 输出 'Npx'(uni.upx2px),其它端输出 'Nrpx'
|
||||
return this.$util.objToStyle({
|
||||
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
|
||||
background: cfg.formBgColor,
|
||||
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
|
||||
'--form-bg-color': cfg.formBgColor,
|
||||
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
|
||||
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
|
||||
'--label-gap': this.$util.rpx(cfg.labelGap),
|
||||
'--input-height': this.$util.rpx(cfg.inputHeight),
|
||||
'--input-radius': this.$util.rpx(cfg.inputRadius),
|
||||
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
||||
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
||||
'--label-color': cfg.labelColor,
|
||||
'--required-color': cfg.requiredColor,
|
||||
'--checked-color': cfg.checkedColor,
|
||||
'--placeholder-color': cfg.placeholderColor,
|
||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||
'--row-divider-color': cfg.rowDividerColor,
|
||||
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
||||
});
|
||||
},
|
||||
rowWrapStyle() {
|
||||
return this.$util.objToStyle({
|
||||
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
|
||||
});
|
||||
},
|
||||
rowWrapClass() {
|
||||
return this.config.showRowDivider === false ? 'order-wrap no-divider' : 'order-wrap';
|
||||
},
|
||||
rowCellStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
|
||||
nameWidthStyle() {
|
||||
const cfg = this.config;
|
||||
if (cfg.labelWidthMode === 'fixed') {
|
||||
return { width: this.$util.rpx(cfg.labelFixedWidth) };
|
||||
}
|
||||
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
|
||||
},
|
||||
// 输入框样式(input / textarea 共用)
|
||||
inputStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': this.$util.rpx(this.config.inputHeight),
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius),
|
||||
'font-size': this.$util.rpx(this.config.labelFontSize),
|
||||
'color': this.config.labelColor,
|
||||
'line-height': this.$util.rpx(this.config.inputHeight)
|
||||
});
|
||||
},
|
||||
// 必填星号颜色
|
||||
requiredColor() {
|
||||
return this.config.requiredColor;
|
||||
},
|
||||
// 复选 / 单选 选中色
|
||||
checkedColor() {
|
||||
return this.config.checkedColor;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.setFormData();
|
||||
this.initFormData();
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
},
|
||||
watch: {
|
||||
data: function() {
|
||||
this.setFormData();
|
||||
data: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.initFormData();
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
}
|
||||
},
|
||||
formConfig: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initFormData() {
|
||||
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
|
||||
this.formData = JSON.parse(JSON.stringify(this.data || []));
|
||||
this.processFormData();
|
||||
},
|
||||
setFormData() {
|
||||
this.formData = this.data;
|
||||
|
||||
this.initFormData();
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
},
|
||||
processFormData() {
|
||||
this.formData.forEach(item => {
|
||||
if (!item.val) item.val = item.value.default ? item.value.default : '';
|
||||
|
||||
@@ -356,6 +566,14 @@
|
||||
}
|
||||
});
|
||||
},
|
||||
// 计算最长 label 宽度(封装自 util.calculateMaxLabelWidth)
|
||||
// 用于 labelWidthMode === 'auto' 时把 .name 的 width 撑开,避免长短 label 错位
|
||||
calculateMaxLabelWidth() {
|
||||
this.maxLabelWidth = this.$util.calculateMaxLabelWidth(
|
||||
this.formData,
|
||||
this.config.labelFontSize
|
||||
);
|
||||
},
|
||||
verify() {
|
||||
let verify = true;
|
||||
|
||||
@@ -643,9 +861,36 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
/* CSS 自定义属性:默认值,与组件内 config.defaults 一一对应,
|
||||
formConfig 未传时作为兜底;运行时会被 formWrapStyle 注入的 inline style 覆盖。
|
||||
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
||||
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
||||
.form-wrap {
|
||||
--form-padding-x: 0rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-padding-y: 20rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 16rpx;
|
||||
--input-height: 60rpx;
|
||||
--input-radius: 0rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 28rpx;
|
||||
--label-color: #1f2937;
|
||||
--required-color: #ef4444;
|
||||
--placeholder-font-size: 28rpx;
|
||||
--checked-color: #ff4544;
|
||||
--placeholder-color: #909399;
|
||||
--row-divider-color: #f0f0f0;
|
||||
--show-row-divider: 1;
|
||||
}
|
||||
|
||||
.order-wrap {
|
||||
padding: 20rpx 0;
|
||||
padding: var(--row-padding-y, 20rpx) 0;
|
||||
|
||||
&.no-divider {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -658,9 +903,10 @@
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
min-height: var(--row-min-height, 60rpx);
|
||||
|
||||
&.textarea {
|
||||
align-items: unset;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.clear-flex {
|
||||
@@ -674,7 +920,8 @@
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: solid 1px #eee;
|
||||
// calc + 0/1 控制分割线显隐(formConfig.showRowDivider = false 时整条隐藏)
|
||||
border-bottom: calc(var(--show-row-divider, 1) * 2rpx) solid var(--row-divider-color, #f0f0f0);
|
||||
}
|
||||
|
||||
&.align-top {
|
||||
@@ -682,28 +929,30 @@
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 80vw;
|
||||
width: var(--label-fixed-width, 180rpx);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: var(--label-gap, 16rpx);
|
||||
}
|
||||
|
||||
.tit {
|
||||
text-align: left;
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
font-size: 28rpx;
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
margin-left: 4rpx;
|
||||
width: 14rpx;
|
||||
text-align: left;
|
||||
@@ -717,16 +966,65 @@
|
||||
text-align: left;
|
||||
|
||||
input {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
text-align: left;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||
// 避免 placeholder / 文字下半部分被裁切
|
||||
line-height: var(--input-height, 60rpx);
|
||||
height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||
.placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
width: 100%;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
|
||||
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
|
||||
min-height: 88rpx;
|
||||
height: 88rpx;
|
||||
line-height: 44rpx;
|
||||
text-align: left;
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.uni-input {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
}
|
||||
|
||||
.select-address {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
}
|
||||
|
||||
.color-tip {
|
||||
color: var(--placeholder-color, #909399);
|
||||
}
|
||||
|
||||
checkbox-group {
|
||||
@@ -847,7 +1145,7 @@
|
||||
|
||||
.iconfont {
|
||||
color: $color-tip;
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
|
||||
.box-flex picker {
|
||||
|
||||
@@ -280,8 +280,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-newform',
|
||||
mixins: [formPlatformMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
@@ -349,60 +352,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 当前运行平台标识(与后端 formConfig.terminal 键名对齐:h5 / wechat / weapp / harmony)
|
||||
platform() {
|
||||
// 优先使用后端在 formConfig.terminal 中使用的键名
|
||||
// #ifdef H5
|
||||
return 'h5';
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'weapp';
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
return 'mp-alipay';
|
||||
// #endif
|
||||
// #ifdef MP-BAIDU
|
||||
return 'mp-baidu';
|
||||
// #endif
|
||||
// #ifdef MP-TOUTIAO
|
||||
return 'mp-toutiao';
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
return 'app';
|
||||
// #endif
|
||||
// #ifdef MP-HARMONY
|
||||
return 'harmony';
|
||||
// #endif
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
return 'quickapp';
|
||||
// #endif
|
||||
return 'h5';
|
||||
},
|
||||
// 平台配置兼容层:后端键名(h5/wechat/weapp/harmony)→ 内部 platform 名
|
||||
platformAliases() {
|
||||
return {
|
||||
'h5': 'h5',
|
||||
'weapp': 'weapp',
|
||||
'mp-weixin': 'weapp',
|
||||
'wechat': 'wechat',
|
||||
'harmony': 'harmony',
|
||||
'app': 'app',
|
||||
'app-plus': 'app'
|
||||
};
|
||||
},
|
||||
// 当前平台的有效配置键集合(用于 config() 中查找 platformCfg)
|
||||
platformConfigKeys() {
|
||||
const cur = this.platform;
|
||||
const aliases = this.platformAliases;
|
||||
// 找出所有映射到当前平台的后端键名
|
||||
const keys = [];
|
||||
Object.keys(aliases).forEach(k => {
|
||||
if (aliases[k] === cur) keys.push(k);
|
||||
});
|
||||
// 加入当前平台名(兼容 formConfig[platform] 这种写法)
|
||||
if (keys.indexOf(cur) === -1) keys.push(cur);
|
||||
return keys;
|
||||
},
|
||||
// 合并配置:内置默认 < 全局 < 当前平台
|
||||
config() {
|
||||
// 内置默认值(最低优先级)- 与后端 D:\projects\shop-projects\backend\src\app\component\view\form\js\design.js getDefaultFormData().formConfig 对齐
|
||||
@@ -424,6 +373,8 @@ export default {
|
||||
labelColor: '#1f2937',
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#ddd',
|
||||
placeholderFontSize: 28,
|
||||
// 提交按钮
|
||||
submitColor: '#ffffff',
|
||||
submitBgColor: '#ff4544',
|
||||
@@ -455,9 +406,9 @@ export default {
|
||||
}
|
||||
let width;
|
||||
if (this.config.labelWidthMode === 'fixed') {
|
||||
width = `${this.config.labelFixedWidth}rpx`;
|
||||
width = this.$util.rpx(this.config.labelFixedWidth);
|
||||
} else {
|
||||
width = this.maxLabelWidth ? `${this.maxLabelWidth}rpx` : 'auto';
|
||||
width = this.maxLabelWidth ? this.$util.rpx(this.maxLabelWidth) : 'auto';
|
||||
}
|
||||
return {
|
||||
width: width
|
||||
@@ -470,30 +421,32 @@ export default {
|
||||
|
||||
return this.$util.objToStyle({
|
||||
// 仅保留左右内边距 = form-padding-x;上下由每行 row-padding-y 承担
|
||||
padding: `0 ${cfg.formPaddingX}rpx`,
|
||||
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
|
||||
background: cfg.formBgColor,
|
||||
// 通过 CSS 变量传递:与后端 design.css 的 :root 变量一一对应
|
||||
'--form-padding-x': `${cfg.formPaddingX}rpx`,
|
||||
'--banner-padding-x': `${cfg.bannerPaddingX}rpx`,
|
||||
'--tip-padding-x': `${cfg.tipPaddingX}rpx`,
|
||||
'--tip-font-size': `${cfg.tipFontSize}rpx`,
|
||||
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
|
||||
'--banner-padding-x': this.$util.rpx(cfg.bannerPaddingX),
|
||||
'--tip-padding-x': this.$util.rpx(cfg.tipPaddingX),
|
||||
'--tip-font-size': this.$util.rpx(cfg.tipFontSize),
|
||||
'--tip-color': cfg.tipColor,
|
||||
'--tip-bg-color': cfg.tipBgColor,
|
||||
'--row-padding-y': `${cfg.rowPaddingY}rpx`,
|
||||
'--row-min-height': `${cfg.rowMinHeight}rpx`,
|
||||
'--label-gap': `${cfg.labelGap}rpx`,
|
||||
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
|
||||
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
|
||||
'--label-gap': this.$util.rpx(cfg.labelGap),
|
||||
'--form-bg-color': cfg.formBgColor,
|
||||
'--row-divider-color': cfg.rowDividerColor,
|
||||
'--input-height': `${cfg.inputHeight}rpx`,
|
||||
'--input-radius': `${cfg.inputRadius}rpx`,
|
||||
'--label-fixed-width': `${cfg.labelFixedWidth}rpx`,
|
||||
'--label-font-size': `${num(cfg.labelFontSize, 28)}rpx`,
|
||||
'--input-height': this.$util.rpx(cfg.inputHeight),
|
||||
'--input-radius': this.$util.rpx(cfg.inputRadius),
|
||||
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
||||
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
||||
'--label-color': cfg.labelColor,
|
||||
'--required-color': cfg.requiredColor,
|
||||
'--checked-color': cfg.checkedColor,
|
||||
'--placeholder-color': cfg.placeholderColor,
|
||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||
'--submit-color': cfg.submitColor,
|
||||
'--submit-bg-color': cfg.submitBgColor,
|
||||
'--submit-margin-top': `${cfg.submitMarginTop || 0}rpx`,
|
||||
'--submit-margin-top': this.$util.rpx(cfg.submitMarginTop || 0),
|
||||
// 分割线显隐
|
||||
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
||||
});
|
||||
@@ -501,7 +454,7 @@ export default {
|
||||
// 行间距样式:仅上下内边距,水平由外层 form-wrap 控制(与后端 form-gorup 对齐)
|
||||
rowWrapStyle() {
|
||||
return this.$util.objToStyle({
|
||||
padding: `${this.config.rowPaddingY}rpx 0`
|
||||
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
|
||||
});
|
||||
},
|
||||
// 行容器 class:用于控制分割线显隐
|
||||
@@ -511,7 +464,7 @@ export default {
|
||||
// 行最小高度
|
||||
rowCellStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': `${this.config.rowMinHeight}rpx`
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签与输入框间距
|
||||
@@ -520,26 +473,21 @@ export default {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'margin-right': `${this.config.labelGap}rpx`
|
||||
'margin-right': this.$util.rpx(this.config.labelGap)
|
||||
};
|
||||
},
|
||||
// 输入框样式(全局,未指定 item 时使用)
|
||||
inputStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': `${this.config.inputHeight}rpx`,
|
||||
'border-radius': `${this.config.inputRadius}rpx`
|
||||
'min-height': this.$util.rpx(this.config.inputHeight),
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius)
|
||||
});
|
||||
},
|
||||
labelStyle() {
|
||||
let style = {};
|
||||
let fontSize = this.config.labelFontSize;
|
||||
if (typeof fontSize === 'number') {
|
||||
fontSize = `${fontSize}rpx`;
|
||||
} else if (typeof fontSize === 'string' && !fontSize.endsWith('rpx') && !fontSize.endsWith('px')) {
|
||||
fontSize = `${fontSize}rpx`;
|
||||
}
|
||||
style.fontSize = fontSize;
|
||||
style.color = this.config.labelColor;
|
||||
const style = {
|
||||
fontSize: this.$util.rpx(this.config.labelFontSize),
|
||||
color: this.config.labelColor
|
||||
};
|
||||
return this.$util.objToStyle(style);
|
||||
},
|
||||
requiredColor() {
|
||||
@@ -559,21 +507,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 处理 rpx 字号:与后端 label_font_size 字段语义对齐
|
||||
_resolveFontSize(value, fallback) {
|
||||
let v = value;
|
||||
if (v === '' || v === null || v === undefined) {
|
||||
v = fallback;
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return `${v}rpx`;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
if (v.endsWith('rpx') || v.endsWith('px')) return v;
|
||||
return `${v}rpx`;
|
||||
}
|
||||
return v;
|
||||
},
|
||||
// 预计算每项的样式(写到 item._labelStyle / item._boxAlignStyle),
|
||||
// 避免在模板里以方法形式调用:微信小程序 WXML 不支持 :style="getXxx(item)" 语法。
|
||||
// 触发时机:processFormData 末尾 / calculateMaxLabelWidth 末尾 / config 变化 / mode 变化。
|
||||
@@ -589,10 +522,7 @@ export default {
|
||||
|
||||
// item 级别 label 字号 / 颜色覆盖(与原 getItemLabelStyle 逻辑一致)
|
||||
if (itemValue.label_font_size !== undefined && itemValue.label_font_size !== null && itemValue.label_font_size !== '') {
|
||||
let fs = itemValue.label_font_size;
|
||||
if (typeof fs === 'number') fs = `${fs}rpx`;
|
||||
else if (typeof fs === 'string' && !fs.endsWith('rpx') && !fs.endsWith('px')) fs = `${fs}rpx`;
|
||||
style['--item-label-font-size'] = fs;
|
||||
style['--item-label-font-size'] = this.$util.rpx(itemValue.label_font_size);
|
||||
}
|
||||
if (itemValue.label_color) {
|
||||
style['--item-label-color'] = itemValue.label_color;
|
||||
@@ -1028,6 +958,7 @@ export default {
|
||||
--submit-color: #ffffff;
|
||||
--submit-bg-color: #ff4544;
|
||||
--placeholder-color: #ddd;
|
||||
--placeholder-font-size: 28rpx;
|
||||
--show-row-divider: 1;
|
||||
|
||||
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
|
||||
@@ -1223,7 +1154,7 @@ export default {
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,7 +1179,7 @@ export default {
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,12 +128,12 @@ export default {
|
||||
},
|
||||
// diy-group 渲染完毕回调, 用于触发版本水印等延后显示元素
|
||||
// 同时通知 splash-state 页面就绪, 启动屏可以关闭
|
||||
// 延迟 500ms 是为了确保页面内容有足够时间渲染
|
||||
// 注意: diy-group 内部 _notifyLoaded 已有 300ms 渲染 settle,
|
||||
// 这里不再叠加 500ms, 避免吃掉 splash 的 2s page-ready 兜底预算
|
||||
// (叠加延迟会导致弱网/冷启动下 splash 走 page-ready-timeout 兜底路径)
|
||||
onDiyGroupLoaded() {
|
||||
this.diyGroupReady = true;
|
||||
setTimeout(() => {
|
||||
try { splashState.notifyPageReady(); } catch (e) {}
|
||||
}, 500);
|
||||
},
|
||||
/**
|
||||
* 版本连击 3 次以上显示构建版本信息(供测试人员使用)
|
||||
|
||||
@@ -180,7 +180,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -160,7 +160,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -153,7 +153,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -203,7 +203,13 @@ page {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -100,7 +100,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -261,7 +261,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -432,7 +432,13 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
481
tests/search-keyword-encoding.test.js
Normal file
481
tests/search-keyword-encoding.test.js
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 搜索关键词跨端编码测试
|
||||
*
|
||||
* 回归 Bug(2026-07-17):
|
||||
* 商品分类页(/pages_goods/category)输入搜索关键词 → 点击搜索 → 跳到
|
||||
* 商品列表页(/pages_goods/list),列表页搜索栏显示的关键词变成
|
||||
* %E6%B2%99%E5%8F%91(对应"沙发")乱码。
|
||||
*
|
||||
* 根因: 微信小程序 / 鸿蒙元服务(mp-weixin / mp-harmony)下,
|
||||
* uni.navigateTo / router.pushUrl 内部会自动对 url 整体做一次
|
||||
* encodeURIComponent。util-navigate.js 的 `_safeEncodeForPlatform` 之前
|
||||
* 在所有平台都主动 encode,导致微信/鸿蒙下 JS encode + 平台原生 encode
|
||||
* = 双重编码。目标页 onLoad(options) 只 decode 一次后,拿到的还是
|
||||
* `%E6%B2%99%E5%8F%91` 这种"已编码字符串",在 <input v-model="keyword">
|
||||
* 上显示成乱码。
|
||||
*
|
||||
* 修复策略:
|
||||
* - 微信小程序 + 鸿蒙元服务(mp-weixin / mp-harmony): `_safeEncodeForPlatform`
|
||||
* 不做 encode,只做 decode roundtrip(防止上游重复 encode 入参)。让平台原生
|
||||
* 去做唯一一次 encode。
|
||||
* - H5 / 华为快应用: 仍然主动 encode 一次(防浏览器 query 错位)。
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/search-keyword-encoding.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。
|
||||
* createNavigateApi({ platform }) 可强制注入平台到 globalThis。
|
||||
*/
|
||||
function loadNavInSandbox() {
|
||||
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, extractInternalAppPath, resolveNavMode, hasStaleUniacidInUrl, pickCurrentUniacid, 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. _safeEncodeForPlatform 平台差异化
|
||||
// =============================================================
|
||||
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
|
||||
// 中文关键词原样返回(鸿蒙原生会再做一次 encode)
|
||||
const r1 = api._safeEncodeForPlatform('沙发');
|
||||
assert.equal(r1, '沙发', `mp-harmony 中文应原样返回,实际: ${r1}`);
|
||||
|
||||
// 含 & 的值原样返回(不能再 encode,否则双重编码)
|
||||
const r2 = api._safeEncodeForPlatform('a&b=c');
|
||||
assert.equal(r2, 'a&b=c', `mp-harmony 特殊字符应原样返回,实际: ${r2}`);
|
||||
|
||||
// 反引号 / 空格原样返回
|
||||
const r3 = api._safeEncodeForPlatform('`hello world`');
|
||||
assert.equal(r3, '`hello world`', `mp-harmony 反引号空格应原样返回,实际: ${r3}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
|
||||
// 上游已 encode 过一次的入参(%E5%8F%91 是"发")
|
||||
const r = api._safeEncodeForPlatform('%E5%8F%91');
|
||||
assert.equal(r, '发', `mp-harmony 已编码入参应 decode 一次,实际: ${r}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: H5 → JS 侧主动 encode', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'h5' });
|
||||
|
||||
const r1 = api._safeEncodeForPlatform('沙发');
|
||||
assert.equal(r1, '%E6%B2%99%E5%8F%91', `h5 中文应 encode,实际: ${r1}`);
|
||||
|
||||
const r2 = api._safeEncodeForPlatform('a&b');
|
||||
assert.equal(r2, 'a%26b', `h5 & 应 encode 成 %26,实际: ${r2}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符 (不 encode 中文)', () => {
|
||||
// 2026-07-17 修正(二次): 微信小程序 split on '&',不 decode value。
|
||||
// JS 只 encode `& = ? # \s`(防 query 边界被破坏),
|
||||
// 中文 / 字母 / 数字 原样保留(防 onLoad 拿到 %xx 乱码)。
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
|
||||
// 中文 原样保留
|
||||
const r1 = api._safeEncodeForPlatform('沙发');
|
||||
assert.equal(r1, '沙发', `mp-weixin 中文应原样,实际: ${r1}`);
|
||||
|
||||
// & encode 成 %26(防 query 边界被破坏)
|
||||
const r2 = api._safeEncodeForPlatform('a&b');
|
||||
assert.equal(r2, 'a%26b', `mp-weixin & 应 encode 成 %26,实际: ${r2}`);
|
||||
|
||||
// = 也 encode
|
||||
const r3 = api._safeEncodeForPlatform('a=b');
|
||||
assert.equal(r3, 'a%3Db', `mp-weixin = 应 encode 成 %3D,实际: ${r3}`);
|
||||
|
||||
// ? 也 encode
|
||||
const r4 = api._safeEncodeForPlatform('a?b');
|
||||
assert.equal(r4, 'a%3Fb', `mp-weixin ? 应 encode 成 %3F,实际: ${r4}`);
|
||||
|
||||
// URL 内的 & 和 = 在 mp-weixin 下会被 encode,但 :// 保留
|
||||
const r5 = api._safeEncodeForPlatform('https://host.com?a=1&b=2');
|
||||
assert.equal(r5, 'https://host.com%3Fa%3D1%26b%3D2', `mp-weixin URL 应 encode 分隔符,实际: ${r5}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 华为快应用 → JS 侧主动 encode', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'quickapp' });
|
||||
|
||||
const r1 = api._safeEncodeForPlatform('沙发');
|
||||
assert.equal(r1, '%E6%B2%99%E5%8F%91', `quickapp 中文应 encode,实际: ${r1}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
|
||||
// 2026-07-17 修正: 微信小程序也属于"不做 JS encode"平台,跟鸿蒙一样
|
||||
const nav = loadNavInSandbox();
|
||||
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
|
||||
// 还原后单次 encode 仍是 %E8%A1%A8
|
||||
for (const p of ['h5', 'quickapp']) {
|
||||
const api = nav.createNavigateApi({ platform: p });
|
||||
const r = api._safeEncodeForPlatform('%E8%A1%A8');
|
||||
assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码,实际: ${r}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 微信小程序下已编码入参 → decode 一次还原', () => {
|
||||
// 上游已经 encode 过一次的入参(防御),防止平台原生 encode 后变成双编码
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
const r = api._safeEncodeForPlatform('%E5%8F%91'); // '发' 已编码
|
||||
assert.equal(r, '发', `mp-weixin 已编码入参应 decode 一次,实际: ${r}`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. 端到端模拟: 搜索关键词 → redirectTo → 模拟 onLoad(options)
|
||||
// 关键: 验证 JS 编码一次 + 平台编码/解码 一次后,onLoad 拿到的 keyword 是原文
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 模拟 redirectTo 的拼装逻辑(同 util.js 中的实现)
|
||||
* - 鸿蒙下用 safeEncodeForPlatform(原文返回)
|
||||
* - 其他平台用 safeEncodeForPlatform(encode 一次)
|
||||
*/
|
||||
function buildRedirectUrl(to, param, encode) {
|
||||
const queryIdx = to.indexOf('?');
|
||||
const path = queryIdx === -1 ? to : to.substring(0, queryIdx);
|
||||
const queryStr = Object.keys(param)
|
||||
.map(k => encode(k) + '=' + encode(param[k]))
|
||||
.join('&');
|
||||
return path + (queryStr ? '?' + queryStr : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟鸿蒙元服务对 url 的处理:
|
||||
* - JS 传入"未编码"的 url,鸿蒙原生会先对整个 query 字符串做一次 encodeURIComponent
|
||||
* (把 = & 这些分隔符也一起编码),然后解析 query 时再 decode 一次
|
||||
* - 最终 onLoad(options) 拿到的 keyword 是原文
|
||||
*
|
||||
* 关键: 鸿蒙原生 encode 整段 query,不会把 = 和 & 当分隔符提前 split
|
||||
* (这跟浏览器/H5 的 queryString 解析方式不同)
|
||||
*/
|
||||
function simulateHarmonyOnLoad(url) {
|
||||
const qIdx = url.indexOf('?');
|
||||
if (qIdx === -1) return { options: {} };
|
||||
const queryStr = url.substring(qIdx + 1);
|
||||
// 鸿蒙原生: 先对整段 query 字符串做 encodeURIComponent(= & 都会变 %xx)
|
||||
const encodedQueryStr = encodeURIComponent(queryStr);
|
||||
// 模拟解析 encoded 字符串: 把 %26 解码回 & 再 split
|
||||
// 关键洞察: encodeURIComponent 后的字符串是安全的(没有裸 = &),所以可以
|
||||
// 直接 decode 然后 split
|
||||
const decodedBack = decodeURIComponent(encodedQueryStr);
|
||||
const options = {};
|
||||
decodedBack.split('&').forEach(kv => {
|
||||
const eqIdx = kv.indexOf('=');
|
||||
if (eqIdx === -1) return;
|
||||
const k = kv.substring(0, eqIdx);
|
||||
const v = kv.substring(eqIdx + 1);
|
||||
options[k] = v;
|
||||
});
|
||||
return { options };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟微信小程序对 url 的处理(2026-07-17 修正):
|
||||
* - JS 只 encode URL 分隔符 (& = ? # \s),中文和字母数字原样保留
|
||||
* - 微信小程序: 不 encode 整体 url, 只 split on '&' / '=',对 value 做 decodeURIComponent
|
||||
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
|
||||
* src 中的 & 已被 JS encode 成 %26,微信分解析后 onLoad 拿到 %26(而非原 &),
|
||||
* 但 webview 只接接收 src 作为 URL 参数,浏览器 fetch 时会 decode %26 为 &,浏览器端解析正常
|
||||
*/
|
||||
function simulateWeixinOnLoad(url) {
|
||||
const qIdx = url.indexOf('?');
|
||||
if (qIdx === -1) return { options: {} };
|
||||
const queryStr = url.substring(qIdx + 1);
|
||||
const options = {};
|
||||
// 微信小程序: 不 auto-encode, 只 split on '&' / '=',对 value decodeURIComponent
|
||||
queryStr.split('&').forEach(kv => {
|
||||
const eqIdx = kv.indexOf('=');
|
||||
if (eqIdx === -1) return;
|
||||
const rawK = kv.substring(0, eqIdx);
|
||||
const rawV = kv.substring(eqIdx + 1);
|
||||
try {
|
||||
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
|
||||
} catch (e) {
|
||||
options[rawK] = rawV;
|
||||
}
|
||||
});
|
||||
return { options };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 H5 浏览器 / 华为快应用 Webview 对 url 的处理:
|
||||
* - JS 已经主动 encode 过整段 query 字符串(关键步骤)
|
||||
* - 浏览器解析 url 时 split on '&' 和 '=',然后对每段 value 做 decodeURIComponent
|
||||
* - 最终 onLoad(options) 拿到的 keyword 是原文
|
||||
*/
|
||||
function simulateBrowserOnLoad(url) {
|
||||
const qIdx = url.indexOf('?');
|
||||
if (qIdx === -1) return { options: {} };
|
||||
const queryStr = url.substring(qIdx + 1);
|
||||
const options = {};
|
||||
queryStr.split('&').forEach(kv => {
|
||||
const eqIdx = kv.indexOf('=');
|
||||
if (eqIdx === -1) return;
|
||||
const rawK = kv.substring(0, eqIdx);
|
||||
const rawV = kv.substring(eqIdx + 1);
|
||||
// 浏览器 split 之后,对 value 做 decodeURIComponent
|
||||
try {
|
||||
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
|
||||
} catch (e) {
|
||||
options[rawK] = rawV;
|
||||
}
|
||||
});
|
||||
return { options };
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 2.1 端到端:鸿蒙元服务(回归 Bug 的主要平台)
|
||||
// =============================================================
|
||||
test('端到端: 鸿蒙元服务 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
// 模拟 /pages_tool/goods/search 中的 search() 方法:
|
||||
// this.$util.redirectTo('/pages_goods/list', { keyword: '沙发' });
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
|
||||
|
||||
// 鸿蒙下 url 不应被双重编码: keyword= 应直接是中文(没 %)
|
||||
assert.ok(!url.includes('%'),
|
||||
`鸿蒙下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
|
||||
assert.ok(url.includes('keyword=沙发'),
|
||||
`鸿蒙下 url 应含 keyword=沙发,实际: ${url}`);
|
||||
|
||||
// 鸿蒙原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
|
||||
const { options } = simulateHarmonyOnLoad(url);
|
||||
assert.equal(options.keyword, '沙发',
|
||||
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
test('端到端: 鸿蒙元服务 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
|
||||
// 注: 鸿蒙元服务下 JS 侧不做 encode(让鸿蒙原生 encode),
|
||||
// 对于含 URL 分隔符 (& = ? #) 的关键词,需要业务方自己 encode,
|
||||
// 否则会被 native 当成 query 分隔符破坏边界。
|
||||
// 本测试覆盖用户实际场景: 中文 + 反引号 + 空格(鸿蒙原生都能正常 encode 处理)
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
const kw = '`hello world` 沙发';
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
|
||||
assert.ok(url.includes('keyword=' + kw),
|
||||
`鸿蒙下 url 应含原始 keyword,实际: ${url}`);
|
||||
|
||||
const { options } = simulateHarmonyOnLoad(url);
|
||||
assert.equal(options.keyword, kw,
|
||||
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2.2 端到端:微信小程序(回归 Bug 的另一平台)
|
||||
// =============================================================
|
||||
test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
|
||||
// 2026-07-17 修正: 微信小程序跟鸿蒙一样,JS 不做 encode,让平台原生去 encode
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
|
||||
|
||||
// 微信下 url 不应被双重编码: keyword= 应直接是中文(没 %)
|
||||
assert.ok(!url.includes('%'),
|
||||
`微信下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
|
||||
assert.ok(url.includes('keyword=沙发'),
|
||||
`微信下 url 应含 keyword=沙发,实际: ${url}`);
|
||||
|
||||
// 微信原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
|
||||
const { options } = simulateWeixinOnLoad(url);
|
||||
assert.equal(options.keyword, '沙发',
|
||||
`微信下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
test('端到端: 微信小程序 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
const kw = '`hello world` 沙发';
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
|
||||
// mp-weixin 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样
|
||||
assert.ok(url.includes('keyword=' + encode('`hello world` 沙发')),
|
||||
`微信下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`);
|
||||
|
||||
const { options } = simulateWeixinOnLoad(url);
|
||||
// decode 之后应还原 keyword(空格 回到)
|
||||
assert.equal(options.keyword, kw,
|
||||
`微信下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2.3 端到端:H5
|
||||
// =============================================================
|
||||
test('端到端: H5 → 搜索关键词在列表页 search 输入框显示原文', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'h5' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
|
||||
assert.ok(url.includes('keyword=%E6%B2%99%E5%8F%91'),
|
||||
`H5 下 url 应含编码后的 keyword,实际: ${url}`);
|
||||
|
||||
// H5 浏览器从 query string 自动 decode
|
||||
const { options } = simulateBrowserOnLoad(url);
|
||||
assert.equal(options.keyword, '沙发',
|
||||
`H5 下 browser decode 后 keyword 应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2.4 端到端:华为快应用
|
||||
// =============================================================
|
||||
test('端到端: 快应用 → 搜索关键词在列表页 search 输入框显示原文', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'quickapp' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
|
||||
assert.ok(url.includes('keyword=%E6%B2%99%E5%8F%91'),
|
||||
`快应用下 url 应含编码后的 keyword,实际: ${url}`);
|
||||
|
||||
const { options } = simulateBrowserOnLoad(url);
|
||||
assert.equal(options.keyword, '沙发',
|
||||
`快应用下 decode 后 keyword 应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. 模拟完整的 bug 现场:商品分类 → 商品列表
|
||||
// =============================================================
|
||||
test('Bug 回归场景: 商品分类页输入"沙发" → 列表页搜索框显示"沙发"(4 平台)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
|
||||
// 模拟 search.vue 中的 search() 方法
|
||||
function searchClick(platform, keyword) {
|
||||
const api = nav.createNavigateApi({ platform });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
// 模拟 redirectTo 拼装 url(纯函数,与 util.js redirectTo 同语义)
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword }, encode);
|
||||
|
||||
// 模拟 onLoad(options) 拿到的 keyword(按平台分派)
|
||||
if (platform === 'mp-harmony') {
|
||||
// 鸿蒙: 平台原生 encode 一次,parse 时 decode 一次
|
||||
return simulateHarmonyOnLoad(url).options.keyword;
|
||||
} else if (platform === 'mp-weixin') {
|
||||
// 微信: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
|
||||
return simulateWeixinOnLoad(url).options.keyword;
|
||||
} else {
|
||||
// H5 / 快应用: 浏览器/H5 风格 parse,decode 一次
|
||||
return simulateBrowserOnLoad(url).options.keyword;
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
|
||||
const keyword = searchClick(p, '沙发');
|
||||
assert.equal(keyword, '沙发',
|
||||
`${p} 平台:onLoad(options).keyword 应是"沙发",实际: ${keyword}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bug 回归场景: 长关键词 + 特殊字符,4 平台都不应乱码', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
|
||||
function searchClick(platform, keyword) {
|
||||
const api = nav.createNavigateApi({ platform });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword }, encode);
|
||||
|
||||
if (platform === 'mp-harmony') {
|
||||
return simulateHarmonyOnLoad(url).options.keyword;
|
||||
} else if (platform === 'mp-weixin') {
|
||||
return simulateWeixinOnLoad(url).options.keyword;
|
||||
} else {
|
||||
return simulateBrowserOnLoad(url).options.keyword;
|
||||
}
|
||||
}
|
||||
|
||||
// 各种边界关键词
|
||||
// 注: 不含 & = ? # 等 URL 分隔符(这些字符即使在鸿蒙下也应业务方自行 encode)
|
||||
const keywords = [
|
||||
'沙发', // 纯中文(本次 Bug 的核心场景)
|
||||
'Nike 运动鞋', // 中英文 + 空格
|
||||
'`反引号`', // 含反引号
|
||||
'100%纯棉', // 含 %
|
||||
'价格>100', // 含 >
|
||||
'a b c', // 多空格
|
||||
];
|
||||
|
||||
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
|
||||
for (const kw of keywords) {
|
||||
const got = searchClick(p, kw);
|
||||
assert.equal(got, kw,
|
||||
`${p} 平台: 关键词 "${kw}" 应原样传递,实际: "${got}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. 防御:微信/鸿蒙下不能误伤已经预 encode 的入参
|
||||
// =============================================================
|
||||
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
// 上游已经 encode 过一次的入参(%E5%8F%91 是"发")
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: '%E5%8F%91' }, encode);
|
||||
// 应该 decode 还原成"发",而不是保留 %E5%8F%91
|
||||
assert.ok(url.includes('keyword=发'),
|
||||
`鸿蒙下已编码入参应被 decode 还原,实际 url: ${url}`);
|
||||
|
||||
// 然后鸿蒙原生 encode 一次,parse decode 一次,回到"发"
|
||||
const { options } = simulateHarmonyOnLoad(url);
|
||||
assert.equal(options.keyword, '发',
|
||||
`鸿蒙下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
test('防御: 微信下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
|
||||
// 2026-07-17 修正: 微信小程序同鸿蒙一样
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
const encode = (v) => api._safeEncodeForPlatform(v);
|
||||
|
||||
const url = buildRedirectUrl('/pages_goods/list', { keyword: '%E5%8F%91' }, encode);
|
||||
assert.ok(url.includes('keyword=发'),
|
||||
`微信下已编码入参应被 decode 还原,实际 url: ${url}`);
|
||||
|
||||
const { options } = simulateWeixinOnLoad(url);
|
||||
assert.equal(options.keyword, '发',
|
||||
`微信下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ function loadNavInSandbox() {
|
||||
.replace(/^export\s+let\s+/gm, 'let ')
|
||||
.replace(/^export\s+var\s+/gm, 'var ')
|
||||
+ '\nmodule.exports = { _toParamValueString, parsePageParams, resolveLinkUrl, '
|
||||
+ 'cleanParamUrls, normalizeUrl, appendQueryParams, extractInternalAppPath, resolveNavMode, getPlatform, shouldEncodeUrl, '
|
||||
+ 'cleanParamUrls, normalizeUrl, appendQueryParams, extractInternalAppPath, resolveNavMode, hasStaleUniacidInUrl, pickCurrentUniacid, getPlatform, shouldEncodeUrl, '
|
||||
+ '_safeEncodeForPlatform, _navigateTo, createNavigateApi };\n';
|
||||
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
|
||||
sandbox.globalThis = sandbox;
|
||||
@@ -54,7 +54,7 @@ test('util-navigate.js: 能被 vm 加载,所有 export 都存在', () => {
|
||||
const expected = [
|
||||
'_toParamValueString', 'parsePageParams', 'resolveLinkUrl',
|
||||
'cleanParamUrls', 'normalizeUrl', 'appendQueryParams',
|
||||
'extractInternalAppPath', 'resolveNavMode',
|
||||
'extractInternalAppPath', 'resolveNavMode', 'hasStaleUniacidInUrl', 'pickCurrentUniacid',
|
||||
'getPlatform', 'shouldEncodeUrl',
|
||||
'_safeEncodeForPlatform', '_navigateTo', 'createNavigateApi',
|
||||
];
|
||||
@@ -230,18 +230,53 @@ test('shouldEncodeUrl: 4 平台都为 true', () => {
|
||||
// =============================================================
|
||||
// 10. _safeEncodeForPlatform
|
||||
// =============================================================
|
||||
test('_safeEncodeForPlatform: 4 平台都主动 encode', () => {
|
||||
test('_safeEncodeForPlatform: H5 / 华为快应用 → 主动 encode', () => {
|
||||
// 2026-07-17 修正: H5 和 quickapp 平台 JS 侧必须主动 encode 一次
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
for (const p of ['h5', 'quickapp']) {
|
||||
const api = nav.createNavigateApi({ platform: p });
|
||||
const r = api._safeEncodeForPlatform('a&b');
|
||||
assert.equal(r, 'a%26b', 'mp-weixin: & 必须 encode');
|
||||
assert.equal(r, 'a%26b', `${p}: & 必须 encode 成 %26,实际: ${r}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 已编码字符串不双编码', () => {
|
||||
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode', () => {
|
||||
// 2026-07-17 修正(二次): 鸿蒙整体 encode url, JS 完全不 encode
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
const r = api._safeEncodeForPlatform('a&b');
|
||||
assert.equal(r, 'a&b', `mp-harmony: JS 不应预 encode,实际: ${r}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符', () => {
|
||||
// 2026-07-17 修正(二次): 微信 split on '&' 但不 decode value, JS 必须 encode `&`
|
||||
// 否则 query 边界被破坏。但中文 / 字母 / 数字原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
assert.equal(api._safeEncodeForPlatform('a&b'), 'a%26b', '& 应 encode 成 %26');
|
||||
assert.equal(api._safeEncodeForPlatform('沙发'), '沙发', '中文不应 encode');
|
||||
assert.equal(api._safeEncodeForPlatform('a=b'), 'a%3Db', '= 应 encode 成 %3D');
|
||||
assert.equal(api._safeEncodeForPlatform('a?b'), 'a%3Fb', '? 应 encode 成 %3F');
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
|
||||
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
|
||||
// 还原后单次 encode 仍是 %E8%A1%A8
|
||||
const nav = loadNavInSandbox();
|
||||
for (const p of ['h5', 'quickapp']) {
|
||||
const api = nav.createNavigateApi({ platform: p });
|
||||
const r = api._safeEncodeForPlatform('%E8%A1%A8');
|
||||
assert.equal(r, '%E8%A1%A8', 'decode roundtrip + re-encode 不双编码');
|
||||
assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码,实际: ${r}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 微信/鸿蒙下已编码入参 → decode 一次还原', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
for (const p of ['mp-weixin', 'mp-harmony']) {
|
||||
const api = nav.createNavigateApi({ platform: p });
|
||||
const r = api._safeEncodeForPlatform('%E8%A1%A8');
|
||||
assert.equal(r, '表', `${p} 已编码入参应 decode 一次还原,实际: ${r}`);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
@@ -520,3 +555,91 @@ test('resolveNavMode: 未设置 / 未知值 → auto', () => {
|
||||
assert.equal(resolveNavMode(null), 'auto');
|
||||
assert.equal(resolveNavMode(undefined), 'auto');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl — URL 原生 uniacid vs 系统当前 uniacid
|
||||
// 修复: 装修数据里硬编码的 uniacid 可能与系统不一致(脏数据 / 过期租户)
|
||||
// =============================================================
|
||||
test('hasStaleUniacidInUrl: URL 无 uniacid → false', () => {
|
||||
const { hasStaleUniacidInUrl } = loadNavInSandbox();
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing?id=130', 2482), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing', 2482), false);
|
||||
});
|
||||
|
||||
test('hasStaleUniacidInUrl: URL uniacid 与当前一致 → false', () => {
|
||||
const { hasStaleUniacidInUrl } = loadNavInSandbox();
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing?uniacid=2482', 2482), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing?uniacid=2482', '2482'), false); // 字符串数字
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing?uniacid=2482&id=130', 2482), false);
|
||||
});
|
||||
|
||||
test('hasStaleUniacidInUrl: URL uniacid 与当前不一致 → true(脏数据)', () => {
|
||||
const { hasStaleUniacidInUrl } = loadNavInSandbox();
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing?uniacid=2482', 2483), true);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/landing?uniacid=2482&id=130', 9999), true);
|
||||
});
|
||||
|
||||
test('hasStaleUniacidInUrl: 兼容 URL 编码 + 大小写 + 多个 query', () => {
|
||||
const { hasStaleUniacidInUrl } = loadNavInSandbox();
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?UNIACID=2482', 2482), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?uniacid=%32%34%38%32', 2482), false); // 全 URL 编码
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?uniacid=2482&a=1&b=2', 2482), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?a=1&uniacid=2482&b=2', 2482), false);
|
||||
});
|
||||
|
||||
test('hasStaleUniacidInUrl: currentUniacid 无效 → false(无法比较)', () => {
|
||||
const { hasStaleUniacidInUrl } = loadNavInSandbox();
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?uniacid=2482', null), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?uniacid=2482', undefined), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?uniacid=2482', ''), false);
|
||||
assert.equal(hasStaleUniacidInUrl('https://partner.com/x?uniacid=2482', 0), false);
|
||||
});
|
||||
|
||||
test('hasStaleUniacidInUrl: 空 / 非法输入 → false(安全降级)', () => {
|
||||
const { hasStaleUniacidInUrl } = loadNavInSandbox();
|
||||
assert.equal(hasStaleUniacidInUrl('', 2482), false);
|
||||
assert.equal(hasStaleUniacidInUrl(null, 2482), false);
|
||||
assert.equal(hasStaleUniacidInUrl(undefined, 2482), false);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// pickCurrentUniacid — 从两个候选中选 uniacid
|
||||
// 实际调用: (uni.getStorageSync('uniacid'), Config.uniacid)
|
||||
// 必须与 http.js 行 168: data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid
|
||||
// 保持完全一致, 避免守卫/请求用不同真值
|
||||
// 优先级: 第一参(运行时) > 第二参(编译期兜底) > 0
|
||||
// =============================================================
|
||||
test('pickCurrentUniacid: 第一参(运行时 storage)非 0 → 选第一参', () => {
|
||||
const { pickCurrentUniacid } = loadNavInSandbox();
|
||||
assert.equal(pickCurrentUniacid(2483, 2482), 2483);
|
||||
assert.equal(pickCurrentUniacid('2483', 2482), 2483); // 字符串数字兼容
|
||||
});
|
||||
|
||||
test('pickCurrentUniacid: 第一参无值 → 回退到第二参(Config 兜底)', () => {
|
||||
const { pickCurrentUniacid } = loadNavInSandbox();
|
||||
assert.equal(pickCurrentUniacid(undefined, 2482), 2482);
|
||||
assert.equal(pickCurrentUniacid(null, 2482), 2482);
|
||||
assert.equal(pickCurrentUniacid('', 2482), 2482); // '' 视为无效
|
||||
});
|
||||
|
||||
test('pickCurrentUniacid: 第一参为 0 → 回退到第二参(关键边界)', () => {
|
||||
const { pickCurrentUniacid } = loadNavInSandbox();
|
||||
// 关键: storage 返回 0(空值)等价于"未设置", 必须回退到 Config.uniacid
|
||||
// 不能因为 storage 是 0 而把 0 当成"已设值"返回
|
||||
assert.equal(pickCurrentUniacid(0, 2482), 2482);
|
||||
assert.equal(pickCurrentUniacid(0, '2482'), 2482);
|
||||
});
|
||||
|
||||
test('pickCurrentUniacid: 两个参都无值 → 0', () => {
|
||||
const { pickCurrentUniacid } = loadNavInSandbox();
|
||||
assert.equal(pickCurrentUniacid(undefined, undefined), 0);
|
||||
assert.equal(pickCurrentUniacid(null, null), 0);
|
||||
assert.equal(pickCurrentUniacid(0, 0), 0);
|
||||
assert.equal(pickCurrentUniacid('', ''), 0);
|
||||
});
|
||||
|
||||
test('pickCurrentUniacid: 字符串数字自动 parseInt', () => {
|
||||
const { pickCurrentUniacid } = loadNavInSandbox();
|
||||
assert.equal(pickCurrentUniacid('2482', undefined), 2482);
|
||||
assert.equal(pickCurrentUniacid('2482abc', undefined), 2482); // 解析到非数字停止
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user