Compare commits

...

32 Commits

Author SHA1 Message Date
a137f3533c Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide3 2026-07-18 11:55:51 +08:00
8e31b9e327 chore:模板4的二级分类按钮正常 2026-07-18 11:26:27 +08:00
460dbcb447 chore:二级分类按钮显示正常 2026-07-18 11:15:58 +08:00
81ecd45de4 chore:二级分类按钮显示 2026-07-18 11:13:34 +08:00
059630ba2c refactor: 重构商品留言表单逻辑,替换为diy-form组件
1.  移除页面data中维护的留言表单数据,改为通过computed实时从商品详情数据构建diy-form所需格式
2.  替换ns-newform为diy-form组件,使用raw模式并接管表单提交逻辑
3.  完善平台别名映射,新增更多小程序平台兼容配置
4.  为diy-form新增占位符样式属性绑定
5.  重构商品表单接口处理逻辑,统一所有元数据字段的初始化和兼容处理
2026-07-18 10:55:39 +08:00
1ac0fb2484 refactor(goods-detail): 升级商品留言表单组件与接口适配
1.  将原有的同步调用改为异步处理handleGoodsSkuData和getGoodsForm方法
2.  重构getGoodsForm接口请求逻辑,兼容新旧版接口返回格式
3.  新增goods_form_config字段支持新表单组件样式配置
4.  替换旧版ns-form为ns-newform组件,完善空数据兜底处理
5.  优化商品留言初始化的可选链操作避免空指针报错
2026-07-18 10:34:37 +08:00
d8816bbe42 chore:还原到模板3二级分类按钮正常 2026-07-18 10:31:35 +08:00
b410597d9c chore 2026-07-18 10:15:27 +08:00
1143ef7b72 fix(util-navigate): 完善鸿蒙元服务编码逻辑注释与说明
更新了util-navigate和对应测试文件的注释,补充鸿蒙原生编码的细节,说明双重编码的问题以及&符号的处理逻辑,修正了注释里的重复表述
2026-07-18 09:24:10 +08:00
987e75c65a Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide3 2026-07-18 09:07:28 +08:00
ac8c461546 chore:二级分类按钮点击一会能切一会不能切 2026-07-18 08:58:52 +08:00
f3032a6004 fix(util-navigate): 修复多平台搜索关键词编码乱码问题
重构跨平台参数编码逻辑:
1. 微信小程序/鸿蒙元服务:仅处理编码边界符和decode roundtrip,不主动编码,交由平台原生处理
2. H5/华为快应用:保留主动编码逻辑,适配浏览器query解析规则
3. 新增专项测试用例覆盖搜索关键词跨端流转场景,修复商品分类页跳转列表页关键词显示乱码的bug
2026-07-18 08:34:43 +08:00
a4d8023e1f chore:二级分类按钮正常 2026-07-17 18:17:45 +08:00
bb0adb0542 style(ns-form, ns-newform): 新增占位符字体大小配置项
1. 为两个表单组件新增placeholderFontSize配置项与对应CSS变量
2. 将输入框占位符字体大小从复用label字号改为独立配置
3. 适配小程序/鸿蒙原生占位符样式规则
2026-07-17 16:14:25 +08:00
a4c218fecc chore: 2026-07-17 16:06:07 +08:00
0d820b04f7 chore:template == 4页面后台选择二级分类正常 2026-07-17 15:53:09 +08:00
70c9c49b94 refactor(form): 抽离平台检测逻辑到共用mixin,统一多端表单配置处理
1. 新增form-platform-mixin.js集中管理平台判断、别名兼容和配置键获取逻辑
2. 移除ns-form和ns-newform组件内重复的平台相关代码
3. 统一替换rpx单位处理逻辑为$util.rpx,简化样式计算代码
4. 移除冗余的_resolveFontSize工具方法,复用现有工具能力
2026-07-17 15:44:40 +08:00
6d1bdb5d8b chore:删除右侧上面的和一级标题一样的字 2026-07-17 15:07:19 +08:00
73b7b2a8df chore:template == 3的模板正常 2026-07-17 14:54:44 +08:00
b2063418fb chore:template == 1的模板正常 2026-07-17 14:01:40 +08:00
21c342e389 chore: 2026-07-17 11:56:44 +08:00
cbf91fa905 feat(util,navigate): add uniacid stale check for wap url
新增租户uniacid一致性校验逻辑,修复装修外链携带过期租户ID导致的跳转错误问题:
1. 新增pickCurrentUniacid和hasStaleUniacidInUrl工具函数
2. 在链接处理逻辑中校验wap_url内的uniacid与当前系统租户ID是否一致
3. 脏数据场景下强制走外链模式而非内部跳转
4. 补充完整的单元测试覆盖
2026-07-17 11:37:53 +08:00
61495b5e90 refactor(utils&form): 优化设备信息获取与表单组件样式配置
1.  为设备信息相关方法添加可选链操作符,避免空值报错
2.  重构getDeviceInfo和getWindowInfo的异常处理逻辑,简化代码
3.  重构ns-form表单组件,新增全局样式配置支持,兼容多端样式自定义
4.  优化表单布局样式、必填项和选中态颜色,修复鸿蒙元服务TextArea适配问题
5.  新增标签宽度自动计算功能,优化表单排版对齐效果
2026-07-17 10:43:49 +08:00
185d4914a6 chore:页面显示正常1模板 2026-07-17 10:21:40 +08:00
e3bbb7c77a chore:商品分类页面符合template==1 2026-07-17 09:58:25 +08:00
4044070867 fix(splash): 移除 onDiyGroupLoaded 多余 500ms 延迟避免 page-ready 兜底超时
diy-group 内部 _notifyLoaded 已有 300ms 渲染 settle, 叠加的 500ms 会吃掉
splash 2s page-ready 兜底预算, 导致弱网/冷启动下走 page-ready-timeout 兜底路径
(splash 在页面就绪前被强制关闭)。改为收到 loaded 事件后立即 notifyPageReady。
2026-07-16 17:20:46 +08:00
41b0d15570 chore: 切换默认启用的项目配置为低空经济
调整注释掉的配置项,将48切换为2482作为默认启用的项目配置
2026-07-16 17:09:30 +08:00
aafb75bad3 style: 调整多个页面的回到顶部按钮位置适配多端
为H5和小程序平台分别配置mescroll-totop的right和bottom样式,修复不同终端下按钮位置适配问题
2026-07-16 17:07:30 +08:00
6e19bc7aff chore: 注释新增低空经济项目配置
暂时注释掉2482号项目的配置代码,避免其被错误加载启用
2026-07-16 16:53:41 +08:00
94e852032b Merge remote-tracking branch 'remotes/origin/dev/1.0' into dev/1.0 2026-07-16 16:46:06 +08:00
5ccbc5d4f4 chore(config): add 18-test development environment configuration
Also add commented reference for the new 18-test environment
2026-07-16 16:45:59 +08:00
cf6d54e2bf fix(util): 修复内部页面绝对URL被误判为外链的问题
新增extractInternalAppPath和resolveNavMode工具函数,在diyRedirectTo中增加内部页面绝对URL还原逻辑:
1. 支持通过open_mode配置三种导航模式:auto/online/internal
2. 严格校验域名避免误伤外部站点,支持强制内部跳转场景
3. 修复H5整页刷新丢失状态、MP被塞进web-view的问题
2026-07-16 16:32:10 +08:00
23 changed files with 4841 additions and 2363 deletions

View File

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

View 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;
}
}
};

View File

@@ -79,7 +79,7 @@ export default {
},
methods: {
// 处理商品详情数据
handleGoodsSkuData() {
async 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);
@@ -186,7 +186,7 @@ export default {
// this.getBarrageData();
if (this.addonIsExist.form) {
this.getGoodsForm();
await this.getGoodsForm();
}
},
/**
@@ -321,17 +321,54 @@ 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({
async getGoodsForm() {
const res = await 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: false
});
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)
);
}
},
/**

View File

@@ -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,24 @@ 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 (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);
}
@@ -616,6 +814,7 @@ export function createNavigateApi(opts = {}) {
cleanParamUrls,
normalizeUrl,
appendQueryParams,
extractInternalAppPath,
parsePageParams,
resolveLinkUrl,
decodeEntity,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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" />
@@ -81,12 +90,25 @@
<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>
@@ -102,23 +124,36 @@
:lastSecondCategoryName="lastSecondCategoryName"
@tologin="toLogin" @selectsku="selectSku($event, 0)" @addCart="addCartPoint"
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</scroll-view>
</scroll-view>
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 1 || 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>
<!-- 模板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'">
@@ -127,6 +162,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>
@@ -143,9 +179,12 @@
<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>
@@ -156,9 +195,10 @@
<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>
@@ -215,6 +255,10 @@ export default {
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);
@@ -908,6 +952,137 @@ export default {
// 风格四
.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;
@@ -1135,6 +1310,125 @@ export default {
}
}
// ===================== 模板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 {

View File

@@ -182,6 +182,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'),

View File

@@ -1,36 +1,59 @@
<template>
<view class="form-wrap form-component">
<view class="form-wrap form-component" :style="formWrapStyle">
<block v-for="(item, index) in formData">
<view class="order-wrap" v-if="item.controller == 'Text'">
<view class="order-cell">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<input type="text" :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val" />
<input
type="text"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
/>
</view>
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Textarea'">
<view class="order-cell flex-box textarea">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
<view class="order-cell flex-box textarea" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<textarea :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val"></textarea>
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
<textarea
:rows="item.value.rows || 2"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
></textarea>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<textarea
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
></textarea>
<!-- #endif -->
</view>
</view>
</view>
<picker mode="selector" :range="item.value.options" v-if="item.controller == 'Select'" @change="pickerChange($event, index)">
<view class="order-wrap">
<view class="order-cell">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<text v-if="item.val != ''">{{ item.val }}</text>
@@ -41,18 +64,18 @@
</view>
</picker>
<view class="order-wrap" v-if="item.controller == 'Checkbox'">
<view class="order-cell">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box check-group-box">
<checkbox-group @change="checkboxChange($event, index)">
<label v-for="(v, k) in item.option_lists" :key="k">
<checkbox :value="v.value" :checked="v.checked" />
<view class="checkbox">
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }"></text>
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }" :style="{ color: v.checked ? checkedColor : '' }"></text>
{{ v.value }}
</view>
</label>
@@ -61,18 +84,18 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Radio'">
<view class="order-cell">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box radio-group-box">
<radio-group @change="radioChange($event, index)">
<label v-for="(v, k) in item.option_lists" :key="k">
<radio :value="v.value" :checked="item.val == v.value" />
<view class="radio-box">
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }"></text>
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }" :style="{ color: item.val == v.value ? checkedColor : '' }"></text>
{{ v.value }}
</view>
</label>
@@ -81,11 +104,11 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Img'">
<view class="order-cell flex-box">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box img-boxs">
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
@@ -97,11 +120,11 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Date'">
<view class="order-cell">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box box-flex">
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
@@ -112,11 +135,11 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Datelimit'">
<view class="order-cell flex-box">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box date-boxs">
<view class="date-box">
@@ -138,11 +161,11 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Time'">
<view class="order-cell">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box box-flex">
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
@@ -153,11 +176,11 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'Timelimit'">
<view class="order-cell flex-box">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box date-boxs">
<view class="date-box">
@@ -179,11 +202,11 @@
</view>
</view>
<view class="order-wrap" v-if="item.controller == 'City'">
<view class="order-cell box-flex">
<view class="name">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
<view class="order-cell box-flex" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
@@ -200,37 +223,224 @@
</template>
<script>
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
export default {
name: 'ns-form',
mixins: [formPlatformMixin],
props: {
data: {
type: Array,
default: {}
default: () => []
},
customAttr: {
type: Object,
default: function() {
return {};
}
},
/**
* 表单样式配置(由后端 designer 通过 API 传入)。
* 字段名同时支持 camelCase(如 formPaddingX / inputHeight / labelColor)
* 与 snake_case(如 form_padding_x / input_height / label_color),camelCase 优先。
* 不传则使用组件内置默认(与重构前硬编码值一致,无视觉回归)。
*
* 支持的键:
* formPaddingX, formBgColor, rowPaddingY, rowMinHeight, labelGap,
* inputHeight, inputRadius, labelWidthMode ('auto'|'fixed'), labelFixedWidth,
* labelFontSize, labelColor, requiredColor, checkedColor,
* placeholderColor, showRowDivider, rowDividerColor
*
* 平台差异化:formConfig.terminal.{h5|weapp|harmony|app|quickapp} 只放差异字段
*/
formConfig: {
type: Object,
default: () => ({})
}
},
data() {
return {
formData: this.data
formData: [],
maxLabelWidth: 0
};
},
computed: {
// 合并配置:内置默认 < 全局 formConfig < 当前平台 formConfig.terminal[platform]
// 同时兼容 snake_case 与 camelCase 字段名
config() {
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
const defaults = {
formPaddingX: 0,
formBgColor: '#ffffff',
rowPaddingY: 20,
rowMinHeight: 60,
labelGap: 16,
inputHeight: 60,
inputRadius: 0,
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
labelFixedWidth: 180,
labelFontSize: 28,
labelColor: '#1f2937',
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#909399',
placeholderFontSize: 28,
showRowDivider: true,
rowDividerColor: '#f0f0f0'
};
const fc = this.formConfig || {};
// 同时读 camelCase 与 snake_case,camelCase 优先
const pick = (camel, snake) => {
const v1 = fc[camel];
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
return fc[snake];
};
const base = {
formPaddingX: pick('formPaddingX', 'form_padding_x'),
formBgColor: pick('formBgColor', 'form_bg_color'),
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
labelGap: pick('labelGap', 'label_gap'),
inputHeight: pick('inputHeight', 'input_height'),
inputRadius: pick('inputRadius', 'input_radius'),
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
showRowDivider: pick('showRowDivider', 'show_row_divider'),
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
};
// 移除 undefined / 空字符串(保留 0 / false)
Object.keys(base).forEach(k => {
if (base[k] === undefined || base[k] === null || base[k] === '') delete base[k];
});
// showRowDivider 在前后端可能传数字 0/1,统一转 boolean
if ('showRowDivider' in base) {
base.showRowDivider = base.showRowDivider === true || base.showRowDivider === 1 || base.showRowDivider === '1';
}
// 2) 当前平台差异化配置(最高优先级),从 formConfig.terminal 取出当前平台 + 所有别名键,合并
const termBlock = fc.terminal || {};
const platformKeys = this.platformConfigKeys || [];
const platformCfg = Object.assign(
{},
...platformKeys.map(k => termBlock[k] || fc[k] || {})
);
// 平台块可能也是 snake_case,统一转 camelCase
const platformCfgCamel = {};
if (this.$util && typeof this.$util.snakeToCamel === 'function') {
Object.keys(platformCfg).forEach(k => {
platformCfgCamel[this.$util.snakeToCamel(k)] = platformCfg[k];
});
} else {
Object.assign(platformCfgCamel, platformCfg);
}
// 3) 三层合并:defaults < base < platformCfgCamel
return Object.assign({}, defaults, base, platformCfgCamel);
},
// 容器样式:内边距 + 背景 + 16 个 CSS 自定义属性
formWrapStyle() {
const cfg = this.config;
const num = (v, fb) => (typeof v === 'number' ? v : parseInt(v) || fb);
// 后端 formConfig 一律以 rpx 传递;
// _rpx() 按端分支:H5 输出 'Npx'(uni.upx2px),其它端输出 'Nrpx'
return this.$util.objToStyle({
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
background: cfg.formBgColor,
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
'--form-bg-color': cfg.formBgColor,
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
'--label-gap': this.$util.rpx(cfg.labelGap),
'--input-height': this.$util.rpx(cfg.inputHeight),
'--input-radius': this.$util.rpx(cfg.inputRadius),
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
'--label-color': cfg.labelColor,
'--required-color': cfg.requiredColor,
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
'--row-divider-color': cfg.rowDividerColor,
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
});
},
rowWrapStyle() {
return this.$util.objToStyle({
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
});
},
rowWrapClass() {
return this.config.showRowDivider === false ? 'order-wrap no-divider' : 'order-wrap';
},
rowCellStyle() {
return this.$util.objToStyle({
'min-height': this.$util.rpx(this.config.rowMinHeight)
});
},
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
nameWidthStyle() {
const cfg = this.config;
if (cfg.labelWidthMode === 'fixed') {
return { width: this.$util.rpx(cfg.labelFixedWidth) };
}
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
},
// 输入框样式(input / textarea 共用)
inputStyle() {
return this.$util.objToStyle({
'min-height': this.$util.rpx(this.config.inputHeight),
'border-radius': this.$util.rpx(this.config.inputRadius),
'font-size': this.$util.rpx(this.config.labelFontSize),
'color': this.config.labelColor,
'line-height': this.$util.rpx(this.config.inputHeight)
});
},
// 必填星号颜色
requiredColor() {
return this.config.requiredColor;
},
// 复选 / 单选 选中色
checkedColor() {
return this.config.checkedColor;
}
},
created() {
this.setFormData();
this.initFormData();
this.$nextTick(() => this.calculateMaxLabelWidth());
},
watch: {
data: function() {
this.setFormData();
data: {
deep: true,
handler() {
this.initFormData();
this.$nextTick(() => this.calculateMaxLabelWidth());
}
},
formConfig: {
deep: true,
handler() {
this.$nextTick(() => this.calculateMaxLabelWidth());
}
}
},
methods: {
initFormData() {
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
this.formData = JSON.parse(JSON.stringify(this.data || []));
this.processFormData();
},
setFormData() {
this.formData = this.data;
this.initFormData();
this.$nextTick(() => this.calculateMaxLabelWidth());
},
processFormData() {
this.formData.forEach(item => {
if (!item.val) item.val = item.value.default ? item.value.default : '';
@@ -356,6 +566,14 @@
}
});
},
// 计算最长 label 宽度(封装自 util.calculateMaxLabelWidth)
// 用于 labelWidthMode === 'auto' 时把 .name 的 width 撑开,避免长短 label 错位
calculateMaxLabelWidth() {
this.maxLabelWidth = this.$util.calculateMaxLabelWidth(
this.formData,
this.config.labelFontSize
);
},
verify() {
let verify = true;
@@ -643,9 +861,36 @@
};
</script>
<style lang="scss">
<style lang="scss" scoped>
/* CSS 自定义属性:默认值,与组件内 config.defaults 一一对应,
formConfig 未传时作为兜底;运行时会被 formWrapStyle 注入的 inline style 覆盖。
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
.form-wrap {
--form-padding-x: 0rpx;
--form-bg-color: #ffffff;
--row-padding-y: 20rpx;
--row-min-height: 60rpx;
--label-gap: 16rpx;
--input-height: 60rpx;
--input-radius: 0rpx;
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-color: #1f2937;
--required-color: #ef4444;
--placeholder-font-size: 28rpx;
--checked-color: #ff4544;
--placeholder-color: #909399;
--row-divider-color: #f0f0f0;
--show-row-divider: 1;
}
.order-wrap {
padding: 20rpx 0;
padding: var(--row-padding-y, 20rpx) 0;
&.no-divider {
border-bottom: 0;
}
&:last-child {
margin-bottom: 0;
@@ -658,9 +903,10 @@
align-items: center;
background: #fff;
position: relative;
min-height: var(--row-min-height, 60rpx);
&.textarea {
align-items: unset;
align-items: flex-start;
}
&.clear-flex {
@@ -674,7 +920,8 @@
&:last-child {
margin-bottom: 0;
border-bottom: solid 1px #eee;
// calc + 0/1 控制分割线显隐(formConfig.showRowDivider = false 时整条隐藏)
border-bottom: calc(var(--show-row-divider, 1) * 2rpx) solid var(--row-divider-color, #f0f0f0);
}
&.align-top {
@@ -682,28 +929,30 @@
}
text {
font-size: $font-size-base;
font-size: var(--label-font-size, $font-size-base);
}
.name {
width: 80vw;
width: var(--label-fixed-width, 180rpx);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--label-gap, 16rpx);
}
.tit {
text-align: left;
font-size: $font-size-base;
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
text {
font-size: $font-size-base;
font-size: var(--label-font-size, $font-size-base);
}
}
.required {
color: red;
font-size: 28rpx;
color: var(--required-color, #ef4444);
font-size: var(--label-font-size, 28rpx);
margin-left: 4rpx;
width: 14rpx;
text-align: left;
@@ -717,16 +966,65 @@
text-align: left;
input {
font-size: $font-size-base;
font-size: var(--label-font-size, $font-size-base);
text-align: left;
color: var(--label-color, #1f2937);
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
// 避免 placeholder / 文字下半部分被裁切
line-height: var(--input-height, 60rpx);
height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
border-radius: var(--input-radius, 0rpx);
box-sizing: border-box;
padding: 0;
background: transparent;
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
&::placeholder {
color: var(--placeholder-color, #909399);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
.placeholder {
color: var(--placeholder-color, #909399);
font-size: var(--placeholder-font-size, 28rpx);
line-height: var(--input-height, 60rpx);
}
textarea {
font-size: $font-size-base;
font-size: var(--label-font-size, $font-size-base);
width: 100%;
color: var(--label-color, #1f2937);
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
min-height: 88rpx;
height: 88rpx;
line-height: 44rpx;
text-align: left;
border-radius: var(--input-radius, 0rpx);
box-sizing: border-box;
padding: 0;
background: transparent;
}
.uni-input {
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
line-height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
}
.select-address {
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
}
.color-tip {
color: var(--placeholder-color, #909399);
}
checkbox-group {
@@ -847,7 +1145,7 @@
.iconfont {
color: $color-tip;
font-size: $font-size-base;
font-size: var(--label-font-size, $font-size-base);
}
.box-flex picker {

View File

@@ -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);
}
}

View File

@@ -4,7 +4,6 @@
"path": "pages/index/index",
"style": {
// "navigationStyle": "custom",
"navigationBarTitleText": "",
"enablePullDownRefresh": true,
"componentPlaceholder": {
"diy-article": "view",
@@ -67,7 +66,6 @@
{
"path": "pages/empty/index",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
}
@@ -147,13 +145,13 @@
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
"diy-video": "view",
"diy-video-list": "view"
}
}
}
},
{
"path": "cart",
},
{
"path": "cart",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
@@ -353,7 +351,6 @@
{
"path": "payment",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
@@ -363,7 +360,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -426,27 +422,24 @@
"path": "detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "detail_local_delivery",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "detail_pickup",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//积分订单详情
@@ -454,9 +447,8 @@
"path": "detail_point",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
}
]
@@ -469,10 +461,7 @@
{
"path": "point/list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -537,7 +526,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -600,42 +588,36 @@
"path": "point/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "point/payment",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
"navigationStyle": "custom"
}
},
{
"path": "point/order_list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "point/result",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//****************** 商户 ******************
{
"path": "merch/merchcategory",
"style": {
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
@@ -770,7 +752,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -833,18 +814,16 @@
"path": "fenxiao/promote",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/apply",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -853,7 +832,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -916,53 +894,47 @@
"path": "fenxiao/relation",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/order_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/team",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/withdraw_apply",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/withdraw_list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/withdrawal_detail",
"style": {
"navigationBarTitleText": ""
// #ifdef APP-PLUS
,"navigationStyle": "custom"
"navigationStyle": "custom"
// #endif
}
},
@@ -970,18 +942,16 @@
"path": "fenxiao/promote_code",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "fenxiao/level",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -990,7 +960,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1053,9 +1022,8 @@
"path": "fenxiao/bill",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1166,18 +1134,16 @@
"path": "webview/webview",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "index/diy",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #ifdef MP-WEIXIN
// "navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"enablePullDownRefresh": true,
"componentPlaceholder": {
"diy-article": "view",
@@ -1244,8 +1210,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"enablePullDownRefresh": true,
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1308,63 +1272,54 @@
"path": "member/modify_face",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/account",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/account_edit",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/apply_withdrawal",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/balance",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
"navigationStyle": "custom"
}
},
{
"path": "member/balance_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/collection",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1373,17 +1328,13 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"disableScroll": true
}
},
{
"path": "member/contact",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"enablePullDownRefresh": true
}
},
@@ -1391,162 +1342,142 @@
"path": "member/footprint",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/level",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/card",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/card_buy",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/card_agreement",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/level_growth_rules",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/point",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
"navigationStyle": "custom"
}
},
{
"path": "member/point_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/withdrawal",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/withdrawal_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/address",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/address_edit",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/pay_password",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancellation",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/assets",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancelstatus",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancelsuccess",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "member/cancelrefuse",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1562,9 +1493,8 @@
"path": "member/info_edit",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************登录模块******************
@@ -1572,9 +1502,8 @@
"path": "login/find",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************商品模块******************
@@ -1584,7 +1513,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1647,18 +1575,16 @@
"path": "goods/coupon_receive",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "goods/evaluate",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1667,7 +1593,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1732,7 +1657,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1795,9 +1719,8 @@
"path": "goods/not_exist",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************CMS模块(6)******************
@@ -1805,18 +1728,16 @@
"path": "help/list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "help/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1825,7 +1746,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1888,9 +1808,8 @@
"path": "notice/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -1899,7 +1818,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
@@ -1962,9 +1880,8 @@
"path": "article/detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************会员充值(4)******************
@@ -1974,7 +1891,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"disableScroll": false
}
},
@@ -1982,9 +1898,8 @@
"path": "recharge/order_list",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************邀请******************
@@ -1992,9 +1907,8 @@
"path": "member/invite_friends",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************支付模块(2)******************
@@ -2002,45 +1916,40 @@
"path": "pay/index",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "pay/wx_pay",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "pay/result",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "pay/cashier",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "storeclose/storeclose",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************订单模块(2)******************
@@ -2048,63 +1957,56 @@
"path": "order/logistics",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/evaluate",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_goods_select",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_type_select",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_batch",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
"path": "order/refund_detail",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -2120,9 +2022,8 @@
"path": "order/detail_virtual",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
//******************登录模块(3)******************
@@ -2139,9 +2040,8 @@
"path": "form/form",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
"navigationStyle": "custom"
// #endif
"navigationBarTitleText": ""
}
},
{
@@ -2150,7 +2050,6 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",

View File

@@ -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 次以上显示构建版本信息(供测试人员使用)

View File

@@ -411,7 +411,12 @@
</view> -->
<!-- v-if="goodsSkuDetail.goodsForm" -->
<view style="padding: 0 30rpx;">
<ns-form :data="informationform" ref="form"></ns-form>
<diy-form
: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 +550,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: {
/**
* 发送商品信息到智能客服

View File

@@ -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: {
@@ -156,7 +157,7 @@ export default {
};
// 处理商品数据
this.handleGoodsSkuData();
await this.handleGoodsSkuData();
// 限时折扣
if (this.goodsSkuDetail.promotion_type == 1 && this.addonIsExist.discount) {
@@ -221,7 +222,6 @@ export default {
//商品留言打开
showinformation(){
this.informationform = this.goodsSkuDetail.goods_form
this.$refs.informationPopup.open();
},
//商品留言关闭
@@ -230,18 +230,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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,481 @@
/**
* 搜索关键词跨端编码测试
*
* 回归 Bug(2026-07-17):
* 商品分类页(/pages_goods/category)输入搜索关键词 → 点击搜索 → 跳到
* 商品列表页(/pages_goods/list),列表页搜索栏显示的关键词变成
* %E6%B2%99%E5%8F%91(对应"沙发")乱码。
*
* 根因: 微信小程序 / 鸿蒙元服务(mp-weixin / mp-harmony)下,
* uni.navigateTo / router.pushUrl 内部会自动对 url 整体做一次
* encodeURIComponent。util-navigate.js 的 `_safeEncodeForPlatform` 之前
* 在所有平台都主动 encode,导致微信/鸿蒙下 JS encode + 平台原生 encode
* = 双重编码。目标页 onLoad(options) 只 decode 一次后,拿到的还是
* `%E6%B2%99%E5%8F%91` 这种"已编码字符串",在 <input v-model="keyword">
* 上显示成乱码。
*
* 修复策略:
* - 微信小程序 + 鸿蒙元服务(mp-weixin / mp-harmony): `_safeEncodeForPlatform`
* 不做 encode,只做 decode roundtrip(防止上游重复 encode 入参)。让平台原生
* 去做唯一一次 encode。
* - H5 / 华为快应用: 仍然主动 encode 一次(防浏览器 query 错位)。
*
* 跑法:
* node --test tests/search-keyword-encoding.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const NAV_SRC = fs.readFileSync(
path.join(__dirname, '..', 'common', 'js', 'util-navigate.js'),
'utf8',
);
/**
* 在沙箱里加载 util-navigate.js,提取 export。
* createNavigateApi({ platform }) 可强制注入平台到 globalThis。
*/
function loadNavInSandbox() {
const moduleObj = { exports: {} };
const wrapped = NAV_SRC
.replace(/^export\s+function\s+/gm, 'function ')
.replace(/^export\s+const\s+/gm, 'const ')
.replace(/^export\s+let\s+/gm, 'let ')
.replace(/^export\s+var\s+/gm, 'var ')
+ '\nmodule.exports = { _toParamValueString, parsePageParams, resolveLinkUrl, '
+ 'cleanParamUrls, normalizeUrl, appendQueryParams, extractInternalAppPath, resolveNavMode, hasStaleUniacidInUrl, pickCurrentUniacid, getPlatform, shouldEncodeUrl, '
+ '_safeEncodeForPlatform, _navigateTo, createNavigateApi };\n';
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(wrapped, sandbox, { filename: 'util-navigate.js' });
return sandbox.module.exports;
}
// =============================================================
// 1. _safeEncodeForPlatform 平台差异化
// =============================================================
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
// 中文关键词原样返回(鸿蒙原生会再做一次 encode)
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '沙发', `mp-harmony 中文应原样返回,实际: ${r1}`);
// 含 & 的值原样返回(不能再 encode,否则双重编码)
const r2 = api._safeEncodeForPlatform('a&b=c');
assert.equal(r2, 'a&b=c', `mp-harmony 特殊字符应原样返回,实际: ${r2}`);
// 反引号 / 空格原样返回
const r3 = api._safeEncodeForPlatform('`hello world`');
assert.equal(r3, '`hello world`', `mp-harmony 反引号空格应原样返回,实际: ${r3}`);
});
test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
// 上游已 encode 过一次的入参(%E5%8F%91 是"发")
const r = api._safeEncodeForPlatform('%E5%8F%91');
assert.equal(r, '发', `mp-harmony 已编码入参应 decode 一次,实际: ${r}`);
});
test('_safeEncodeForPlatform: H5 → JS 侧主动 encode', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'h5' });
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '%E6%B2%99%E5%8F%91', `h5 中文应 encode,实际: ${r1}`);
const r2 = api._safeEncodeForPlatform('a&b');
assert.equal(r2, 'a%26b', `h5 & 应 encode 成 %26,实际: ${r2}`);
});
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符 (不 encode 中文)', () => {
// 2026-07-17 修正(二次): 微信小程序 split on '&',不 decode value。
// JS 只 encode `& = ? # \s`(防 query 边界被破坏),
// 中文 / 字母 / 数字 原样保留(防 onLoad 拿到 %xx 乱码)。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
// 中文 原样保留
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '沙发', `mp-weixin 中文应原样,实际: ${r1}`);
// & encode 成 %26(防 query 边界被破坏)
const r2 = api._safeEncodeForPlatform('a&b');
assert.equal(r2, 'a%26b', `mp-weixin & 应 encode 成 %26,实际: ${r2}`);
// = 也 encode
const r3 = api._safeEncodeForPlatform('a=b');
assert.equal(r3, 'a%3Db', `mp-weixin = 应 encode 成 %3D,实际: ${r3}`);
// ? 也 encode
const r4 = api._safeEncodeForPlatform('a?b');
assert.equal(r4, 'a%3Fb', `mp-weixin ? 应 encode 成 %3F,实际: ${r4}`);
// URL 内的 & 和 = 在 mp-weixin 下会被 encode,但 :// 保留
const r5 = api._safeEncodeForPlatform('https://host.com?a=1&b=2');
assert.equal(r5, 'https://host.com%3Fa%3D1%26b%3D2', `mp-weixin URL 应 encode 分隔符,实际: ${r5}`);
});
test('_safeEncodeForPlatform: 华为快应用 → JS 侧主动 encode', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'quickapp' });
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '%E6%B2%99%E5%8F%91', `quickapp 中文应 encode,实际: ${r1}`);
});
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
// 2026-07-17 修正: 微信小程序也属于"不做 JS encode"平台,跟鸿蒙一样
const nav = loadNavInSandbox();
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
// 还原后单次 encode 仍是 %E8%A1%A8
for (const p of ['h5', 'quickapp']) {
const api = nav.createNavigateApi({ platform: p });
const r = api._safeEncodeForPlatform('%E8%A1%A8');
assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码,实际: ${r}`);
}
});
test('_safeEncodeForPlatform: 微信小程序下已编码入参 → decode 一次还原', () => {
// 上游已经 encode 过一次的入参(防御),防止平台原生 encode 后变成双编码
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const r = api._safeEncodeForPlatform('%E5%8F%91'); // '发' 已编码
assert.equal(r, '发', `mp-weixin 已编码入参应 decode 一次,实际: ${r}`);
});
// =============================================================
// 2. 端到端模拟: 搜索关键词 → redirectTo → 模拟 onLoad(options)
// 关键: 验证 JS 编码一次 + 平台编码/解码 一次后,onLoad 拿到的 keyword 是原文
// =============================================================
/**
* 模拟 redirectTo 的拼装逻辑(同 util.js 中的实现)
* - 鸿蒙下用 safeEncodeForPlatform(原文返回)
* - 其他平台用 safeEncodeForPlatform(encode 一次)
*/
function buildRedirectUrl(to, param, encode) {
const queryIdx = to.indexOf('?');
const path = queryIdx === -1 ? to : to.substring(0, queryIdx);
const queryStr = Object.keys(param)
.map(k => encode(k) + '=' + encode(param[k]))
.join('&');
return path + (queryStr ? '?' + queryStr : '');
}
/**
* 模拟鸿蒙元服务对 url 的处理:
* - JS 传入"未编码"的 url,鸿蒙原生会先对整个 query 字符串做一次 encodeURIComponent
* (把 = & 这些分隔符也一起编码),然后解析 query 时再 decode 一次
* - 最终 onLoad(options) 拿到的 keyword 是原文
*
* 关键: 鸿蒙原生 encode 整段 query,不会把 = 和 & 当分隔符提前 split
* (这跟浏览器/H5 的 queryString 解析方式不同)
*/
function simulateHarmonyOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
// 鸿蒙原生: 先对整段 query 字符串做 encodeURIComponent(= & 都会变 %xx)
const encodedQueryStr = encodeURIComponent(queryStr);
// 模拟解析 encoded 字符串: 把 %26 解码回 & 再 split
// 关键洞察: encodeURIComponent 后的字符串是安全的(没有裸 = &),所以可以
// 直接 decode 然后 split
const decodedBack = decodeURIComponent(encodedQueryStr);
const options = {};
decodedBack.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const k = kv.substring(0, eqIdx);
const v = kv.substring(eqIdx + 1);
options[k] = v;
});
return { options };
}
/**
* 模拟微信小程序对 url 的处理(2026-07-17 修正):
* - JS 只 encode URL 分隔符 (& = ? # \s),中文和字母数字原样保留
* - 微信小程序: 不 encode 整体 url, 只 split on '&' / '=',对 value 做 decodeURIComponent
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
* src 中的 & 已被 JS encode 成 %26,微信分解析后 onLoad 拿到 %26(而非原 &),
* 但 webview 只接接收 src 作为 URL 参数,浏览器 fetch 时会 decode %26 为 &,浏览器端解析正常
*/
function simulateWeixinOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
const options = {};
// 微信小程序: 不 auto-encode, 只 split on '&' / '=',对 value decodeURIComponent
queryStr.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const rawK = kv.substring(0, eqIdx);
const rawV = kv.substring(eqIdx + 1);
try {
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
} catch (e) {
options[rawK] = rawV;
}
});
return { options };
}
/**
* 模拟 H5 浏览器 / 华为快应用 Webview 对 url 的处理:
* - JS 已经主动 encode 过整段 query 字符串(关键步骤)
* - 浏览器解析 url 时 split on '&' 和 '=',然后对每段 value 做 decodeURIComponent
* - 最终 onLoad(options) 拿到的 keyword 是原文
*/
function simulateBrowserOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
const options = {};
queryStr.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const rawK = kv.substring(0, eqIdx);
const rawV = kv.substring(eqIdx + 1);
// 浏览器 split 之后,对 value 做 decodeURIComponent
try {
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
} catch (e) {
options[rawK] = rawV;
}
});
return { options };
}
// =============================================================
// 2.1 端到端:鸿蒙元服务(回归 Bug 的主要平台)
// =============================================================
test('端到端: 鸿蒙元服务 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
// 模拟 /pages_tool/goods/search 中的 search() 方法:
// this.$util.redirectTo('/pages_goods/list', { keyword: '沙发' });
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 鸿蒙下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`鸿蒙下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`鸿蒙下 url 应含 keyword=沙发,实际: ${url}`);
// 鸿蒙原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '沙发',
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
test('端到端: 鸿蒙元服务 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
// 注: 鸿蒙元服务下 JS 侧不做 encode(让鸿蒙原生 encode),
// 对于含 URL 分隔符 (& = ? #) 的关键词,需要业务方自己 encode,
// 否则会被 native 当成 query 分隔符破坏边界。
// 本测试覆盖用户实际场景: 中文 + 反引号 + 空格(鸿蒙原生都能正常 encode 处理)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
const kw = '`hello world` 沙发';
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
assert.ok(url.includes('keyword=' + kw),
`鸿蒙下 url 应含原始 keyword,实际: ${url}`);
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, kw,
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.2 端到端:微信小程序(回归 Bug 的另一平台)
// =============================================================
test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
// 2026-07-17 修正: 微信小程序跟鸿蒙一样,JS 不做 encode,让平台原生去 encode
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 微信下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`微信下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`微信下 url 应含 keyword=沙发,实际: ${url}`);
// 微信原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
const { options } = simulateWeixinOnLoad(url);
assert.equal(options.keyword, '沙发',
`微信下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
test('端到端: 微信小程序 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const kw = '`hello world` 沙发';
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
// mp-weixin 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样
assert.ok(url.includes('keyword=' + encode('`hello world` 沙发')),
`微信下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`);
const { options } = simulateWeixinOnLoad(url);
// decode 之后应还原 keyword(空格 回到)
assert.equal(options.keyword, kw,
`微信下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.3 端到端:H5
// =============================================================
test('端到端: H5 → 搜索关键词在列表页 search 输入框显示原文', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'h5' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
assert.ok(url.includes('keyword=%E6%B2%99%E5%8F%91'),
`H5 下 url 应含编码后的 keyword,实际: ${url}`);
// H5 浏览器从 query string 自动 decode
const { options } = simulateBrowserOnLoad(url);
assert.equal(options.keyword, '沙发',
`H5 下 browser decode 后 keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.4 端到端:华为快应用
// =============================================================
test('端到端: 快应用 → 搜索关键词在列表页 search 输入框显示原文', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'quickapp' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
assert.ok(url.includes('keyword=%E6%B2%99%E5%8F%91'),
`快应用下 url 应含编码后的 keyword,实际: ${url}`);
const { options } = simulateBrowserOnLoad(url);
assert.equal(options.keyword, '沙发',
`快应用下 decode 后 keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 3. 模拟完整的 bug 现场:商品分类 → 商品列表
// =============================================================
test('Bug 回归场景: 商品分类页输入"沙发" → 列表页搜索框显示"沙发"(4 平台)', () => {
const nav = loadNavInSandbox();
// 模拟 search.vue 中的 search() 方法
function searchClick(platform, keyword) {
const api = nav.createNavigateApi({ platform });
const encode = (v) => api._safeEncodeForPlatform(v);
// 模拟 redirectTo 拼装 url(纯函数,与 util.js redirectTo 同语义)
const url = buildRedirectUrl('/pages_goods/list', { keyword }, encode);
// 模拟 onLoad(options) 拿到的 keyword(按平台分派)
if (platform === 'mp-harmony') {
// 鸿蒙: 平台原生 encode 一次,parse 时 decode 一次
return simulateHarmonyOnLoad(url).options.keyword;
} else if (platform === 'mp-weixin') {
// 微信: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
return simulateWeixinOnLoad(url).options.keyword;
} else {
// H5 / 快应用: 浏览器/H5 风格 parse,decode 一次
return simulateBrowserOnLoad(url).options.keyword;
}
}
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const keyword = searchClick(p, '沙发');
assert.equal(keyword, '沙发',
`${p} 平台:onLoad(options).keyword 应是"沙发",实际: ${keyword}`);
}
});
test('Bug 回归场景: 长关键词 + 特殊字符,4 平台都不应乱码', () => {
const nav = loadNavInSandbox();
function searchClick(platform, keyword) {
const api = nav.createNavigateApi({ platform });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword }, encode);
if (platform === 'mp-harmony') {
return simulateHarmonyOnLoad(url).options.keyword;
} else if (platform === 'mp-weixin') {
return simulateWeixinOnLoad(url).options.keyword;
} else {
return simulateBrowserOnLoad(url).options.keyword;
}
}
// 各种边界关键词
// 注: 不含 & = ? # 等 URL 分隔符(这些字符即使在鸿蒙下也应业务方自行 encode)
const keywords = [
'沙发', // 纯中文(本次 Bug 的核心场景)
'Nike 运动鞋', // 中英文 + 空格
'`反引号`', // 含反引号
'100%纯棉', // 含 %
'价格>100', // 含 >
'a b c', // 多空格
];
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
for (const kw of keywords) {
const got = searchClick(p, kw);
assert.equal(got, kw,
`${p} 平台: 关键词 "${kw}" 应原样传递,实际: "${got}"`);
}
}
});
// =============================================================
// 4. 防御:微信/鸿蒙下不能误伤已经预 encode 的入参
// =============================================================
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
// 上游已经 encode 过一次的入参(%E5%8F%91 是"发")
const url = buildRedirectUrl('/pages_goods/list', { keyword: '%E5%8F%91' }, encode);
// 应该 decode 还原成"发",而不是保留 %E5%8F%91
assert.ok(url.includes('keyword=发'),
`鸿蒙下已编码入参应被 decode 还原,实际 url: ${url}`);
// 然后鸿蒙原生 encode 一次,parse decode 一次,回到"发"
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '发',
`鸿蒙下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
});
test('防御: 微信下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
// 2026-07-17 修正: 微信小程序同鸿蒙一样
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '%E5%8F%91' }, encode);
assert.ok(url.includes('keyword=发'),
`微信下已编码入参应被 decode 还原,实际 url: ${url}`);
const { options } = simulateWeixinOnLoad(url);
assert.equal(options.keyword, '发',
`微信下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
});

View File

@@ -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,54 @@ 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(让平台去做)', () => {
// 2026-07-17 修正: 鸿蒙整体 encode url, JS 完全不 encode
// (再 encode 会双重编码; webview src 含 & 时,鸿蒙原生 encode 会自动变 %26,webview 能正常打开)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a&b', `mp-harmony: JS 不应预 encode,实际: ${r}`);
});
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符', () => {
// 2026-07-17 修正(二次): 微信 split on '&' 但不 decode value, JS 必须 encode `&`
// 否则 query 边界被破坏。但中文 / 字母 / 数字原样保留(否则 onLoad 拿到 %xx 乱码)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
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 +378,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 +407,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); // 解析到非数字停止
});