Compare commits
78 Commits
61e7a0d859
...
yuanlaide6
| Author | SHA1 | Date | |
|---|---|---|---|
| c9aa4c75ae | |||
| a1db706072 | |||
| 276120162a | |||
| f523122889 | |||
| cea0e36d5d | |||
| 0169f25ca5 | |||
| 3939d15f2a | |||
| fd4edc177d | |||
| f96fee60d3 | |||
| d57b69d7cb | |||
| d875642d99 | |||
| 0bcb5e7f4a | |||
| 65013a35f0 | |||
| 31daebc83b | |||
| b8894ea2dc | |||
| ff130bbf15 | |||
| 3a88941828 | |||
| b8c813e43b | |||
| 73776841d8 | |||
| cd2705e5b9 | |||
| 02b8966a91 | |||
| 2adb574dd5 | |||
| 5b7351dcd2 | |||
| 63e2cb2ff7 | |||
| d65f27b256 | |||
| ce7cbc75df | |||
| a6091f6b68 | |||
| 9d6c3218fb | |||
| 71ea945743 | |||
| c8d92d8100 | |||
| 5831189e5e | |||
| 109cba6b6c | |||
| f01a1d7efc | |||
| ae5b878822 | |||
| 7d4ecb9731 | |||
| b5e9940934 | |||
| 018616dd0e | |||
| 74e53a9ca6 | |||
| fdc0b54fbd | |||
| 8e31b9e327 | |||
| 460dbcb447 | |||
| 81ecd45de4 | |||
| 059630ba2c | |||
| 1ac0fb2484 | |||
| d8816bbe42 | |||
| b410597d9c | |||
| 1143ef7b72 | |||
| 987e75c65a | |||
| ac8c461546 | |||
| f3032a6004 | |||
| a4d8023e1f | |||
| bb0adb0542 | |||
| a4c218fecc | |||
| 0d820b04f7 | |||
| 70c9c49b94 | |||
| 6d1bdb5d8b | |||
| 73b7b2a8df | |||
| b2063418fb | |||
| 21c342e389 | |||
| cbf91fa905 | |||
| 61495b5e90 | |||
| 185d4914a6 | |||
| e3bbb7c77a | |||
| 4044070867 | |||
| 41b0d15570 | |||
| aafb75bad3 | |||
| 6e19bc7aff | |||
| 94e852032b | |||
| 5ccbc5d4f4 | |||
| cf6d54e2bf | |||
| 19bcf64844 | |||
| a27ae6cba0 | |||
| 660ab27377 | |||
| d78a23602e | |||
| 7a6495fa33 | |||
| 7f2e5d3934 | |||
| 39d4264389 | |||
| 0ca64b7dcf |
@@ -45,7 +45,11 @@ const localDevConfig = ({
|
||||
'11-test': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
},
|
||||
'18-test': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'local-2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'http://localhost:8050/',
|
||||
@@ -58,7 +62,11 @@ const localDevConfig = ({
|
||||
'local-11': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
},
|
||||
'local-18': { // 人工晶体
|
||||
uniacid: 18,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'100': {
|
||||
uniacid: 100,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
@@ -113,7 +121,7 @@ const localDevConfig = ({
|
||||
},
|
||||
'2482': { // 低空经济
|
||||
uniacid: 2482,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
domain: 'https://xcx3.aigc-quickapp.com/',
|
||||
},
|
||||
'2485': { // 验证电子表单前后端数据一致性
|
||||
uniacid: 2485,
|
||||
@@ -123,17 +131,37 @@ const localDevConfig = ({
|
||||
uniacid: 2821,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'18': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'48': { // 测试平台
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'54': { // 测试平台
|
||||
uniacid: 54,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
// })['2821']; // 离型纸硅油纸
|
||||
})['2482']; // 低空经济
|
||||
})['2821']; // 离型纸硅油纸
|
||||
// })['2482']; // 低空经济
|
||||
// })['48']; // 离型纸硅油纸
|
||||
// })['2485']; // 验证电子表单前后端数据一致性
|
||||
// })['5g-11']; // 数码
|
||||
// })['5g-213']; // 选择要使用的环境配置
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['local-1']; // 本地
|
||||
// })['local-18']; // 本地-人工晶体-有商品
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
// })['18-test']; // test
|
||||
export default localDevConfig;
|
||||
@@ -1,6 +1,7 @@
|
||||
import WxMap from 'common/js/map-wx-jssdk.js';
|
||||
import Config from '@/common/js/config.js';
|
||||
import util from '@/common/js/util.js';
|
||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
|
||||
@@ -51,6 +52,11 @@ export default {
|
||||
lastDiyInfoTime: 0, // 上次成功加载的时间戳
|
||||
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
|
||||
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
|
||||
|
||||
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
|
||||
diyLoadError: false,
|
||||
diyLoadErrorMsg: '',
|
||||
diyLoadErrorUrl: '', // 出错的网络请求地址
|
||||
|
||||
//启动广告
|
||||
adv:{},
|
||||
@@ -310,6 +316,41 @@ export default {
|
||||
}
|
||||
|
||||
},
|
||||
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
||||
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
|
||||
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
|
||||
_showDiyLoadError(err, url) {
|
||||
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
|
||||
this.diyLoadErrorUrl = url || '';
|
||||
this.diyLoadError = true;
|
||||
},
|
||||
// 根据错误信息组织更详细的提示文案
|
||||
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
|
||||
_buildDiyErrorMsg(err) {
|
||||
const info = classifyHttpError(err);
|
||||
|
||||
// 服务端明确返回的业务消息(字符串):直接透传
|
||||
if (info.type === 'business') return info.raw;
|
||||
|
||||
const key = httpErrorKey(info.type);
|
||||
let reason = this.$lang(key);
|
||||
|
||||
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
|
||||
if (info.statusCode) reason += '(' + info.statusCode + ')';
|
||||
|
||||
// empty / unknown 不走模板,直接返回通用网络异常
|
||||
if (info.type === 'empty' || info.type === 'unknown') return reason;
|
||||
|
||||
const tpl = this.$lang('diy.loadFailTpl');
|
||||
return tpl.replace('{reason}', reason);
|
||||
},
|
||||
// 错误横幅「重试」入口
|
||||
retryDiyInfo() {
|
||||
this.diyLoadError = false;
|
||||
this.diyLoadErrorMsg = '';
|
||||
this.diyLoadErrorUrl = '';
|
||||
this.getDiyInfo(true);
|
||||
},
|
||||
async getDiyInfo(force = false) {
|
||||
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
|
||||
const now = Date.now();
|
||||
@@ -324,9 +365,12 @@ export default {
|
||||
|
||||
this.diyInfoLoading = true;
|
||||
let res;
|
||||
// 出错时用于展示的完整请求地址(Request URL),与 http.js 拼接/归一化方式保持一致
|
||||
const diyInfoUrl = '/api/diyview/info';
|
||||
const diyInfoFullUrl = ((Config.baseUrl || '') + diyInfoUrl).replace(/(?<!:)\/+/g, '/');
|
||||
try {
|
||||
res = await this.$api.sendRequest({
|
||||
url: '/api/diyview/info',
|
||||
url: diyInfoUrl,
|
||||
data: {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
@@ -337,10 +381,9 @@ export default {
|
||||
} catch (e) {
|
||||
console.error('getDiyInfo request failed', e);
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
this.$util.showToast({
|
||||
title: this.$lang('diy.networkError')
|
||||
});
|
||||
this.diyInfoLoading = false;
|
||||
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
|
||||
this._showDiyLoadError(e, diyInfoFullUrl);
|
||||
return;
|
||||
}
|
||||
this.diyInfoLoading = false;
|
||||
@@ -348,6 +391,14 @@ export default {
|
||||
if (!res || res.code != 0 || !res.data) {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
|
||||
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
|
||||
if (!res) {
|
||||
this._showDiyLoadError(null, diyInfoFullUrl);
|
||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
if (res && res.code == -3) {
|
||||
this.$util.showToast({
|
||||
title: res.message
|
||||
@@ -357,9 +408,9 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$util.showToast({
|
||||
title: this.$lang('diy.noDiyData')
|
||||
});
|
||||
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
|
||||
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
|
||||
this._showDiyLoadError(res && res.message, diyInfoFullUrl);
|
||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||
return;
|
||||
@@ -367,6 +418,10 @@ export default {
|
||||
|
||||
this.lastDiyInfoTime = now;
|
||||
this.lastDiyInfoLang = currentLang;
|
||||
// 成功加载后清除错误提示
|
||||
this.diyLoadError = false;
|
||||
this.diyLoadErrorMsg = '';
|
||||
this.diyLoadErrorUrl = '';
|
||||
|
||||
let diyDataValue = res.data;
|
||||
if (diyDataValue.value) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class EventSafety {
|
||||
// 安全的方法
|
||||
matches: (selector) => EventSafety.safeMatches(target, selector),
|
||||
// 数据集
|
||||
dataset: target.dataset || {}
|
||||
dataset: target?.dataset || {}
|
||||
}
|
||||
|
||||
return safeTarget
|
||||
|
||||
112
common/js/form-platform-mixin.js
Normal file
112
common/js/form-platform-mixin.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 键名对齐)
|
||||
* 后端 terminal 键:h5 / weapp / harmony / app / mp-alipay / mp-baidu / mp-toutiao
|
||||
*/
|
||||
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 名 兼容层
|
||||
* canonical 与 platform() 返回值一致;别名为兼容 DB 旧 app_type(upgrade-to-ai-agent.sql)
|
||||
* 及 uniapp / 设计器的历史命名。键名严格区分大小写。
|
||||
*/
|
||||
platformAliases() {
|
||||
return {
|
||||
'h5': 'h5',
|
||||
'weapp': 'weapp',
|
||||
'mp-weixin': 'weapp',
|
||||
'wechat': 'weapp',
|
||||
'harmony': 'harmony',
|
||||
'app': 'app',
|
||||
'app-plus': 'app',
|
||||
'mp-alipay': 'mp-alipay',
|
||||
'aliapp': 'mp-alipay',
|
||||
'alipay': 'mp-alipay',
|
||||
'mp-baidu': 'mp-baidu',
|
||||
'baiduapp': 'mp-baidu',
|
||||
'baidu': 'mp-baidu',
|
||||
'mp-toutiao': 'mp-toutiao',
|
||||
'toutiao': 'mp-toutiao'
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 当前平台的有效配置键集合
|
||||
* 用于 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -79,114 +79,127 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
// 处理商品详情数据
|
||||
handleGoodsSkuData() {
|
||||
// this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
|
||||
//设置标题
|
||||
// this.$langConfig.title(this.navbarData.title);
|
||||
if (this.goodsSkuDetail.config) {
|
||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||
}
|
||||
|
||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||
|
||||
this.modifyGoodsInfo();
|
||||
|
||||
// 初始化商品详情视图数据
|
||||
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
else this.goodsSkuDetail.sku_images = [];
|
||||
}
|
||||
|
||||
// 多规格时合并主图
|
||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||
|
||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||
}
|
||||
|
||||
let maxHeight = '';
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||
if (typeof item.pic_spec == "string")
|
||||
item.pic_spec = item.pic_spec.split('*');
|
||||
|
||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||
|
||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||
maxHeight = item.pic_spec[1];
|
||||
async handleGoodsSkuData() {
|
||||
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
|
||||
try {
|
||||
if (this.goodsSkuDetail.config) {
|
||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||
}
|
||||
});
|
||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||
|
||||
// 当前商品SKU规格
|
||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||
this.modifyGoodsInfo();
|
||||
|
||||
// 商品属性
|
||||
if (this.goodsSkuDetail.goods_attr_format) {
|
||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||
// 初始化商品详情视图数据
|
||||
try {
|
||||
if (this.$refs && this.$refs.goodsDetailView) {
|
||||
this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('goodsDetailView.init failed:', e);
|
||||
}
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
else this.goodsSkuDetail.sku_images = [];
|
||||
}
|
||||
|
||||
// 多规格时合并主图
|
||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||
|
||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||
}
|
||||
|
||||
let maxHeight = '';
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||
if (typeof item.pic_spec == "string")
|
||||
item.pic_spec = item.pic_spec.split('*');
|
||||
|
||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||
|
||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||
maxHeight = item.pic_spec[1];
|
||||
}
|
||||
});
|
||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||
|
||||
// 当前商品SKU规格
|
||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||
|
||||
// 商品属性
|
||||
if (this.goodsSkuDetail.goods_attr_format) {
|
||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品SKU格式
|
||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||
// 商品SKU格式
|
||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||
|
||||
// 商品详情
|
||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||
// 商品详情
|
||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||
|
||||
//商品服务
|
||||
if (this.goodsSkuDetail.goods_service) {
|
||||
for (let i in this.goodsSkuDetail.goods_service) {
|
||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||
//商品服务
|
||||
if (this.goodsSkuDetail.goods_service) {
|
||||
for (let i in this.goodsSkuDetail.goods_service) {
|
||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.contactData = {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
path: this.shareUrl,
|
||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
this.contactData = {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
path: this.shareUrl,
|
||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
try {
|
||||
if (this.$refs && this.$refs.goodsPromotion) {
|
||||
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
}
|
||||
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
|
||||
|
||||
if (this.goodsRoute != '/pages_goods/detail') this.setPublicShare();
|
||||
|
||||
// this.getBarrageData();
|
||||
if (this.addonIsExist.form) {
|
||||
this.getGoodsForm();
|
||||
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
|
||||
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
|
||||
try {
|
||||
this._goodsFormPromise = this.getGoodsForm();
|
||||
} catch (e) {
|
||||
console.error('getGoodsForm kicked off failed:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('handleGoodsSkuData failed:', e);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -321,17 +334,76 @@ export default {
|
||||
},
|
||||
/**
|
||||
* 获取商品表单
|
||||
* 后端 goodsform 接口与 info() 对齐,返回 {json_data, form_config(camelCase),
|
||||
* form_name, form_tip, form_banner, show_banner, show_tip}。
|
||||
* 全部字段都落到 goodsSkuDetail 上:表单字段 / 样式 / banner / tip 元数据,
|
||||
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
|
||||
*/
|
||||
getGoodsForm() {
|
||||
this.$api.sendRequest({
|
||||
url: "/form/api/form/goodsform",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) this.$set(this.goodsSkuDetail, 'goods_form', res.data);
|
||||
}
|
||||
});
|
||||
async getGoodsForm() {
|
||||
// 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
|
||||
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
|
||||
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
|
||||
const emptyFn = () => {
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
||||
};
|
||||
emptyFn();
|
||||
return;
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await this.$api.sendRequest({
|
||||
url: "/form/api/form/goodsform",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
format: 'object'
|
||||
},
|
||||
async: false
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('getGoodsForm request failed', e);
|
||||
res = null;
|
||||
}
|
||||
const empty = () => {
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
||||
};
|
||||
if (!res || res.code != 0 || !res.data) {
|
||||
empty();
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(res.data)) {
|
||||
// 旧版扁平数组兼容
|
||||
empty();
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', res.data);
|
||||
return;
|
||||
}
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', res.data.json_data || []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', res.data.form_config || {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', res.data.form_name || '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', res.data.form_tip || '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', res.data.form_banner || '');
|
||||
// show_banner/show_tip 后端存 0/1,归一化 boolean
|
||||
this.$set(
|
||||
this.goodsSkuDetail,
|
||||
'goods_form_show_banner',
|
||||
res.data.show_banner == 1 ? true : (res.data.show_banner === 0 ? false : true)
|
||||
);
|
||||
this.$set(
|
||||
this.goodsSkuDetail,
|
||||
'goods_form_show_tip',
|
||||
res.data.show_tip == 1 ? true : (res.data.show_tip === 0 ? false : true)
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
101
common/js/http-error.js
Normal file
101
common/js/http-error.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 通用网络/接口错误分类器
|
||||
*
|
||||
* 适用场景:
|
||||
* - uniapp 各端统一错误分类(diy.js / 商品 / 订单 / 自定义页面等)
|
||||
* - 任意传入 {statusCode, errMsg, message} 形式错误对象的位置
|
||||
*
|
||||
* 设计要点:
|
||||
* 1) 与 Vue / $lang 解耦:不调用任何 i18n 函数,只返回分类结果 + 原始错误字段
|
||||
* 调用方拿到 type 后,自行决定使用 $lang('http.timeout') / $lang('http.serverError')
|
||||
* 或其它命名空间的文案,完全保持各模块原有的 key 命名习惯
|
||||
* 2) 通用网络/接口错误统一映射到 common.http.* 命名空间:
|
||||
* - http.networkError: 通用网络异常(兜底)
|
||||
* - http.timeout: 请求超时
|
||||
* - http.serverError: 服务器错误(5xx)
|
||||
* - http.notFound: 接口不存在(404)
|
||||
* - http.auth: 无访问权限(401/403)
|
||||
* - http.unknown: 其他非 2xx
|
||||
* 3) 兼容多种错误对象形态:
|
||||
* - uniapp request: { statusCode, errMsg, data }
|
||||
* - axios: { response: { status }, message }
|
||||
* - fetch / 原生: { message, name }
|
||||
*
|
||||
* @param {Object|string} err 错误对象或后端业务消息字符串
|
||||
* @returns {{ type: string, statusCode?: number, errMsg?: string, raw?: any }}
|
||||
* - type 取值:
|
||||
* 'business' - 后端返回的业务消息字符串(已透传到 raw)
|
||||
* 'empty' - 错误对象为空
|
||||
* 'notFound' - HTTP 404
|
||||
* 'server' - HTTP 5xx
|
||||
* 'auth' - HTTP 401 / 403
|
||||
* 'request' - 其它非 2xx 状态码
|
||||
* 'timeout' - errMsg 含 timeout
|
||||
* 'network' - errMsg 含 connect / network / fail
|
||||
* 'unknown' - 无法分类
|
||||
* - statusCode: 若有则原样返回
|
||||
* - errMsg: 归一后的原始消息
|
||||
* - raw: 原始输入,方便调用方进一步处理
|
||||
*/
|
||||
export function classifyHttpError(err) {
|
||||
// 1) 无错误对象
|
||||
if (!err) {
|
||||
return { type: 'empty', raw: err };
|
||||
}
|
||||
|
||||
// 2) 后端直接返回的业务消息(字符串)
|
||||
if (typeof err === 'string') {
|
||||
return { type: 'business', raw: err };
|
||||
}
|
||||
|
||||
// 3) 字段归一(兼容 uni / axios / 原生 Error)
|
||||
const statusCode = err.statusCode
|
||||
?? err.response?.status
|
||||
?? err.status;
|
||||
const errMsg = err.errMsg || err.message || '';
|
||||
|
||||
if (statusCode) {
|
||||
if (statusCode === 404) return { type: 'notFound', statusCode, errMsg, raw: err };
|
||||
if (statusCode >= 500) return { type: 'server', statusCode, errMsg, raw: err };
|
||||
if (statusCode === 401 || statusCode === 403) return { type: 'auth', statusCode, errMsg, raw: err };
|
||||
return { type: 'request', statusCode, errMsg, raw: err };
|
||||
}
|
||||
|
||||
if (/timeout/i.test(errMsg)) {
|
||||
return { type: 'timeout', errMsg, raw: err };
|
||||
}
|
||||
if (/(connect|network|fail)/i.test(errMsg)) {
|
||||
return { type: 'network', errMsg, raw: err };
|
||||
}
|
||||
|
||||
return { type: 'unknown', errMsg, raw: err };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 classifyHttpError 的结果,映射到 common.http.* 命名空间的 i18n key
|
||||
*
|
||||
* 通用网络/接口错误统一走 http.*,调用方通过 $lang(key) 解析文案。
|
||||
* 原 diy.errTimeout 这类 key 已合并到 http.*,保持项目国际化更专业。
|
||||
*
|
||||
* @param {string} type classifyHttpError 返回的 type
|
||||
* @returns {string|null} 对应的 http.* key;type='business' 时返回 null(透传 raw)
|
||||
*/
|
||||
export function httpErrorKey(type) {
|
||||
switch (type) {
|
||||
case 'empty': return 'http.networkError';
|
||||
case 'business': return null; // 透传,不用 key
|
||||
case 'notFound': return 'http.notFound';
|
||||
case 'server': return 'http.serverError';
|
||||
case 'auth': return 'http.auth';
|
||||
case 'request': return 'http.unknown';
|
||||
case 'timeout': return 'http.timeout';
|
||||
case 'network': return 'http.networkError';
|
||||
case 'unknown':
|
||||
default: return 'http.networkError';
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
classifyHttpError,
|
||||
httpErrorKey,
|
||||
};
|
||||
@@ -368,6 +368,186 @@ export function normalizeUrl(urlString) {
|
||||
return schemeAndHost + normalizedPath + normalizedQuery + hash;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// extractInternalAppPath
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 从一个 URL 中提取"本 App 内部页面路径"。
|
||||
*
|
||||
* 背景 / 修复的 bug:
|
||||
* 后台装修数据里,内部页面常被存成【绝对 URL】,例如
|
||||
* https://host/hwappx/common/pages_tool/form/formdata?id=130
|
||||
* diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是:
|
||||
* - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失
|
||||
* - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view
|
||||
* 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径,
|
||||
* 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。
|
||||
*
|
||||
* 判定规则(双闸门,避免误伤真正的外部站点):
|
||||
* 闸门 1 —— host 校验(相对路径直接放行):
|
||||
* 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感);
|
||||
* 否则返回 null(视为真外链, 维持原逻辑)。
|
||||
* ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链,
|
||||
* 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。
|
||||
* 闸门 2 —— 路径校验:
|
||||
* pathname 中出现以 App 页面根开头的段(pages / pages_xxx),
|
||||
* 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回;
|
||||
* 未命中返回 null。
|
||||
*
|
||||
* @example
|
||||
* const H = ['xcx6.aigc-quickapp.com']
|
||||
* extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H)
|
||||
* // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原)
|
||||
* extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H)
|
||||
* // null (子域不同 → 维持外链)
|
||||
* extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H)
|
||||
* // null (外域名 → 维持外链,不误伤)
|
||||
* extractInternalAppPath('/pages_goods/detail?id=9', H)
|
||||
* // '/pages_goods/detail?id=9' (相对路径 → 内部)
|
||||
*
|
||||
* @param {string} url 绝对或相对 URL
|
||||
* @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取);
|
||||
* 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。
|
||||
* @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null
|
||||
*/
|
||||
export function extractInternalAppPath(url, allowedHosts) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
let rest = url;
|
||||
// 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部)
|
||||
const hostMatch = rest.match(/^https?:\/\/([^/]*)/i);
|
||||
if (hostMatch) {
|
||||
if (allowedHosts !== true) {
|
||||
const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, '');
|
||||
const allow = _normalizeHostList(allowedHosts);
|
||||
if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改
|
||||
}
|
||||
rest = rest.substring(hostMatch[0].length);
|
||||
}
|
||||
// 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx)
|
||||
const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i);
|
||||
if (!pageMatch) return null;
|
||||
const internal = rest.substring(pageMatch.index);
|
||||
return internal || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。
|
||||
* 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。
|
||||
* @param {string|string[]} hosts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function _normalizeHostList(hosts) {
|
||||
if (!hosts) return [];
|
||||
const arr = Array.isArray(hosts) ? hosts : [hosts];
|
||||
const out = [];
|
||||
for (const h of arr) {
|
||||
if (!h || typeof h !== 'string') continue;
|
||||
let s = h.trim().toLowerCase();
|
||||
const m = s.match(/^https?:\/\/([^/]*)/i);
|
||||
if (m) s = m[1] || '';
|
||||
else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path
|
||||
s = s.replace(/:\d+$/, ''); // 去端口
|
||||
if (s) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。
|
||||
*
|
||||
* 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode):
|
||||
* - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致)
|
||||
* - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view)
|
||||
* - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上
|
||||
*
|
||||
* @param {Object} link
|
||||
* @returns {'internal'|'online'|'auto'}
|
||||
*/
|
||||
export function resolveNavMode(link) {
|
||||
if (!link || typeof link !== 'object') return 'auto';
|
||||
const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||||
const raw = link.open_mode != null ? link.open_mode
|
||||
: (link.openMode != null ? link.openMode
|
||||
: (pp.open_mode != null ? pp.open_mode : pp.openMode));
|
||||
const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : '';
|
||||
if (m === 'internal' || m === 'spa') return 'internal';
|
||||
if (m === 'online' || m === 'web') return 'online';
|
||||
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
|
||||
// =============================================================
|
||||
@@ -484,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)) {
|
||||
@@ -516,7 +697,16 @@ export function _safeEncodeForPlatform(v) {
|
||||
once = s;
|
||||
}
|
||||
|
||||
// 4 平台都主动 encode 一次
|
||||
const platform = getPlatform();
|
||||
|
||||
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
|
||||
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
|
||||
if (['mp-weixin','mp-harmony'].includes(platform)) {
|
||||
return _encodeUrlDelimiters(once);
|
||||
}
|
||||
|
||||
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
|
||||
return encodeURIComponent(once);
|
||||
}
|
||||
|
||||
@@ -616,6 +806,7 @@ export function createNavigateApi(opts = {}) {
|
||||
cleanParamUrls,
|
||||
normalizeUrl,
|
||||
appendQueryParams,
|
||||
extractInternalAppPath,
|
||||
parsePageParams,
|
||||
resolveLinkUrl,
|
||||
decodeEntity,
|
||||
|
||||
3842
common/js/util.js
3842
common/js/util.js
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,35 @@
|
||||
<template>
|
||||
<view data-component-name="diy-bottom-nav" v-if="shouldShowTabBar">
|
||||
<view class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
||||
<view class="tabbar-border"></view>
|
||||
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
||||
<view class="bd">
|
||||
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
||||
:animation="isCartItem(item.link) ? cartAnimation : null"
|
||||
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
||||
<block v-if="verify(item.link)">
|
||||
<image v-if="item.selected_icon_type == 'img'"
|
||||
:src="$util.img(item.selectedIconPath)" />
|
||||
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
||||
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
||||
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
||||
:value="item.style ? item.style : null"></diy-icon>
|
||||
</block>
|
||||
<view class="cart-count-mark font-size-activity-tag"
|
||||
:class="{ max: cartNumber > 99 }"
|
||||
:style="{ background: 'var(--price-color)' }"
|
||||
v-if="isCartItem(item.link) && cartNumber > 0">
|
||||
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
||||
<!-- 修复底部 TabBar 频闪:
|
||||
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
|
||||
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
|
||||
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
|
||||
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
|
||||
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
|
||||
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
||||
<view class="tabbar-border"></view>
|
||||
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
||||
<view class="bd">
|
||||
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
||||
:animation="isCartItem(item.link) ? cartAnimation : null"
|
||||
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
||||
<block v-if="verify(item.link)">
|
||||
<image v-if="item.selected_icon_type == 'img'"
|
||||
:src="$util.img(item.selectedIconPath)" />
|
||||
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
||||
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
||||
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
||||
:value="item.style ? item.style : null"></diy-icon>
|
||||
</block>
|
||||
<view class="cart-count-mark font-size-activity-tag"
|
||||
:class="{ max: cartNumber > 99 }"
|
||||
:style="{ background: 'var(--price-color)' }"
|
||||
v-if="isCartItem(item.link) && cartNumber > 0">
|
||||
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="label"
|
||||
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
|
||||
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
|
||||
@@ -38,7 +43,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 解决fixed定位后底部导航栏塌陷问题 -->
|
||||
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
|
||||
<view class="tab-bar-placeholder"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<view data-component-name="diy-category" :class="['category-page-wrap', 'category-template-' + value.template]"
|
||||
:style="{ height: 'calc(100vh - ' + tabBarHeight + ')' }">
|
||||
<!-- 模板4: 顶部搜索框(图片样式) -->
|
||||
<view class="search-box search-box-template-4" v-if="value.template == 4 && value.search"
|
||||
@tap.stop="onClickSearch()">
|
||||
<view class="search-content">
|
||||
<input type="text" class="uni-input" maxlength="50" :placeholder="$lang('components-diy.diy-search.searchBtn')"
|
||||
confirm-type="search" @tap.stop="onClickSearch()" disabled="true" />
|
||||
<text class="iconfont icon-sousuo3"></text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- <block v-if="value.template == 4">
|
||||
<view class="search-box" v-if="value.search" @tap.stop="$util.redirectTo('/pages_tool/goods/search')" :style="navbarInnerStyle">
|
||||
@@ -28,7 +37,7 @@
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || MP-HARMONY -->
|
||||
|
||||
<view class="search-box" v-if="value.search" @tap.stop="onClickSearch()">
|
||||
<view class="search-box" v-if="value.search && value.template != 4" @tap.stop="onClickSearch()">
|
||||
<view class="search-content">
|
||||
<input type="text" class="uni-input" maxlength="50" :placeholder="$lang('components-diy.diy-search.searchBtn')" confirm-type="search"
|
||||
@tap.stop="onClickSearch()" disabled="true" />
|
||||
@@ -77,35 +86,88 @@
|
||||
</uni-popup>
|
||||
</view>
|
||||
|
||||
<view class="content-box" v-if="categoryTree">
|
||||
<!-- 全屏骨架占位 (只显示: 左分类树 + 右商品网格 + 底部空隙, 不画 navbar/TabBar,
|
||||
避免与小程序/H5 顶部 navbar / diy-bottom-nav 错位)。
|
||||
通过 v-if 拿到 categoryTreeLoading 时按真实布局骨架填充。
|
||||
search-box / 商品 nav-bar 由外层 category.vue 渲染, 不在本组件里动它 -->
|
||||
<view class="category-page-skeleton" v-if="categoryTreeLoading">
|
||||
<view class="skeleton-content">
|
||||
<view class="skeleton-tree">
|
||||
<view class="skeleton-tree-item" v-for="n in 8" :key="'sk-tree-' + n"></view>
|
||||
</view>
|
||||
<view class="skeleton-grid">
|
||||
<view class="skeleton-card" v-for="n in 6" :key="'sk-card-' + n"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="content-box" v-else-if="categoryTree">
|
||||
<block v-if="categoryTree.length">
|
||||
<scroll-view scroll-y="true" class="tree-wrap">
|
||||
<view class="category-item-wrap">
|
||||
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
|
||||
{ select: select == index },
|
||||
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
||||
{ 'border-top': value.template == 4 && select - 1 === index }
|
||||
]" @tap.stop="switchOneCategory(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
<!-- 模板4: 左侧显示一级分类(顶部轮播中的分类) -->
|
||||
<view v-if="value.template == 4">
|
||||
<view class="category-item" v-for="(item, index) in templateFourData" :key="'one-' + index" :class="[
|
||||
{ select: oneCategorySelect == index },
|
||||
{ 'border-bottom': oneCategorySelect + 1 === index },
|
||||
{ 'border-top': oneCategorySelect - 1 === index }
|
||||
]" @tap.stop="templateFourOneFn(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 其他模板: 左侧显示二级分类 -->
|
||||
<view v-else>
|
||||
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
|
||||
{ select: select == index },
|
||||
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
||||
{ 'border-top': value.template == 4 && select - 1 === index }
|
||||
]" @tap.stop="switchOneCategory(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="right-flex-wrap">
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-if="value.template == 1 || loadType == 'all'"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
||||
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
|
||||
@refresherrestore="onRestore">
|
||||
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@loadfinish="getHeightArea"></diy-category-item>
|
||||
</view>
|
||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||
</scroll-view>
|
||||
<!-- 模板99 + 二级分类: 全局二级分类按钮(去重, 一直显示) + 商品列表 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-if="value.template == 99 && template99Category"
|
||||
:refresher-enabled="true" refresher-default-style="none"
|
||||
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
|
||||
<diy-category-item :category="template99Category" :value="value" ref="categoryItem" :index="0" :select="0"
|
||||
:oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
:currentOneCategory="categoryTree && categoryTree[select] ? categoryTree[select] : null"
|
||||
:lastSecondCategoryName="lastSecondCategoryName"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, 0)" @addCart="addCartPoint"
|
||||
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 模板1: 仿京东/淘宝商品分类 - 按 template 2 的 part-load 逻辑, 切换一级分类时只渲染选中分类的子分类网格 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 1"
|
||||
:refresher-enabled="true" refresher-default-style="none"
|
||||
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
|
||||
<view class="child-category-wrap" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index" :style="{ display: select == index ? 'block' : 'none' }">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
||||
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
|
||||
@refresherrestore="onRestore">
|
||||
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index" :style="{ display: value.template == 99 && select != index ? 'none' : 'block' }">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="content-wrap"
|
||||
v-if="(value.template == 2 || value.template == 3 || value.template == 4) && loadType == 'part'">
|
||||
@@ -114,6 +176,7 @@
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:last="index == categoryTree.length - 1 ? true : false" :select="select"
|
||||
:oneCategorySelect="oneCategorySelect" :isList="isList" @tologin="toLogin"
|
||||
:currentOneCategory="value.template == 4 && templateFourData[oneCategorySelect] ? templateFourData[oneCategorySelect] : null"
|
||||
@selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@switch="switchOneCategory"></diy-category-item>
|
||||
</view>
|
||||
@@ -125,14 +188,13 @@
|
||||
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="category-empty" v-else>
|
||||
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="cart-box" v-if="(value.template == 2 || value.template == 4) && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
||||
<!-- 模板4: 底部购物车栏 -->
|
||||
<view class="cart-box cart-box-template-4"
|
||||
v-if="value.template == 4 && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
||||
<view class="left-wrap">
|
||||
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation" @tap.stop="$util.redirectTo('/pages_goods/cart')">
|
||||
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation"
|
||||
@tap.stop="$util.redirectTo('/pages_goods/cart')">
|
||||
<text class="iconfont icon-ziyuan1"></text>
|
||||
<view class="num" v-if="cartNumber">{{ cartNumber < 99 ? cartNumber : '99+' }}</view>
|
||||
</view>
|
||||
@@ -143,12 +205,13 @@
|
||||
<text class="unit font-size-tag price-font">.{{ cartTotalMoney[1] ? cartTotalMoney[1] : '00' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="right-wrap"><button type="primary" class="settlement-btn" @tap.stop="settlement">去结算</button>
|
||||
<view class="right-wrap"><button type="primary" class="settlement-btn"
|
||||
@tap.stop="settlement">去结算</button>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="cart-point" :style="{ left: item.left + 'px', top: item.top + 'px' }" :key="index"
|
||||
v-for="(item, index) in carIconList"></view>
|
||||
<view class="cart-point" v-for="(item, index) in carIconList" :key="index"
|
||||
:style="{ left: item.left + 'px', top: item.top + 'px' }"></view>
|
||||
|
||||
<ns-goods-sku-category ref="skuSelect" @refresh="refreshData" @addCart="addCartPoint"></ns-goods-sku-category>
|
||||
</view>
|
||||
@@ -194,12 +257,22 @@ export default {
|
||||
loadType: '',
|
||||
templateFourData: [],
|
||||
isList: false,
|
||||
lang: uni.getStorageSync("lang")//en-us 英文
|
||||
lang: uni.getStorageSync("lang"),
|
||||
lastSecondCategoryName: '',
|
||||
template99Category: null,
|
||||
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
|
||||
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
|
||||
// 显示骨架占位而非空态文案
|
||||
categoryTreeLoading: true
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getCategoryTree();
|
||||
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
|
||||
// 模板4: 默认单列列表布局(图片样式)
|
||||
if (this.value.template == 4) {
|
||||
this.isList = true;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
query = uni.createSelectorQuery().in(this);
|
||||
@@ -308,6 +381,13 @@ export default {
|
||||
* 加防御性检查,避免上线后第一次进入崩溃
|
||||
*/
|
||||
getHeightArea(index) {
|
||||
// 节流: 500ms 内的重复请求复用上次结果, 避免每次 onShow 都跑 selectAll 触发频闪
|
||||
const now = Date.now();
|
||||
if (this._lastHeightAreaTime && now - this._lastHeightAreaTime < 500 && this.heightArea && this.heightArea.length) {
|
||||
if (index != -1 && index < this.categoryTree.length - 1 && Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[index + 1]) this.$refs.categoryItem[index + 1].getGoodsList();
|
||||
return;
|
||||
}
|
||||
this._lastHeightAreaTime = now;
|
||||
let heightArea = [];
|
||||
if (!query || typeof query.selectAll !== 'function') {
|
||||
query = uni.createSelectorQuery && uni.createSelectorQuery().in(this);
|
||||
@@ -331,12 +411,13 @@ export default {
|
||||
})
|
||||
.exec();
|
||||
this.heightArea = heightArea;
|
||||
if (index != -1 && index < this.categoryTree.length - 1) this.$refs.categoryItem[index + 1].getGoodsList();
|
||||
if (index != -1 && index < this.categoryTree.length - 1 && Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[index + 1]) this.$refs.categoryItem[index + 1].getGoodsList();
|
||||
},
|
||||
/**
|
||||
* 获取全部分类
|
||||
*/
|
||||
getCategoryTree() {
|
||||
this.categoryTreeLoading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goodscategory/tree',
|
||||
data: {
|
||||
@@ -361,10 +442,85 @@ export default {
|
||||
return item;
|
||||
});
|
||||
}
|
||||
// 模板99 + 二级分类: 全局查询所有有商品的二级分类(去重), 按钮一直显示
|
||||
if (this.value.template == 99) {
|
||||
this.loadGlobalSecondCategoryBtns();
|
||||
}
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 失败也保证 loading 关闭, 让骨架替换为"暂无分类"空态
|
||||
this.categoryTree = this.categoryTree || [];
|
||||
},
|
||||
complete: () => {
|
||||
// 无论成功失败都关闭 loading 骨架
|
||||
this.categoryTreeLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 全局查询所有有商品的二级分类, 按名称去重, 汇总成虚拟分类供 template99 渲染按钮
|
||||
* 只要任何商品属于该二级分类, 按钮就显示; 切换一级分类时按钮不变(一直显示)
|
||||
* 新增的二级分类只要有商品也会自动包含
|
||||
*/
|
||||
loadGlobalSecondCategoryBtns() {
|
||||
if (!this.categoryTree || !this.categoryTree.length) {
|
||||
this.template99Category = { category_name: '', child_list: [] };
|
||||
return;
|
||||
}
|
||||
// 收集所有二级分类(保留原始顺序, 不去重, 查询所有同名后再去重)
|
||||
const allSecond = [];
|
||||
this.categoryTree.forEach(one => {
|
||||
if (one.child_list && one.child_list.length) {
|
||||
one.child_list.forEach(two => {
|
||||
allSecond.push(Object.assign({ _origIndex: allSecond.length }, two));
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!allSecond.length) {
|
||||
this.template99Category = { category_name: '', child_list: [] };
|
||||
return;
|
||||
}
|
||||
let pending = allSecond.length;
|
||||
const results = [];
|
||||
const finishOne = () => {
|
||||
pending--;
|
||||
if (pending <= 0) {
|
||||
results.sort((a, b) => a._origIndex - b._origIndex);
|
||||
// 按名称去重, 同名只保留第一个有商品的(只要任一同名有商品就显示)
|
||||
const deduped = [];
|
||||
const seenNames = {};
|
||||
results.forEach(item => {
|
||||
if (!seenNames[item.category_name]) {
|
||||
seenNames[item.category_name] = true;
|
||||
deduped.push(item);
|
||||
}
|
||||
});
|
||||
this.template99Category = {
|
||||
category_name: '',
|
||||
child_list: deduped
|
||||
};
|
||||
}
|
||||
};
|
||||
allSecond.forEach(two => {
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goodssku/pageByCategory',
|
||||
data: {
|
||||
category_id: two.category_id,
|
||||
page: 1,
|
||||
page_size: 1
|
||||
},
|
||||
success: res => {
|
||||
if (res && res.code == 0 && res.data && res.data.list && res.data.list.length > 0) {
|
||||
results.push(two);
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
finishOne();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 切换一级分类
|
||||
* @param {Object} index
|
||||
@@ -373,9 +529,11 @@ export default {
|
||||
if (index >= this.categoryTree.length) return;
|
||||
this.select = index;
|
||||
this.categoryId = 'category-' + index;
|
||||
// 阻止切换分类之后滚动事件也立即执行
|
||||
this.scrollLock = true;
|
||||
},
|
||||
saveSecondCategoryName(name) {
|
||||
this.lastSecondCategoryName = name;
|
||||
},
|
||||
touchStart() {
|
||||
this.scrollLock = false;
|
||||
},
|
||||
@@ -393,10 +551,10 @@ export default {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
|
||||
scrollTop - contentWrapHeight < 300) {
|
||||
this.$refs.categoryItem[this.select].getGoodsList();
|
||||
}
|
||||
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
|
||||
scrollTop - contentWrapHeight < 300) {
|
||||
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
|
||||
}
|
||||
}
|
||||
},
|
||||
onRefresh() {
|
||||
@@ -549,7 +707,7 @@ export default {
|
||||
},
|
||||
// 操作多规格商品弹框后,刷新商品数据
|
||||
refreshData() {
|
||||
this.$refs.categoryItem[this.select].loadGoodsCartNum(true);
|
||||
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].loadGoodsCartNum(true);
|
||||
},
|
||||
changeListStyle() {
|
||||
this.isList = !this.isList;
|
||||
@@ -822,8 +980,217 @@ export default {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 全屏骨架占位 — 不画 navbar/TabBar (由外层 category.vue 渲染), 避免错位
|
||||
// 兼容鸿蒙元服务 (JSVM 对 gap 支持不一): 用 margin 兜底替代 gap
|
||||
.category-page-skeleton {
|
||||
flex: 1;
|
||||
height: 0; // 与 .content-box 一致, 让 flex 子项撑开父容器剩余高度
|
||||
display: flex;
|
||||
background-color: #f4f6fa;
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
.skeleton-tree {
|
||||
width: 170rpx;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20rpx 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// #ifndef MP-HARMONY
|
||||
gap: 18rpx;
|
||||
// #endif
|
||||
flex-shrink: 0;
|
||||
|
||||
.skeleton-tree-item {
|
||||
height: 40rpx;
|
||||
// #ifndef MP-HARMONY
|
||||
margin: 0 28rpx;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
margin: 9rpx 28rpx; // 18rpx 间距 上下各 9rpx, 替代 gap:18rpx
|
||||
// #endif
|
||||
|
||||
border-radius: 12rpx;
|
||||
background: linear-gradient(90deg, #ececec 25%, #f5f5f5 37%, #ececec 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: category-skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
// #ifndef MP-HARMONY
|
||||
gap: 16rpx;
|
||||
// #endif
|
||||
background-color: #f4f6fa;
|
||||
min-width: 0;
|
||||
|
||||
.skeleton-card {
|
||||
width: calc((100% - 32rpx) / 3);
|
||||
height: 220rpx;
|
||||
// #ifdef MP-HARMONY
|
||||
margin: 8rpx;
|
||||
// #endif
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(90deg, #ececec 25%, #f5f5f5 37%, #ececec 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: category-skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes category-skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// 风格四
|
||||
.category-template-4 {
|
||||
// 模板4 顶部搜索框(图片样式)
|
||||
.search-box-template-4 {
|
||||
padding: 20rpx 30rpx 16rpx;
|
||||
background: #fff;
|
||||
|
||||
.search-content {
|
||||
height: 64rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
|
||||
input {
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
padding: 0 30rpx 0 30rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
color: #89899a;
|
||||
width: 70rpx;
|
||||
text-align: center;
|
||||
right: 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板4 底部购物车栏(图片样式)
|
||||
.cart-box-template-4 {
|
||||
height: 110rpx;
|
||||
padding: 0 30rpx;
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
.left-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cart-icon {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
position: relative;
|
||||
margin-top: -10rpx;
|
||||
|
||||
.iconfont {
|
||||
color: var(--btn-text-color, #fff);
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background-color: $base-color;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
|
||||
}
|
||||
|
||||
.num {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(20%, 0);
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
padding: 0 8rpx;
|
||||
min-width: 32rpx;
|
||||
border-radius: 18rpx;
|
||||
background-color: var(--price-color, #FF6B35);
|
||||
border: 2rpx solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
line-height: 1;
|
||||
|
||||
.title {
|
||||
color: #333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.unit,
|
||||
.money {
|
||||
color: var(--price-color, #FF6B35);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.money {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settlement-btn {
|
||||
margin: 0;
|
||||
width: 220rpx;
|
||||
font-weight: bold;
|
||||
border-radius: 50rpx;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
font-size: 30rpx;
|
||||
background: $base-color;
|
||||
color: var(--btn-text-color, #fff);
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
.search-content input {
|
||||
background-color: #f1f1f1;
|
||||
@@ -1050,4 +1417,226 @@ export default {
|
||||
border-bottom-right-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板1 仿京东/淘宝商品分类样式 =====================
|
||||
.category-template-1 {
|
||||
.search-box {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
|
||||
.search-content {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
|
||||
input {
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
right: 16rpx;
|
||||
color: #89899a;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-box {
|
||||
background: #fff;
|
||||
|
||||
.tree-wrap {
|
||||
width: 170rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.right-flex-wrap {
|
||||
background: #fff;
|
||||
|
||||
.content-wrap {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
|
||||
::v-deep .category-adv {
|
||||
padding: 0 20rpx;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
::v-deep .category-list {
|
||||
margin: 0 20rpx;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep .category-title-with-lines {
|
||||
padding: 30rpx 0 20rpx;
|
||||
}
|
||||
|
||||
// 穿透修复: 图片不需要灰色背景框,与目标图保持一致
|
||||
::v-deep .img-box {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
width: 70% !important;
|
||||
padding-bottom: 70% !important;
|
||||
margin-bottom: 16rpx !important;
|
||||
}
|
||||
|
||||
::v-deep .img-box image {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
// 左侧分类栏样式
|
||||
.tree-wrap .category-item {
|
||||
padding: 28rpx 16rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 0;
|
||||
|
||||
view {
|
||||
color: #666;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.border-top,
|
||||
&.border-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: #fff;
|
||||
|
||||
view {
|
||||
color: var(--base-color, #FF6B35);
|
||||
font-weight: bold;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
width: 6rpx;
|
||||
height: 32rpx;
|
||||
background: var(--base-color, #FF6B35);
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0 6rpx 6rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板99 商品分类样式 =====================
|
||||
.category-template-99 {
|
||||
.search-box {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
.search-content {
|
||||
height: 64rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
|
||||
input {
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
padding: 0 80rpx 0 30rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
color: #89899a;
|
||||
width: 70rpx;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
> .iconfont {
|
||||
font-size: 40rpx;
|
||||
color: #666;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.content-box {
|
||||
.tree-wrap {
|
||||
width: 200rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.right-flex-wrap {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-wrap .category-item {
|
||||
padding: 30rpx 16rpx;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
view {
|
||||
color: #333;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: #fff;
|
||||
|
||||
view {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
width: 6rpx;
|
||||
height: 36rpx;
|
||||
background: #FF4646;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0 6rpx 6rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
background: #f5f5f5;
|
||||
padding: 0;
|
||||
// #ifdef H5
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
.child-category {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
|
||||
<!-- Banner -->
|
||||
<view v-if="value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
|
||||
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
|
||||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
||||
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
||||
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
||||
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
|
||||
</view>
|
||||
<!-- 提示语 -->
|
||||
<view v-if="value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
|
||||
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
@@ -70,13 +75,15 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle === 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
var v = this.value || {};
|
||||
css += 'background-color:' + (v.contentBgColor || '') + ';';
|
||||
if (v.elementAngle === 'round') {
|
||||
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
},
|
||||
@@ -182,6 +189,9 @@ export default {
|
||||
labelColor: pick('labelColor', 'label_color'),
|
||||
requiredColor: pick('requiredColor', 'required_color'),
|
||||
checkedColor: pick('checkedColor', 'checked_color'),
|
||||
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
submitText: pick('submitText', 'submit_text'),
|
||||
submitColor: pick('submitColor', 'submit_color'),
|
||||
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
||||
|
||||
@@ -42,82 +42,92 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.loading-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 997;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.loading-anim {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 40%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.loading-anim > .item {
|
||||
position: relative;
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
perspective: 1600rpx;
|
||||
transform-style: preserve-3d;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.loading-anim .border {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid;
|
||||
}
|
||||
|
||||
.loading-anim .out {
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
|
||||
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
|
||||
border-left-color: var(--base-color, #FF4646);
|
||||
border-right-color: #C5C5C5 !important;
|
||||
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
border-top-color: var(--base-color, #FF4646);
|
||||
border-bottom-color: #C5C5C5 !important;
|
||||
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
animation: spin 0.6s linear normal infinite;
|
||||
}
|
||||
|
||||
.loading-anim .in {
|
||||
top: 25%;
|
||||
left: 25%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-anim .mid {
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
<style lang="scss">
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.loading-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 997;
|
||||
background: #f8f8f8;
|
||||
// 修复: 强制合成层, 让 spin 动画只在自身合成层跑, 不触发兄弟节点 (尤其是底部 TabBar) 重绘
|
||||
// 兼容环境: 微信小程序 / 鸿蒙元服务 / H5 都受益
|
||||
// - 微信小程序: spin 动画每帧导致兄弟 fixed 元素 TabBar 重绘
|
||||
// - 鸿蒙元服务 JSVM: 同上, JSVM 重绘更明显
|
||||
// - H5: will-change 提示浏览器持续合成
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.loading-anim {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 40%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.loading-anim > .item {
|
||||
position: relative;
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
perspective: 1600rpx;
|
||||
transform-style: preserve-3d;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.loading-anim .border {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid;
|
||||
}
|
||||
|
||||
.loading-anim .out {
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
|
||||
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
|
||||
border-left-color: var(--base-color, #FF4646);
|
||||
border-right-color: #C5C5C5 !important;
|
||||
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
border-top-color: var(--base-color, #FF4646);
|
||||
border-bottom-color: #C5C5C5 !important;
|
||||
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
animation: spin 0.6s linear normal infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.loading-anim .in {
|
||||
top: 25%;
|
||||
left: 25%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
animation: spin 0.8s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.loading-anim .mid {
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
border-left-color: transparent;
|
||||
animation: spin 0.6s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,59 @@
|
||||
<template>
|
||||
<view class="form-wrap form-component">
|
||||
<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="form-wrap form-component" :style="formWrapStyle">
|
||||
<block v-for="(item, index) in formData" :key="index">
|
||||
<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,61 +352,7 @@ 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 对齐
|
||||
const defaults = {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,14 @@ export const lang = {
|
||||
serviceUnavailable: 'Navigation service temporarily unavailable',
|
||||
},
|
||||
|
||||
// diy custom page
|
||||
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
|
||||
diy: {
|
||||
networkError: 'Network error, please try again later',
|
||||
networkErrorTitle: 'Notice',
|
||||
retry: 'Retry',
|
||||
noDiyData: 'Custom page data not configured',
|
||||
locationNotAvailable: 'Location not available',
|
||||
// detailed data-load failure template (diy-specific, {reason} is filled by generic http.*)
|
||||
loadFailTpl: 'Failed to load page data: {reason}, please try again later',
|
||||
},
|
||||
|
||||
//customer service
|
||||
@@ -134,12 +137,18 @@ export const lang = {
|
||||
requestFailed: '{url} request failed. {err}',
|
||||
// network timeout
|
||||
timeout: 'Network request timeout, please try again later',
|
||||
// network connection failed
|
||||
networkError: 'Network connection failed, please check your network',
|
||||
// network connection failed / generic network error (unified copy, reused by diy and other generic scenarios)
|
||||
networkError: 'Network error, please try again later',
|
||||
// server error
|
||||
serverError: 'Service temporarily unavailable, please try again later',
|
||||
serverError: 'Server error, please try again later',
|
||||
// API not found (HTTP 404)
|
||||
notFound: 'API not found',
|
||||
// no access permission (HTTP 401 / 403)
|
||||
auth: 'No access permission',
|
||||
// request aborted
|
||||
aborted: 'Request cancelled',
|
||||
// unknown error (errorType fallback, e.g. non-5xx/404/401/403 HTTP status)
|
||||
unknown: 'Request failed, please try again later',
|
||||
// unknown error
|
||||
unknownError: 'Request failed, please try again later',
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ export const lang = {
|
||||
|
||||
// Product Message
|
||||
productMessage: 'Product Message',
|
||||
formEmptyTip: 'No message fields to fill',
|
||||
|
||||
// AI Customer Service
|
||||
aiCustomerService: 'AI Assistant',
|
||||
|
||||
@@ -61,11 +61,14 @@ export const lang = {
|
||||
weChatWorkKefu: '企业微信客服',
|
||||
},
|
||||
|
||||
// diy 自定义页面
|
||||
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
|
||||
diy: {
|
||||
networkError: '网络异常,请稍后重试',
|
||||
networkErrorTitle: '提示',
|
||||
retry: '重试',
|
||||
noDiyData: '未配置自定义页面数据',
|
||||
locationNotAvailable: '未获取到定位',
|
||||
// 数据加载失败详细文案(diy 专属模板,内部 {reason} 由通用 http.* 填充)
|
||||
loadFailTpl: '页面数据加载失败:{reason},请稍后重试',
|
||||
},
|
||||
|
||||
// 客服服务
|
||||
@@ -134,12 +137,18 @@ export const lang = {
|
||||
requestFailed: '{url}请求失败. {err}',
|
||||
// 网络超时
|
||||
timeout: '网络请求超时,请稍后重试',
|
||||
// 网络连接失败
|
||||
networkError: '网络连接失败,请检查网络设置',
|
||||
// 网络连接失败 / 通用网络异常(统一文案,供 diy 等通用场景复用)
|
||||
networkError: '网络异常,请稍后重试',
|
||||
// 服务器错误
|
||||
serverError: '服务暂时不可用,请稍后重试',
|
||||
serverError: '服务器异常,请稍后重试',
|
||||
// 接口不存在(HTTP 404)
|
||||
notFound: '接口不存在',
|
||||
// 无访问权限(HTTP 401 / 403)
|
||||
auth: '无访问权限',
|
||||
// 请求被中断
|
||||
aborted: '请求已取消',
|
||||
// 未知错误(errorType 分类兜底,如非 5xx/404/401/403 的 HTTP 状态码)
|
||||
unknown: '请求失败,请稍后重试',
|
||||
// 未知错误
|
||||
unknownError: '请求失败,请稍后重试',
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@ export const lang = {
|
||||
|
||||
// 商品留言
|
||||
productMessage: '商品留言',
|
||||
formEmptyTip: '暂无需填写的留言字段',
|
||||
|
||||
// 智能客服
|
||||
aiCustomerService: '智能客服',
|
||||
|
||||
173
pages.json
173
pages.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
@@ -151,7 +151,7 @@
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "cart",
|
||||
"style": {
|
||||
@@ -363,7 +363,7 @@
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "",
|
||||
"navigationBarTitleText": "",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
@@ -635,7 +635,7 @@
|
||||
//****************** 商户 ******************
|
||||
{
|
||||
"path": "merch/merchcategory",
|
||||
"style": {
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
@@ -960,10 +960,10 @@
|
||||
{
|
||||
"path": "fenxiao/withdrawal_detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
// #ifdef APP-PLUS
|
||||
,"navigationStyle": "custom"
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1084,8 +1084,8 @@
|
||||
"pages": [
|
||||
{
|
||||
"path": "ai-chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能客服"
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能客服"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1093,7 +1093,7 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "vr/index",
|
||||
"style": {
|
||||
@@ -2208,7 +2208,7 @@
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "weapp/order_shipping",
|
||||
"style": {
|
||||
@@ -2314,78 +2314,78 @@
|
||||
},
|
||||
//******************文件模块******************
|
||||
{
|
||||
"path": "files/list",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "查看文件",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
"diy-bargain": "view",
|
||||
"diy-bottom-nav": "view",
|
||||
"diy-category": "view",
|
||||
"diy-category-item": "view",
|
||||
"diy-channel-list": "view",
|
||||
"diy-channel-video": "view",
|
||||
"diy-comp-extend": "view",
|
||||
"diy-coupon": "view",
|
||||
"diy-digit": "view",
|
||||
"diy-fenxiao-goods-list": "view",
|
||||
"diy-float-btn": "view",
|
||||
"diy-follow-official-account": "view",
|
||||
"diy-form": "view",
|
||||
"diy-goods-brand": "view",
|
||||
"diy-goods-list": "view",
|
||||
"diy-goods-recommend": "view",
|
||||
"diy-graphic-nav": "view",
|
||||
"diy-group": "view",
|
||||
"diy-groupbuy": "view",
|
||||
"diy-horz-blank": "view",
|
||||
"diy-horz-line": "view",
|
||||
"diy-hot-area": "view",
|
||||
"diy-icon": "view",
|
||||
"diy-image-nav": "view",
|
||||
"diy-img-ads": "view",
|
||||
"diy-index-page": "view",
|
||||
"diy-kefu": "view",
|
||||
"diy-listmenu": "view",
|
||||
"diy-live": "view",
|
||||
"diy-many-goods-list": "view",
|
||||
"diy-map": "view",
|
||||
"diy-member-info": "view",
|
||||
"diy-member-my-order": "view",
|
||||
"diy-merch-list": "view",
|
||||
"diy-notes": "view",
|
||||
"diy-notice": "view",
|
||||
"diy-payment-qrcode": "view",
|
||||
"diy-picture": "view",
|
||||
"diy-pinfan": "view",
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
"diy-seckill": "view",
|
||||
"diy-store": "view",
|
||||
"diy-store-label": "view",
|
||||
"diy-tab": "view",
|
||||
"diy-text": "view",
|
||||
"diy-video": "view",
|
||||
"diy-video-list": "view"
|
||||
"path": "files/list",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "查看文件",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
"diy-bargain": "view",
|
||||
"diy-bottom-nav": "view",
|
||||
"diy-category": "view",
|
||||
"diy-category-item": "view",
|
||||
"diy-channel-list": "view",
|
||||
"diy-channel-video": "view",
|
||||
"diy-comp-extend": "view",
|
||||
"diy-coupon": "view",
|
||||
"diy-digit": "view",
|
||||
"diy-fenxiao-goods-list": "view",
|
||||
"diy-float-btn": "view",
|
||||
"diy-follow-official-account": "view",
|
||||
"diy-form": "view",
|
||||
"diy-goods-brand": "view",
|
||||
"diy-goods-list": "view",
|
||||
"diy-goods-recommend": "view",
|
||||
"diy-graphic-nav": "view",
|
||||
"diy-group": "view",
|
||||
"diy-groupbuy": "view",
|
||||
"diy-horz-blank": "view",
|
||||
"diy-horz-line": "view",
|
||||
"diy-hot-area": "view",
|
||||
"diy-icon": "view",
|
||||
"diy-image-nav": "view",
|
||||
"diy-img-ads": "view",
|
||||
"diy-index-page": "view",
|
||||
"diy-kefu": "view",
|
||||
"diy-listmenu": "view",
|
||||
"diy-live": "view",
|
||||
"diy-many-goods-list": "view",
|
||||
"diy-map": "view",
|
||||
"diy-member-info": "view",
|
||||
"diy-member-my-order": "view",
|
||||
"diy-merch-list": "view",
|
||||
"diy-notes": "view",
|
||||
"diy-notice": "view",
|
||||
"diy-payment-qrcode": "view",
|
||||
"diy-picture": "view",
|
||||
"diy-pinfan": "view",
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
"diy-seckill": "view",
|
||||
"diy-store": "view",
|
||||
"diy-store-label": "view",
|
||||
"diy-tab": "view",
|
||||
"diy-text": "view",
|
||||
"diy-video": "view",
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
@@ -2416,14 +2416,12 @@
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"custom": {
|
||||
"x-skeleton": "@/uni_modules/x-skeleton/components/x-skeleton/x-skeleton.vue",
|
||||
"mp-html": "@/uni_modules/mp-html/components/mp-html/mp-html.vue",
|
||||
"mp-html": "@/uni_modules/mp-html/components/mp-html/mp-html.vue",
|
||||
"uni-*(\\W.*)": "@/uni_modules/uni$1/components/uni$1/uni$1.vue",
|
||||
|
||||
"mescroll-uni": "@/components/mescroll/my-list-mescroll.vue",
|
||||
"ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue",
|
||||
|
||||
"ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue",
|
||||
"diy-*(\\W.*)": "@/components-diy/diy$1.vue"
|
||||
}
|
||||
},
|
||||
@@ -2431,8 +2429,9 @@
|
||||
"pages/index/index": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"pages_tool"
|
||||
"pages_tool",
|
||||
"components-diy"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,15 @@
|
||||
<view :style="themeColor">
|
||||
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
|
||||
class="page-img">
|
||||
<!-- diy 数据网络加载失败提示(页面内横幅,各端可靠,悬浮在最上层) -->
|
||||
<view v-if="diyLoadError" class="diy-load-error">
|
||||
<view class="diy-load-error__body">
|
||||
<text class="diy-load-error__msg">{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}</text>
|
||||
<text v-if="diyLoadErrorUrl" class="diy-load-error__url" selectable user-select>{{ diyLoadErrorUrl }}</text>
|
||||
</view>
|
||||
<view class="diy-load-error__btn" @tap="retryDiyInfo">{{ $lang('diy.retry') || '重试' }}</view>
|
||||
</view>
|
||||
|
||||
<view class="site-info-box"
|
||||
v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode">
|
||||
<view class="site-info">
|
||||
@@ -179,6 +188,52 @@ export default {
|
||||
z-index: 122;
|
||||
}
|
||||
|
||||
.diy-load-error {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 28rpx;
|
||||
padding-top: calc(24rpx + var(--status-bar-height, 0px));
|
||||
background-color: #fff5f5;
|
||||
border-bottom: 1rpx solid #ffccc7;
|
||||
}
|
||||
|
||||
.diy-load-error__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diy-load-error__msg {
|
||||
font-size: 26rpx;
|
||||
color: #cf1322;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diy-load-error__url {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diy-load-error__btn {
|
||||
margin-left: 20rpx;
|
||||
padding: 10rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #fff;
|
||||
background-color: #cf1322;
|
||||
border-radius: 32rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.small-bot swiper {
|
||||
width: 560rpx;
|
||||
height: 784rpx;
|
||||
|
||||
@@ -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);
|
||||
try { splashState.notifyPageReady(); } catch (e) {}
|
||||
},
|
||||
/**
|
||||
* 版本连击 3 次以上显示构建版本信息(供测试人员使用)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<view>
|
||||
<view scroll-y="true" class="goods-detail" :class="isIphoneX ? 'active' : ''">
|
||||
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
|
||||
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 view 子树静默不渲染, 导致整个详情页空白 -->
|
||||
<view class="goods-detail" :class="isIphoneX ? 'active' : ''">
|
||||
<view class="goods-container">
|
||||
<!-- AI客服悬浮按钮 -->
|
||||
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<view :style="themeColor">
|
||||
<view v-show="diyData && diyData.value && diyData.value.length">
|
||||
<block v-for="(item, index) in diyData.value" :key="index">
|
||||
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
|
||||
<view v-if="diyData && diyData.value && diyData.value.length">
|
||||
<block v-for="(item, index) in diyData.value" :key="item.id || item.componentName || index">
|
||||
<view v-if="item.componentName == 'GoodsCategory'">
|
||||
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
|
||||
</view>
|
||||
@@ -15,8 +16,8 @@
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
|
||||
<!-- 底部tabBar -->
|
||||
<view id="tab-bar">
|
||||
<!-- 底部tabBar: loading 期间隐藏, 等 loading-cover 关闭后下一 tick 再挂载, 避免 TabBar 与 loading-cover 同时 GPU 合成导致频闪 -->
|
||||
<view id="tab-bar" v-if="!loadingVisible">
|
||||
<diy-bottom-nav></diy-bottom-nav>
|
||||
</view>
|
||||
</view>
|
||||
@@ -27,7 +28,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
diyData: { value: [] },
|
||||
mpShareData: null //小程序分享数据
|
||||
mpShareData: null, //小程序分享数据
|
||||
// 修复底部 TabBar 频闪: 用本地 loadingVisible 状态镜像 <loading-cover> 的可见性,
|
||||
// loading-cover 的 spin 动画与 diy-bottom-nav 都是 fixed/z-index 高 + GPU 合成,
|
||||
// 在 MP-Weixin 下会互相触发重绘, 让用户看到 TabBar 抖动。
|
||||
// 策略: loading-cover 显示期间, 也把 tabBar 先收起来, loading-cover 隐藏后
|
||||
// 下一次 nextTick 再放出 tabBar, 把整段 loading 过程的合成层冲突降到最低。
|
||||
loadingVisible: true
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
@@ -40,8 +47,13 @@ export default {
|
||||
this.$util.hideHomeButton();
|
||||
try {
|
||||
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
|
||||
if (this.$refs.category && this.$refs.category[0]) {
|
||||
// 节流: 避免 onShow 重复触发 pageShow() 中的 getHeightArea(-1) 高耗时选择器查询, 引起频闪和性能损耗
|
||||
const now = Date.now();
|
||||
if (this._lastPageShowTime && now - this._lastPageShowTime < 500) {
|
||||
// 短时间内重复触发, 跳过本次 pageShow
|
||||
} else if (this.$refs.category && this.$refs.category[0]) {
|
||||
this.$refs.category[0].pageShow();
|
||||
this._lastPageShowTime = now;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('onShow pageShow failed', e);
|
||||
@@ -109,6 +121,13 @@ export default {
|
||||
this._diyInfoLoading = false;
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
uni.stopPullDownRefresh();
|
||||
// loading-cover 收起后, 等下一 tick 让出 frame, 再挂载 diy-bottom-nav
|
||||
// (避免两个 fixed 高 z-index GPU 合成层在同帧 mount 触发 MP-Weixin 重绘频闪)
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
this.loadingVisible = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<view :style="themeColor">
|
||||
<!-- 鸿蒙兼容: themeColor 是 CSS 变量字符串, 鸿蒙 JSVM 早期版本对 :style="字符串" 处理可能不稳.
|
||||
兜底: 只有非空字符串才绑定 :style. -->
|
||||
<view :style="themeColor || undefined">
|
||||
<!-- #ifndef H5 -->
|
||||
<!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0">
|
||||
<ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar>
|
||||
@@ -411,7 +413,17 @@
|
||||
</view> -->
|
||||
<!-- v-if="goodsSkuDetail.goodsForm" -->
|
||||
<view style="padding: 0 30rpx;">
|
||||
<ns-form :data="informationform" ref="form"></ns-form>
|
||||
<!-- 表单暂无字段时的友好提示, 避免"打开弹窗却什么都不显示"困惑用户 -->
|
||||
<view v-if="!formDiyValue || !formDiyValue.jsonData || !formDiyValue.jsonData.length" class="form-empty" style="padding: 60rpx 0; text-align: center; color: #999;">
|
||||
{{ $lang('goods.detail.formEmptyTip') }}
|
||||
</view>
|
||||
<diy-form
|
||||
v-else
|
||||
:value="formDiyValue"
|
||||
dataMode="raw"
|
||||
:show-submit="false"
|
||||
ref="form"
|
||||
></diy-form>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="button-box"><button type="primary" @click="saveubfirnation()">{{ $lang('goods.detail.confirm') }}</button></view>
|
||||
@@ -545,6 +557,21 @@ export default {
|
||||
nsGoodsPromotion
|
||||
},
|
||||
mixins: [base, detail, scroll],
|
||||
computed: {
|
||||
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
||||
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
||||
formDiyValue() {
|
||||
const sku = this.goodsSkuDetail || {};
|
||||
return {
|
||||
jsonData: sku.goods_form || [],
|
||||
formConfig: sku.goods_form_config || {},
|
||||
show_banner: sku.goods_form_show_banner === true,
|
||||
form_banner: sku.goods_form_banner || '',
|
||||
show_tip: sku.goods_form_show_tip === true,
|
||||
form_tip: sku.goods_form_tip || ''
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 发送商品信息到智能客服
|
||||
|
||||
@@ -40,7 +40,9 @@ export default {
|
||||
this.isAction = false;
|
||||
},
|
||||
onUnload() {
|
||||
if (!this.storeToken && this.$refs.login) this.$refs.login.cancelCompleteInfo();
|
||||
if (!this.storeToken && this.$refs.login && typeof this.$refs.login.cancelCompleteInfo === 'function') {
|
||||
this.$refs.login.cancelCompleteInfo();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
if (!this.storeToken) {
|
||||
|
||||
@@ -24,9 +24,10 @@ export default {
|
||||
|
||||
membercard: null, // 会员卡信息
|
||||
hackReset: true,
|
||||
cardOff: false,
|
||||
informationform:[]//留言表单
|
||||
|
||||
cardOff: false
|
||||
// 留言表单数据不再在 data() 维护:detail.vue 的 computed formDiyValue
|
||||
// 直接从 goodsSkuDetail.goods_form / goods_form_config / goods_form_* 实时读取并打包成
|
||||
// diy-form raw 模式期望的 value 结构。
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -52,11 +53,19 @@ export default {
|
||||
}
|
||||
return discount.toFixed(2);
|
||||
},
|
||||
//表单高度
|
||||
FormHeight(){
|
||||
let height = 48;
|
||||
if (this.goodsForm && this.goodsForm.length) {
|
||||
height += this.goodsForm.length * 5;
|
||||
// 弹窗高度: 受限于视口, 内部表单 <scroll-view> 自带滚动, 不再按字段数无限加高
|
||||
FormHeight() {
|
||||
// 上限 65vh: 留足顶部状态栏/导航栏 + 底部 home indicator, 也不会显得"一打开就半屏遮脸"
|
||||
// 字段多时通过内部 <scroll-view scroll-y> 自然滚动
|
||||
const formList = (this.goodsSkuDetail && this.goodsSkuDetail.goods_form)
|
||||
|| this.goodsForm;
|
||||
let height = 45;
|
||||
if (formList && formList.length) {
|
||||
// 少量字段: 每个约 5vh; 字段多时递增放缓并封顶, 避免接近屏顶
|
||||
// 5 个字段 → 45 + min(10, 25) = 55vh
|
||||
// 10 个字段 → 45 + min(10, 50) = 55vh (已封顶)
|
||||
const dynamic = Math.min(10, formList.length * 5);
|
||||
height = Math.min(55, height + dynamic);
|
||||
}
|
||||
height += 'vh';
|
||||
return height;
|
||||
@@ -88,8 +97,20 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
async onShow() {
|
||||
//同步获取商品详情
|
||||
await this.getGoodsSkuDetail();
|
||||
// 同步获取商品详情
|
||||
// 鸿蒙元服务兼容: 包 try/catch, 防止内部任何抛错导致整个 page 显示空白
|
||||
try {
|
||||
await this.getGoodsSkuDetail();
|
||||
} catch (e) {
|
||||
console.error('getGoodsSkuDetail failed:', e);
|
||||
// 即使失败也要保证 loading-cover 关闭, 避免空白屏
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const ref = this.$refs && this.$refs.loadingCover;
|
||||
if (ref && typeof ref.hide === 'function') ref.hide();
|
||||
} catch (e2) { /* swallow */ }
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
onHide() {
|
||||
this.couponBtnSwitch = false;
|
||||
@@ -111,14 +132,28 @@ export default {
|
||||
},
|
||||
// 获取商品详情
|
||||
async getGoodsSkuDetail() {
|
||||
let res = await this.$api.sendRequest({
|
||||
url: '/api/goodssku/detail',
|
||||
async: false,
|
||||
data: {
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsId,
|
||||
let res;
|
||||
try {
|
||||
// 修复鸿蒙元服务: 必须传 async: false !
|
||||
// 当 App.vue 的 boot (lang/theme/remote-config) 未完成时,
|
||||
// sendRequest 在 async !== false 模式下会直接 return undefined (不返回 promise),
|
||||
// 导致 res.code undefined / res.data 抛 TypeError, 整个 onShow 失败.
|
||||
res = await this.$api.sendRequest({
|
||||
url: '/api/goodssku/detail',
|
||||
async: false,
|
||||
data: {
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsId,
|
||||
}
|
||||
});
|
||||
// 防御: 即便 async:false 仍可能在极端情况下返回 undefined
|
||||
if (!res || typeof res !== 'object') {
|
||||
throw new Error('sendRequest returned non-object response (likely boot not complete)');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('getGoodsSkuDetail sendRequest failed:', e);
|
||||
throw e;
|
||||
}
|
||||
let data = res.data;
|
||||
if (data.goods_sku_detail != null) {
|
||||
if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) {
|
||||
@@ -129,7 +164,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name
|
||||
|
||||
|
||||
// 关键改动: 先一次性赋值, 释放事件循环让 MP-Weixin 把首屏 (含媒体/价格/标题) 渲出来,
|
||||
// 再 $nextTick 后才跑同步字段变换 (sku_images split / JSON.parse / 属性去重 等),
|
||||
// 避免连续多次 Vue 响应式写触发同一帧内多次 vdom 重算, 显著改善"打开详情很慢"的体感。
|
||||
this.goodsSkuDetail = data.goods_sku_detail;
|
||||
if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id;
|
||||
if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id;
|
||||
@@ -155,8 +193,27 @@ export default {
|
||||
goods_id: this.goodsId
|
||||
};
|
||||
|
||||
// 处理商品数据
|
||||
this.handleGoodsSkuData();
|
||||
// 让首屏主区域先绘制
|
||||
// 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
|
||||
// #ifdef MP-HARMONY
|
||||
await new Promise(resolve => setTimeout(resolve, 16));
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-HARMONY
|
||||
await this.$nextTick();
|
||||
// #endif
|
||||
|
||||
// 关闭 loading-cover 提前到 nextTick 后, 让用户看到首屏内容
|
||||
// 用 setTimeout 0 推迟到下一个 task, 避开当前 sync 阶段 vendor 报错导致 hide 失败
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const ref = this.$refs && this.$refs.loadingCover;
|
||||
if (ref && typeof ref.hide === 'function') ref.hide();
|
||||
} catch (e) { /* swallow */ }
|
||||
}, 0);
|
||||
|
||||
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
|
||||
await this.handleGoodsSkuData();
|
||||
|
||||
// 限时折扣
|
||||
if (this.goodsSkuDetail.promotion_type == 1 && this.addonIsExist.discount) {
|
||||
@@ -207,8 +264,6 @@ export default {
|
||||
// this.handleBundlingData(this.goodsSkuDetail.bundling_list);
|
||||
// }
|
||||
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
|
||||
} else {
|
||||
this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo');
|
||||
}
|
||||
@@ -220,9 +275,35 @@ export default {
|
||||
},
|
||||
|
||||
//商品留言打开
|
||||
showinformation(){
|
||||
this.informationform = this.goodsSkuDetail.goods_form
|
||||
this.$refs.informationPopup.open();
|
||||
async showinformation(){
|
||||
// 修复: 第一次打开时, uni-popup 内部 <scroll-view><diy-form> 还没完成首次渲染,
|
||||
// 直接 open() 会让用户看到空白弹窗。这里:
|
||||
// 1) 若商品表单后台请求尚未完成 (或根本没触发), 先发起并 await 该请求
|
||||
// 2) 用 $nextTick 等组件挂载完成后再打开
|
||||
try {
|
||||
// 若页面已进入但 _goodsFormPromise 还没建 (如 sku 切换/数据未到等场景),
|
||||
// 这里补一个并发请求; 若已存在, 复用旧 promise 不重复请求。
|
||||
if (!this._goodsFormPromise || typeof this._goodsFormPromise.then !== 'function') {
|
||||
const loader = (this.getGoodsForm && typeof this.getGoodsForm === 'function')
|
||||
? this.getGoodsForm()
|
||||
: Promise.resolve();
|
||||
this._goodsFormPromise = loader;
|
||||
await loader;
|
||||
} else {
|
||||
await this._goodsFormPromise;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('await goods form promise failed', e);
|
||||
}
|
||||
// 第一次强制刷新 popup 内部组件布局 (nextTick), 确保表单完整渲染
|
||||
// 兼容鸿蒙元服务: $nextTick 不可靠时降级到 setTimeout
|
||||
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
|
||||
// 微信小程序下 scroll-view 在首次以 transform 隐藏后再显示时, 内部子节点布局可能未刷新
|
||||
// 双 nextTick 兜底, 保证表单完整可见
|
||||
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
|
||||
if (this.$refs.informationPopup && typeof this.$refs.informationPopup.open === 'function') {
|
||||
this.$refs.informationPopup.open();
|
||||
}
|
||||
},
|
||||
//商品留言关闭
|
||||
closeinformationPopup(){
|
||||
@@ -230,18 +311,18 @@ export default {
|
||||
},
|
||||
//保存商品留言
|
||||
saveubfirnation(){
|
||||
//缺少
|
||||
// ref 指向 <diy-form>:diy-form 只暴露 getFormData() 方法,无 formData 属性
|
||||
if (!this.$refs.form.verify()) return;
|
||||
|
||||
|
||||
// uni.setStorageSync('goodFormData', {
|
||||
// goods_id: this.goodsSkuDetail.goods_id,
|
||||
// form_data: this.$refs.form.formData
|
||||
// form_data: this.$refs.form.getFormData()
|
||||
// });
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goods/information',
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
form_data: JSON.stringify(this.$refs.form.formData)
|
||||
form_data: JSON.stringify(this.$refs.form.getFormData())
|
||||
},
|
||||
success: res => {
|
||||
var data = res.data;
|
||||
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #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>
|
||||
497
tests/search-keyword-encoding.test.js
Normal file
497
tests/search-keyword-encoding.test.js
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* 搜索关键词跨端编码测试
|
||||
*
|
||||
* 回归 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">
|
||||
* 上显示成乱码。
|
||||
*
|
||||
* 修复策略(2026-07-17 → 2026-07-18):
|
||||
* - 微信小程序 + 鸿蒙元服务(mp-weixin / mp-harmony): `_safeEncodeForPlatform`
|
||||
* 只 encode URL 分隔符 `& = ? # \s`(`_encodeUrlDelimiters`),中文 / 字母 /
|
||||
* 数字 / 标点原样保留。鸿蒙之前的"完全交给平台 encode"假设是错的 —— 鸿蒙
|
||||
* onLoad 行为跟微信一样, split on '&' / '=' 但不 decode value, 所以 JS
|
||||
* 必须主动 encode 分隔符防 query 边界被破坏。
|
||||
* - H5 / 华为快应用: 仍然主动 encode 一次(标准 encodeURIComponent, 防浏览器
|
||||
* 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 分隔符(和 mp-weixin 一致)', () => {
|
||||
// 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 必须 encode 分隔符。
|
||||
// - 中文 / 反引号 / 其他标点 原样
|
||||
// - & = ? # 空格 encode 成 %xx
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
|
||||
// 中文原样
|
||||
const r1 = api._safeEncodeForPlatform('沙发');
|
||||
assert.equal(r1, '沙发', `mp-harmony 中文应原样,实际: ${r1}`);
|
||||
|
||||
// & = encode 成 %xx
|
||||
const r2 = api._safeEncodeForPlatform('a&b=c');
|
||||
assert.equal(r2, 'a%26b%3Dc', `mp-harmony 分隔符应 encode,实际: ${r2}`);
|
||||
|
||||
// 空格 encode 成 %20(防 query 边界被破坏)
|
||||
const r3 = api._safeEncodeForPlatform('`hello world`');
|
||||
assert.equal(r3, '`hello%20world`', `mp-harmony 空格应 encode,实际: ${r3}`);
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => {
|
||||
// 2026-07-18 仍成立: JS 侧 decode roundtrip 在所有平台都执行,
|
||||
// 解码后只 encode 分隔符, 中文 / %xx 形式 都恢复成原文。
|
||||
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-18 修正: 微信 / 鸿蒙走 _encodeUrlDelimiters 也会经过 decode roundtrip,
|
||||
// 解码后只 encode 分隔符, 已编码的 %xx 形式(中文等)都恢复成原文, 不会双编码。
|
||||
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 的处理(2026-07-18 修正):
|
||||
* 鸿蒙 onLoad 行为跟微信一致 —— 不整体 encode url,只 split on '&' / '=',
|
||||
* 对 value 做 decodeURIComponent。
|
||||
*
|
||||
* JS 侧 `_safeEncodeForPlatform` 已经把 `& = ? # \s` 编码成 %xx, 鸿蒙 split
|
||||
* 之后直接拿到 key=value, 对 value decode 一次还原原文。
|
||||
* 最终 onLoad(options) 拿到的 keyword 是原文。
|
||||
*
|
||||
* 关键: 鸿蒙 / 微信 都是 split-on-& + decode, 跟 H5 / 快应用(browser) 行为一致,
|
||||
* 跟以前假设的"鸿蒙整体 encode"不同。
|
||||
*/
|
||||
function simulateHarmonyOnLoad(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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟微信小程序对 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 输入框显示原文(不乱码)', () => {
|
||||
// 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 只 encode 分隔符。
|
||||
// 中文不在分隔符列表里, 所以 url 里中文原样保留; 鸿蒙 split-on-& 之后
|
||||
// decode 一次, options.keyword 仍是原文。
|
||||
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);
|
||||
|
||||
// 鸿蒙下中文不在 encode 列表里, url 应含 keyword=沙发
|
||||
assert.ok(url.includes('keyword=沙发'),
|
||||
`鸿蒙下 url 应含 keyword=沙发,实际: ${url}`);
|
||||
|
||||
// 鸿蒙 split-on-& 之后 decode 一次, 最终 options.keyword = '沙发'
|
||||
const { options } = simulateHarmonyOnLoad(url);
|
||||
assert.equal(options.keyword, '沙发',
|
||||
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
test('端到端: 鸿蒙元服务 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
|
||||
// 2026-07-18 修正: 鸿蒙 / 微信一样, JS encode 空格 → %20(防 query 边界被破坏),
|
||||
// 中文 / 反引号原样; 鸿蒙 split + decode 后还原成原文。
|
||||
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);
|
||||
// mp-harmony 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样
|
||||
assert.ok(url.includes('keyword=' + encode(kw)),
|
||||
`鸿蒙下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`);
|
||||
|
||||
const { options } = simulateHarmonyOnLoad(url);
|
||||
// decode 之后应还原 keyword
|
||||
assert.equal(options.keyword, kw,
|
||||
`鸿蒙下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2.2 端到端:微信小程序(回归 Bug 的另一平台)
|
||||
// =============================================================
|
||||
test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
|
||||
// 2026-07-18 修正: 微信 onLoad 是 split-on-& + decode 一次, JS 侧只 encode 分隔符,
|
||||
// 中文原样保留, 最终 options.keyword 是原文。
|
||||
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 应直接是中文(JS 不预 encode 中文)
|
||||
assert.ok(!url.includes('%'),
|
||||
`微信下 url 不应含 %xx(JS 不预 encode 中文),实际 url: ${url}`);
|
||||
assert.ok(url.includes('keyword=沙发'),
|
||||
`微信下 url 应含 keyword=沙发,实际: ${url}`);
|
||||
|
||||
// 微信 split-on-& + 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') {
|
||||
// 鸿蒙: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
|
||||
// (2026-07-18 修正: 跟微信行为一致)
|
||||
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 一次还原(避免双编码)', () => {
|
||||
// 2026-07-18 修正: 鸿蒙 / 微信 onLoad 行为一致, JS 侧 decode roundtrip
|
||||
// 后只 encode 分隔符, 所以已编码入参还原成原文, 不会再被二次 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}`);
|
||||
|
||||
// 鸿蒙 split-on-& + decode 后回到"发"
|
||||
const { options } = simulateHarmonyOnLoad(url);
|
||||
assert.equal(options.keyword, '发',
|
||||
`鸿蒙下 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}`);
|
||||
});
|
||||
@@ -140,15 +140,38 @@ const TABBAR_SRC = fs.readFileSync(
|
||||
|
||||
function loadTabbarInSandbox() {
|
||||
const moduleObj = { exports: {} };
|
||||
// util-tabbar.js 通过 import 引用 util-navigate.js 的 _toParamValueString,
|
||||
// vm 沙箱里没有 import 解析, 必须先把 import 行替换掉,
|
||||
// 然后在 wrapped 源码顶部内联一份 _toParamValueString(原实现 3 行, 直接搬过来)
|
||||
// 还要把源码里 `globalThis.__TABBAR_DEBUG__` 改写成 `global.__TABBAR_DEBUG__`:
|
||||
// - 沙箱里 globalThis 是独立的 vm 上下文对象(sandbox.globalThis = sandbox),
|
||||
// 跟测试在 outer globalThis 上设的 `globalThis.__TABBAR_DEBUG__ = true` 不是同一个
|
||||
// - 改成 `global.__TABBAR_DEBUG__` 后, 沙箱的 `global` 被绑到外层 Node global,
|
||||
// 测试在 outer 写的 flag 就能被沙箱里的 checkTabBarActive 看到
|
||||
const wrapped = TABBAR_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 ')
|
||||
// ESM → CJS: 删掉 import 行(vm 沙箱不支持 import)
|
||||
.replace(/^import\s+[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, '')
|
||||
// 把 globalThis.__TABBAR_DEBUG__ 重定向到 global.__TABBAR_DEBUG__,
|
||||
// 让测试在 outer 设的 flag 能穿透进沙箱
|
||||
.replace(/globalThis\.__TABBAR_DEBUG__/g, 'global.__TABBAR_DEBUG__')
|
||||
// 在文件开头注入 _toParamValueString(util-tabbar.js 的唯一外部依赖)
|
||||
.replace(
|
||||
/^/,
|
||||
'function _toParamValueString(v) {\n'
|
||||
+ '\tif (v === null || v === undefined) return "";\n'
|
||||
+ '\tif (typeof v === "object") return JSON.stringify(v);\n'
|
||||
+ '\treturn String(v);\n'
|
||||
+ '}\n\n'
|
||||
)
|
||||
+ '\nmodule.exports = { resolvePageUrl, isPageInRouteList, resolveLinkUrl, '
|
||||
+ 'makeSystemTabBarList, adaptSubpackageUrl, isTabBarRootPage, '
|
||||
+ 'isCurrentPageTabBarRoot, checkTabBarActive, resolveEntryTab, lookupPageTabMap };\n';
|
||||
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
|
||||
// 把外层 global 注入沙箱, 配合上面的源码改写让 __TABBAR_DEBUG__ flag 穿透
|
||||
const sandbox = { console, global, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(wrapped, sandbox, { filename: 'util-tabbar.js' });
|
||||
|
||||
@@ -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, 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,6 +54,7 @@ test('util-navigate.js: 能被 vm 加载,所有 export 都存在', () => {
|
||||
const expected = [
|
||||
'_toParamValueString', 'parsePageParams', 'resolveLinkUrl',
|
||||
'cleanParamUrls', 'normalizeUrl', 'appendQueryParams',
|
||||
'extractInternalAppPath', 'resolveNavMode', 'hasStaleUniacidInUrl', 'pickCurrentUniacid',
|
||||
'getPlatform', 'shouldEncodeUrl',
|
||||
'_safeEncodeForPlatform', '_navigateTo', 'createNavigateApi',
|
||||
];
|
||||
@@ -229,18 +230,58 @@ 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' });
|
||||
const r = api._safeEncodeForPlatform('a&b');
|
||||
assert.equal(r, 'a%26b', 'mp-weixin: & 必须 encode');
|
||||
for (const p of ['h5', 'quickapp']) {
|
||||
const api = nav.createNavigateApi({ platform: p });
|
||||
const r = api._safeEncodeForPlatform('a&b');
|
||||
assert.equal(r, 'a%26b', `${p}: & 必须 encode 成 %26,实际: ${r}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('_safeEncodeForPlatform: 已编码字符串不双编码', () => {
|
||||
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 只 encode 分隔符(和 mp-weixin 行为一致)', () => {
|
||||
// 2026-07-18 修正: 鸿蒙元服务的 onLoad 行为跟 mp-weixin 一致 —— split on '&' / '='
|
||||
// 解析 url 但不 decode value 给 onLoad, 所以 JS 必须 encode `& = ? # \s` 防
|
||||
// query 边界被破坏, 但中文 / 字母 / 数字原样保留(否则 onLoad 拿到 %xx 乱码)。
|
||||
// (此前假设"鸿蒙原生 encode 整段 query"是错的 —— 实际它跟微信一样 split-on-& 不 decode)
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
|
||||
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: 微信小程序 → JS 只 encode 分隔符', () => {
|
||||
// 2026-07-17 修正(二次): 微信 split on '&' 但不 decode value, JS 必须 encode `&`
|
||||
// 否则 query 边界被破坏。但中文 / 字母 / 数字原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||
const nav = loadNavInSandbox();
|
||||
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
|
||||
const r = api._safeEncodeForPlatform('%E8%A1%A8');
|
||||
assert.equal(r, '%E8%A1%A8', 'decode roundtrip + re-encode 不双编码');
|
||||
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', `${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}`);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
@@ -341,17 +382,19 @@ test('util.js: 通过 import 引用 util-navigate 的方法,挂载到 default ex
|
||||
// 关键 import
|
||||
assert.ok(utilSrc.includes("from './util-navigate.js'"),
|
||||
'util.js 应 import util-navigate.js');
|
||||
assert.ok(utilSrc.includes('_cleanParamUrls'),
|
||||
'util.js 应 import _cleanParamUrls 别名');
|
||||
assert.ok(utilSrc.includes('_normalizeUrl'),
|
||||
'util.js 应 import _normalizeUrl 别名');
|
||||
assert.ok(utilSrc.includes('_navigateTo'),
|
||||
'util.js 应 import _navigateTo 别名');
|
||||
// default export 里的挂载
|
||||
assert.ok(utilSrc.includes('cleanParamUrls: (v) => _cleanParamUrls(v)'),
|
||||
assert.ok(utilSrc.includes('cleanParamUrls'),
|
||||
'util.js 应 import cleanParamUrls');
|
||||
assert.ok(utilSrc.includes('normalizeUrl as _normalizeUrlBase'),
|
||||
'util.js 应 import normalizeUrl 别名 _normalizeUrlBase');
|
||||
assert.ok(utilSrc.includes('extractInternalAppPath as _extractInternalAppPath'),
|
||||
'util.js 应 import extractInternalAppPath 别名');
|
||||
assert.ok(utilSrc.includes('resolveNavMode as _resolveNavMode'),
|
||||
'util.js 应 import resolveNavMode 别名');
|
||||
// default export 里的挂载(cleanParamUrls 直接挂, normalizeUrl 用包装函数委托)
|
||||
assert.ok(/cleanParamUrls,/.test(utilSrc),
|
||||
'util.js 应把 cleanParamUrls 挂载到 default export');
|
||||
assert.ok(utilSrc.includes('_navigateTo: (args) => _navigateTo(args)'),
|
||||
'util.js 应把 _navigateTo 挂载到 default export');
|
||||
assert.ok(utilSrc.includes('return _normalizeUrlBase(urlString)'),
|
||||
'util.js 的 normalizeUrl 应委托给 _normalizeUrlBase');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
@@ -368,13 +411,240 @@ test('util.js: redirectTo / diyRedirectTo 仍在 util.js(没被搬走)', () => {
|
||||
// diyRedirectTo 函数定义
|
||||
assert.ok(/^\s*diyRedirectTo\s*\(\s*link\s*\)\s*\{/m.test(utilSrc),
|
||||
'diyRedirectTo 应仍在 util.js');
|
||||
// redirectTo 内部调 this._safeEncodeForPlatform
|
||||
assert.ok(utilSrc.includes('this._safeEncodeForPlatform'),
|
||||
'redirectTo 应调 this._safeEncodeForPlatform');
|
||||
// redirectTo 内部调 this._navigateTo
|
||||
assert.ok(utilSrc.includes('this._navigateTo('),
|
||||
'redirectTo 应调 this._navigateTo');
|
||||
// diyRedirectTo 内部调 this.cleanParamUrls
|
||||
// redirectTo 内部调 _safeEncodeForPlatform(模块函数直调)
|
||||
assert.ok(utilSrc.includes('_safeEncodeForPlatform('),
|
||||
'redirectTo 应调 _safeEncodeForPlatform');
|
||||
// diyRedirectTo 内部调 this.cleanParamUrls 清理脏 link
|
||||
assert.ok(utilSrc.includes('this.cleanParamUrls('),
|
||||
'diyRedirectTo 应调 this.cleanParamUrls');
|
||||
// diyRedirectTo 内部调 _resolveNavMode + _extractInternalAppPath(内部页绝对 URL 还原)
|
||||
assert.ok(utilSrc.includes('_resolveNavMode('),
|
||||
'diyRedirectTo 应调 _resolveNavMode');
|
||||
assert.ok(utilSrc.includes('_extractInternalAppPath('),
|
||||
'diyRedirectTo 应调 _extractInternalAppPath');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// extractInternalAppPath — 内部页面绝对 URL 还原(双闸门)
|
||||
// 修复: 内部页被存成绝对 URL 时被误判外链 → 整页刷新 / web-view
|
||||
// =============================================================
|
||||
test('extractInternalAppPath: 相对内部路径直接放行', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
assert.equal(nav.extractInternalAppPath('/pages_goods/detail?id=9'), '/pages_goods/detail?id=9');
|
||||
assert.equal(nav.extractInternalAppPath('/pages/index/index'), '/pages/index/index');
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 同域绝对 URL → 还原为相对路径', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['xcx6.aigc-quickapp.com'];
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H),
|
||||
'/pages_tool/form/formdata?id=1',
|
||||
);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 同平台不同子域(域名不完全一致)→ null(严格模式)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['xcx6.aigc-quickapp.com']; // App 跑在 xcx6, 数据链接是 xcx3 → 视为外链
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata', H),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 传入完整 URL 作为白名单也能提取 host(完全一致才还原)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['https://xcx6.aigc-quickapp.com/']; // Config.h5Domain 形式
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages/index/index', H),
|
||||
'/pages/index/index',
|
||||
);
|
||||
// host 不一致 → null
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 外链域名不同(Shopify / Facebook)→ 不误伤, 返回 null', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['xcx6.aigc-quickapp.com'];
|
||||
assert.equal(nav.extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H), null);
|
||||
assert.equal(nav.extractInternalAppPath('https://www.facebook.com/pages/Name/123', H), null);
|
||||
assert.equal(nav.extractInternalAppPath('https://example.com/pages_tool/x', H), null);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 绝对 URL 但路径无 /pages 段 → null(真外链)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['xcx6.aigc-quickapp.com'];
|
||||
assert.equal(nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com/activity/2026', H), null);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 无白名单时绝对 URL 一律 null(不猜测)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
assert.equal(nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index'), null);
|
||||
assert.equal(nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', []), null);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 空/非法输入安全返回 null', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
assert.equal(nav.extractInternalAppPath('', ['a.com']), null);
|
||||
assert.equal(nav.extractInternalAppPath(null, ['a.com']), null);
|
||||
assert.equal(nav.extractInternalAppPath(undefined), null);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: host 大小写不敏感 + 忽略端口', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['xcx6.aigc-quickapp.com'];
|
||||
// host 大写
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://XCX6.AIGC-QUICKAPP.COM/hwappx/common/pages/index/index', H),
|
||||
'/pages/index/index',
|
||||
);
|
||||
// 带端口(白名单不带端口, 也应命中)
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://xcx6.aigc-quickapp.com:8443/hwappx/common/pages_goods/detail', H),
|
||||
'/pages_goods/detail',
|
||||
);
|
||||
});
|
||||
|
||||
test('extractInternalAppPath: 各分包页面根都能识别 + 保留 hash', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
const H = ['h.com'];
|
||||
assert.equal(nav.extractInternalAppPath('https://h.com/base/pages_promotion/point/list', H), '/pages_promotion/point/list');
|
||||
assert.equal(nav.extractInternalAppPath('https://h.com/base/pages_order/detail?id=2', H), '/pages_order/detail?id=2');
|
||||
assert.equal(nav.extractInternalAppPath('/pages/index/index#top'), '/pages/index/index#top');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// extractInternalAppPath: 强制内部(allowedHosts === true 跳过 host 校验)
|
||||
// =============================================================
|
||||
test('extractInternalAppPath: allowedHosts=true → 跳过 host 校验(强制内部)', () => {
|
||||
const nav = loadNavInSandbox();
|
||||
// 不同子域, 正常严格模式下会 null; 传 true 强制还原
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', true),
|
||||
'/pages_tool/form/formdata?id=1',
|
||||
);
|
||||
// 完全陌生域名也能被强制还原(调用方已明确表示是内部页)
|
||||
assert.equal(
|
||||
nav.extractInternalAppPath('https://any-host.example.net/x/pages/index/index', true),
|
||||
'/pages/index/index',
|
||||
);
|
||||
// 但仍要求路径带 /pages 段, 没有则 null
|
||||
assert.equal(nav.extractInternalAppPath('https://any-host.example.net/activity/2026', true), null);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// resolveNavMode — 统一开关 open_mode(internal/online/auto)
|
||||
// =============================================================
|
||||
test('resolveNavMode: open_mode=internal/spa → internal', () => {
|
||||
const { resolveNavMode } = loadNavInSandbox();
|
||||
assert.equal(resolveNavMode({ open_mode: 'internal' }), 'internal');
|
||||
assert.equal(resolveNavMode({ open_mode: 'SPA' }), 'internal');
|
||||
assert.equal(resolveNavMode({ openMode: 'internal' }), 'internal');
|
||||
assert.equal(resolveNavMode({ page_params: { open_mode: 'internal' } }), 'internal');
|
||||
});
|
||||
|
||||
test('resolveNavMode: open_mode=online/web → online', () => {
|
||||
const { resolveNavMode } = loadNavInSandbox();
|
||||
assert.equal(resolveNavMode({ open_mode: 'online' }), 'online');
|
||||
assert.equal(resolveNavMode({ open_mode: 'WEB' }), 'online');
|
||||
assert.equal(resolveNavMode({ page_params: { open_mode: 'web' } }), 'online');
|
||||
});
|
||||
|
||||
test('resolveNavMode: 未设置 / 未知值 → auto', () => {
|
||||
const { resolveNavMode } = loadNavInSandbox();
|
||||
assert.equal(resolveNavMode({}), 'auto');
|
||||
assert.equal(resolveNavMode({ open_mode: 'xxx' }), '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); // 解析到非数字停止
|
||||
});
|
||||
|
||||
@@ -3,7 +3,49 @@ const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { zionUniMpLoadPackagePlugin } = require('zion-uniapp-mp-load-package/webpack');
|
||||
|
||||
console.log('[vue.config] 已加载');
|
||||
/**
|
||||
* 判断当前是否为"正式包发行"构建。
|
||||
* 触发任一条件即视为生产构建,执行 test-gate 门禁:
|
||||
* - NODE_ENV === 'production': vue-cli-service build / uni build --mode production
|
||||
* - UNI_SCRIPT 已设置: HBuilderX 通过 package.json -> uni-app.scripts 触发的发行
|
||||
* 两条信号覆盖后,无论走 HBuilderX 发行按钮、vue-cli-service build、还是 uni-cli,
|
||||
* 都无法绕开门禁(因为都会 require 本文件)。
|
||||
*/
|
||||
function _isProductionRelease() {
|
||||
if (process.env.NODE_ENV === 'production') return true;
|
||||
if (process.env.UNI_SCRIPT) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产构建门禁: 同步执行 test-gate.js, 失败立即终止构建 (零产物)。
|
||||
* 与 package.json 的 "prebuild": "npm run test" 互补 —— 那条只覆盖 npm run build,
|
||||
* 本函数覆盖所有会 require vue.config.js 的入口 (HBuilderX / vue-cli-service / uni-cli)。
|
||||
*
|
||||
* 关键: 使用 process.execPath 而非字符串 "node",避免依赖 PATH 查找 node。
|
||||
* 原因: HBuilderX 等 IDE 启动子进程时,PATH 中可能根本没有 node 条目 (本项目
|
||||
* 就曾因 Machine PATH 残留指向 v14.21.3 的 Huawei node 而触发问题)。
|
||||
* process.execPath 始终是"当前正在运行 vue.config.js 的那个 node 二进制",
|
||||
* HBuilderX 自带 node (v22.22.2) ≥ 18,天然满足 test-gate 的版本要求。
|
||||
*/
|
||||
function _enforceTestGate() {
|
||||
if (!_isProductionRelease()) return; // 开发态 / 运行态不拦截
|
||||
console.log(`\n🛡️ [test-gate] 检测到生产构建,先执行测试门禁 (${process.versions.node})...`);
|
||||
try {
|
||||
execSync(`"${process.execPath}" scripts/test-gate.js`, {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
console.log('🛡️ [test-gate] ✅ 测试通过,放行生产构建\n');
|
||||
} catch (e) {
|
||||
console.error('\n❌ [test-gate] 测试未通过,已阻止生产构建');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
_enforceTestGate();
|
||||
|
||||
console.log(`[vue.config] 已加载 | node ${process.versions.node} @ ${process.execPath}`);
|
||||
|
||||
// Build-time: 给所有 page 注入 diy componentPlaceholder
|
||||
// 见 scripts/inject-placeholder.js
|
||||
@@ -451,7 +493,7 @@ class VersionInjectPlugin {
|
||||
module.exports = {
|
||||
productionSourceMap: false,
|
||||
configureWebpack: config => {
|
||||
console.log('config.mode = ', config.mode);
|
||||
console.log('[vue.config] config.mode = ', config.mode);
|
||||
|
||||
// lang/ 目录排除: 仅在 remote-only 模式下生效
|
||||
// 原理: webpack 把 require(`@/lang/${lang}/${path}.js`) 解析为 ContextModule,
|
||||
@@ -507,7 +549,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
if (config.mode === 'production') {
|
||||
console.log('开始尝试配置极限压缩');
|
||||
console.log('[vue.config] 开始尝试配置极限压缩');
|
||||
|
||||
if (process.env.UNI_PLATFORM === 'mp-weixin') {
|
||||
// 配置zion-uniapp-mp-load-package插件
|
||||
@@ -542,13 +584,14 @@ module.exports = {
|
||||
}
|
||||
|
||||
// 区分平台配置压缩选项
|
||||
const isMpHarmony = process.env.UNI_PLATFORM === 'mp-harmony';
|
||||
const enableKeepConsole = process.env.UNI_PLATFORM === 'mp-harmony';
|
||||
console.log(`[vue.config] 是否保留console语句 = ${Boolean(enableKeepConsole)}`);
|
||||
|
||||
// 基础压缩配置(通用)
|
||||
const terserOptions = {
|
||||
compress: {
|
||||
drop_console: !isMpHarmony, // 鸿蒙保留 console 用于调试
|
||||
drop_debugger: !isMpHarmony,
|
||||
drop_console: !enableKeepConsole, // 鸿蒙保留 console 用于调试
|
||||
drop_debugger: !enableKeepConsole,
|
||||
dead_code: true,
|
||||
unused: true,
|
||||
toplevel: true,
|
||||
@@ -567,7 +610,7 @@ module.exports = {
|
||||
side_effects: true,
|
||||
keep_fargs: false,
|
||||
keep_fnames: false,
|
||||
pure_funcs: isMpHarmony ? [] : ['console.log', 'console.warn', 'console.error', 'console.info'],
|
||||
pure_funcs: enableKeepConsole ? [] : ['console.log', 'console.warn', 'console.error', 'console.info'],
|
||||
pure_getters: true,
|
||||
unsafe_math: true,
|
||||
},
|
||||
@@ -594,7 +637,7 @@ module.exports = {
|
||||
};
|
||||
|
||||
config.optimization.minimizer[terserPluginIndex].options.terserOptions = terserOptions;
|
||||
console.log('配置极限压缩完成');
|
||||
console.log('[vue.config] 配置极限压缩完成');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user