Compare commits

..

1 Commits

28 changed files with 3940 additions and 4747 deletions

View File

@@ -62,11 +62,7 @@ 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/',
@@ -121,7 +117,7 @@ const localDevConfig = ({
},
'2482': { // 低空经济
uniacid: 2482,
domain: 'https://xcx3.aigc-quickapp.com/',
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2485': { // 验证电子表单前后端数据一致性
uniacid: 2485,
@@ -131,18 +127,14 @@ const localDevConfig = ({
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
'54': { // 测试平台
uniacid: 54,
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',
@@ -150,8 +142,8 @@ const localDevConfig = ({
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
})['2821']; // 离型纸硅油纸
// })['2482']; // 低空经济
// })['2821']; // 离型纸硅油纸
})['54']; // 低空经济
// })['48']; // 离型纸硅油纸
// })['2485']; // 验证电子表单前后端数据一致性
// })['5g-11']; // 数码
@@ -159,7 +151,6 @@ const localDevConfig = ({
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
// })['local-1']; // 本地
// })['local-18']; // 本地-人工晶体-有商品
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

View File

@@ -1,7 +1,6 @@
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();
@@ -52,11 +51,6 @@ export default {
lastDiyInfoTime: 0, // 上次成功加载的时间戳
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
diyLoadError: false,
diyLoadErrorMsg: '',
diyLoadErrorUrl: '', // 出错的网络请求地址
//启动广告
adv:{},
@@ -316,41 +310,6 @@ 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();
@@ -365,12 +324,9 @@ 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: diyInfoUrl,
url: '/api/diyview/info',
data: {
id: this.id,
name: this.name,
@@ -381,9 +337,10 @@ 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;
@@ -391,14 +348,6 @@ 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
@@ -408,9 +357,9 @@ export default {
return;
}
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
this._showDiyLoadError(res && res.message, diyInfoFullUrl);
this.$util.showToast({
title: this.$lang('diy.noDiyData')
});
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
@@ -418,10 +367,6 @@ export default {
this.lastDiyInfoTime = now;
this.lastDiyInfoLang = currentLang;
// 成功加载后清除错误提示
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
let diyDataValue = res.data;
if (diyDataValue.value) {

View File

@@ -44,7 +44,7 @@ export class EventSafety {
// 安全的方法
matches: (selector) => EventSafety.safeMatches(target, selector),
// 数据集
dataset: target?.dataset || {}
dataset: target.dataset || {}
}
return safeTarget

View File

@@ -80,126 +80,113 @@ export default {
methods: {
// 处理商品详情数据
async handleGoodsSkuData() {
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
try {
if (this.goodsSkuDetail.config) {
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
// 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];
}
});
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
this.modifyGoodsInfo();
// 当前商品SKU规格
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
// 初始化商品详情视图数据
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;
}
// 商品属性
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'
})
};
try {
if (this.$refs && this.$refs.goodsPromotion) {
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
}
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
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);
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
try {
this._goodsFormPromise = this.getGoodsForm();
} catch (e) {
console.error('getGoodsForm kicked off failed:', e);
}
} catch (e) {
console.error('handleGoodsSkuData failed:', e);
if (this.goodsRoute != '/pages_goods/detail') this.setPublicShare();
// this.getBarrageData();
if (this.addonIsExist.form) {
await this.getGoodsForm();
}
},
/**
@@ -340,35 +327,13 @@ export default {
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
*/
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 res = await this.$api.sendRequest({
url: "/form/api/form/goodsform",
data: {
goods_id: this.goodsSkuDetail.goods_id
},
async: false
});
const empty = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});

View File

@@ -1,101 +0,0 @@
/**
* 通用网络/接口错误分类器
*
* 适用场景:
* - 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,
};

View File

@@ -702,10 +702,18 @@ export function _safeEncodeForPlatform(v) {
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
if (['mp-weixin','mp-harmony'].includes(platform)) {
if (platform === 'mp-weixin') {
return _encodeUrlDelimiters(once);
}
// 鸿蒙元服务: uni.navigateTo 内部整体 encode url(把中文、& 等都 encode)。
// JS 侧完全不 encode,直接返回 decode roundtrip 后的原文,让鸿蒙原生做唯一一次 encode。
// (再 encode 会双重编码 → onLoad 拿到 %E6%B2%99%E5%8F%91 乱码)
// webview URL 含 & 时, JS 不 encode,鸿蒙原生 encode 后 & 变 %26,webview 能正常打开。
if (platform === 'mp-harmony') {
return once;
}
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
return encodeURIComponent(once);
}

View File

@@ -1,35 +1,30 @@
<template>
<!-- 修复底部 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 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 }}
</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 }">
@@ -43,7 +38,7 @@
</view>
</view>
</view>
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
<!-- 解决fixed定位后底部导航栏塌陷问题 -->
<view class="tab-bar-placeholder"></view>
</view>
</template>

View File

@@ -16,7 +16,7 @@
</view>
</view>
<view class="category-goods-list" v-if="selectedSecondCategory">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" :data-template="value.template" v-if="secondCategoryGoodsList.length">
<view class="goods-list double-column show" :data-template="value.template" v-if="secondCategoryGoodsList.length">
<view class="goods-item" v-for="(item, index) in secondCategoryGoodsList" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="imgError(index)" show-menu-by-longpress />
@@ -35,13 +35,6 @@
</block>
</view>
</view>
<view class="footer-wrap">
<view class="consult-now-btn-wrap" v-if="item.isinformation == 1">
<view class="consult-now-btn" @tap.stop="sendToAIChat(item)">
{{ $lang('components-diy.diy-category-item.consultNow') }}
</view>
</view>
</view>
</view>
</view>
</view>
@@ -2388,30 +2381,25 @@ export default {
.second-category-btns {
display: flex;
flex-wrap: wrap;
// #ifdef H5
padding: 20rpx 10rpx 6rpx 24rpx;
// #endif
// #ifndef H5
padding: 20rpx 20rpx 10rpx;
// #endif
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
.second-category-btn {
padding: 10rpx 24rpx;
padding: 14rpx 32rpx;
background: #f5f5f5;
color: #666;
border-radius: 30rpx;
font-size: 20rpx;
margin: 0 14rpx 17rpx 0;
border-radius: 40rpx;
font-size: 26rpx;
margin: 0 16rpx 16rpx 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.selected {
background: #FF4646;
color: #FFFFFF;
background: #FF6B35;
color: #fff;
}
}
}
@@ -2507,99 +2495,6 @@ export default {
}
}
}
.footer-wrap {
display: flex;
justify-content: flex-end;
margin-top: 10rpx;
}
}
&.single-column {
flex-direction: column;
padding: 0 20rpx 0 8rpx;
gap: 20rpx;
.goods-item {
width: calc(100% + 20rpx);
flex-direction: row;
align-items: flex-start;
background: #fff;
border-radius: 12rpx;
margin-bottom: 0;
padding: 20rpx;
box-sizing: border-box;
height: 220rpx;
.goods-img {
width: 180rpx;
height: 180rpx;
padding-top: 0;
margin-right: 14rpx;
border-radius: 12rpx;
flex-shrink: 0;
image {
border-radius: 12rpx;
}
}
.info-wrap {
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.name-wrap {
.goods-name {
font-size: 28rpx;
color: #333;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
margin-top: 0;
white-space: normal;
text-overflow: clip;
}
}
.price-wrap {
margin-top: 8rpx;
display: flex;
align-items: baseline;
justify-content: flex-start;
.discount-price {
color: #FF6B35;
font-size: 32rpx;
font-weight: bold;
.unit {
font-size: 24rpx;
}
}
}
.footer-wrap {
display: flex;
justify-content: flex-end;
margin-top: auto;
}
}
}
&.double-column {
.goods-item {
width: calc(50% - 10rpx);
flex-direction: column;
background: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
}
}
}

View File

@@ -86,21 +86,7 @@
</uni-popup>
</view>
<!-- 全屏骨架占位 (只显示: 左分类树 + 右商品网格 + 底部空隙, 不画 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">
<view class="content-box" v-if="categoryTree">
<block v-if="categoryTree.length">
<scroll-view scroll-y="true" class="tree-wrap">
<view class="category-item-wrap">
@@ -188,6 +174,10 @@
<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>
<!-- 模板4: 底部购物车栏 -->
<view class="cart-box cart-box-template-4"
@@ -210,8 +200,8 @@
</view>
</view>
<view class="cart-point" v-for="(item, index) in carIconList" :key="index"
:style="{ left: item.left + 'px', top: item.top + 'px' }"></view>
<view class="cart-point" :style="{ left: item.left + 'px', top: item.top + 'px' }" :key="index"
v-for="(item, index) in carIconList"></view>
<ns-goods-sku-category ref="skuSelect" @refresh="refreshData" @addCart="addCartPoint"></ns-goods-sku-category>
</view>
@@ -259,11 +249,7 @@ export default {
isList: false,
lang: uni.getStorageSync("lang"),
lastSecondCategoryName: '',
template99Category: null,
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
// 显示骨架占位而非空态文案
categoryTreeLoading: true
template99Category: null
};
},
created() {
@@ -381,13 +367,6 @@ 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);
@@ -417,7 +396,6 @@ export default {
* 获取全部分类
*/
getCategoryTree() {
this.categoryTreeLoading = true;
this.$api.sendRequest({
url: '/api/goodscategory/tree',
data: {
@@ -447,14 +425,6 @@ export default {
this.loadGlobalSecondCategoryBtns();
}
}
},
fail: () => {
// 失败也保证 loading 关闭, 让骨架替换为"暂无分类"空态
this.categoryTree = this.categoryTree || [];
},
complete: () => {
// 无论成功失败都关闭 loading 骨架
this.categoryTreeLoading = false;
}
});
},
@@ -980,84 +950,6 @@ 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 顶部搜索框(图片样式)
@@ -1601,7 +1493,7 @@ export default {
background: #fff;
view {
color: #333;
color: #FF6B35;
font-weight: bold;
font-size: 28rpx;
}
@@ -1610,7 +1502,7 @@ export default {
content: ' ';
width: 6rpx;
height: 36rpx;
background: #FF4646;
background: #FF6B35;
display: block;
position: absolute;
left: 0;
@@ -1624,14 +1516,6 @@ export default {
.content-wrap {
background: #f5f5f5;
padding: 0;
// #ifdef H5
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
background: transparent;
}
// #endif
}
.child-category {

View File

@@ -1,16 +1,11 @@
<template>
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
<!-- Banner -->
<!-- 鸿蒙元服务运行时在首次 _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">
<view v-if="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 && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<view v-if="value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
@@ -75,15 +70,13 @@ export default {
},
computed: {
style() {
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
var css = '';
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;';
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;';
}
return css;
},

View File

@@ -42,92 +42,82 @@ 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;
// 修复: 强制合成层, 让 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 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>

View File

@@ -1,6 +1,6 @@
<template>
<view class="form-wrap form-component" :style="formWrapStyle">
<block v-for="(item, index) in formData" :key="index">
<block v-for="(item, index) in formData">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">

View File

@@ -54,14 +54,11 @@ export const lang = {
serviceUnavailable: 'Navigation service temporarily unavailable',
},
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
// diy custom page
diy: {
networkErrorTitle: 'Notice',
retry: 'Retry',
networkError: 'Network error, please try again later',
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
@@ -137,18 +134,12 @@ export const lang = {
requestFailed: '{url} request failed. {err}',
// network timeout
timeout: 'Network request timeout, please try again later',
// network connection failed / generic network error (unified copy, reused by diy and other generic scenarios)
networkError: 'Network error, please try again later',
// network connection failed
networkError: 'Network connection failed, please check your network',
// server error
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',
serverError: 'Service temporarily unavailable, please try again later',
// 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',
},

View File

@@ -115,7 +115,6 @@ export const lang = {
// Product Message
productMessage: 'Product Message',
formEmptyTip: 'No message fields to fill',
// AI Customer Service
aiCustomerService: 'AI Assistant',

View File

@@ -61,14 +61,11 @@ export const lang = {
weChatWorkKefu: '企业微信客服',
},
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
// diy 自定义页面
diy: {
networkErrorTitle: '提示',
retry: '重试',
networkError: '网络异常,请稍后重试',
noDiyData: '未配置自定义页面数据',
locationNotAvailable: '未获取到定位',
// 数据加载失败详细文案(diy 专属模板,内部 {reason} 由通用 http.* 填充)
loadFailTpl: '页面数据加载失败:{reason},请稍后重试',
},
// 客服服务
@@ -137,18 +134,12 @@ export const lang = {
requestFailed: '{url}请求失败. {err}',
// 网络超时
timeout: '网络请求超时,请稍后重试',
// 网络连接失败 / 通用网络异常(统一文案,供 diy 等通用场景复用)
networkError: '网络异常,请稍后重试',
// 网络连接失败
networkError: '网络连接失败,请检查网络设置',
// 服务器错误
serverError: '服务器异常,请稍后重试',
// 接口不存在(HTTP 404)
notFound: '接口不存在',
// 无访问权限(HTTP 401 / 403)
auth: '无访问权限',
serverError: '服务暂时不可用,请稍后重试',
// 请求被中断
aborted: '请求已取消',
// 未知错误(errorType 分类兜底,如非 5xx/404/401/403 的 HTTP 状态码)
unknown: '请求失败,请稍后重试',
// 未知错误
unknownError: '请求失败,请稍后重试',
},

View File

@@ -108,7 +108,6 @@ export const lang = {
// 商品留言
productMessage: '商品留言',
formEmptyTip: '暂无需填写的留言字段',
// 智能客服
aiCustomerService: '智能客服',

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,6 @@
<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">
@@ -188,52 +179,6 @@ 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;

View File

@@ -1,8 +1,6 @@
<template>
<view>
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 view 子树静默不渲染, 导致整个详情页空白 -->
<view class="goods-detail" :class="isIphoneX ? 'active' : ''">
<view scroll-y="true" class="goods-detail" :class="isIphoneX ? 'active' : ''">
<view class="goods-container">
<!-- AI客服悬浮按钮 -->
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">

View File

@@ -1,8 +1,7 @@
<template>
<view :style="themeColor">
<!-- 使用 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-show="diyData && diyData.value && diyData.value.length">
<block v-for="(item, index) in diyData.value" :key="index">
<view v-if="item.componentName == 'GoodsCategory'">
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
</view>
@@ -16,8 +15,8 @@
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
<!-- 底部tabBar: loading 期间隐藏, 等 loading-cover 关闭后下一 tick 再挂载, 避免 TabBar 与 loading-cover 同时 GPU 合成导致频闪 -->
<view id="tab-bar" v-if="!loadingVisible">
<!-- 底部tabBar -->
<view id="tab-bar">
<diy-bottom-nav></diy-bottom-nav>
</view>
</view>
@@ -28,13 +27,7 @@ export default {
data() {
return {
diyData: { value: [] },
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
mpShareData: null //小程序分享数据
};
},
onLoad() {
@@ -47,13 +40,8 @@ export default {
this.$util.hideHomeButton();
try {
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
// 节流: 避免 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]) {
if (this.$refs.category && this.$refs.category[0]) {
this.$refs.category[0].pageShow();
this._lastPageShowTime = now;
}
} catch (e) {
console.warn('onShow pageShow failed', e);
@@ -121,13 +109,6 @@ 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;
});
});
}
});
},

View File

@@ -1,7 +1,5 @@
<template>
<!-- 鸿蒙兼容: themeColor 是 CSS 变量字符串, 鸿蒙 JSVM 早期版本对 :style="字符串" 处理可能不稳.
兜底: 只有非空字符串才绑定 :style. -->
<view :style="themeColor || undefined">
<view :style="themeColor">
<!-- #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>
@@ -413,12 +411,7 @@
</view> -->
<!-- v-if="goodsSkuDetail.goodsForm" -->
<view style="padding: 0 30rpx;">
<!-- 表单暂无字段时的友好提示, 避免"打开弹窗却什么都不显示"困惑用户 -->
<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"

View File

@@ -40,9 +40,7 @@ export default {
this.isAction = false;
},
onUnload() {
if (!this.storeToken && this.$refs.login && typeof this.$refs.login.cancelCompleteInfo === 'function') {
this.$refs.login.cancelCompleteInfo();
}
if (!this.storeToken && this.$refs.login) this.$refs.login.cancelCompleteInfo();
},
onReady() {
if (!this.storeToken) {

View File

@@ -53,19 +53,11 @@ export default {
}
return discount.toFixed(2);
},
// 弹窗高度: 受限于视口, 内部表单 <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);
//表单高度
FormHeight(){
let height = 48;
if (this.goodsForm && this.goodsForm.length) {
height += this.goodsForm.length * 5;
}
height += 'vh';
return height;
@@ -97,20 +89,8 @@ export default {
// #endif
},
async onShow() {
// 同步获取商品详情
// 鸿蒙元服务兼容: 包 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);
}
//同步获取商品详情
await this.getGoodsSkuDetail();
},
onHide() {
this.couponBtnSwitch = false;
@@ -132,28 +112,14 @@ export default {
},
// 获取商品详情
async getGoodsSkuDetail() {
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)');
let res = await this.$api.sendRequest({
url: '/api/goodssku/detail',
async: false,
data: {
sku_id: this.skuId,
goods_id: this.goodsId,
}
} 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) {
@@ -164,10 +130,7 @@ 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;
@@ -193,26 +156,7 @@ export default {
goods_id: this.goodsId
};
// 让首屏主区域先绘制
// 兼容鸿蒙元服务: $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();
// 限时折扣
@@ -264,6 +208,8 @@ 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');
}
@@ -275,35 +221,8 @@ export default {
},
//商品留言打开
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();
}
showinformation(){
this.$refs.informationPopup.open();
},
//商品留言关闭
closeinformationPopup(){

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,11 @@
* `%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 错位)。
* 不做 encode,只做 decode roundtrip(防止上游重复 encode 入参)。让平台原生
* 去做唯一一次 encode。
* - H5 / 华为快应用: 仍然主动 encode 一次(防浏览器 query 错位)。
*
* 跑法:
* node --test tests/search-keyword-encoding.test.js
@@ -64,29 +61,24 @@ function loadNavInSandbox() {
// =============================================================
// 1. _safeEncodeForPlatform 平台差异化
// =============================================================
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 只 encode 分隔符(和 mp-weixin 一致)', () => {
// 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 必须 encode 分隔符。
// - 中文 / 反引号 / 其他标点 原样
// - & = ? # 空格 encode 成 %xx
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
// 中文原样
// 中文关键词原样返回(鸿蒙原生会再做一次 encode)
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '沙发', `mp-harmony 中文应原样,实际: ${r1}`);
assert.equal(r1, '沙发', `mp-harmony 中文应原样返回,实际: ${r1}`);
// & = encode 成 %xx
// 含 & 的值原样返回(不能再 encode,否则双重编码)
const r2 = api._safeEncodeForPlatform('a&b=c');
assert.equal(r2, 'a%26b%3Dc', `mp-harmony 分隔符应 encode,实际: ${r2}`);
assert.equal(r2, 'a&b=c', `mp-harmony 特殊字符应原样返回,实际: ${r2}`);
// 空格 encode 成 %20(防 query 边界被破坏)
// 反引号 / 空格原样返回
const r3 = api._safeEncodeForPlatform('`hello world`');
assert.equal(r3, '`hello%20world`', `mp-harmony 空格应 encode,实际: ${r3}`);
assert.equal(r3, '`hello world`', `mp-harmony 反引号空格应原样返回,实际: ${r3}`);
});
test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => {
// 2026-07-18 仍成立: JS 侧 decode roundtrip 在所有平台都执行,
// 解码后只 encode 分隔符, 中文 / %xx 形式 都恢复成原文。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
@@ -143,8 +135,7 @@ test('_safeEncodeForPlatform: 华为快应用 → JS 侧主动 encode', () => {
});
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
// 2026-07-18 修正: 微信 / 鸿蒙走 _encodeUrlDelimiters 也会经过 decode roundtrip,
// 解码后只 encode 分隔符, 已编码的 %xx 形式(中文等)都恢复成原文, 不会双编码。
// 2026-07-17 修正: 微信小程序也属于"不做 JS encode"平台,跟鸿蒙一样
const nav = loadNavInSandbox();
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
// 还原后单次 encode 仍是 %E8%A1%A8
@@ -183,33 +174,31 @@ function buildRedirectUrl(to, param, encode) {
}
/**
* 模拟鸿蒙元服务对 url 的处理(2026-07-18 修正):
* 鸿蒙 onLoad 行为跟微信一致 —— 不整体 encode url,只 split on '&' / '=',
* 对 value 做 decodeURIComponent。
* 模拟鸿蒙元服务对 url 的处理:
* - JS 传入"未编码"的 url,鸿蒙原生会先对整个 query 字符串做一次 encodeURIComponent
* (把 = & 这些分隔符也一起编码),然后解析 query 时再 decode 一次
* - 最终 onLoad(options) 拿到的 keyword 是原文
*
* JS 侧 `_safeEncodeForPlatform` 已经把 `& = ? # \s` 编码成 %xx, 鸿蒙 split
* 之后直接拿到 key=value, 对 value decode 一次还原原文。
* 最终 onLoad(options) 拿到的 keyword 是原文。
*
* 关键: 鸿蒙 / 微信 都是 split-on-& + decode, 跟 H5 / 快应用(browser) 行为一致,
* 跟以前假设的"鸿蒙整体 encode"不同。
* 关键: 鸿蒙原生 encode 整段 query,不会把 = 和 & 当分隔符提前 split
* (这跟浏览器/H5 的 queryString 解析方式不同)
*/
function simulateHarmonyOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
// 鸿蒙原生: 先对整段 query 字符串做 encodeURIComponent(= & 都会变 %xx)
const encodedQueryStr = encodeURIComponent(queryStr);
// 模拟解析 encoded 字符串: 把 %26 解码回 & 再 split
// 关键洞察: encodeURIComponent 后的字符串是安全的(没有裸 = &),所以可以
// 直接 decode 然后 split
const decodedBack = decodeURIComponent(encodedQueryStr);
const options = {};
// 鸿蒙元服务: 不 auto-encode, 只 split on '&' / '=', 对 value decodeURIComponent
queryStr.split('&').forEach(kv => {
decodedBack.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;
}
const k = kv.substring(0, eqIdx);
const v = kv.substring(eqIdx + 1);
options[k] = v;
});
return { options };
}
@@ -218,7 +207,7 @@ function simulateHarmonyOnLoad(url) {
* 模拟微信小程序对 url 的处理(2026-07-17 修正):
* - JS 只 encode URL 分隔符 (& = ? # \s),中文和字母数字原样保留
* - 微信小程序: 不 encode 整体 url, 只 split on '&' / '=',对 value 做 decodeURIComponent
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
* src 中的 & 已被 JS encode 成 %26,微信分解析后 onLoad 拿到 %26(而非原 &),
* 但 webview 只接接收 src 作为 URL 参数,浏览器 fetch 时会 decode %26 为 &,浏览器端解析正常
*/
@@ -272,9 +261,6 @@ function simulateBrowserOnLoad(url) {
// 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);
@@ -283,54 +269,55 @@ test('端到端: 鸿蒙元服务 → 搜索关键词"沙发"在列表页 search
// this.$util.redirectTo('/pages_goods/list', { keyword: '沙发' });
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 鸿蒙下中文不在 encode 列表里, url 应含 keyword=沙发
// 鸿蒙下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`鸿蒙下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`鸿蒙下 url 应含 keyword=沙发,实际: ${url}`);
// 鸿蒙 split-on-& 之后 decode 一次, 最终 options.keyword = '沙发'
// 鸿蒙原生自动 encode 一次,然后解析时 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 后还原成原文。
// 注: 鸿蒙元服务下 JS 侧不做 encode(让鸿蒙原生 encode),
// 对于含 URL 分隔符 (& = ? #) 的关键词,需要业务方自己 encode,
// 否则会被 native 当成 query 分隔符破坏边界。
// 本测试覆盖用户实际场景: 中文 + 反引号 + 空格(鸿蒙原生都能正常 encode 处理)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
const kw = '`hello world` 沙发';
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
// mp-harmony 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样
assert.ok(url.includes('keyword=' + encode(kw)),
`鸿蒙下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`);
assert.ok(url.includes('keyword=' + kw),
`鸿蒙下 url 应含原始 keyword,实际: ${url}`);
const { options } = simulateHarmonyOnLoad(url);
// decode 之后应还原 keyword
assert.equal(options.keyword, kw,
`鸿蒙下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`);
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.2 端到端:微信小程序(回归 Bug 的另一平台)
// =============================================================
test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
// 2026-07-18 修正: 微信 onLoad 是 split-on-& + decode 一次, JS 侧只 encode 分隔符,
// 中文原样保留, 最终 options.keyword 是原文。
// 2026-07-17 修正: 微信小程序跟鸿蒙一样,JS 不做 encode,让平台原生去 encode
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 微信下 url 应直接是中文(JS 不预 encode 中文)
// 微信下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`微信下 url 不应含 %xx(JS 不预 encode 中文),实际 url: ${url}`);
`微信下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`微信下 url 应含 keyword=沙发,实际: ${url}`);
// 微信 split-on-& + decode 一次, 最终 options.keyword = '沙发'
// 微信原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
const { options } = simulateWeixinOnLoad(url);
assert.equal(options.keyword, '沙发',
`微信下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
@@ -403,8 +390,7 @@ test('Bug 回归场景: 商品分类页输入"沙发" → 列表页搜索框显
// 模拟 onLoad(options) 拿到的 keyword(按平台分派)
if (platform === 'mp-harmony') {
// 鸿蒙: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
// (2026-07-18 修正: 跟微信行为一致)
// 鸿蒙: 平台原生 encode 一次,parse 时 decode 一次
return simulateHarmonyOnLoad(url).options.keyword;
} else if (platform === 'mp-weixin') {
// 微信: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
@@ -462,9 +448,7 @@ test('Bug 回归场景: 长关键词 + 特殊字符,4 平台都不应乱码', ()
// =============================================================
// 4. 防御:微信/鸿蒙下不能误伤已经预 encode 的入参
// =============================================================
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免双编码)', () => {
// 2026-07-18 修正: 鸿蒙 / 微信 onLoad 行为一致, JS 侧 decode roundtrip
// 后只 encode 分隔符, 所以已编码入参还原成原文, 不会再被二次 encode。
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
@@ -475,10 +459,10 @@ test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免双编
assert.ok(url.includes('keyword=发'),
`鸿蒙下已编码入参应被 decode 还原,实际 url: ${url}`);
// 鸿蒙 split-on-& + decode 后回到"发"
// 然后鸿蒙原生 encode 一次,parse decode 一次,回到"发"
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '发',
`鸿蒙下 decode 一次后应是原文,实际: ${options.keyword}`);
`鸿蒙下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
});
test('防御: 微信下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {

View File

@@ -140,38 +140,15 @@ 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';
// 把外层 global 注入沙箱, 配合上面的源码改写让 __TABBAR_DEBUG__ flag 穿透
const sandbox = { console, global, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(wrapped, sandbox, { filename: 'util-tabbar.js' });

View File

@@ -240,17 +240,13 @@ test('_safeEncodeForPlatform: H5 / 华为快应用 → 主动 encode', () => {
}
});
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)
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
// 2026-07-17 修正: 鸿蒙整体 encode url, JS 完全不 encode
// (再 encode 会双重编码; webview src 含 & 时,鸿蒙原生 encode 会自动变 %26,webview 能正常打开)
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');
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a&b', `mp-harmony: JS 不应预 encode,实际: ${r}`);
});
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符', () => {

View File

@@ -3,49 +3,7 @@ const fs = require('fs');
const { execSync } = require('child_process');
const { zionUniMpLoadPackagePlugin } = require('zion-uniapp-mp-load-package/webpack');
/**
* 判断当前是否为"正式包发行"构建。
* 触发任一条件即视为生产构建,执行 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}`);
console.log('[vue.config] 已加载');
// Build-time: 给所有 page 注入 diy componentPlaceholder
// 见 scripts/inject-placeholder.js
@@ -493,7 +451,7 @@ class VersionInjectPlugin {
module.exports = {
productionSourceMap: false,
configureWebpack: config => {
console.log('[vue.config] config.mode = ', config.mode);
console.log('config.mode = ', config.mode);
// lang/ 目录排除: 仅在 remote-only 模式下生效
// 原理: webpack 把 require(`@/lang/${lang}/${path}.js`) 解析为 ContextModule,
@@ -549,7 +507,7 @@ module.exports = {
}
if (config.mode === 'production') {
console.log('[vue.config] 开始尝试配置极限压缩');
console.log('开始尝试配置极限压缩');
if (process.env.UNI_PLATFORM === 'mp-weixin') {
// 配置zion-uniapp-mp-load-package插件
@@ -584,14 +542,13 @@ module.exports = {
}
// 区分平台配置压缩选项
const enableKeepConsole = process.env.UNI_PLATFORM === 'mp-harmony';
console.log(`[vue.config] 是否保留console语句 = ${Boolean(enableKeepConsole)}`);
const isMpHarmony = process.env.UNI_PLATFORM === 'mp-harmony';
// 基础压缩配置(通用)
const terserOptions = {
compress: {
drop_console: !enableKeepConsole, // 鸿蒙保留 console 用于调试
drop_debugger: !enableKeepConsole,
drop_console: !isMpHarmony, // 鸿蒙保留 console 用于调试
drop_debugger: !isMpHarmony,
dead_code: true,
unused: true,
toplevel: true,
@@ -610,7 +567,7 @@ module.exports = {
side_effects: true,
keep_fargs: false,
keep_fnames: false,
pure_funcs: enableKeepConsole ? [] : ['console.log', 'console.warn', 'console.error', 'console.info'],
pure_funcs: isMpHarmony ? [] : ['console.log', 'console.warn', 'console.error', 'console.info'],
pure_getters: true,
unsafe_math: true,
},
@@ -637,7 +594,7 @@ module.exports = {
};
config.optimization.minimizer[terserPluginIndex].options.terserOptions = terserOptions;
console.log('[vue.config] 配置极限压缩完成');
console.log('配置极限压缩完成');
}
}
}