Compare commits

..

1 Commits

160 changed files with 3688 additions and 24290 deletions

63
.gitignore vendored
View File

@@ -7,65 +7,8 @@
.codebuddy
.vscode
.trae
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
# 清理历史 (仅限团队协商后操作)
.local.config.js
# 鸿蒙元服务构建产物 (被 .gitignore 的只是产物, HAR 元数据需要 git 管理)
# 重要: ascf_build 根目录的元数据 (oh-package.json5 / Index.ets / build-profile.json5 / ...)
# 必须被 git 管理, 否则无法追踪 "module.name 被清空" 等回归。
# 这些元数据通过下方的 ! 取反规则显式纳入 git。
# 鸿蒙元服务构建产物
stub-app-projects/mp-harmony/ascf/ascf_src
stub-app-projects/mp-harmony/entry/src/main/resources/rawfile
# ascf_build/<pkg>/ 是 HAR 包定义
# 区分两类内容:
# 1. 必须入 git (元数据 / 构建配置 / HAR 入口):
# - oh-package.json5, build-profile.json5, hvigorfile.ts
# - obfuscation-rules.txt, src/main/module.json5
# - src/main/resources/base/element/string.json (HAR i18n)
# 2. 每次构建覆盖 (不应入 git):
# - src/main/resources/rawfile/** : 鸿蒙端从 ascf_src 同步的编译产物
# - Index.ets : 空壳文件, 无内容
# - build/, oh_modules/, .preview/, .cxx/, .test/, node_modules/
# rawfile/ 整体 ignore (鸿蒙端每次 Build 重新生成)
stub-app-projects/mp-harmony/ascf/ascf_build/**/rawfile/**
# Index.ets 是空壳 (每个包都是 0 字节), 不入 git
stub-app-projects/mp-harmony/ascf/ascf_build/**/Index.ets
# 其他产物目录
stub-app-projects/mp-harmony/ascf/ascf_build/**/build
stub-app-projects/mp-harmony/ascf/ascf_build/**/oh_modules
stub-app-projects/mp-harmony/ascf/ascf_build/**/.preview
stub-app-projects/mp-harmony/ascf/ascf_build/**/.cxx
stub-app-projects/mp-harmony/ascf/ascf_build/**/.test
stub-app-projects/mp-harmony/ascf/ascf_build/**/node_modules
# entry 下的产物
stub-app-projects/mp-harmony/entry/build
stub-app-projects/mp-harmony/entry/oh_modules
stub-app-projects/mp-harmony/entry/.preview
stub-app-projects/mp-harmony/entry/.cxx
stub-app-projects/mp-harmony/entry/.test
stub-app-projects/mp-harmony/entry/node_modules
# 鸿蒙工程顶层产物 (.hvigor / hvigor / .idea 已在根 .gitignore 范围或更精准的鸿蒙子 .gitignore)
stub-app-projects/mp-harmony/oh_modules
stub-app-projects/mp-harmony/.hvigor
stub-app-projects/mp-harmony/.idea
# 鸿蒙 hvigor 自身依赖
stub-app-projects/mp-harmony/hvigor
# Trae/IDE 临时文件
.hvigor/outputs
.trae_permission_check_*
# 鸿蒙签名材料 (敏感, 绝不能提交到公共 git)
# 仓库有 origin (github.com/Zajd/jsysq-uniapp) 是公开仓库, 签名材料必须 gitignore
stub-app-projects/mp-harmony/**/cert-profile
stub-app-projects/mp-harmony/**/.p12
stub-app-projects/mp-harmony/**/.p7b
stub-app-projects/mp-harmony/**/.cer
stub-app-projects/mp-harmony/**/.csr
stub-app-projects/mp-harmony/**/material
stub-app-projects/mp-harmony/ascf/ascf_build
stub-app-projects/mp-harmony/entry/src/main/resources

View File

@@ -59,15 +59,10 @@ const localDevConfig = ({
domain: 'http://192.168.197.1:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'local-11': { // 测试平台
uniacid: 11,
domain: 'http://localhost:8050/',
},
},
'100': {
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
@@ -88,10 +83,6 @@ const localDevConfig = ({
uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
'47': { // 修改弹窗
uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/',
@@ -136,42 +127,32 @@ 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',
},
'test-12': { // 户外鞋,测试优惠券
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
// })['2821']; // 离型纸硅油纸
// })['2482']; // 低空经济
})['54']; // 低空经济
// })['48']; // 离型纸硅油纸
// })['2485']; // 验证电子表单前后端数据一致性
// })['5g-11']; // 数码
// })['5g-213']; // 选择要使用的环境配置
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['5g-213']; // 选择要使用的环境配置
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
})['2811']; // 本地
// })['local-1']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test
// })['test-12']; // test
// })['18-test']; // test
export default localDevConfig;

33
App.vue
View File

@@ -271,56 +271,27 @@
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
// 只弹一次: 同意后写 storage,后续不再弹
// 异步查询平台隐私状态(微信/快应用), H5 同步返回
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
//
// 鸿蒙元服务(双轨制):
// 已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
// 应用层不调 PrivacyManager.show() (会和系统弹窗叠加被驳回).
// #ifdef MP-HARMONY
// 鸿蒙: 走平台隐私托管, markSkipped 写 storage
try {
await Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig])
PrivacyManager.markSkipped()
} catch (e) { console.warn('[privacy] MP-HARMONY markSkipped fail', e); }
// #endif
// #ifndef MP-HARMONY
try {
await PrivacyManager.show({
urlFallback: _buildPrivacyUrlFallback(options),
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
})
} catch (e) { console.warn('[privacy] show fail', e); }
// #endif
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
//
// 关键 (鸿蒙 + 其他平台): 这里只发"软隐藏"信号, splash 实际关闭要等
// splashState._softHide() 看到 _hasMounted && _pageReady 才真关。
// _pageReady 由首页 diy-group 渲染完时调 notifyPageReady() 触发。
// 这样保证 splash **一直显示**, 直到首页 diy 内容真的渲染好,
// 避免 splash 关闭后到 diy 内容出现之间出现白屏。
//
// 鸿蒙端 (mode='strict'):
// 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好
// 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底
// (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏)
// strict 模式只受 DEFAULT_TIMEOUT=12s 总超时兜底, 实际由 notifyPageReady() 触发
// #ifndef H5
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
if (_needSplash) {
// 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
// 避免 splash 提前关导致白屏
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
}
// #endif

View File

@@ -1,7 +1,6 @@
import WxMap from 'common/js/map-wx-jssdk.js';
import Config from '@/common/js/config.js';
import util from '@/common/js/util.js';
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
let systemInfo = util.getDeviceInfo();
@@ -52,15 +51,10 @@ export default {
lastDiyInfoTime: 0, // 上次成功加载的时间戳
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
diyLoadError: false,
diyLoadErrorMsg: '',
diyLoadErrorUrl: '', // 出错的网络请求地址
//启动广告
adv: {},
adv:{},
};
},
onLoad(option) {
@@ -123,10 +117,10 @@ export default {
// 关闭定位
this.enterDefaultStore();
}
},
onShow() {
this.init();
},
onHide() {
@@ -211,12 +205,12 @@ export default {
}
},
methods: {
play() {
play(){
},
async init() {
// 定位信息过期后,重新获取定位
if (this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
if(this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
this.$util.getLocation({
fail: (res) => {
// 拒绝定位,进入默认总店
@@ -232,7 +226,6 @@ export default {
}
}
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
await this.getDiyInfo();
//获取启动广告
await this.getDiyAdv();
@@ -290,7 +283,7 @@ export default {
}, 50);
// #endif
},
async getDiyAdv() {
async getDiyAdv(){
//启动广告
let res = await this.$api.sendRequest({
url: '/api/diyview/getstartadv',
@@ -299,13 +292,13 @@ export default {
});
this.adv = res.value
// 弹框形式,首次弹出 1,每次弹出 0
if (this.adv.advshow == 1) {
if(this.adv.advshow == 1){
setTimeout(() => {
if (res.value.advtype == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
@@ -315,75 +308,25 @@ export default {
}
}, 500);
}
},
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
// @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business'
_showDiyLoadError(err, url, errorType) {
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
this.diyLoadErrorUrl = url || '';
this.diyLoadError = true;
// 通知 splash: diy 加载失败, 启动屏应该让位给错误横幅
// 用 uni.$emit 而非直接 import splash-state, 避免循环依赖
// reason 拼成 'diy-{errorType}-error' 方便业务方区分 (splash:diy-error 事件)
const reason = errorType ? `diy-${errorType}-error` : 'diy-load-error'
try { uni.$emit && uni.$emit('diy:load-error', { reason, errorType, err, url }); } catch (e) { }
},
// 根据错误信息组织更详细的提示文案
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
_buildDiyErrorMsg(err) {
const info = classifyHttpError(err);
// 服务端明确返回的业务消息(字符串):直接透传
if (info.type === 'business') return info.raw;
const key = httpErrorKey(info.type);
let reason = this.$lang(key);
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
if (info.statusCode) reason += '(' + info.statusCode + ')';
// empty / unknown 不走模板,直接返回通用网络异常
if (info.type === 'empty' || info.type === 'unknown') return reason;
const tpl = this.$lang('diy.loadFailTpl', { reason });
return tpl;
},
// 错误横幅「重试」入口
retryDiyInfo() {
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
this.getDiyInfo(true);
},
async getDiyInfo(force = false) {
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
const now = Date.now();
const currentLang = uni.getStorageSync("lang") || '';
console.log('[DIY][getDiyInfo] start', 'force=', force, 'id=', this.id, 'name=', this.name, 'lang=', currentLang, 'loading=', this.diyInfoLoading, 'lastAt=', this.lastDiyInfoTime, 'lastLang=', this.lastDiyInfoLang);
if (!force) {
if (this.diyInfoLoading) {
console.log('[DIY][getDiyInfo] skip: already loading');
return;
}
if (this.diyInfoLoading) return;
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
&& this.lastDiyInfoLang === currentLang) {
console.log('[DIY][getDiyInfo] skip: throttled by ttl');
return;
}
}
this.diyInfoLoading = true;
let res;
// 出错时用于展示的完整请求地址(Request URL),与 http.js 拼接/归一化方式保持一致
const diyInfoUrl = '/api/diyview/info';
const diyInfoFullUrl = ((Config.baseUrl || '') + diyInfoUrl).replace(/(?<!:)\/+/g, '/');
try {
res = await this.$api.sendRequest({
url: diyInfoUrl,
url: '/api/diyview/info',
data: {
id: this.id,
name: this.name,
@@ -392,30 +335,19 @@ export default {
async: false
});
} catch (e) {
console.error('[DIY][getDiyInfo] request failed', e);
console.error('getDiyInfo request failed', e);
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
this.$util.showToast({
title: this.$lang('diy.networkError')
});
this.diyInfoLoading = false;
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
// errorType='network': 网络异常 (uni.request catch)
this._showDiyLoadError(e, diyInfoFullUrl, 'network');
return;
}
this.diyInfoLoading = false;
console.log('[DIY][getDiyInfo] got response', 'code=', res && res.code, 'hasData=', !!(res && res.data), 'valueType=', res && res.data && typeof res.data.value, 'valueLen=', res && res.data && (typeof res.data.value === 'string' ? res.data.value.length : (res.data.value ? res.data.value.length : null)));
if (!res || res.code != 0 || !res.data) {
console.warn('[DIY][getDiyInfo] invalid response', JSON.stringify(res));
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
if (!res) {
// errorType='no-response': 完全没有 res 对象
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}
if (res && res.code == -3) {
this.$util.showToast({
title: res.message
@@ -425,10 +357,9 @@ export default {
return;
}
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
// errorType='business': 业务失败 (code!=0 / 消息提示)
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
this.$util.showToast({
title: this.$lang('diy.noDiyData')
});
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
@@ -436,20 +367,10 @@ export default {
this.lastDiyInfoTime = now;
this.lastDiyInfoLang = currentLang;
// 成功加载后清除错误提示
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
let diyDataValue = res.data;
if (diyDataValue.value) {
try {
this.diyData = JSON.parse(diyDataValue.value);
} catch (parseErr) {
console.error('[DIY][getDiyInfo] JSON.parse(value) failed', parseErr, 'value preview=', String(diyDataValue.value).slice(0, 200));
throw parseErr;
}
console.log('[DIY][getDiyInfo] parsed diyData', 'globalKeys=', this.diyData && this.diyData.global ? Object.keys(this.diyData.global) : null, 'valueCount=', this.diyData && this.diyData.value && this.diyData.value.length, 'topIndexValue before=', this.topIndexValue);
this.diyData = JSON.parse(diyDataValue.value);
// 导航栏标题要根据语言环境切换
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
@@ -458,22 +379,22 @@ export default {
this.mpCollect = this.diyData.global.mpCollect;
this.setPublicShare();
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
// 弹框形式,首次弹出 1,每次弹出 0
setTimeout(() => {
if (this.diyData.global.popWindow.count == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
} else if (this.diyData.global.popWindow.count == 0) {
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
// 弹框形式,首次弹出 1,每次弹出 0
setTimeout(() => {
if (this.diyData.global.popWindow.count == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
}, 500);
}*/
} else if (this.diyData.global.popWindow.count == 0) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
}
}, 500);
}*/
// 修改diy数据结构排序
let searchIndex = -1;
@@ -537,9 +458,6 @@ export default {
this.openBottomNav = this.diyData.global.openBottomNav;
}
this.isShowCopyRight = true;
console.log('[DIY][getDiyInfo] done', 'topIndexValue after=', this.topIndexValue && this.topIndexValue.componentName, 'valueCount=', this.diyData.value.length, 'openBottomNav=', this.openBottomNav, 'isShowCopyRight=', this.isShowCopyRight);
} else {
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
}
},
closePopupWindow() {

View File

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

View File

@@ -80,126 +80,113 @@ export default {
methods: {
// 处理商品详情数据
async handleGoodsSkuData() {
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
try {
if (this.goodsSkuDetail.config) {
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
// this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
//设置标题
// this.$langConfig.title(this.navbarData.title);
if (this.goodsSkuDetail.config) {
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
}
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
this.modifyGoodsInfo();
// 初始化商品详情视图数据
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
sku_id: this.skuId,
goods_id: this.goodsSkuDetail.goods_id,
preview: this.preview,
source_member: this.source_member,
posterParams: this.posterParams,
posterApi: this.posterApi,
shareUrl: this.shareUrl,
goodsRoute: this.goodsRoute,
isVirtual: this.goodsSkuDetail.is_virtual,
deliveryType: this.goodsSkuDetail.express_type,
whetherCollection: this.goodsSkuDetail.is_collect,
evaluateConfig: this.goodsSkuDetail.evaluate_config,
evaluateList: this.goodsSkuDetail.evaluate_list,
evaluateCount: this.goodsSkuDetail.evaluate_count
});
//媒体
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
else this.goodsSkuDetail.sku_images = [];
}
// 多规格时合并主图
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
}
let maxHeight = '';
let systemInfo = uni.getSystemInfoSync();
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
if (typeof item.pic_spec == "string")
item.pic_spec = item.pic_spec.split('*');
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
item.pic_spec[0] = item.pic_spec[0] / ratio;
item.pic_spec[1] = item.pic_spec[1] / ratio;
if (!maxHeight || maxHeight > item.pic_spec[1]) {
maxHeight = item.pic_spec[1];
}
});
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
this.modifyGoodsInfo();
// 当前商品SKU规格
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
// 初始化商品详情视图数据
try {
if (this.$refs && this.$refs.goodsDetailView) {
this.$refs.goodsDetailView.init({
sku_id: this.skuId,
goods_id: this.goodsSkuDetail.goods_id,
preview: this.preview,
source_member: this.source_member,
posterParams: this.posterParams,
posterApi: this.posterApi,
shareUrl: this.shareUrl,
goodsRoute: this.goodsRoute,
isVirtual: this.goodsSkuDetail.is_virtual,
deliveryType: this.goodsSkuDetail.express_type,
whetherCollection: this.goodsSkuDetail.is_collect,
evaluateConfig: this.goodsSkuDetail.evaluate_config,
evaluateList: this.goodsSkuDetail.evaluate_list,
evaluateCount: this.goodsSkuDetail.evaluate_count
});
}
} catch (e) {
console.error('goodsDetailView.init failed:', e);
}
//媒体
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
else this.goodsSkuDetail.sku_images = [];
}
// 多规格时合并主图
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
}
let maxHeight = '';
let systemInfo = uni.getSystemInfoSync();
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
if (typeof item.pic_spec == "string")
item.pic_spec = item.pic_spec.split('*');
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
item.pic_spec[0] = item.pic_spec[0] / ratio;
item.pic_spec[1] = item.pic_spec[1] / ratio;
if (!maxHeight || maxHeight > item.pic_spec[1]) {
maxHeight = item.pic_spec[1];
}
});
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
// 当前商品SKU规格
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
// 商品属性
if (this.goodsSkuDetail.goods_attr_format) {
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
for (var j = 0; j < goods_attr_format.length; j++) {
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
}
// 商品属性
if (this.goodsSkuDetail.goods_attr_format) {
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
for (var j = 0; j < goods_attr_format.length; j++) {
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
}
}
}
}
// 商品SKU格式
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
// 商品SKU格式
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
// 商品详情
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
// 商品详情
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
//商品服务
if (this.goodsSkuDetail.goods_service) {
for (let i in this.goodsSkuDetail.goods_service) {
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
}
//商品服务
if (this.goodsSkuDetail.goods_service) {
for (let i in this.goodsSkuDetail.goods_service) {
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
}
}
this.contactData = {
title: this.goodsSkuDetail.sku_name,
path: this.shareUrl,
img: this.$util.img(this.goodsSkuDetail.sku_image, {
size: 'big'
})
};
try {
if (this.$refs && this.$refs.goodsPromotion) {
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
}
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
this.contactData = {
title: this.goodsSkuDetail.sku_name,
path: this.shareUrl,
img: this.$util.img(this.goodsSkuDetail.sku_image, {
size: 'big'
})
};
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
try {
this._goodsFormPromise = this.getGoodsForm();
} catch (e) {
console.error('getGoodsForm kicked off failed:', e);
}
} catch (e) {
console.error('handleGoodsSkuData failed:', e);
if (this.goodsRoute != '/pages_goods/detail') this.setPublicShare();
// this.getBarrageData();
if (this.addonIsExist.form) {
await this.getGoodsForm();
}
},
/**
@@ -340,35 +327,13 @@ export default {
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
*/
async getGoodsForm() {
// 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
const emptyFn = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
};
emptyFn();
return;
}
let res;
try {
res = await this.$api.sendRequest({
url: "/form/api/form/goodsform",
data: {
goods_id: this.goodsSkuDetail.goods_id,
format: 'object'
},
async: false
});
} catch (e) {
console.warn('getGoodsForm request failed', e);
res = null;
}
const res = await this.$api.sendRequest({
url: "/form/api/form/goodsform",
data: {
goods_id: this.goodsSkuDetail.goods_id
},
async: false
});
const empty = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});

View File

@@ -1,101 +0,0 @@
/**
* 通用网络/接口错误分类器
*
* 适用场景:
* - uniapp 各端统一错误分类(diy.js / 商品 / 订单 / 自定义页面等)
* - 任意传入 {statusCode, errMsg, message} 形式错误对象的位置
*
* 设计要点:
* 1) 与 Vue / $lang 解耦:不调用任何 i18n 函数,只返回分类结果 + 原始错误字段
* 调用方拿到 type 后,自行决定使用 $lang('http.timeout') / $lang('http.serverError')
* 或其它命名空间的文案,完全保持各模块原有的 key 命名习惯
* 2) 通用网络/接口错误统一映射到 common.http.* 命名空间:
* - http.networkError: 通用网络异常(兜底)
* - http.timeout: 请求超时
* - http.serverError: 服务器错误(5xx)
* - http.notFound: 接口不存在(404)
* - http.auth: 无访问权限(401/403)
* - http.unknown: 其他非 2xx
* 3) 兼容多种错误对象形态:
* - uniapp request: { statusCode, errMsg, data }
* - axios: { response: { status }, message }
* - fetch / 原生: { message, name }
*
* @param {Object|string} err 错误对象或后端业务消息字符串
* @returns {{ type: string, statusCode?: number, errMsg?: string, raw?: any }}
* - type 取值:
* 'business' - 后端返回的业务消息字符串(已透传到 raw)
* 'empty' - 错误对象为空
* 'notFound' - HTTP 404
* 'server' - HTTP 5xx
* 'auth' - HTTP 401 / 403
* 'request' - 其它非 2xx 状态码
* 'timeout' - errMsg 含 timeout
* 'network' - errMsg 含 connect / network / fail
* 'unknown' - 无法分类
* - statusCode: 若有则原样返回
* - errMsg: 归一后的原始消息
* - raw: 原始输入,方便调用方进一步处理
*/
export function classifyHttpError(err) {
// 1) 无错误对象
if (!err) {
return { type: 'empty', raw: err };
}
// 2) 后端直接返回的业务消息(字符串)
if (typeof err === 'string') {
return { type: 'business', raw: err };
}
// 3) 字段归一(兼容 uni / axios / 原生 Error)
const statusCode = err.statusCode
?? err.response?.status
?? err.status;
const errMsg = err.errMsg || err.message || '';
if (statusCode) {
if (statusCode === 404) return { type: 'notFound', statusCode, errMsg, raw: err };
if (statusCode >= 500) return { type: 'server', statusCode, errMsg, raw: err };
if (statusCode === 401 || statusCode === 403) return { type: 'auth', statusCode, errMsg, raw: err };
return { type: 'request', statusCode, errMsg, raw: err };
}
if (/timeout/i.test(errMsg)) {
return { type: 'timeout', errMsg, raw: err };
}
if (/(connect|network|fail)/i.test(errMsg)) {
return { type: 'network', errMsg, raw: err };
}
return { type: 'unknown', errMsg, raw: err };
}
/**
* 根据 classifyHttpError 的结果,映射到 common.http.* 命名空间的 i18n key
*
* 通用网络/接口错误统一走 http.*,调用方通过 $lang(key) 解析文案。
* 原 diy.errTimeout 这类 key 已合并到 http.*,保持项目国际化更专业。
*
* @param {string} type classifyHttpError 返回的 type
* @returns {string|null} 对应的 http.* key;type='business' 时返回 null(透传 raw)
*/
export function httpErrorKey(type) {
switch (type) {
case 'empty': return 'http.networkError';
case 'business': return null; // 透传,不用 key
case 'notFound': return 'http.notFound';
case 'server': return 'http.serverError';
case 'auth': return 'http.auth';
case 'request': return 'http.unknown';
case 'timeout': return 'http.timeout';
case 'network': return 'http.networkError';
case 'unknown':
default: return 'http.networkError';
}
}
export default {
classifyHttpError,
httpErrorKey,
};

View File

@@ -44,15 +44,6 @@ function _sleep(ms) {
}
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
const reqId = 'req_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
const reqStart = Date.now();
console.log('[HTTP][REQUEST]', reqId, method, url, JSON.stringify({
data: data,
header: header,
dataType: dataType,
responseType: responseType,
timeout: timeout
}));
return new Promise((resolve, reject) => {
uni.request({
url: url,
@@ -63,7 +54,6 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
responseType: responseType,
timeout: timeout,
success: (res) => {
console.log('[HTTP][RESPONSE]', reqId, method, url, 'statusCode=', res.statusCode, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res.data));
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
return;
@@ -83,7 +73,6 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
resolve(res.data);
},
fail: (res) => {
console.error('[HTTP][FAIL]', reqId, method, url, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res));
reject(res);
}
});
@@ -98,20 +87,15 @@ async function _requestWithRetry(params, url, method, data, header, dataType, re
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
console.log('[HTTP][RETRY]', method, url, 'attempt=', attempt, 'maxRetries=', maxRetries);
}
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
return result;
} catch (err) {
lastError = err;
lastStatusCode = err && err.statusCode;
console.warn('[HTTP][RETRY-FAIL]', method, url, 'attempt=', attempt, 'errMsg=', err && err.errMsg, 'statusCode=', lastStatusCode);
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
break;
}
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
console.log('[HTTP][RETRY-WAIT]', method, url, 'delay=', delay + 'ms');
await _sleep(delay);
}
}
@@ -223,7 +207,6 @@ export default {
}
},
uploadBase64(params) {
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
uni.request({
url: Config.baseUrl + '/api/upload/headimgBase64',
method: 'POST',
@@ -238,17 +221,14 @@ export default {
dataType: 'json',
responseType: 'text',
success: res => {
console.log('[HTTP][RESPONSE] uploadBase64', 'statusCode=', res.statusCode, JSON.stringify(res.data));
typeof params.success == 'function' && params.success(res.data);
},
fail: () => {
console.error('[HTTP][FAIL] uploadBase64', JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});
},
pullImage(params) {
console.log('[HTTP][REQUEST] pullImage', Config.baseUrl + '/api/upload/headimgPull', 'path=', params.path);
uni.request({
url: Config.baseUrl + '/api/upload/headimgPull',
method: 'POST',
@@ -263,17 +243,14 @@ export default {
dataType: 'json',
responseType: 'text',
success: res => {
console.log('[HTTP][RESPONSE] pullImage', 'statusCode=', res.statusCode, JSON.stringify(res.data));
typeof params.success == 'function' && params.success(res.data);
},
fail: () => {
console.error('[HTTP][FAIL] pullImage', JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});
},
upload(params) {
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
uni.uploadFile({
url: Config.baseUrl + params.url,
filePath: params.filePath,
@@ -287,11 +264,9 @@ export default {
'content-type': 'application/x-www-form-urlencoded;application/json'
},
success: (res) => {
console.log('[HTTP][RESPONSE] upload', Config.baseUrl + params.url, 'statusCode=', res.statusCode, res.data);
typeof params.success == 'function' && params.success(JSON.parse(res.data));
},
fail: (res) => {
console.error('[HTTP][FAIL] upload', Config.baseUrl + params.url, JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});

View File

@@ -1247,7 +1247,7 @@ export default {
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
if (cachedFieldResult !== undefined) {
let value = cachedFieldResult;
// 没找到翻译
@@ -1256,6 +1256,10 @@ export default {
if (hasDefault) {
return arguments[1];
}
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
if (!__DEV__) {
return '';
}
// 开发环境 → 返回 raw key(便于发现问题)
return value;
}
@@ -1346,6 +1350,9 @@ export default {
// 有默认值 → 使用默认值
if (hasDefault) {
value = arguments[1];
} else if (!__DEV__) {
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
value = '';
}
// 开发环境无默认值 → 保留 raw key(便于发现问题)
} else if (arguments.length > 1) {
@@ -1360,7 +1367,7 @@ export default {
if (value == undefined) value = '';
// 多语言调试
if (field == value) {
if (__DEV__ && field == value) {
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
}

View File

@@ -147,13 +147,20 @@ function getQuickAppPrivacySetting() {
}
/**
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
* @returns {Promise<{needAuthorization: boolean}>}
* 鸿蒙元服务: 查询平台级隐私设置
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
*/
function getHarmonyPrivacySetting() {
return new Promise((resolve) => {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
// #ifndef MP-HARMONY
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
});
}
@@ -181,7 +188,7 @@ async function checkPrivacy() {
}
// #endif
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
// H5 / 其他平台: 仅查 storage
return checkPrivacySync();
}
@@ -723,9 +730,8 @@ const PrivacyManager = {
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
// 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
_needShow = result.needAuthorization;
@@ -735,18 +741,7 @@ const PrivacyManager = {
return;
}
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗
// 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue
// 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗
_needShow = false;
_pendingShow = false;
_urlRuleDecided = true;
console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗');
return;
// #endif
// 其他平台: 设置待弹窗标志 + emit 触发自建弹窗
// 设置待弹窗标志
// 已 mounted 的页面组件通过事件接收
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
_pendingShow = true;
@@ -762,28 +757,18 @@ const PrivacyManager = {
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
*
* 历史兼容:
* 鸿蒙元服务兼容:
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
*
* 鸿蒙元服务(双轨制):
* 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
* 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
* 鸿蒙系统级弹窗叠加出现"多次弹出"。
* @returns {boolean} true=需要弹窗,false=不需要
*/
consumePendingShow() {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
return false;
// #endif
if (_pendingShow && _needShow) {
_pendingShow = false;
return true;
}
// 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 同一次进程内只补一次,避免隐私弹窗被反复触发
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {

View File

@@ -33,16 +33,7 @@ function useNativeLoading() {
// #endif
}
// 总兜底超时档位 (show 时可覆盖):
// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松)
// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲)
// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲)
// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用
// 业务方覆盖: splashState.show({ timeout: 17000 })
// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低
// 严防因为 timeout 太短导致用户看到白屏.
// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳
const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s)
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
@@ -80,111 +71,6 @@ class SplashState {
// 避免多通道并发时, splash 组件 re-render 风暴
this._pendingProgress = null; // 待 emit 的最新进度
this._flushTimer = null; // 节流定时器
// 网络环境 (用于 show() 自动选档)
// _networkType: 'wifi' | '4g' | '5g' | '3g' | '2g' | 'none' | 'unknown'
// _networkReady: false 表示还没检测完 (splash 显示中第一次构造时常见)
// _networkReady 后, 网络变化时通过 onNetworkStatusChange 实时更新
this._networkType = 'unknown';
this._networkReady = false;
// 启动网络检测 (异步, 不阻塞 splash 显示)
this._initNetworkDetect();
// 监听 diy 加载失败事件 (diy.js 通过 uni.$emit 触发)
// 收到后调 notifyDiyLoadError (严格模式下必须能关闭 splash)
// 解耦: splash-state 不直接 import diy.js
// 透传 reason / errorType / err / url 给业务方监听 splash:diy-error 使用
try {
if (typeof uni !== 'undefined' && uni.$on) {
uni.$on('diy:load-error', (payload) => {
try { this.notifyDiyLoadError(payload || {}) } catch (e) {}
})
}
} catch (e) {}
}
/**
* 初始化网络检测
* - 调 uni.getNetworkType 异步获取当前网络类型
* - 监听 uni.onNetworkStatusChange 实时更新
* - H5 平台降级: 调 navigator.onLine, 不支持细粒度网络类型
* 失败保护: try-catch 包, 任意失败都保持 _networkType='unknown' (不会破坏 splash)
*/
_initNetworkDetect() {
try {
// #ifdef H5
// H5 平台: navigator.onLine 是粗粒度, 只能判断在线/离线
// 仅在 navigator.onLine 是明确 boolean (true/false) 时才是 H5
// Node 24+ 有 navigator 但 onLine 是 undefined, 不算 H5 (走下面 uni.getNetworkType)
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
// 初始检测: 用 _updateNetworkType (内部会触发事件)
this._updateNetworkType(navigator.onLine ? 'wifi' : 'none', { source: 'h5-initial' })
// 监听 online/offline 事件
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('online', () => {
this._updateNetworkType('wifi', { source: 'h5-online', isConnected: true })
})
window.addEventListener('offline', () => {
this._updateNetworkType('none', { source: 'h5-offline', isConnected: false })
})
}
return
}
// navigator.onLine 不是 boolean (Node/SSR) - 走 uni.getNetworkType
// #endif
// #ifndef H5
if (typeof uni === 'undefined' || !uni.getNetworkType) {
// 不可用: 用 _updateNetworkType 设 unknown (会触发事件)
this._updateNetworkType('unknown', { source: 'no-uni-api' })
return
}
// 异步获取当前网络类型
uni.getNetworkType({
success: (res) => {
this._updateNetworkType((res && res.networkType) || 'unknown', { source: 'uni-initial' })
},
fail: () => {
this._updateNetworkType('unknown', { source: 'uni-initial-fail' })
},
})
// 监听网络变化
if (uni.onNetworkStatusChange) {
uni.onNetworkStatusChange((res) => {
const newType = (res && res.networkType)
|| (res && res.isConnected === false ? 'none' : 'unknown')
this._updateNetworkType(newType, {
source: 'uni-change',
isConnected: res && typeof res.isConnected === 'boolean' ? res.isConnected : undefined,
})
})
}
// #endif
} catch (e) {
// 任意失败都用 unknown, 不影响 splash
this._updateNetworkType('unknown', { source: 'init-error' })
}
}
/**
* 根据当前网络环境选档
* - 离线 (none): 4000 (4s, 让 diy 错误信号有时间触发)
* - 弱网 (2g/3g): 22000 (22s 慢网档, 多 2s 缓冲)
* - 正常 (4g/5g/wifi): 12000 (12s 默认档, 多 2s 缓冲)
* - 未知 (unknown): 17000 (17s 中档, 保守)
* - 网络未就绪: 12000 (DEFAULT_TIMEOUT, 保守)
*/
_pickTimeoutByNetwork() {
if (!this._networkReady) return DEFAULT_TIMEOUT // 网络检测未就绪, 用默认
switch (this._networkType) {
case 'none': return 4000 // 离线: 4s 兜底
case '2g':
case '3g': return 22000 // 弱网: 22s 慢网档
case '4g':
case '5g':
case 'wifi': return 12000 // 正常: 12s 默认档
case 'unknown':
default: return 17000 // 未知: 17s 中档 (保守)
}
}
show(opts = {}) {
@@ -225,34 +111,7 @@ class SplashState {
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
}
// 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效)
// 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s
// timeout 低于 5s 会经常触发兜底强关, 用户看到白屏
// 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn
// 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT
const MIN_RECOMMENDED_TIMEOUT = 5000
let timeout
if (opts.timeout) {
timeout = opts.timeout
if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) {
console.warn(
`[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` +
`可能导致启动期白屏. 生产环境建议 10000-20000ms.`
)
}
} else {
// 自动选档: 根据当前网络环境选 timeout
timeout = this._pickTimeoutByNetwork()
}
this._armTimeout(timeout);
}
/**
* 设置/重设 splash 总超时兜底
* - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用
* - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏
*/
_armTimeout(timeout) {
const timeout = opts.timeout || DEFAULT_TIMEOUT;
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
@@ -266,55 +125,6 @@ class SplashState {
}, timeout);
}
/**
* 网络变化时更新 _networkType, 并按需重启 splash 兜底
* @param {string} newType 新的网络类型
* @param {Object} [meta] 附加信息 { isConnected, source: 'h5-online' | 'uni-change' | ... }
* @returns {boolean} 是否真的更新了 (新旧不同时为 true)
*/
_updateNetworkType(newType, meta) {
newType = newType || 'unknown'
const oldType = this._networkType
const changed = oldType !== newType
// 关键: 即使 type 没变, _networkReady 也要设 true
// 原因: 首次调用 _updateNetworkType 是从 _initNetworkDetect 进来
// 此时 _networkType='unknown' (初始), 可能 newType 也是 'unknown' (uni 不可用)
// 但 _networkReady 仍要从 false → true (让 _pickTimeoutByNetwork 工作)
this._networkReady = true
if (!changed) return false
this._networkType = newType
// 网络变化时: 如果 splash 还 visible, 重启兜底 (用新档)
// 业务场景:
// - wifi → 2g: 启动期远端慢, 兜底延长, 避免过早强关
// - 2g → wifi: 兜底缩短, 避免过晚强关
// - any → none: 兜底缩短到 4s, 让 diy 错误信号更快触发
// - none → any: 兜底恢复正常档, 给足时间
const shouldReboot = this._visible && this._pendingHide === null
// 关键: 仅当 splash 还没进入"软隐藏"阶段时重启兜底
// _pendingHide 不为 null 表示业务方已调 hide(), 等 notifyPageReady/DiyLoadError 即可
// 此时网络变化不影响兜底 (业务方已接管决策)
if (shouldReboot) {
const newTimeout = this._pickTimeoutByNetwork()
this._armTimeout(newTimeout)
}
// 发 splash:network-change 事件 (业务方监听)
// payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout }
// retryRecommended: 从 none 恢复时建议重试 (业务方重调 diy 接口)
// rebootSplashTimeout: 是否重启了 splash 兜底 (true/false)
const eventPayload = {
from: oldType,
to: newType,
isConnected: meta && typeof meta.isConnected === 'boolean' ? meta.isConnected : newType !== 'none',
retryRecommended: oldType === 'none' && newType !== 'none', // 离线→在线, 建议重试
rebootSplashTimeout: shouldReboot,
source: (meta && meta.source) || 'unknown',
}
this._emit('splash:network-change', eventPayload)
return true
}
/**
* 隐藏 splash
* @param {Object} opts
@@ -326,22 +136,15 @@ class SplashState {
* @param {string} [opts.mode] - 离开动画模式
* - 'default': 默认 480ms 动画
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
* - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
* 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
* 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
* splash 必须真等到首页 diy 内容渲染好才能关
*/
hide(opts = {}) {
const reason = opts.reason || 'manual';
const useStrict = opts.mode === 'strict' || opts._strict === true;
// boot-complete 默认走软隐藏, 避免时序问题
const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
// strict 模式复用 fast 动画
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
const mode = opts.mode === 'fast' ? 'fast' : 'default';
if (useSoft) {
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
this._softHide(opts);
this._softHide({ reason, mode });
} else {
this._doHide({ reason, mode });
}
@@ -351,17 +154,10 @@ class SplashState {
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
* 如果组件 1s 内没 mount, 兜底自动 hide
* 如果页面 2s 内没就绪, 兜底自动 hide
*
* strict 模式 (鸿蒙已接入平台托管):
* 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
* 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
* 保证 splash 一定等到首页 diy 内容真的渲染好
*/
_softHide(opts) {
if (!this._visible) return; // 已经隐藏了
this._pendingHide = opts;
// strict 模式识别: mode='strict' 或 _strict=true
const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
// 如果组件已经 mount 且页面已就绪, 立即隐藏
if (this._hasMounted && this._pageReady) {
@@ -369,12 +165,6 @@ class SplashState {
return;
}
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
if (useStrict) {
return;
}
// 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => {
@@ -387,27 +177,15 @@ class SplashState {
/**
* 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 hide
* strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
*/
_flushPendingHide() {
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
// 已被 notifyDiyLoadError 拿走 _pendingHide 并自己 _doHide: noop
// 原因: notifyMounted 50ms 后会调 _flushPendingHide, 但 _pendingHide 已被 notifyDiyLoadError 消费
// 不应再调 _doHide (避免重复发 splash:hide 或时序错乱)
if (!this._pendingHide) return;
const _opts = this._pendingHide
const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true)
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
if (!this._pageReady) {
// strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
// 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
if (useStrict) {
return;
}
// 兜底: 2s 内没就绪, 强制隐藏
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = setTimeout(() => {
@@ -426,11 +204,6 @@ class SplashState {
/**
* 实际隐藏 (硬路径)
* @param {Object} opts
* @param {string} [opts.reason='manual'] 隐藏原因
* @param {string} [opts.mode='default'] 动画模式
* @param {boolean} [opts._silent=false] true=不发 splash:hide 事件
* 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide
*/
_doHide(opts) {
if (!this._visible) return; // 防重入
@@ -452,8 +225,6 @@ class SplashState {
if (useNativeLoading()) {
try { uni.hideLoading(); } catch (e) {}
}
// _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide
if (opts._silent) return;
this._emit('splash:hide', {
reason: opts.reason || 'manual',
mode: opts.mode || 'default',
@@ -493,60 +264,6 @@ class SplashState {
}
}
/**
* diy 加载失败时通知 (网络异常/业务失败/无响应)
* - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上)
* - 触发独立事件 'splash:diy-error' (不是 'splash:hide')
* 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型
* 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError)
*
* @param {Object} [opts]
* @param {string} [opts.reason='diy-load-error'] 隐藏原因
* 用于 'splash:diy-error' 事件的 payload, 业务方可区分:
* - 'diy-network-error': 网络异常 (uni.request catch)
* - 'diy-no-response': 无响应 (res 为空)
* - 'diy-business-error': 业务失败 (code!=0)
* @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response'
* @param {Object|string} [opts.err] 原始错误对象
* @param {string} [opts.url] 出错的请求地址
*/
notifyDiyLoadError(opts = {}) {
// 已 _pageReady=true (notifyPageReady 之后): noop
// 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱)
// 不应再触发 splash:diy-error (已经发过 splash:hide)
if (this._pageReady) return;
// 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪")
// 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉
this._pageReady = true
// 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError)
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId)
this._pageReadyGraceId = null
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId)
this._mountGraceId = null
}
// 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误)
this._emit('splash:diy-error', {
reason: opts.reason || 'diy-load-error',
errorType: opts.errorType,
err: opts.err,
url: opts.url,
})
// 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide
// 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件
// notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide
// (业务方根据事件类型区分, 不能混淆)
if (this._pendingHide) {
// 把 _pendingHide 取出, 直接 _doHide 关闭
// _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error)
const _pendingOpts = this._pendingHide
this._pendingHide = null
setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 50)
}
}
/**
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减

View File

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

View File

@@ -102,36 +102,6 @@ export function parsePageParams(raw, defaultValue = {}) {
}
/**
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
*
* 设计动机:URL query 中布尔值的合法写法不止一种——
* ?show_home=1 -> '1' -> true
* ?show_home=true -> 'true' -> true
* ?show_home=true -> true -> true
* ?show_home=0 -> '0' -> false
* ?show_home=false -> 'false' -> false
* ?show_home= -> '' -> false
* 未传 -> undefined -> false
*
* 与裸 `Boolean(x)` 的差异:
* Boolean('false') === true (任何非空字符串都是 true)
* Boolean('0') === true (同上)
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
*
* @param {*} val 任意值
* @returns {boolean}
*/
export function coerceBool(val) {
if (val === true || val === 1) return true;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
return s === '1' || s === 'true';
}
return false;
}
// 关于页面路径,使用常量导出
export const INDEX_PAGE_URL = '/pages/index/index';
export const CATEGORY_PAGE_URL = '/pages_goods/category';
@@ -487,9 +457,6 @@ export default {
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
parsePageParams,
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
coerceBool,
/** HTML 实体解码 */
decodeEntity,
@@ -544,27 +511,6 @@ export default {
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
//
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
// 修复场景:
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
// onShow 死循环跳登录页.
// 排除场景 (重要, 避免影响):
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
if (cleanPath === LOGIN_PAGE_URL) {
mode = 'reLaunch';
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
}
}
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
@@ -592,18 +538,18 @@ export default {
merged.page_params = page_params;
}
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 是已编码字符串(典型表现:商品分类 → 商品列表,
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 是已编码字符串(典型表现:商品分类 → 商品列表,
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
const queryStr = Object.keys(merged)
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
.join('&');
@@ -669,33 +615,30 @@ export default {
const diyTabBar = store.state.tabBarList;
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
const cleanUrl = url.split('?')[0];
const isExcludedPage = NO_TAB_PAGES.has(cleanUrl);
if (!isExcludedPage) {
const isDiyTabRoot = diyTabBar.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
return normalizedTab === normalizedUrl;
const isDiyTabRoot = diyTabBar.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
return normalizedTab === normalizedUrl;
});
if (isDiyTabRoot) {
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
// #ifdef MP-HARMONY
navApi = uni.reLaunch;
// #endif
navApi({
url,
fail: (err) => {
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
}
});
if (isDiyTabRoot) {
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
// #ifdef MP-HARMONY
navApi = uni.reLaunch;
// #endif
navApi({
url,
fail: (err) => {
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
}
});
return;
}
return;
}
}
@@ -726,41 +669,6 @@ export default {
break;
case 'reLaunch':
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
// H5 平台修复 (2026-07-23):
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
// 注销/退出登录后用户能看到旧会员信息。
// location.replace 不产生新历史条目, 直接替换当前条目,
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
// #ifdef H5
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
&& typeof window.location.replace === 'function') {
window.location.replace(url);
return;
}
// #endif
// 小程序平台修复 (2026-07-23):
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
// 解决方案:
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
// #ifdef MP
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
return;
}
globalThis.__reLaunchLock = true;
setTimeout(() => {
uni.reLaunch({ url });
// 300ms 后释放锁
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
}, 0);
return;
// #endif
// APP / 其他平台: 原生 reLaunch
uni.reLaunch({
url
});

View File

@@ -1,35 +1,30 @@
<template>
<!-- 修复底部 TabBar 频闪:
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
<view class="tabbar-border"></view>
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
<view class="bd">
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
:animation="isCartItem(item.link) ? cartAnimation : null"
:id="isCartItem(item.link) ? 'tabbarCart' : null">
<block v-if="verify(item.link)">
<image v-if="item.selected_icon_type == 'img'"
:src="$util.img(item.selectedIconPath)" />
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
:value="item.selected_style ? item.selected_style : null"></diy-icon>
</block>
<block v-else>
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
:value="item.style ? item.style : null"></diy-icon>
</block>
<view class="cart-count-mark font-size-activity-tag"
:class="{ max: cartNumber > 99 }"
:style="{ background: 'var(--price-color)' }"
v-if="isCartItem(item.link) && cartNumber > 0">
{{ cartNumber > 99 ? '99+' : cartNumber }}
</view>
<view data-component-name="diy-bottom-nav" v-if="shouldShowTabBar">
<view class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
<view class="tabbar-border"></view>
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
<view class="bd">
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
:animation="isCartItem(item.link) ? cartAnimation : null"
:id="isCartItem(item.link) ? 'tabbarCart' : null">
<block v-if="verify(item.link)">
<image v-if="item.selected_icon_type == 'img'"
:src="$util.img(item.selectedIconPath)" />
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
:value="item.selected_style ? item.selected_style : null"></diy-icon>
</block>
<block v-else>
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
:value="item.style ? item.style : null"></diy-icon>
</block>
<view class="cart-count-mark font-size-activity-tag"
:class="{ max: cartNumber > 99 }"
:style="{ background: 'var(--price-color)' }"
v-if="isCartItem(item.link) && cartNumber > 0">
{{ cartNumber > 99 ? '99+' : cartNumber }}
</view>
</view>
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
@@ -43,7 +38,7 @@
</view>
</view>
</view>
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
<!-- 解决fixed定位后底部导航栏塌陷问题 -->
<view class="tab-bar-placeholder"></view>
</view>
</template>

View File

@@ -16,7 +16,7 @@
</view>
</view>
<view class="category-goods-list" v-if="selectedSecondCategory">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" :data-template="value.template" v-if="secondCategoryGoodsList.length">
<view class="goods-list double-column show" :data-template="value.template" v-if="secondCategoryGoodsList.length">
<view class="goods-item" v-for="(item, index) in secondCategoryGoodsList" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="imgError(index)" show-menu-by-longpress />
@@ -35,13 +35,6 @@
</block>
</view>
</view>
<view class="footer-wrap">
<view class="consult-now-btn-wrap" v-if="item.isinformation == 1">
<view class="consult-now-btn" @tap.stop="sendToAIChat(item)">
{{ $lang('components-diy.diy-category-item.consultNow') }}
</view>
</view>
</view>
</view>
</view>
</view>
@@ -58,7 +51,7 @@
<view :class="['category-title', 'category-title-with-lines']">{{ one.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list double-column show" v-if="template1ChildGoodsList[one.category_id] && template1ChildGoodsList[one.category_id].length">
<view class="goods-item" v-for="(item, index) in template1ChildGoodsList[one.category_id]" :key="index">
<view class="goods-item" v-for="(item, index) in template1ChildGoodsList[one.category_id]" :key="one.category_id + '-' + index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(one.category_id, index)" show-menu-by-longpress />
<view class="color-base-bg goods-tag" v-if="item.label_name">{{ item.label_name }}</view>
@@ -516,7 +509,7 @@ export default {
currentOneCategory: {
type: Object,
default: null
},
}
},
mixins: [DiyMinx],
data() {
@@ -582,7 +575,7 @@ export default {
*/
isGroupedDisplay() {
return this.value.template == 1 && this.value.level == 3;
},
}
},
created() {
this.type = this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods';
@@ -2388,29 +2381,25 @@ export default {
.second-category-btns {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
padding: 20rpx 20rpx 20rpx 24rpx;
height: 100rpx;
padding: 20rpx 20rpx 10rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
box-sizing: border-box;
.second-category-btn {
padding: 10rpx 24rpx;
padding: 14rpx 32rpx;
background: #f5f5f5;
color: #666;
border-radius: 30rpx;
font-size: 20rpx;
margin: 0 14rpx 0rpx 0;
border-radius: 40rpx;
font-size: 26rpx;
margin: 0 16rpx 16rpx 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.selected {
background: #FF4646;
color: #FFFFFF;
background: #FF6B35;
color: #fff;
}
}
}
@@ -2506,99 +2495,6 @@ export default {
}
}
}
.footer-wrap {
display: flex;
justify-content: flex-end;
margin-top: 10rpx;
}
}
&.single-column {
flex-direction: column;
padding: 0 20rpx 0 8rpx;
gap: 20rpx;
.goods-item {
width: calc(100% + 20rpx);
flex-direction: row;
align-items: flex-start;
background: #fff;
border-radius: 12rpx;
margin-bottom: 0;
padding: 20rpx;
box-sizing: border-box;
height: 220rpx;
.goods-img {
width: 180rpx;
height: 180rpx;
padding-top: 0;
margin-right: 14rpx;
border-radius: 12rpx;
flex-shrink: 0;
image {
border-radius: 12rpx;
}
}
.info-wrap {
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.name-wrap {
.goods-name {
font-size: 28rpx;
color: #333;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
margin-top: 0;
white-space: normal;
text-overflow: clip;
}
}
.price-wrap {
margin-top: 8rpx;
display: flex;
align-items: baseline;
justify-content: flex-start;
.discount-price {
color: #FF6B35;
font-size: 32rpx;
font-weight: bold;
.unit {
font-size: 24rpx;
}
}
}
.footer-wrap {
display: flex;
justify-content: flex-end;
margin-top: auto;
}
}
}
&.double-column {
.goods-item {
width: calc(50% - 10rpx);
flex-direction: column;
background: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
}
}
}

View File

@@ -86,27 +86,13 @@
</uni-popup>
</view>
<!-- 全屏骨架占位 (只显示: 左分类树 + 右商品网格 + 底部空隙, 不画 navbar/TabBar,
避免与小程序/H5 顶部 navbar / diy-bottom-nav 错位)。
通过 v-if 拿到 categoryTreeLoading 时按真实布局骨架填充。
search-box / 商品 nav-bar 由外层 category.vue 渲染, 不在本组件里动它 -->
<view class="category-page-skeleton" v-if="categoryTreeLoading">
<view class="skeleton-content">
<view class="skeleton-tree">
<view class="skeleton-tree-item" v-for="n in 8" :key="n"></view>
</view>
<view class="skeleton-grid">
<view class="skeleton-card" v-for="n in 6" :key="n"></view>
</view>
</view>
</view>
<view class="content-box" v-if="categoryTree">
<block v-if="categoryTree.length">
<scroll-view scroll-y="true" class="tree-wrap">
<view class="category-item-wrap">
<!-- 模板4: 左侧显示一级分类(顶部轮播中的分类) -->
<view v-if="value.template == 4">
<view class="category-item" v-for="(item, index) in templateFourData" :key="index" :class="[
<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 }
@@ -119,8 +105,7 @@
<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 },
{ 'first-category-item': index == 0 && value.template == 99 }
{ 'border-top': value.template == 4 && select - 1 === index }
]" @tap.stop="switchOneCategory(index)">
<view class="">{{ item.category_name }}</view>
</view>
@@ -164,7 +149,6 @@
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
:currentOneCategory="categoryTree && categoryTree[select] ? categoryTree[select] : null"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</view>
@@ -190,6 +174,10 @@
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
</view>
</view>
<view class="category-empty" v-else>
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
</view>
<!-- 模板4: 底部购物车栏 -->
<view class="cart-box cart-box-template-4"
@@ -212,8 +200,8 @@
</view>
</view>
<view class="cart-point" v-for="(item, index) in carIconList" :key="index"
:style="{ left: item.left + 'px', top: item.top + 'px' }"></view>
<view class="cart-point" :style="{ left: item.left + 'px', top: item.top + 'px' }" :key="index"
v-for="(item, index) in carIconList"></view>
<ns-goods-sku-category ref="skuSelect" @refresh="refreshData" @addCart="addCartPoint"></ns-goods-sku-category>
</view>
@@ -261,12 +249,7 @@ export default {
isList: false,
lang: uni.getStorageSync("lang"),
lastSecondCategoryName: '',
template99Category: null,
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
// 显示骨架占位而非空态文案
categoryTreeLoading: true,
template99Category: null
};
},
created() {
@@ -303,14 +286,7 @@ export default {
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) { },
categoryTreeLoading: function (nval, oval) {
if (nval === false && oval === true) {
this.$nextTick(() => {
if (!this.heightArea.length) this.getHeightArea(-1);
});
}
}
componentRefresh: function (nval) { }
},
computed: {
cartTotalMoney() {
@@ -384,7 +360,6 @@ export default {
this.$store.dispatch('getCartNumber');
if (!this.heightArea.length) this.getHeightArea(-1);
},
/**
* 获取高度区间
*
@@ -392,13 +367,6 @@ export default {
* 加防御性检查,避免上线后第一次进入崩溃
*/
getHeightArea(index) {
// 节流: 500ms 内的重复请求复用上次结果, 避免每次 onShow 都跑 selectAll 触发频闪
const now = Date.now();
if (this._lastHeightAreaTime && now - this._lastHeightAreaTime < 500 && this.heightArea && this.heightArea.length) {
if (index != -1 && index < this.categoryTree.length - 1 && Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[index + 1]) this.$refs.categoryItem[index + 1].getGoodsList();
return;
}
this._lastHeightAreaTime = now;
let heightArea = [];
if (!query || typeof query.selectAll !== 'function') {
query = uni.createSelectorQuery && uni.createSelectorQuery().in(this);
@@ -428,7 +396,6 @@ export default {
* 获取全部分类
*/
getCategoryTree() {
this.categoryTreeLoading = true;
this.$api.sendRequest({
url: '/api/goodscategory/tree',
data: {
@@ -458,14 +425,6 @@ export default {
this.loadGlobalSecondCategoryBtns();
}
}
},
fail: () => {
// 失败也保证 loading 关闭, 让骨架替换为"暂无分类"空态
this.categoryTree = this.categoryTree || [];
},
complete: () => {
// 无论成功失败都关闭 loading 骨架
this.categoryTreeLoading = false;
}
});
},
@@ -740,12 +699,11 @@ export default {
}
.content-box {
flex: 1;
height: 0;
display: flex;
align-items: flex-start;
flex: 1;
height: 0;
display: flex;
.tree-wrap {
.tree-wrap {
width: 170rpx;
height: 100%;
background-color: #f5f5f5;
@@ -992,86 +950,6 @@ export default {
opacity: 0;
}
// 全屏骨架占位 — 不画 navbar/TabBar (由外层 category.vue 渲染), 避免错位
// 兼容鸿蒙元服务 (JSVM 对 gap 支持不一): 用 margin 兜底替代 gap
.category-page-skeleton {
flex: 1;
height: 0; // 与 .content-box 一致, 让 flex 子项撑开父容器剩余高度
display: flex;
background-color: #f4f6fa;
.skeleton-content {
flex: 1;
display: flex;
min-height: 0;
.skeleton-tree {
width: 170rpx;
background-color: #f5f5f5;
padding: 20rpx 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
// #ifndef MP-HARMONY
gap: 18rpx;
// #endif
flex-shrink: 0;
.skeleton-tree-item {
height: 40rpx;
// #ifndef MP-HARMONY
margin: 0 28rpx;
// #endif
// #ifdef MP-HARMONY
margin: 9rpx 28rpx; // 18rpx 间距 上下各 9rpx, 替代 gap:18rpx
// #endif
border-radius: 12rpx;
background: linear-gradient(90deg, #ececec 25%, #f5f5f5 37%, #ececec 63%);
background-size: 400% 100%;
animation: category-skeleton-shimmer 1.4s ease infinite;
}
}
.skeleton-grid {
flex: 1;
padding: 24rpx;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
// #ifndef MP-HARMONY
gap: 16rpx;
// #endif
background-color: #f4f6fa;
min-width: 0;
.skeleton-card {
width: calc((100% - 32rpx) / 3);
height: 220rpx;
// #ifdef MP-HARMONY
margin: 8rpx;
// #endif
border-radius: 16rpx;
background: linear-gradient(90deg, #ececec 25%, #f5f5f5 37%, #ececec 63%);
background-size: 400% 100%;
animation: category-skeleton-shimmer 1.4s ease infinite;
}
}
}
}
@keyframes category-skeleton-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
// 风格四
.category-template-4 {
// 模板4 顶部搜索框(图片样式)
@@ -1604,21 +1482,18 @@ export default {
.tree-wrap .category-item {
padding: 30rpx 16rpx;
background-color: #f5f5f5;
white-space: normal;
word-break: break-all;
view {
color: #333;
font-size: 26rpx;
line-height: 1.5;
line-height: 1.3;
}
&.select {
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
view {
color: #333;
color: #FF6B35;
font-weight: bold;
font-size: 28rpx;
}
@@ -1627,7 +1502,7 @@ export default {
content: ' ';
width: 6rpx;
height: 36rpx;
background: #FF4646;
background: #FF6B35;
display: block;
position: absolute;
left: 0;
@@ -1638,32 +1513,9 @@ export default {
}
}
// 模板99: 第一个分类项固定高度,与右侧二级分类按钮区域对齐
.tree-wrap .category-item.first-category-item {
height: 100rpx;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
view {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.content-wrap {
background: #f5f5f5;
padding: 0;
// #ifdef H5
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
background: transparent;
}
// #endif
}
.child-category {

View File

@@ -265,7 +265,7 @@ export default {
// #ifndef MP-WEIXIN
if (item?.channelType === 'wechat') {
uni.showToast({
title: this.$lang('wechatChannel.wechatMpOnly'),
title: '视频号仅支持在微信小程序环境中播放',
icon: 'none',
duration: 2000
});

View File

@@ -1,16 +1,11 @@
<template>
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
<!-- Banner -->
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
r=function(){var o=this.value.show_banner...this.$mp.data...})
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
<view v-if="value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
</view>
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<view v-if="value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
@@ -22,18 +17,13 @@
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
</template>
<script>
// 自定义表单
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
import DiyMinx from './minx.js'
import NsNewform from '@/components/ns-newform/ns-newform.vue'
@@ -57,12 +47,6 @@ export default {
showSubmit: {
type: Boolean,
default: true
},
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
onSubmit: {
type: Function,
default: null
}
},
mixins: [DiyMinx],
@@ -86,15 +70,13 @@ export default {
},
computed: {
style() {
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
var css = '';
var v = this.value || {};
css += 'background-color:' + (v.contentBgColor || '') + ';';
if (v.elementAngle === 'round') {
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
css += 'background-color:' + this.value.contentBgColor + ';';
if (this.value.elementAngle === 'round') {
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
}
return css;
},
@@ -159,10 +141,6 @@ export default {
submitText() {
return this.mergedFormConfig.submitText || '提交';
},
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
hasCustomSubmit() {
return typeof this.onSubmit === 'function';
},
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
formCfg() {
@@ -202,18 +180,11 @@ export default {
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
submitText: pick('submitText', 'submit_text'),
submitColor: pick('submitColor', 'submit_color'),
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
@@ -301,26 +272,24 @@ export default {
return true;
},
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
handleSubmit() {
// 提交表单
submitForm() {
var self = this;
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
if (!this.verify()) {
return;
}
const formData = this.getFormData();
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
if (this.hasCustomSubmit) {
return this.onSubmit(formData);
}
// 兜底:内置 sendRequest 流程
this.defaultSubmit(formData);
},
// 公共兜底实现:默认的 /form/api/form/create 流程;
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
defaultSubmit(submitFormData) {
var self = this;
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
if (this.$listeners && this.$listeners.submit) {
this.$emit('submit', this.getFormData());
return;
}
// 获得保单数据
const submitFormData = this.getFormData();
// 提交到后端
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
@@ -332,7 +301,7 @@ export default {
success: function(res) {
if (res.code === 0) {
uni.showToast({
title: self.$lang('submitSuccess'),
title: '提交成功',
icon: 'success'
});
// 重置表单
@@ -341,7 +310,7 @@ export default {
}
} else {
uni.showToast({
title: res.message || self.$lang('submitFail'),
title: res.message || '提交失败',
icon: 'none'
});
}

View File

@@ -159,10 +159,7 @@
<!-- 商品推荐 -->
<diy-goods-recommend :value="item"></diy-goods-recommend>
</template>
<template v-if="item.componentName == 'DiyRecommendCode'">
<!-- 自定义推荐码组件 -->
<diy-recommend-code :value="item"></diy-recommend-code>
</template>
<template v-if="item.componentName == 'GoodsBrand'">
<!-- 商品品牌 -->
<diy-goods-brand :value="item"></diy-goods-brand>
@@ -309,11 +306,9 @@ export default {
}
},
created() {
console.log('[DIY-GROUP][created] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
this.processData();
},
mounted() {
console.log('[DIY-GROUP][mounted] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
if (this.diyData && this.diyData.value) {
this._notifyLoaded();
}
@@ -354,13 +349,11 @@ export default {
},
methods: {
_notifyLoaded() {
console.log('[DIY-GROUP][_notifyLoaded] enter, will emit loaded in 300ms');
if (this._loadedTimer) clearTimeout(this._loadedTimer);
this._loadedTimer = setTimeout(() => {
console.log('[DIY-GROUP][_notifyLoaded] emit loaded');
this.$emit('loaded');
}, 300);
},
if (this._loadedTimer) clearTimeout(this._loadedTimer);
this._loadedTimer = setTimeout(() => {
this.$emit('loaded');
}, 300);
},
processData() {
if (!this.diyGlobalData || !this.diyGlobalData.value) {
this.processedData = [];

File diff suppressed because it is too large Load Diff

View File

@@ -42,92 +42,82 @@ export default {
};
</script>
<style lang="scss">
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading-layer {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 997;
background: #f8f8f8;
// 修复: 强制合成层, 让 spin 动画只在自身合成层跑, 不触发兄弟节点 (尤其是底部 TabBar) 重绘
// 兼容环境: 微信小程序 / 鸿蒙元服务 / H5 都受益
// - 微信小程序: spin 动画每帧导致兄弟 fixed 元素 TabBar 重绘
// - 鸿蒙元服务 JSVM: 同上, JSVM 重绘更明显
// - H5: will-change 提示浏览器持续合成
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
}
.loading-anim {
position: absolute;
left: 50%;
top: 40%;
transform: translate(-50%, -50%);
}
.loading-anim > .item {
position: relative;
width: 70rpx;
height: 70rpx;
perspective: 1600rpx;
transform-style: preserve-3d;
transition: all 0.2s ease-out;
}
.loading-anim .border {
position: absolute;
border-radius: 50%;
border: 6rpx solid;
}
.loading-anim .out {
top: 15%;
left: 15%;
width: 70%;
height: 70%;
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
border-left-color: var(--base-color, #FF4646);
border-right-color: #C5C5C5 !important;
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
border-top-color: var(--base-color, #FF4646);
border-bottom-color: #C5C5C5 !important;
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
animation: spin 0.6s linear normal infinite;
transform-origin: center center;
}
.loading-anim .in {
top: 25%;
left: 25%;
width: 50%;
height: 50%;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
animation: spin 0.8s linear infinite;
transform-origin: center center;
}
.loading-anim .mid {
top: 40%;
left: 40%;
width: 20%;
height: 20%;
border-left-color: transparent;
animation: spin 0.6s linear infinite;
transform-origin: center center;
}
<style lang="scss">
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading-layer {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 997;
background: #f8f8f8;
}
.loading-anim {
position: absolute;
left: 50%;
top: 40%;
transform: translate(-50%, -50%);
}
.loading-anim > .item {
position: relative;
width: 70rpx;
height: 70rpx;
perspective: 1600rpx;
transform-style: preserve-3d;
transition: all 0.2s ease-out;
}
.loading-anim .border {
position: absolute;
border-radius: 50%;
border: 6rpx solid;
}
.loading-anim .out {
top: 15%;
left: 15%;
width: 70%;
height: 70%;
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
border-left-color: var(--base-color, #FF4646);
border-right-color: #C5C5C5 !important;
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
border-top-color: var(--base-color, #FF4646);
border-bottom-color: #C5C5C5 !important;
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
animation: spin 0.6s linear normal infinite;
}
.loading-anim .in {
top: 25%;
left: 25%;
width: 50%;
height: 50%;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
animation: spin 0.8s linear infinite;
}
.loading-anim .mid {
top: 40%;
left: 40%;
width: 20%;
height: 20%;
border-left-color: transparent;
border-right-color: transparent;
animation: spin 0.6s linear infinite;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<view class="form-wrap form-component" :style="formWrapStyle">
<block v-for="(item, index) in formData" :key="index">
<block v-for="(item, index) in formData">
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
@@ -37,26 +37,12 @@
></textarea>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序原生 textarea:
不用 auto-height(会随内容无限扩展遮挡后续组件),改用 textareaStyle 中
固定 height 176rpx(约 4 行)+ line-height 44rpx,可视区域比原 88rpx 翻倍,
内容多时内部滚动条 thumb 比例改善一倍,且高度可控。 -->
<textarea
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="textareaStyle"
></textarea>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<textarea
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
></textarea>
<!-- #endif -->
<textarea
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
></textarea>
<!-- #endif -->
</view>
</view>
@@ -84,8 +70,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<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">
@@ -104,8 +90,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<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">
@@ -295,17 +281,10 @@
labelFixedWidth: 180,
labelFontSize: 28,
labelColor: '#1f2937',
labelFontWeight: 600,
labelAutoMaxWidth: 200,
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#909399',
placeholderFontSize: 28,
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: '#e5e7eb',
inputBgColor: '#f9fafb',
inputFocusBorderColor: '#ff4544',
inputFocusBgColor: '#ffffff',
showRowDivider: true,
rowDividerColor: '#f0f0f0'
};
@@ -329,15 +308,10 @@
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
showRowDivider: pick('showRowDivider', 'show_row_divider'),
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
};
@@ -389,16 +363,10 @@
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
'--label-color': cfg.labelColor,
'--label-font-weight': cfg.labelFontWeight,
'--required-color': cfg.requiredColor,
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
// 输入框视觉增强(与 ns-newform 对齐)
'--input-border-color': cfg.inputBorderColor,
'--input-bg-color': cfg.inputBgColor,
'--input-focus-border-color': cfg.inputFocusBorderColor,
'--input-focus-bg-color': cfg.inputFocusBgColor,
'--row-divider-color': cfg.rowDividerColor,
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
});
@@ -434,26 +402,6 @@
'line-height': this.$util.rpx(this.config.inputHeight)
});
},
// textarea 专用 inline style(仅微信小程序端使用,见 template 中 #ifdef MP-WEIXIN 分支)
// 设计要点:
// 1) 不复用 inputStyle 的 min-height / line-height:这两个值取自 inputHeight(默认 60rpx),
// 对 textarea 太小,会让微信小程序原生 textarea 单行过高、可视区域过小,
// 内容稍多即溢出导致原生滚动条 thumb 异常小;
// 2) 不使用 auto-height:在微信小程序原生 textarea 上,auto-height 会让 textarea 随内容
// 无限扩展,遮挡后续组件,导致"看不到下面的其他组件";
// 3) 改用固定 height 176rpx(约 4 行,line-height 44rpx),相比原 CSS height: 88rpx(2 行)
// 可视区域翻倍,内容多时内部滚动条 thumb 比例改善一倍,且高度可控不会遮挡后续组件。
textareaStyle() {
const h = this.$util.rpx(176);
return this.$util.objToStyle({
'height': h,
'min-height': h,
'line-height': this.$util.rpx(44),
'border-radius': this.$util.rpx(this.config.inputRadius),
'font-size': this.$util.rpx(this.config.labelFontSize),
'color': this.config.labelColor
});
},
// 必填星号颜色
requiredColor() {
return this.config.requiredColor;
@@ -483,33 +431,10 @@
}
},
methods: {
// 预计算每项的 option_layout inline style(与 ns-newform 对齐)
// 跨端兼容:小程序 / 鸿蒙元服务 WXML 不支持模板里的方法调用,必须预计算到 item 上
recomputeItemStyles() {
if (!this.formData || this.formData.length === 0) return;
this.formData.forEach((item) => {
const itemValue = (item && item.value) || {};
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'vertical') {
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '8px';
} else {
// wrap 模式
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '6px';
}
this.$set(item, '_optionLayoutStyle', optStyle);
});
},
initFormData() {
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
this.formData = JSON.parse(JSON.stringify(this.data || []));
this.processFormData();
// 预计算每项 inline style(与 ns-newform 对齐,跨端兼容)
this.recomputeItemStyles();
},
setFormData() {
this.initFormData();
@@ -517,9 +442,7 @@
},
processFormData() {
this.formData.forEach(item => {
// Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
if (!item.val && !skipDefault) item.val = item.value.default ? item.value.default : '';
if (!item.val) item.val = item.value.default ? item.value.default : '';
if (item.value.options) {
item.option_lists = [];
@@ -954,16 +877,10 @@
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-color: #1f2937;
--label-font-weight: 600;
--required-color: #ef4444;
--placeholder-font-size: 28rpx;
--checked-color: #ff4544;
--placeholder-color: #909399;
// 输入框视觉增强(与 ns-newform 对齐)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-focus-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--show-row-divider: 1;
}
@@ -1027,7 +944,6 @@
text-align: left;
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
font-weight: var(--label-font-weight, 600);
text {
font-size: var(--label-font-size, $font-size-base);
@@ -1049,8 +965,6 @@
line-height: inherit;
text-align: left;
// H5 端 <input> 可能被 uni-app 编译为 <uni-input> 包装组件,::v-deep 穿透 scoped 命中真实原生 input
::v-deep input,
input {
font-size: var(--label-font-size, $font-size-base);
text-align: left;
@@ -1062,16 +976,8 @@
min-height: var(--input-height, 60rpx);
border-radius: var(--input-radius, 0rpx);
box-sizing: border-box;
padding: 0 12rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
padding: 0;
background: transparent;
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
@@ -1089,7 +995,6 @@
line-height: var(--input-height, 60rpx);
}
::v-deep textarea,
textarea {
font-size: var(--label-font-size, $font-size-base);
width: 100%;
@@ -1102,16 +1007,8 @@
text-align: left;
border-radius: var(--input-radius, 0rpx);
box-sizing: border-box;
padding: 12rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
padding: 0;
background: transparent;
}
.uni-input {
@@ -1119,11 +1016,6 @@
color: var(--label-color, #1f2937);
line-height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 0rpx);
transition: all 0.3s ease;
}
.select-address {
@@ -1145,15 +1037,6 @@
flex-wrap: wrap;
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
}
}
label {
display: flex;
align-items: center;

View File

@@ -10,14 +10,14 @@
return {
list: [],
config: {
title: '',
title: '猜你喜欢',
sources: 'sort',
supportPage: [],
goodsIds: [],
fontWeight: false,
padding: 0,
cartEvent: 'detail',
text: '',
text: '购买',
textColor: '#FFFFFF',
theme: 'default',
aroundRadius: 25,
@@ -67,13 +67,10 @@
}
},
mounted() {
// 初始化 i18n 默认文案,实际展示以服务端配置 config.title / config.text 为准
if (!this.config.title) this.config.title = this.$lang('common.goodsRecommendTitle');
if (!this.config.text) this.config.text = this.$lang('buyNow');
if (this.auto) {
this.getLikeList();
}
},
if (this.auto) {
this.getLikeList();
}
},
methods: {
init() {
this.list = [];

View File

@@ -56,18 +56,10 @@
<!-- 手机号授权按钮 -->
<block v-if="isAgree">
<!-- #ifdef MP-WEIXIN -->
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
@getphonenumber="getPhoneNumber">
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
</button>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务无 getPhoneNumber 微信 API,改为弹出手机号+验证码表单 -->
<button class="auth-login save-btn border-0" style="border: none;" @tap="showHarmonyMobileForm = true">
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
</button>
<!-- #endif -->
</block>
<block v-else>
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
@@ -75,27 +67,6 @@
</button>
</block>
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙专属:手机号+验证码表单 -->
<view v-if="showHarmonyMobileForm" class="harmony-mobile-form" @touchmove.prevent.stop>
<view class="item-wrap">
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.mobilePlaceholder')" v-model="formData.mobile" maxlength="11" />
</view>
<view class="item-wrap" v-if="isOpenCaptcha == 1">
<text class="label">{{ $lang('components.ns-login.captchaLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.captchaPlaceholder')" v-model="formData.vercode" maxlength="4" />
<image :src="captcha.img" class="captcha" @tap="getCaptcha" show-menu-by-longpress></image>
</view>
<view class="item-wrap">
<text class="label">{{ $lang('components.ns-login.dynacodeLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.dynacodePlaceholder')" v-model="formData.dynacode" maxlength="4" />
<view class="send color-base-text" @tap="sendMobileCode">{{ dynacodeData.codeText }}</view>
</view>
<button type="default" class="save-btn" @tap="harmonySaveMobile" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
</view>
<!-- #endif -->
<view @tap="closeLogin"
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
{{ $lang('components.ns-login.notNow') }}
@@ -138,20 +109,11 @@
<view class="item-wrap" v-if="forceBindingMobileControl">
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
<!-- #ifdef MP-WEIXIN -->
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
@getphonenumber="getPhoneNumber">
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
</button>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙无 getPhoneNumber,展示手动输入的手机号,没有时显示"获取手机号"提示用户去上面表单录入 -->
<view class="auth-login border-0" :plain="true">
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
<text class="color-base-text" v-else @tap="showHarmonyMobileForm = true">{{ $lang('components.ns-login.getMobile') }}</text>
</view>
<!-- #endif -->
</view>
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
</template>
@@ -208,16 +170,12 @@ export default {
isSend: false
},
// 小程序获取手机号所需数据
authMobileData: {
iv: '',
encryptedData: ''
},
site_name: '',
// #ifdef MP-HARMONY
// 鸿蒙专属:控制手机号+验证码表单显示
showHarmonyMobileForm: false,
// #endif
};
authMobileData: {
iv: '',
encryptedData: ''
},
site_name: ''
};
},
options: {
styleIsolation: 'shared'
@@ -245,7 +203,7 @@ export default {
if (this.forceBindingMobileControl) {
if (this.formData.mobile.length == 0) return true;
// #ifdef H5 || MP-HARMONY
// #ifdef H5
// 验证码
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
// 动态码
@@ -385,7 +343,7 @@ export default {
openCompleteInfoPop() {
this.getRegisterConfig();
// #ifdef H5 || MP-HARMONY
// #ifdef H5
if (!this.storeToken) this.getCaptchaConfig();
// #endif
@@ -718,26 +676,6 @@ export default {
this.forceBindMobile();
},
// #ifdef MP-HARMONY
// 鸿蒙元服务保存手机号:复用 H5 的强制绑定手机号流程
harmonySaveMobile() {
if (this.forceBindingMobileControl) {
let data = {
mobile: this.formData.mobile,
key: this.formData.key,
code: this.formData.dynacode
};
if (this.captcha.id != '') {
data.captcha_id = this.captcha.id;
data.captcha_code = this.formData.vercode;
}
if (!this.verify(data)) return;
}
// 鸿蒙环境下通常也开启了 forceBindingMobileControl(走 forceBindMobile 接口)
this.forceBindMobile();
},
// #endif
// 微信小程序保存数据
saveMp() {
if (this.formData.mobile === '') {

View File

@@ -53,7 +53,7 @@
let systemInfo = uni.getSystemInfoSync();
let menuButtonInfo = {};
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
// #endif

View File

@@ -9,10 +9,10 @@
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<input
type="text"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
<input
type="text"
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
placeholder-class="placeholder color-tip"
v-model="item.val"
/>
</view>
@@ -27,9 +27,9 @@
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<textarea
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
<textarea
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
placeholder-class="placeholder color-tip"
v-model="item.val"
></textarea>
</view>
@@ -64,8 +64,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<view :class="['box', 'check-group-box', 'align-left']">
<checkbox-group @change="checkboxChange($event, index)">
<label v-for="(v, k) in item.option_lists" :key="k">
<checkbox :value="v.value" :checked="v.checked"></checkbox>
<view class="checkbox">
@@ -92,8 +92,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<view :class="['box', 'radio-group-box', 'align-left']">
<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"></radio>
<view class="radio-box">
@@ -371,18 +371,10 @@ export default {
labelFixedWidth: 180,
labelFontSize: 28,
labelColor: '#1f2937',
labelFontWeight: 600,
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#ddd',
placeholderFontSize: 28,
// 输入框视觉增强(可由后端 form_config 覆盖)
inputBorderColor: '#e5e7eb',
inputBgColor: '#f9fafb',
inputFocusBorderColor: '#ff4544',
inputFocusBgColor: '#ffffff',
labelFontWeight: 600,
// 提交按钮
submitColor: '#ffffff',
submitBgColor: '#ff4544',
@@ -452,12 +444,6 @@ export default {
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
'--input-border-color': cfg.inputBorderColor,
'--input-bg-color': cfg.inputBgColor,
'--input-focus-border-color': cfg.inputFocusBorderColor,
'--input-focus-bg-color': cfg.inputFocusBgColor,
'--label-font-weight': cfg.labelFontWeight,
'--submit-color': cfg.submitColor,
'--submit-bg-color': cfg.submitBgColor,
'--submit-margin-top': this.$util.rpx(cfg.submitMarginTop || 0),
@@ -550,23 +536,6 @@ export default {
// item 级别 box 对齐方式(与原 getItemBoxAlignStyle 逻辑一致)
const align = itemValue.input_align;
this.$set(item, '_boxAlignStyle', align ? { 'text-align': align } : {});
// 选项排列样式:vertical 时给 checkbox-group / radio-group 加 flex-direction
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'vertical') {
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '8px';
} else {
// wrap 模式:用 display: flex 基础属性兜底(不强制,让浏览器按 SCSS 默认渲染)
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '6px';
}
this.$set(item, '_optionLayoutStyle', optStyle);
});
},
// 容器样式(提供给外部 diy-form 使用)
@@ -590,13 +559,8 @@ export default {
if (len > maxChars) maxChars = len;
});
// 与后端公式一致:chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2)
let widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
let widthRpx = Math.round(widthPx * 2);
// auto 模式下加宽度上限:超长 title 撑爆布局时,按 labelAutoMaxWidth 截断(rpx)
// 上限内 ellipsis 截断,保证控件区有足够空间;上限外也不会让 label 占满整行
const maxWidthRpx = parseInt(this.config.labelAutoMaxWidth) || 200;
if (widthRpx > maxWidthRpx) widthRpx = maxWidthRpx;
this.maxLabelWidth = widthRpx;
const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
this.maxLabelWidth = Math.round(widthPx * 2);
// 宽度变了,需要把全局 nameWidthStyle 重新合到每项 _labelStyle 里
this.recomputeItemStyles();
},
@@ -614,9 +578,7 @@ export default {
processFormData() {
this.formData.forEach((item) => {
// Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
if (!item.val && !skipDefault) {
if (!item.val) {
item.val = item.value.default ? item.value.default : '';
}
@@ -997,12 +959,6 @@ export default {
--submit-bg-color: #ff4544;
--placeholder-color: #ddd;
--placeholder-font-size: 28rpx;
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-focus-bg-color: #ffffff;
--label-font-weight: 600;
--show-row-divider: 1;
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
@@ -1054,13 +1010,13 @@ export default {
&.vertical-layout {
flex-direction: column;
align-items: flex-start;
.name {
width: 100%;
margin-bottom: var(--label-gap, 16rpx);
margin-bottom: 8rpx;
margin-right: 0;
}
.box {
width: 100%;
}
@@ -1110,7 +1066,7 @@ export default {
text-align: left;
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
color: var(--item-label-color, var(--label-color, #1f2937));
font-weight: var(--item-label-font-weight, var(--label-font-weight, 600));
font-weight: 500;
letter-spacing: 0.2rpx;
text {
@@ -1178,105 +1134,49 @@ export default {
width: 100%;
}
// H5 端 <input> 被 uni-app 编译为 <uni-input> 自定义组件,
// 真实 <input> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
::v-deep input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0 12rpx;
background: var(--input-bg-color, #f9fafb);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// 非 H5 端(小程序 / 鸿蒙 / App)<input> 是直接原生元素,普通选择器即可
input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border: none;
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0 12rpx;
background: var(--input-bg-color, #f9fafb);
padding: 0;
background: transparent;
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
background: transparent;
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
::v-deep textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
min-height: 120rpx;
line-height: 1.5;
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
padding: 12rpx;
background: var(--input-bg-color, #f9fafb);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
height: auto;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
min-height: 120rpx;
line-height: 1.5;
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border: none;
border-radius: var(--input-radius, 10rpx);
padding: 12rpx;
background: var(--input-bg-color, #f9fafb);
padding: 0;
background: transparent;
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
height: auto;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
background: transparent;
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
@@ -1339,16 +1239,6 @@ export default {
}
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
}
}
label {
display: inline-flex;
align-items: center;

View File

@@ -70,11 +70,37 @@ export default {
return {
isIphoneX: false,
payIndex: 0,
payTypeList: [],
// #ifdef H5
payTypeList: [{
name: this.$lang('components.nsPayment.alipay'),
icon: 'icon-zhifubaozhifu-',
type: 'alipay'
},
{
name: this.$lang('components.nsPayment.wechatpay'),
icon: 'icon-weixin1',
type: 'wechatpay'
}
],
timer: null,
// #endif
// #ifdef MP-WEIXIN
payTypeList: [{
name: this.$lang('components.nsPayment.wechatpay'),
provider: 'wxpay',
icon: 'icon-weixin1',
type: 'wechatpay'
}],
isMatched: 0, // 支付是否需要校验
// #endif
// #ifdef MP-ALIPAY
payTypeList: [{
name: this.$lang('components.nsPayment.alipay'),
icon: 'icon-zhifubaozhifu-',
type: 'alipay',
provider: 'alipay'
}],
// #endif
payInfo: {},
balanceConfig: 0,
// 预售页面判断
@@ -82,30 +108,6 @@ export default {
};
},
created(e) {
// #ifdef H5
this.payTypeList = [
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay' },
{ name: this.$lang('components.nsPayment.wechatpay'), icon: 'icon-weixin1', type: 'wechatpay' },
];
// #endif
// #ifdef MP-WEIXIN
this.payTypeList = [
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
];
// #endif
// #ifdef MP-ALIPAY
this.payTypeList = [
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay', provider: 'alipay' },
];
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务的支付通道: 与微信小程序相同的 provider='wxpay',
// 但走鸿蒙 JSSDK 的 requestPayment(走鸿蒙支付服务而非 wx.requestPayment)
this.payTypeList = [
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
];
// #endif
let url;
// #ifdef H5
url = window.location.href

View File

@@ -95,16 +95,6 @@
i18nKey: 'components.payment.alipay'
}],
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务的支付通道: provider='wxpay' 由鸿蒙 JSSDK 接管
payTypeList: [{
name: '微信支付',
provider: 'wxpay',
icon: 'icon-weixin1',
type: 'wechatpay',
i18nKey: 'components.payment.wechatpay'
}],
// #endif
payInfo: null,
balanceConfig: 0,
// 预售页面判断

View File

@@ -1,13 +1,4 @@
<template>
<!-- #ifdef MP-HARMONY -->
<!--
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
-->
<!-- #endif-->
<!-- #ifndef MP-HARMONY -->
<view>
<view class="privacy-mask" v-if="showPop">
<view class="privacy-wrap">
@@ -15,7 +6,7 @@
<view class="privacy-desc">
{{ i18n.descPrefix }}
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
<!-- #ifdef QUICKAPP-WEBVIEW || H5 -->
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY -->
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
<!-- #endif -->
{{ i18n.descSuffix }}
@@ -26,15 +17,13 @@
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
<!-- #endif -->
<!-- #ifdef H5 -->
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
}}</button>
<!-- #ifdef H5 || MP-HARMONY -->
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button>
<!-- #endif -->
</view>
</view>
</view>
</view>
<!-- #endif -->
</template>
<script>
@@ -59,12 +48,6 @@ export default {
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
// 弹窗前才参与 i18n.privacyContractName 的填充
platformContractName: '',
// 平台协议名称就绪 Promise:
// created() 中发起异步查询, success/fail 时 resolve
// popUp() 中 await 此 Promise 后再调 prepareI18n(), 保证 $lang() 与
// platformContractName 都已经准备好, 不会出现"空串被永久缓存"的问题
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"是同源契约)
platformContractNameReady: null,
// 弹窗文案: 模板只读 i18n.xxx
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
@@ -87,22 +70,10 @@ export default {
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
created() {
try {
// 创建平台协议名称就绪 Promise
// 后续 wx/has/uni.* 三端的异步 API 都会挂到这个 promise 上 resolve,
// popUp() 中 await 后再 prepareI18n(), 保证 platformContractName 已就绪
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"契约同源)
this.platformContractNameReady = new Promise((resolve) => {
this._resolvePlatformContractName = resolve
})
// #ifdef MP-WEIXIN
wx.getPrivacySetting({
success: (res) => {
this.platformContractName = res.privacyContractName || ''
this._resolvePlatformContractName && this._resolvePlatformContractName()
},
fail: () => {
this._resolvePlatformContractName && this._resolvePlatformContractName()
}
})
// #endif
@@ -114,28 +85,13 @@ export default {
if (res.privacyContractName) {
this.platformContractName = res.privacyContractName
}
this._resolvePlatformContractName && this._resolvePlatformContractName()
},
fail: () => {
this._resolvePlatformContractName && this._resolvePlatformContractName()
},
})
} else {
// 无 API 时立即 resolve (避免 await 永久阻塞)
this._resolvePlatformContractName && this._resolvePlatformContractName()
}
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
// 立即 resolve, 让 popUp 的 await 不永久阻塞
this._resolvePlatformContractName && this._resolvePlatformContractName()
// #endif
// #ifdef H5
// H5 无平台隐私 API, 立即 resolve
this._resolvePlatformContractName && this._resolvePlatformContractName()
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
// #endif
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
@@ -223,14 +179,14 @@ export default {
* 统一弹窗 + 关闭其他页面的弹窗
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
*/
async showPrivacyPopup() {
showPrivacyPopup() {
// 先通知其他实例关闭
try { uni.$emit('privacy:forceClose'); } catch (e) { }
try { uni.$emit('privacy:forceClose'); } catch (e) {}
// 直接弹窗: boot 阶段已经保证 bundle 完整
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
// 不完整时 force 重试, 仍不完整则报错不静默回退
// 所以这里无需再检测/自愈, 信任 boot 已就绪
await this.popUp()
this.popUp()
},
/**
@@ -309,8 +265,7 @@ export default {
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
// #endif
},
@@ -329,22 +284,22 @@ export default {
*/
closeOtherPagePopUp(closePopUp) {
// 通过全局事件通知所有页面关闭弹窗
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) { }
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) {}
},
openPrivacyContract() {
// #ifdef MP-WEIXIN
wx.openPrivacyContract({
success(res) { },
fail(err) { }
success(res) {},
fail(err) {}
});
// #endif
// #ifdef QUICKAPP-WEBVIEW
if (typeof has != 'undefined' && has?.openPrivacySetting) {
has.openPrivacySetting({
success: (res) => { },
fail: (err) => { }
success: (res) => {},
fail: (err) => {}
});
} else {
this.$util.redirectTo(privacyContractPage);
@@ -352,8 +307,8 @@ export default {
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
// 鸿蒙元服务: 使用应用内协议页面
this.$util.redirectTo(privacyContractPage);
// #endif
// #ifdef H5
@@ -365,8 +320,8 @@ export default {
// #ifdef QUICKAPP-WEBVIEW
if (typeof has != 'undefined' && has?.openPrivacySetting) {
has.openPrivacySetting({
success: (res) => { },
fail: (err) => { }
success: (res) => {},
fail: (err) => {}
});
} else {
this.$util.redirectTo(privacyServicePage);
@@ -374,7 +329,7 @@ export default {
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
this.$util.redirectTo(privacyServicePage);
// #endif
// #ifdef H5
@@ -389,7 +344,7 @@ export default {
this.privacyResolves.clear()
// 先通知其他实例关闭, 再关闭自己, 避免叠层
try { uni.$emit('privacy:forceClose'); } catch (e) { }
try { uni.$emit('privacy:forceClose'); } catch (e) {}
this.disPopUp()
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
@@ -414,7 +369,7 @@ export default {
this.privacyResolves.clear()
// 先通知其他实例关闭, 再关闭自己
try { uni.$emit('privacy:forceClose'); } catch (e) { }
try { uni.$emit('privacy:forceClose'); } catch (e) {}
this.disPopUp()
// 由 PrivacyManager 管理 storage
@@ -435,9 +390,9 @@ export default {
*/
i18nPrepareFn() {
let appName, privacyContractName, privacyServiceName;
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
// #ifdef H5
// #ifdef H5 || MP-HARMONY
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
@@ -461,33 +416,20 @@ export default {
return result;
},
async popUp() {
popUp() {
// 弹窗前**一次性**计算所有 i18n 文案
// 必须先等两类数据准备好:
// 1) Lang.boot() 完成 — 由 i18n-lazy mixin 隐含保证
// (调用方 App.vue 在 await PrivacyManager.show() 后才触发弹窗,
// PrivacyManager.show() await 了 boot 守卫)
// 2) platformContractName 就绪 — 微信/快应用/鸿蒙三端的 API 都是异步的,
// success 回调时机不确定, 直接 prepareI18n() 会读到空字符串, 导致
// "空串被永久缓存" (与 i18n-lazy mixin 警告的问题同源)
// 因此这里 await platformContractNameReady, 然后再 prepareI18n。
if (this.platformContractNameReady) {
try {
await this.platformContractNameReady
} catch (e) {
// 防御性: 即便 ready 出错也继续 (走 fallback 默认文案)
}
}
// boot 阶段已用 isBundleComplete() 保证 bundle 完整
// $lang() 返回真实翻译(效果与 H5 一致)
this.prepareI18n();
this.showPop = true;
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: true }) } catch (e) { }
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: true }) } catch (e) {}
},
disPopUp() {
this.showPop = false;
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: false }) } catch (e) { }
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: false }) } catch (e) {}
},
},
beforeDestroy() {
@@ -496,11 +438,6 @@ export default {
uni.$off('privacy:show', this.handlePrivacyShow)
uni.$off('privacy:forceClose', this.handleForceClose)
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
// 销毁时强制 resolve, 避免 popUp() 中的 await 永久挂起 (例如用户提前离开页面)
if (typeof this._resolvePlatformContractName === 'function') {
this._resolvePlatformContractName()
this._resolvePlatformContractName = null
}
},
}
</script>
@@ -554,7 +491,7 @@ export default {
.privacy-button-btn {
color: #FFF;
font-size: 30rpx;
/* #ifdef QUICKAPP-WEBVIEW || H5 */
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
font-size: 28rpx;
/* #endif */
font-weight: 500;

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@ export const lang = {
// 底部选项卡
tabBar: {
home: 'Home',
category: 'Category',
cart: 'Cart',
member: 'Me',
home: 'index',
category: 'category',
cart: 'cart',
member: 'member',
},
//公共
@@ -54,15 +54,11 @@ export const lang = {
serviceUnavailable: 'Navigation service temporarily unavailable',
},
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
// diy custom page
diy: {
networkErrorTitle: 'Notice',
networkError: 'Network error, please try again later',
retry: 'Retry',
noDiyData: 'Custom page data not configured',
locationNotAvailable: 'Location not available',
// detailed data-load failure template (diy-specific, {reason} is filled by generic http.*)
loadFailTpl: 'Failed to load page data: {reason}, please try again later',
},
//customer service
@@ -112,7 +108,6 @@ export const lang = {
notSupported: 'Current environment does not support WeChat Channel',
missingFields: 'Missing required field: {field}',
playFailed: 'WeChat video playback failed!',
wechatMpOnly: 'WeChat Channel only plays in WeChat Mini Program',
},
// uniapp utility functions
@@ -139,18 +134,12 @@ export const lang = {
requestFailed: '{url} request failed. {err}',
// network timeout
timeout: 'Network request timeout, please try again later',
// network connection failed / generic network error (unified copy, reused by diy and other generic scenarios)
networkError: 'Network error, please try again later',
// network connection failed
networkError: 'Network connection failed, please check your network',
// server error
serverError: 'Server error, please try again later',
// API not found (HTTP 404)
notFound: 'API not found',
// no access permission (HTTP 401 / 403)
auth: 'No access permission',
serverError: 'Service temporarily unavailable, please try again later',
// request aborted
aborted: 'Request cancelled',
// unknown error (errorType fallback, e.g. non-5xx/404/401/403 HTTP status)
unknown: 'Request failed, please try again later',
// unknown error
unknownError: 'Request failed, please try again later',
},
@@ -258,14 +247,7 @@ export const lang = {
addToCartFailMin: 'Minimum purchase quantity not met',
addToCartFailStock: 'Insufficient Stock',
// generic submit/version/loading text (key lives in root because common.js's lang object IS the common namespace)
submitSuccess: 'Submit Success',
submitFail: 'Submit Failed',
versionInfo: 'Version Info',
commitId: 'Commit ID',
buildTime: 'Build Time',
loading: 'Loading...',
// 咨询
consult: 'Consult Now',
consultSuccess: 'Consultation Successful',
consultFail: 'Consultation Failed',

View File

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

View File

@@ -3,6 +3,5 @@ export const lang = {
title: 'Payment Method',
paymentAmount: 'Payment Amount',
confirmPayment: 'Confirm Payment',
seeOrder: 'View Order',
payInfoNotFound: 'Payment info not found!'
seeOrder: 'View Order'
}

View File

@@ -61,15 +61,11 @@ export const lang = {
weChatWorkKefu: '企业微信客服',
},
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
// diy 自定义页面
diy: {
networkErrorTitle: '提示',
networkError: '网络异常,请稍后重试',
retry: '重试',
noDiyData: '未配置自定义页面数据',
locationNotAvailable: '未获取到定位',
// 数据加载失败详细文案(diy 专属模板,内部 {reason} 由通用 http.* 填充)
loadFailTpl: '页面数据加载失败:{reason},请稍后重试',
},
// 客服服务
@@ -112,7 +108,6 @@ export const lang = {
notSupported: '当前环境不支持微信视频号',
missingFields: '缺少必要字段: {field}',
playFailed: '微信视频播放失败!',
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
},
// uniapp 工具函数
@@ -139,18 +134,12 @@ export const lang = {
requestFailed: '{url}请求失败. {err}',
// 网络超时
timeout: '网络请求超时,请稍后重试',
// 网络连接失败 / 通用网络异常(统一文案,供 diy 等通用场景复用)
networkError: '网络异常,请稍后重试',
// 网络连接失败
networkError: '网络连接失败,请检查网络设置',
// 服务器错误
serverError: '服务器异常,请稍后重试',
// 接口不存在(HTTP 404)
notFound: '接口不存在',
// 无访问权限(HTTP 401 / 403)
auth: '无访问权限',
serverError: '服务暂时不可用,请稍后重试',
// 请求被中断
aborted: '请求已取消',
// 未知错误(errorType 分类兜底,如非 5xx/404/401/403 的 HTTP 状态码)
unknown: '请求失败,请稍后重试',
// 未知错误
unknownError: '请求失败,请稍后重试',
},
@@ -256,14 +245,6 @@ export const lang = {
addToCartFailMin: '已达到最小购买数量',
addToCartFailStock: '库存不足',
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
submitSuccess: '提交成功',
submitFail: '提交失败',
versionInfo: '版本信息',
commitId: '提交 ID',
buildTime: '构建时间',
loading: '加载中...',
consult: '立即咨询',
consultSuccess: '咨询成功',
consultFail: '咨询失败',

View File

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

View File

@@ -3,6 +3,5 @@ export const lang = {
title: '支付方式',
paymentAmount: '支付金额',
confirmPayment: '确认支付',
seeOrder: '查看订单',
payInfoNotFound: '未获取到支付信息!'
seeOrder: '查看订单'
}

View File

@@ -80,18 +80,10 @@ Vue.mixin(MpShareDefault);
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
// golbalConfig.js 已把 componentRefresh 注册为全局 computed,
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
// 同时调用 lang.refresh() 让当前页面的 navigationBarTitleText 跟随语种变化
// (pages.json 里的硬编码中文标题需要被 $lang('title') 覆盖)
Vue.mixin({
watch: {
componentRefresh() {
this.$forceUpdate();
try {
if (this.$langConfig && typeof this.$langConfig.refresh === 'function') {
// emit=false: lang.js 已 emit 过,这里避免重复 store.commit
this.$langConfig.refresh(false);
}
} catch (e) { /* 容错:refresh 失败不影响主流程 */ }
}
}
});

View File

@@ -136,23 +136,7 @@
"sassImplementationName" : "dart-sass",
"mp-harmony" : {
"distribute" : {
"bundleName" : "com.atomicservice.6917607804641099460"
},
// 隐私托管: 元服务必须使用 AGC 后台的标准化隐私声明托管服务,
// 弹窗由鸿蒙系统自动弹出, 应用层不能自建.
// 下方的 privacy 块仅作为应用内协议页兜底 (例如用户在系统设置里手动查看协议),
// 不参与首次启动弹窗逻辑.
"privacy" : {
"privacyContractUrl" : "YOUR_DOMAIN/api/agreementview/privacy?uniacid=YOUR_UNIACID",
"serviceContractUrl" : "YOUR_DOMAIN/api/agreementview/service?uniacid=YOUR_UNIACID",
"privacyContractName" : {
"zh" : "隐私政策",
"en" : "Privacy Policy"
},
"serviceContractName" : {
"zh" : "用户服务协议",
"en" : "User Service Agreement"
}
"bundleName" : "com.atomicservice.6917577984608507983"
}
}
}

View File

@@ -1,92 +1,72 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
"prebuild": "npm run test",
"prestart": "npm run test"
},
"devDependencies": {
"dart-sass": "^1.25.0",
"terser-webpack-plugin": "^5.3.10",
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0"
},
"uni-app": {
"scripts": {
"weixin": {
"title": "微信小程序自定义发行",
"env": {
"UNI_PLATFORM": "mp-weixin"
},
"define": {
"MP_WEIXIN": true,
"PRODUCTION": true
}
},
"h5-xcx20.5g-quickapp.com": {
"title": "H5-xcx20.5g-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx20.5g-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-xcx.aigc-quickapp.com": {
"title": "H5-xcx.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-5g.aigc-quickapp.com": {
"title": "H5-5g.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://5g.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-test.aigc-quickapp.com": {
"title": "H5-test.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://test.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"http://localhost:8050/": {
"title": "H5-localhost:8050",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "http://localhost:8050/"
},
"define": {
"PRODUCTION": true
}
},
"http://192.168.8.118:8050/": {
"title": "H5-192.168.8.118:8050",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "http://192.168.8.118:8050/"
},
"define": {
"PRODUCTION": true
}
}
}
}
}
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
"prebuild": "npm run test",
"prestart": "npm run test"
},
"devDependencies": {
"dart-sass": "^1.25.0",
"terser-webpack-plugin": "^5.3.10",
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0"
},
"uni-app": {
"scripts": {
"weixin": {
"title": "微信小程序自定义发行",
"env": {
"UNI_PLATFORM": "mp-weixin"
},
"define": {
"MP_WEIXIN": true,
"PRODUCTION": true
}
},
"h5-xcx20.5g-quickapp.com": {
"title": "H5-xcx20.5g-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx20.5g-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-xcx.aigc-quickapp.com": {
"title": "H5-xcx.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-5g.aigc-quickapp.com": {
"title": "H5-5g.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://5g.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-test.aigc-quickapp.com": {
"title": "H5-test.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://test.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,6 @@
<view :style="themeColor">
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
class="page-img">
<!-- diy 数据网络加载失败提示(页面内横幅,各端可靠,悬浮在最上层) -->
<view v-if="diyLoadError" class="diy-load-error">
<view class="diy-load-error__body">
<text class="diy-load-error__msg">{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}</text>
<text v-if="diyLoadErrorUrl" class="diy-load-error__url" selectable user-select>{{ diyLoadErrorUrl }}</text>
</view>
<view class="diy-load-error__btn" @tap="retryDiyInfo">{{ $lang('diy.retry') || '重试' }}</view>
</view>
<view class="site-info-box"
v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode">
<view class="site-info">
@@ -188,52 +179,6 @@ export default {
z-index: 122;
}
.diy-load-error {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100000;
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
padding-top: calc(24rpx + var(--status-bar-height, 0px));
background-color: #fff5f5;
border-bottom: 1rpx solid #ffccc7;
}
.diy-load-error__body {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.diy-load-error__msg {
font-size: 26rpx;
color: #cf1322;
line-height: 1.5;
}
.diy-load-error__url {
margin-top: 6rpx;
font-size: 22rpx;
color: #999;
line-height: 1.4;
word-break: break-all;
}
.diy-load-error__btn {
margin-left: 20rpx;
padding: 10rpx 28rpx;
font-size: 26rpx;
color: #fff;
background-color: #cf1322;
border-radius: 32rpx;
white-space: nowrap;
}
.small-bot swiper {
width: 560rpx;
height: 784rpx;

View File

@@ -55,7 +55,7 @@ export default {
onShow() {
// 获取关注公众号二维码
this.getFollowQrcode();
// 重新获取店铺信息
this.shopInfo = uni.getStorageSync('shopInfo');
},
@@ -160,10 +160,10 @@ export default {
} catch (e) {}
if (v && v.commitId) {
uni.showModal({
title: this.$lang('versionInfo'),
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false
});
title: '版本信息',
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false
});
} else {
console.error('未获取到构建版本信息');
}

View File

@@ -1,31 +1,7 @@
<template>
<view>
<!-- 骨架屏:商品详情加载时按结构分段占位(banner / 价格 / 标题 / 规格 / 商家 / 详情 / 推荐)
关闭条件: loading=false 且已拿到真实商品数据(goods_id>0); 任一不满足都保留骨架屏 -->
<view v-if="loading || !hasRealData" class="goods-detail-skeleton">
<x-skeleton data-component-name="goods-detail-view-banner" :loading="true" :configs="skeletonBanner"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-price" :loading="true" :configs="skeletonPrice"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-title" :loading="true" :configs="skeletonTitle"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-sku" :loading="true" :configs="skeletonSku"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-merch" :loading="true" :configs="skeletonMerch"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-detail" :loading="true" :configs="skeletonDetail"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-recommend" :loading="true" :configs="skeletonRecommend"></x-skeleton>
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
<view class="goods-detail-skeleton-action">
<view class="skeleton-action-icons">
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
</view>
<view class="skeleton-action-btn"></view>
<view class="skeleton-action-btn primary"></view>
</view>
</view>
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 view 子树静默不渲染, 导致整个详情页空白 -->
<view v-else class="goods-detail-wrap">
<!-- 操作区域 slot: 与 .goods-detail 同一 v-else 分支, 避免骨架屏状态下被父组件渲染真实按钮(基于默认 goods_state=0 显示"该商品已下架") -->
<slot name="action"></slot>
<view class="goods-detail" :class="isIphoneX ? 'active' : ''">
<view class="goods-container">
<view scroll-y="true" class="goods-detail" :class="isIphoneX ? 'active' : ''">
<view class="goods-container">
<!-- AI客服悬浮按钮 -->
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
<image :src="aiAvatarUrl" mode="aspectFill" class="ai-avatar-icon" />
@@ -483,7 +459,7 @@
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
</button>
</view>
<!-- #ifdef H5 || MP-HARMONY -->
<!-- #ifdef H5 -->
<view class="share-box" @click="copyUrl">
<button class="share-btn" :plain="true">
<view class="iconfont icon-fuzhilianjie"></view>
@@ -498,10 +474,11 @@
</view>
<slot name="fixedbtn"></slot>
</view>
</view>
</view>
<!-- 操作区域 -->
<slot name="action"></slot>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login>
</view>
@@ -524,142 +501,11 @@ export default {
default: () => {
return {};
}
},
// 是否显示骨架屏(true:展示骨架;false:展示真实内容)
loading: {
type: Boolean,
default: true
}
},
components: {
pengpaiFadeinOut
},
data() {
// 商品详情骨架屏:模拟「轮播图 + 价格行 + 标题简介 + 规格行 + 商家 + 详情 + 推荐」
return {
// 1. 轮播图:方形 banner 占位
skeletonBanner: {
padding: '0',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '24rpx',
headShow: true,
headWidth: '100%',
headHeight: '750rpx',
headBorderRadius: '0',
textShow: false
},
// 2. 价格行:一行价 + 一行原价
skeletonPrice: {
padding: '24rpx 24rpx 16rpx',
gridRows: 2,
gridColumns: 1,
gridRowsGap: '16rpx',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: false,
textShow: true,
textRows: 1,
textRowsGap: '0',
textWidth: ['40%', '30%'],
textHeight: ['48rpx', '24rpx'],
textBorderRadius: '6rpx'
},
// 3. 标题/简介:标题两行 + 简介 + 标签
skeletonTitle: {
padding: '8rpx 24rpx 24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '20rpx',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: false,
textShow: true,
textRows: 4,
textRowsGap: '16rpx',
textWidth: ['90%', '60%', '100%', '40%'],
textHeight: ['40rpx', '40rpx', '24rpx', '24rpx'],
textBorderRadius: '6rpx'
},
// 4. 规格行:单行高度的小条
skeletonSku: {
padding: '20rpx 24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '0',
itemDirection: 'row',
itemAlign: 'center',
headShow: true,
headWidth: '120rpx',
headHeight: '32rpx',
headBorderRadius: '6rpx',
textShow: true,
textRows: 1,
textRowsGap: '0',
textWidth: '60%',
textHeight: '28rpx',
textBorderRadius: '6rpx',
itemGap: '20rpx'
},
// 5. 商家:头像 + 名称
skeletonMerch: {
padding: '24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '0',
itemDirection: 'row',
itemAlign: 'center',
headShow: true,
headWidth: '80rpx',
headHeight: '80rpx',
headBorderRadius: '50%',
textShow: true,
textRows: 2,
textRowsGap: '12rpx',
textWidth: ['50%', '80%'],
textHeight: ['28rpx', '20rpx'],
textBorderRadius: '6rpx',
itemGap: '24rpx'
},
// 6. 商品详情:多行段落
skeletonDetail: {
padding: '24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '0',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: false,
textShow: true,
textRows: 6,
textRowsGap: '16rpx',
textWidth: ['100%', '100%', '90%', '100%', '80%', '60%'],
textHeight: '24rpx',
textBorderRadius: '6rpx'
},
// 7. 推荐:两列卡片缩略图
skeletonRecommend: {
padding: '24rpx',
gridRows: 1,
gridColumns: 2,
gridRowsGap: '24rpx',
gridColumnsGap: '24rpx',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: true,
headWidth: '100%',
headHeight: '320rpx',
headBorderRadius: '12rpx',
textShow: true,
textRows: 2,
textRowsGap: '12rpx',
textWidth: ['100%', '70%'],
textHeight: ['24rpx', '24rpx'],
textBorderRadius: '6rpx',
itemGap: '12rpx'
}
};
},
computed: {
aiAvatarUrl() {
return this.$util.img(IMAGE_CONFIG.aiAvatar);
@@ -678,11 +524,6 @@ export default {
hasDetailContent() {
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
return hasPdf || this.hasHtmlTab;
},
// 是否有真实商品数据: goods_id > 0 才算拿到商品详情
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
hasRealData() {
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
}
},
mixins: [scroll, detail]
@@ -691,79 +532,6 @@ export default {
<style lang="scss">
@import '@/common/css/goods_detail.scss';
/* 骨架屏外层:与商品详情整体上对齐,左右内边距由各 skeleton 自己控制 */
.goods-detail-skeleton {
width: 100%;
background-color: #fff;
}
/* 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 高度接近真实按钮 */
.goods-detail-skeleton-action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 100rpx;
display: flex;
align-items: center;
background-color: #fff;
z-index: 9;
padding: 0 16rpx;
box-sizing: border-box;
border-top: 1rpx solid #f2f2f2;
padding-bottom: env(safe-area-inset-bottom);
}
.goods-detail-skeleton-action .skeleton-action-icons {
display: flex;
align-items: center;
margin-right: 16rpx;
}
.goods-detail-skeleton-action .skeleton-action-icon {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #EAEDF5;
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
background-size: 400% 100%;
animation: skActAnim 1.8s ease infinite;
margin-right: 16rpx;
}
.goods-detail-skeleton-action .skeleton-action-icon:last-child {
margin-right: 0;
}
.goods-detail-skeleton-action .skeleton-action-btn {
flex: 1;
height: 70rpx;
border-radius: 10rpx;
margin-right: 16rpx;
background-color: #EAEDF5;
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
background-size: 400% 100%;
animation: skActAnim 1.8s ease infinite;
}
.goods-detail-skeleton-action .skeleton-action-btn:last-child {
margin-right: 0;
}
.goods-detail-skeleton-action .skeleton-action-btn.primary {
background: linear-gradient(90deg, #d8d8d8 25%, #ececec 37%, #d8d8d8 50%);
background-size: 400% 100%;
}
@keyframes skActAnim {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
</style>
<style scoped>
.ai-chat-float-btn {

View File

@@ -1,7 +1,6 @@
<template>
<view :style="themeColor">
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
<view v-if="diyData && diyData.value && diyData.value.length">
<view v-show="diyData && diyData.value && diyData.value.length">
<block v-for="(item, index) in diyData.value" :key="index">
<view v-if="item.componentName == 'GoodsCategory'">
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
@@ -16,8 +15,8 @@
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
<!-- 底部tabBar: loading 期间隐藏, 等 loading-cover 关闭后下一 tick 再挂载, 避免 TabBar 与 loading-cover 同时 GPU 合成导致频闪 -->
<view id="tab-bar" v-if="!loadingVisible">
<!-- 底部tabBar -->
<view id="tab-bar">
<diy-bottom-nav></diy-bottom-nav>
</view>
</view>
@@ -28,13 +27,7 @@ export default {
data() {
return {
diyData: { value: [] },
mpShareData: null, //小程序分享数据
// 修复底部 TabBar 频闪: 用本地 loadingVisible 状态镜像 <loading-cover> 的可见性,
// loading-cover 的 spin 动画与 diy-bottom-nav 都是 fixed/z-index 高 + GPU 合成,
// 在 MP-Weixin 下会互相触发重绘, 让用户看到 TabBar 抖动。
// 策略: loading-cover 显示期间, 也把 tabBar 先收起来, loading-cover 隐藏后
// 下一次 nextTick 再放出 tabBar, 把整段 loading 过程的合成层冲突降到最低。
loadingVisible: true
mpShareData: null //小程序分享数据
};
},
onLoad() {
@@ -47,13 +40,8 @@ export default {
this.$util.hideHomeButton();
try {
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
// 节流: 避免 onShow 重复触发 pageShow() 中的 getHeightArea(-1) 高耗时选择器查询, 引起频闪和性能损耗
const now = Date.now();
if (this._lastPageShowTime && now - this._lastPageShowTime < 500) {
// 短时间内重复触发, 跳过本次 pageShow
} else if (this.$refs.category && this.$refs.category[0]) {
if (this.$refs.category && this.$refs.category[0]) {
this.$refs.category[0].pageShow();
this._lastPageShowTime = now;
}
} catch (e) {
console.warn('onShow pageShow failed', e);
@@ -121,13 +109,6 @@ export default {
this._diyInfoLoading = false;
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
uni.stopPullDownRefresh();
// loading-cover 收起后, 等下一 tick 让出 frame, 再挂载 diy-bottom-nav
// (避免两个 fixed 高 z-index GPU 合成层在同帧 mount 触发 MP-Weixin 重绘频闪)
this.$nextTick(() => {
this.$nextTick(() => {
this.loadingVisible = false;
});
});
}
});
},

View File

@@ -1,14 +1,12 @@
<template>
<!-- 鸿蒙兼容: themeColor 是 CSS 变量字符串, 鸿蒙 JSVM 早期版本对 :style="字符串" 处理可能不稳.
兜底: 只有非空字符串才绑定 :style. -->
<view :style="themeColor || undefined">
<view :style="themeColor">
<!-- #ifndef H5 -->
<!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0">
<ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar>
</view> -->
<!-- #endif -->
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<!-- 价格区域 -->
<template v-slot:price>
<!-- 限时折扣 -->
@@ -74,9 +72,7 @@
</view>
<view class="follow-and-share">
<text class="follow iconfont icon-share" @click="openSharePopup()"></text>
<!-- #ifndef MP-HARMONY -->
<text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text>
<!-- #endif -->
</view>
</block>
<block v-else>
@@ -415,12 +411,7 @@
</view> -->
<!-- v-if="goodsSkuDetail.goodsForm" -->
<view style="padding: 0 30rpx;">
<!-- 表单暂无字段时的友好提示, 避免"打开弹窗却什么都不显示"困惑用户 -->
<view v-if="!formDiyValue || !formDiyValue.jsonData || !formDiyValue.jsonData.length" class="form-empty" style="padding: 60rpx 0; text-align: center; color: #999;">
{{ $lang('goods.detail.formEmptyTip') }}
</view>
<diy-form
v-else
:value="formDiyValue"
dataMode="raw"
:show-submit="false"
@@ -459,7 +450,7 @@
/>
<block v-else>
<ns-goods-action-icon :text="$lang('goods.detail.home')" icon="icon-shouye1" @click="goHome" />
<!-- #ifdef MP-WEIXIN -->
<ns-goods-action-icon
:text="$lang('goods.detail.cart')"
@@ -467,12 +458,9 @@
icon="icon-gouwuche2"
:corner-mark="cartNumber > 0 ? cartNumber + '' : ''"
@click="goCart"
/>
/>
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙平台: 隐藏购物车、客服、收藏按钮 -->
<!-- #endif -->
<!-- #ifdef H5 -->
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
<!-- #endif -->
@@ -528,9 +516,7 @@
</goods-detail-view>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login>
<!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
<loading-cover ref="loadingCover"></loading-cover>
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>

View File

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

View File

@@ -4,10 +4,7 @@ export default {
goodsRoute: '/pages_goods/detail',
couponList: [], //优惠券列表
couponBtnSwitch: false, //获取优惠券防止重复提交
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
loading: true,
posterApi: '/api/goods/poster',
//满减活动
@@ -56,19 +53,11 @@ export default {
}
return discount.toFixed(2);
},
// 弹窗高度: 受限于视口, 内部表单 <scroll-view> 自带滚动, 不再按字段数无限加高
FormHeight() {
// 上限 65vh: 留足顶部状态栏/导航栏 + 底部 home indicator, 也不会显得"一打开就半屏遮脸"
// 字段多时通过内部 <scroll-view scroll-y> 自然滚动
const formList = (this.goodsSkuDetail && this.goodsSkuDetail.goods_form)
|| this.goodsForm;
let height = 45;
if (formList && formList.length) {
// 少量字段: 每个约 5vh; 字段多时递增放缓并封顶, 避免接近屏顶
// 5 个字段 → 45 + min(10, 25) = 55vh
// 10 个字段 → 45 + min(10, 50) = 55vh (已封顶)
const dynamic = Math.min(10, formList.length * 5);
height = Math.min(55, height + dynamic);
//表单高度
FormHeight(){
let height = 48;
if (this.goodsForm && this.goodsForm.length) {
height += this.goodsForm.length * 5;
}
height += 'vh';
return height;
@@ -100,19 +89,8 @@ export default {
// #endif
},
async onShow() {
// 同步获取商品详情
// 鸿蒙元服务兼容: 包 try/catch, 防止内部任何抛错导致整个 page 显示空白
try {
await this.getGoodsSkuDetail();
} catch (e) {
console.error('getGoodsSkuDetail failed:', e);
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
// 避免错误地把页面切到"空详情"状态
uni.showToast({
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
icon: 'none'
});
}
//同步获取商品详情
await this.getGoodsSkuDetail();
},
onHide() {
this.couponBtnSwitch = false;
@@ -134,28 +112,14 @@ export default {
},
// 获取商品详情
async getGoodsSkuDetail() {
let res;
try {
// 修复鸿蒙元服务: 必须传 async: false !
// 当 App.vue 的 boot (lang/theme/remote-config) 未完成时,
// sendRequest 在 async !== false 模式下会直接 return undefined (不返回 promise),
// 导致 res.code undefined / res.data 抛 TypeError, 整个 onShow 失败.
res = await this.$api.sendRequest({
url: '/api/goodssku/detail',
async: false,
data: {
sku_id: this.skuId,
goods_id: this.goodsId,
}
});
// 防御: 即便 async:false 仍可能在极端情况下返回 undefined
if (!res || typeof res !== 'object') {
throw new Error('sendRequest returned non-object response (likely boot not complete)');
let res = await this.$api.sendRequest({
url: '/api/goodssku/detail',
async: false,
data: {
sku_id: this.skuId,
goods_id: this.goodsId,
}
} catch (e) {
console.error('getGoodsSkuDetail sendRequest failed:', e);
throw e;
}
});
let data = res.data;
if (data.goods_sku_detail != null) {
if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) {
@@ -166,10 +130,7 @@ export default {
return;
}
if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name
// 关键改动: 先一次性赋值, 释放事件循环让 MP-Weixin 把首屏 (含媒体/价格/标题) 渲出来,
// 再 $nextTick 后才跑同步字段变换 (sku_images split / JSON.parse / 属性去重 等),
// 避免连续多次 Vue 响应式写触发同一帧内多次 vdom 重算, 显著改善"打开详情很慢"的体感。
this.goodsSkuDetail = data.goods_sku_detail;
if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id;
if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id;
@@ -181,10 +142,7 @@ export default {
this.chatRoomParams = {
sku_id: this.goodsSkuDetail.sku_id
};
// 鸿蒙兼容: 后端在部分场景下可能不返回 goods_promotion, 缺字段直接 .[0] 会抛 TypeError,
// 整个 onShow 走 catch 路径无法关闭骨架屏, 表现为"数据已回来但永远显示骨架屏"
const promotionList = this.goodsSkuDetail?.goods_promotion || [];
let typeId = promotionList.length > 0 ? promotionList[0] : undefined;
let typeId = this.goodsSkuDetail.goods_promotion[0];
if (typeId) {
// 限时折扣
if (typeId.discount_id) {
@@ -198,27 +156,7 @@ export default {
goods_id: this.goodsId
};
// 让首屏主区域先绘制
// 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
// #ifdef MP-HARMONY
await new Promise(resolve => setTimeout(resolve, 16));
// 修复鸿蒙元服务: 不依赖 setTimeout 关闭骨架屏.
// 鸿蒙 JSVM 在 onShow 早期窗口的 setTimeout 回调有时被推迟/丢弃, 表现为"数据已回来但永远显示骨架屏".
// 数据已赋值 + 首屏让步, 同步直接关闭骨架屏, 由 hasRealData 兜底避免空详情.
this.loading = false;
// #endif
// #ifndef MP-HARMONY
await this.$nextTick();
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
// 用户会看到骨架屏 → 真实详情 的清晰过渡
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
setTimeout(() => {
this.loading = false;
}, 300);
// #endif
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
// 处理商品数据
await this.handleGoodsSkuData();
// 限时折扣
@@ -255,7 +193,7 @@ export default {
}
// 优惠券
if (Array.isArray(this.goodsSkuDetail.coupon_list)) {
if (this.goodsSkuDetail.coupon_list) {
this.couponList = this.goodsSkuDetail.coupon_list;
this.couponList.forEach(v => {
if (v.count == v.lead_count) v.useState = 2;
@@ -270,6 +208,8 @@ export default {
// this.handleBundlingData(this.goodsSkuDetail.bundling_list);
// }
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
} else {
this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo');
}
@@ -281,35 +221,8 @@ export default {
},
//商品留言打开
async showinformation(){
// 修复: 第一次打开时, uni-popup 内部 <scroll-view><diy-form> 还没完成首次渲染,
// 直接 open() 会让用户看到空白弹窗。这里:
// 1) 若商品表单后台请求尚未完成 (或根本没触发), 先发起并 await 该请求
// 2) 用 $nextTick 等组件挂载完成后再打开
try {
// 若页面已进入但 _goodsFormPromise 还没建 (如 sku 切换/数据未到等场景),
// 这里补一个并发请求; 若已存在, 复用旧 promise 不重复请求。
if (!this._goodsFormPromise || typeof this._goodsFormPromise.then !== 'function') {
const loader = (this.getGoodsForm && typeof this.getGoodsForm === 'function')
? this.getGoodsForm()
: Promise.resolve();
this._goodsFormPromise = loader;
await loader;
} else {
await this._goodsFormPromise;
}
} catch (e) {
console.warn('await goods form promise failed', e);
}
// 第一次强制刷新 popup 内部组件布局 (nextTick), 确保表单完整渲染
// 兼容鸿蒙元服务: $nextTick 不可靠时降级到 setTimeout
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
// 微信小程序下 scroll-view 在首次以 transform 隐藏后再显示时, 内部子节点布局可能未刷新
// 双 nextTick 兜底, 保证表单完整可见
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
if (this.$refs.informationPopup && typeof this.$refs.informationPopup.open === 'function') {
this.$refs.informationPopup.open();
}
showinformation(){
this.$refs.informationPopup.open();
},
//商品留言关闭
closeinformationPopup(){

View File

@@ -37,7 +37,7 @@ export default {
fontWeight: false,
padding: 0,
cartEvent: "detail",
text: "",
text: "购买",
textColor: "#FFFFFF",
theme: "default",
aroundRadius: 25,
@@ -67,7 +67,7 @@ export default {
},
onLoad(options) {
this.config.text = this.$lang('goods.list.buyText') || this.$lang('buyNow');
this.config.text = this.$lang('goods.list.buyText');
this.$api.sendRequest({
url: '/api/lucky/status',

View File

@@ -234,7 +234,7 @@
<view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1">
<text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text>
<view class="box text-overflow"@click="openSiteCoupon(calculateGoodsData.merch_id)">
<template v-if="(orderCreateData.coupon && orderCreateData.coupon[calculateGoodsData.merch_id] && orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id)">
<template v-if="orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id">
<text class="money price-font" style="max-width: 100%;">{{ $lang('order.payment.orderMoney.couponSelected', { count: 1 }) }}</text>
<!-- <text class="unit price-font">优惠</text>
<text class="money price-font">{{ (calculateData && calculateData.coupon_money ? calculateData.coupon_money : 0) | moneyFormat }}</text> -->
@@ -550,7 +550,7 @@
<view v-if="couponItem.type == 'discount' && couponItem.discount_limit > 0" class="limit">{{ $lang('order.payment.couponPopup.maxDiscount', { amount: couponItem.discount_limit }) }}</view>
<view class="time font-size-goods-tag">{{ $lang('order.payment.couponPopup.validPeriod') }}{{ couponItem.end_time ? $util.timeStampTurnTime(couponItem.end_time) : $lang('order.payment.couponPopup.permanentValid') }}</view>
</view>
<view class="iconfont" :class="selectCouponMap[merchCoupon.merch_id] == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
<view class="iconfont" :class="selectCouponId == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
</view>
</view>
</view>

View File

@@ -55,8 +55,7 @@ export default {
merch_id: 0,
data: []
},
// 按 merch_id 隔离,避免多商户之间互相污染
selectCouponMap: {}
selectCouponId:0
}
},
inject: ['promotion'],
@@ -86,7 +85,7 @@ export default {
goodsData() {
if (this.paymentData) {
this.paymentData.goods_list.forEach(item => {
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
})
return this.paymentData;
}
@@ -94,7 +93,7 @@ export default {
calculateGoodsData() {
if (this.calculateData) {
this.calculateData.goods_list.forEach(item => {
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
})
return this.calculateData;
}
@@ -190,7 +189,7 @@ export default {
//多商户商品分类
goodsSpecFormat(goods_list){
goods_list.forEach(item => {
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
})
return goods_list
},
@@ -250,11 +249,7 @@ export default {
this.modules = data.modules;
//记录多商户优惠券
// 兜底为对象,避免后续被当作数组处理
if (!data.coupon || typeof data.coupon !== 'object' || Array.isArray(data.coupon)) {
data.coupon = {};
}
this.orderCreateData.coupon = data.coupon;
this.orderCreateData.coupon = data.coupon
//查询优惠券
this.getCouponList();
@@ -370,17 +365,7 @@ export default {
}
});
//多商户
// 保留用户已选的 coupon_id,后端回传的 res.data.coupon 默认都是 0 会覆盖掉
const prevCoupon = (this.orderCreateData.coupon && typeof this.orderCreateData.coupon === 'object' && !Array.isArray(this.orderCreateData.coupon)) ? this.orderCreateData.coupon : {};
this.orderCreateData.coupon = res.data.coupon || {};
if (this.orderCreateData.coupon && typeof this.orderCreateData.coupon === 'object' && !Array.isArray(this.orderCreateData.coupon)) {
Object.keys(prevCoupon).forEach(merch_id => {
const chosen = prevCoupon[merch_id] && prevCoupon[merch_id].coupon_id;
if (chosen && (!this.orderCreateData.coupon[merch_id] || !this.orderCreateData.coupon[merch_id].coupon_id)) {
this.orderCreateData.coupon[merch_id] = { coupon_id: chosen };
}
});
}
this.orderCreateData.coupon = res.data.coupon
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
@@ -746,52 +731,44 @@ export default {
this.tempData = null;
},
//优惠券单独多商户
/**
* 显示店铺优惠券信息
* @param {Object} siteId
* @param {Object} couponData
*/
openSiteCoupon(merch_id) {
this.tempData = this.$util.deepClone(this.orderCreateData);
this.merchCoupon.merch_id = merch_id;
// 从当前订单已选的 coupon_id 取值,没有则取弹窗里缓存的,再没有为 0
const exist = this.orderCreateData.coupon && this.orderCreateData.coupon[merch_id];
const cached = this.selectCouponMap[merch_id] || 0;
this.selectCouponMap[merch_id] = exist ? (exist.coupon_id || 0) : cached;
// this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
// 兜底: 列表为空时给一个空数组,弹窗不会渲染空指针
this.merchCoupon.data = (this.coupon_list && this.coupon_list[merch_id]) || [];
this.$refs.couponPopup.open();
this.$forceUpdate();
},
/**商户选择券*/
selectCoupon(data, merch_id) {
// if (this.orderCreateData.coupon[merch_id].coupon_id == data.coupon_id){
// this.orderCreateData.coupon[merch_id].coupon_id = 0
// }else{
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
// };
const cur = this.selectCouponMap[merch_id] || 0;
this.selectCouponMap[merch_id] = (cur != data.coupon_id) ? data.coupon_id : 0;
this.$forceUpdate();
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
},
//确定使用优惠券
useCpopon() {
const merch_id = this.merchCoupon.merch_id;
// 确保 orderCreateData.coupon 是对象结构,后端按 merch_id 取值
if (!this.orderCreateData.coupon || typeof this.orderCreateData.coupon !== 'object' || Array.isArray(this.orderCreateData.coupon)) {
this.orderCreateData.coupon = {};
}
this.orderCreateData.coupon[merch_id] = {
coupon_id: this.selectCouponMap[merch_id] || 0
};
this.$refs.couponPopup.close();
this.calculate();
},
/**
* 显示店铺优惠券信息
* @param {Object} siteId
* @param {Object} couponData
*/
openSiteCoupon(merch_id) {
this.tempData = this.$util.deepClone(this.orderCreateData);
this.merchCoupon.merch_id = merch_id;
this.selectCouponId = this.orderCreateData.coupon[merch_id].coupon_id;
// this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
this.merchCoupon.data = this.coupon_list[merch_id];
this.$refs.couponPopup.open();
this.$forceUpdate();
},
/**商户选择券*/
selectCoupon(data,merch_id) {
// if (this.orderCreateData.coupon[merch_id].coupon_id == data.coupon_id){
// this.orderCreateData.coupon[merch_id].coupon_id = 0
// }else{
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
// };
if (this.selectCouponId != data.coupon_id) {
this.selectCouponId = data.coupon_id;
} else {
this.selectCouponId = 0;
}
this.$forceUpdate();
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
},
//确定使用优惠券
useCpopon() {
this.orderCreateData.coupon[this.merchCoupon.merch_id].coupon_id = this.selectCouponId;
this.$refs.couponPopup.close();
this.calculate();
},
//-------------------
/**

View File

@@ -39,7 +39,7 @@ export default {
fontWeight: false,
padding: 0,
cartEvent: "detail",
text: '',
text: this.$lang('merch.list.buyText'),
textColor: "#FFFFFF",
theme: "default",
aroundRadius: 25,
@@ -66,7 +66,6 @@ export default {
},
onLoad(options) {
this.config.text = this.$lang('merch.list.buyText');
this.categoryId = options.category_id || 0;
this.keyword = options.keyword || '';
this.coupon = options.coupon || 0;

View File

@@ -397,7 +397,7 @@ export default {
scrollTop: 0,
scrollIntoViewId: null,
hasMoreHistory: false,
loadingText: '',
loadingText: this.$lang('ai-chat.index.loadMoreHistory'),
voiceInputing: false,
audioContext: null,
currentAudio: null,
@@ -417,7 +417,7 @@ export default {
localUserAvatar: '',
showAvatarSelector: false, // 头像选择弹窗控制
localUserNickname: '', // 当前用户昵称
defaultNickname: '', // 默认昵称
defaultNickname: this.$lang('ai-chat.index.defaultNickname'), // 默认昵称
showNicknameEditor: false, // 昵称编辑弹窗控制
// AI头像错误状态
avatarError: false,
@@ -493,9 +493,6 @@ export default {
created() {
this.messages = [...this.initialMessages]
this.messageId = this.messages.length
// 初始化 i18n 字段(在 created 中同步赋值,避免 remote-only 首屏 data() 缓存空串)
this.loadingText = this.$lang('ai-chat.index.loadMoreHistory')
this.defaultNickname = this.$lang('ai-chat.index.defaultNickname')
this.initAudioContext()
// 初始化用户头像(缓存优先)
this.initUserAvatarData()

View File

@@ -29,8 +29,7 @@
detail: null,
isRepeat: false,
complete: false,
scroll: true,
urlParams: {}
scroll:true
};
},
onLoad(data) {
@@ -40,27 +39,12 @@
// #endif
this.id = data.id || 0;
// 通用 URL 参数收集:除 id/scene 外的所有参数都透传给后端
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
const reservedKeys = ['uniacid', 'id', 'scene'];
this.urlParams = {};
for (let key in data) {
if (reservedKeys.indexOf(key) !== -1) continue;
this.urlParams[key] = data[key];
}
if (data.scene) {
var sceneParams = decodeURIComponent(data.scene);
sceneParams = sceneParams.split('&');
if (sceneParams.length) {
sceneParams.forEach(item => {
if (item.indexOf('id') != -1) this.id = item.split('-')[1];
// scene 中也可能携带其他参数
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
}
});
}
}
@@ -105,9 +89,7 @@
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData),
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData)
},
success: res => {
if (res.code == 0) {
@@ -180,4 +162,4 @@
bottom: 180rpx !important;
/* #endif */
}
</style>
</style>

View File

@@ -1,41 +1,21 @@
<template>
<view :style="themeColor">
<!--
自绘导航栏(仅在 navigationStyle:custom 的端渲染:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
- pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
- 左侧:hasBackStack 为 true 时显示返回箭头(点击 navigateBack),showHomeBtn=true 时再叠加 Home 胶囊
- 中央:表单标题(form_name)
- 整体高度 = 状态栏高度 + 44px 内容区,由 customNavbarStyle 动态算
H5 端保留系统原生 navbar,不渲染此块
-->
<!-- #ifndef H5 -->
<view class="form-home-navbar" :style="customNavbarStyle">
<view class="form-home-navbar__left">
<view v-if="hasBackStack" class="form-home-navbar__back" @tap="goBack" hover-class="form-home-navbar__btn--hover">
<text class="form-home-navbar__back-icon">‹</text>
</view>
<view v-if="showHomeBtn" class="form-home-capsule" @tap="goHome" hover-class="form-home-capsule--hover">
<text class="iconfont icon-shouye1 form-home-capsule__icon"></text>
</view>
</view>
<view class="form-home-navbar__title">{{ navbarTitle }}</view>
<view class="form-home-navbar__placeholder" />
</view>
<!-- #endif -->
<block v-if="detail">
<view class="page">
<view class="system-form-wrap">
<!-- 复用 diy-form:banner / tip / 表单字段 / 提交按钮 全部由其内部渲染,样式(bannerPaddingX/tipColor/submitBgColor 等)由 form_config 控制。
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 this 会漂移到子组件实例 -->
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
@submit 接管提交逻辑(带 uniacid + showModal) -->
<diy-form
ref="form"
:value="diyValue"
dataMode="raw"
@submit="create"
/>
</view>
</view>
</block>
<block v-else>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
:isIndex="false"></ns-empty>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')" :isIndex="false"></ns-empty>
</block>
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
@@ -45,9 +25,13 @@
<script>
import diyForm from '@/components-diy/diy-form.vue';
import scroll from '@/common/js/scroll-view.js';
export default {
components: {
diyForm
},
mixins: [scroll],
data() {
return {
@@ -59,16 +43,6 @@ export default {
uniacid: 0,
isFromMpWebView: false, // 是否是从微信小程序的WebView跳转过来的
hideNavbar: false, // 查询参数中支持隐藏Navbar
showHomeBtn: false, // 查询参数 show_home=1 时,在系统标题栏左侧显示 Home 按钮
urlParams: {}, // 通用 URL 透传参数收集
handleSubmit: null, // create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
// #ifndef H5
navbarHeight: 44, // 自绘 navbar 内容区高度(不含状态栏),跨端兜底 44px
statusBarHeight: 20, // 系统状态栏高度,自绘 navbar 时需要预留顶部空隙
navbarTitle: '', // 自绘 navbar 中央显示的标题
hasBackStack: false, // 自绘 navbar 左上角是否显示返回箭头:页面栈 > 1 时显示
// #endif
// #ifdef H5
formTitle: '', // 查询参数传入的Title
@@ -76,30 +50,7 @@ export default {
// #endif
}
},
created() {
// 预绑定 this:create 重度依赖本组件 this(id/uniacid/memberInfo/$api/$lang/$util 等)。
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
// bind 后无论子组件如何调用,this 始终是 formdata 实例。
this.handleSubmit = this.create.bind(this);
},
computed: {
// #ifndef H5
// 自绘 navbar 的高度样式
// - 内容区高度 = navbarHeight(兜底 44px)
// - 整体高度 = 状态栏高度 + 内容区高度
// - padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡
customNavbarStyle() {
try {
const sys = uni.getSystemInfoSync();
const sb = sys.statusBarHeight || 0;
const h = this.navbarHeight || 44;
return `height: ${sb + h}px; padding-top: ${sb}px;`;
} catch (e) {
return 'height: 64px; padding-top: 20px;';
}
},
// #endif
// 构造 diy-form raw 模式期望的 value 形态:
// - jsonData:透传给 ns-newform(已是 ns-newform 期望的 [{ controller, value, val }])
// - formConfig:后端返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
@@ -119,7 +70,6 @@ export default {
}
},
onLoad(options) {
console.log("=====表单页面====", options)
if (!options.uniacid) {
uni.showModal({
title: this.$lang('form.formdata.tip'),
@@ -140,41 +90,7 @@ export default {
this.uniacid = options.uniacid
this.id = options.id || 0
this.isFromMpWebView = options.from === 'mp-webview';
this.hideNavbar = this.$util.coerceBool(options?.hide_navbar);
// show_home=1 时在系统标题栏左侧显示 Home 按钮,点击回到首页 INDEX_PAGE_URL
// 解析规则统一走 this.$util.coerceBool:支持 '1' / 'true' / 1 / true 四种写法
// 仅在 navigationStyle:custom 端生效(APP / 微信小程序 / 华为快应用 / 鸿蒙元服务);
// H5 保留系统原生 navbar,不支持 show_home,参数被忽略(这是预期行为)
// 注:H5 嵌入微信小程序 webview(from=mp-webview)的场景也属于 H5 范畴,
// 此场景下也没有 Home 入口——后续如需支持,需要单独处理(导航靠宿主小程序接管)
this.showHomeBtn = this.$util.coerceBool(options?.show_home);
// #ifndef H5
// 自绘 navbar 需要用到状态栏高度 / navbar 内容区高度(跨端兜底)
try {
const sys = uni.getSystemInfoSync();
if (sys.statusBarHeight) this.statusBarHeight = sys.statusBarHeight;
// uniapp 在不同端 menuButton / navigationBar 高度略有差异,给个安全兜底即可
if (typeof sys.navbarHeight === 'number' && sys.navbarHeight > 0) {
this.navbarHeight = sys.navbarHeight;
}
} catch (e) { /* keep defaults */ }
// 自绘 navbar 是否显示返回箭头:getCurrentPages().length > 1 即页面有栈
try {
const pages = getCurrentPages ? getCurrentPages() : [];
this.hasBackStack = Array.isArray(pages) && pages.length > 1;
} catch (e) { /* keep default */ }
// #endif
// 通用 URL 参数收集:除业务保留字段外,其余全部透传给后端
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
const reservedKeys = ['uniacid', 'id', 'scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
this.urlParams = {};
for (let key in options) {
if (reservedKeys.indexOf(key) !== -1) continue;
this.urlParams[key] = options[key];
}
this.hideNavbar = Boolean(options?.hide_navbar);
if (options.scene) {
const scene = decodeURIComponent(options.scene)
@@ -183,11 +99,6 @@ export default {
if (item.indexOf('id') != -1) {
this.id = item.split('-')[1]
}
// scene 中也可能携带其他参数
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
}
})
}
@@ -202,7 +113,7 @@ export default {
this.getData()
},
watch: {
storeToken(newVal, oldVal) {
newVal && this.getData()
@@ -212,20 +123,6 @@ export default {
this.updateNavbarTitle();
},
methods: {
// #ifndef H5
// 返回上一页;用 navigateBack,栈深度>1 时由 hasBackStack 控制按钮可见性
goBack() {
uni.navigateBack({ delta: 1 });
},
// #endif
// 返回首页;
// INDEX_PAGE_URL 是 TabBar 根页面,不能用裸 uni.redirectTo(会报"can not redirectTo a tabBar page")
// 必须用 this.$util.redirectTo,内部对 TabBar 页面自动走 uni.switchTab
// (鸿蒙元服务下还会用 reLaunch 兜底,见 common/js/util.js)
// H5 端保留 show_home / goHome(虽然模板里不渲染自绘 navbar,但保留方法调用不报错)
goHome() {
this.$util.redirectTo(this.$util.INDEX_PAGE_URL, {}, 'redirectTo');
},
getData() {
this.$api.sendRequest({
url: '/form/api/form/info',
@@ -253,37 +150,22 @@ export default {
formName = this.formTitle;
}
// #endif
// H5 端:系统原生 navbar —— uni.setNavigationBarTitle 改原生标题
// #ifdef H5
uni.setNavigationBarTitle({ title: formName });
// #endif
// 非 H5 端:custom 模式下没有原生 navbar,直接写 data.navbarTitle,
// 由模板里的 .form-home-navbar__title 显示
// #ifndef H5
this.navbarTitle = formName;
// #endif
},
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
// 因此内部所有 this.xxx 都指向 formdata 实例,不会漂移到子组件。
// 由 diy-form 内置提交按钮触发:diy-form 已先做 verify(),仅在通过时才 emit('submit', formData)
create(formData) {
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
if (this.isRepeat) return;
if (this.isRepeat) return
this.isRepeat = true;
this.isRepeat = true
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
uniacid: this.uniacid
},
success: res => {
if (res.code == 0) {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
@@ -296,10 +178,6 @@ export default {
title: res.message
})
}
},
fail: () => {
// 网络失败时复位防重复标记,允许用户重试
this.isRepeat = false
}
})
}
@@ -307,114 +185,6 @@ export default {
}
</script>
<style lang="scss" scoped>
/*
* 自绘 navbar(非 H5 端:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
* - pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
* - 整体高度由 inline style 注入:statusBarHeight + 44px
* - 左侧 Home 胶囊 + 中央标题 + 右侧占位,标题过长自动省略
*/
/* #ifndef H5 */
/* --- 自绘 navbar 容器 --- */
.form-home-navbar {
position: relative;
width: 100%;
box-sizing: border-box;
background: #ffffff;
/* 高度由 inline style 通过 customNavbarStyle 注入:statusBarHeight + 44px */
/* padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡 */
/* 浮在表单内容之上,避免被 diy-form 等组件覆盖 */
z-index: 100;
/* 让内容靠 flex 自动落到 padding-top 之下的区域 */
display: flex;
flex-direction: row;
align-items: center;
}
.form-home-navbar__left {
/* 不再用 absolute,跟着 navbar 的 padding-top 下移 */
flex: none;
/* 宽度自适应:返回按钮 56rpx + Home 胶囊 56rpx + 间距 12rpx + 左 padding 16rpx = 最多 ~140rpx */
min-width: 88rpx;
max-width: 200rpx;
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 16rpx;
box-sizing: border-box;
gap: 12rpx;
}
.form-home-navbar__back {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.form-home-navbar__back-icon {
font-size: 48rpx;
line-height: 1;
color: #000;
font-weight: 300;
}
.form-home-navbar__btn--hover {
background: rgba(0, 0, 0, 0.06);
}
.form-home-navbar__title {
flex: 1;
height: 44px;
line-height: 44px;
text-align: center;
font-size: 32rpx;
color: #000;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
}
.form-home-navbar__placeholder {
/* 与 __left 对称,保持标题视觉居中 */
flex: none;
min-width: 88rpx;
max-width: 200rpx;
}
/* --- Home 胶囊主体 --- */
.form-home-capsule {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.form-home-capsule--hover {
background: rgba(0, 0, 0, 0.06);
}
/*
* Home 图标 —— 使用项目统一的 iconfont icon-shouye1(unicode \e02d)
* 与 pages_goods/detail.vue 的首页按钮共用同一个图标,跨端视觉一致
*/
.form-home-capsule__icon {
font-size: 32rpx;
line-height: 1;
color: #000;
display: block;
}
/* #endif */
</style>
<style lang="scss" scoped>
page {
background: #fff;

View File

@@ -79,7 +79,7 @@ export default {
setPublicShare() {
let shareUrl = this.$config.h5Domain + '/pages_tool/help/list';
this.$util.setPublicShare({
title: this.$lang('help.list.title'),
title: '帮助中心',
desc: '',
link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''

View File

@@ -7,7 +7,7 @@
</view>
</view>
<view class="body-wrap">
<view class="form-wrap">
<view class="form-wrap">
<view class="input-wrap" v-show="loginMode == 'mobile'">
<view class="content">
<!-- <view class="area-code">+86</view> -->
@@ -324,16 +324,16 @@ export default {
}
},
mobileAuthLogin(e) {
if (e.detail.errMsg == 'getPhoneNumber:ok') {
var data = {
iv: e.detail.iv,
encryptedData: e.detail.encryptedData
};
if (Object.keys(this.authInfo).length) {
Object.assign(data, this.authInfo);
if (this.authInfo.nickName) data.nickname = this.authInfo.nickName;
if (this.authInfo.avatarUrl) data.headimg = this.authInfo.avatarUrl;
}
if (e.detail.errMsg == 'getPhoneNumber:ok') {
var data = {
iv: e.detail.iv,
encryptedData: e.detail.encryptedData
};
if (Object.keys(this.authInfo).length) {
Object.assign(data, this.authInfo);
if (this.authInfo.nickName) data.nickname = this.authInfo.nickName;
if (this.authInfo.avatarUrl) data.headimg = this.authInfo.avatarUrl;
}
if (uni.getStorageSync('source_member')) data.source_member = uni.getStorageSync('source_member');
if (this.isSub) return;

View File

@@ -95,10 +95,7 @@ export default {
// 判断登录
if (!this.storeToken) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.getAccountInfo();
}
@@ -115,8 +112,8 @@ export default {
this.fenxiao_info = res.data.fenxiao_info;
}
} else if (res.code == -1) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});

View File

@@ -20,10 +20,7 @@
<script>
import htmlParser from '@/common/js/html-parser';
import memberPublic from './public/js/member-public.js';
export default {
mixins: [memberPublic],
data() {
return {
agreement: {},
@@ -34,10 +31,7 @@ export default {
if (option.back) this.back = option.back;
// 判断登录
if (!this.storeToken) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.getCancelAgreement();
}
@@ -51,8 +45,9 @@ export default {
this.agreement = res.data;
if (this.agreement.content) this.agreement.content = htmlParser(this.agreement.content);
} else if (res.code == -1) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.redirectToLoginOnce();
this.$store.commit('setToken', '');
this.$store.commit('setMemberInfo', '');
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});

View File

@@ -13,10 +13,7 @@
</template>
<script>
import memberPublic from './public/js/member-public.js';
export default {
mixins: [memberPublic],
components: {},
data() {
return {
@@ -29,8 +26,7 @@ export default {
// 判断登录
if (!this.storeToken) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.getStatus();
}
@@ -43,8 +39,9 @@ export default {
if (res.code >= 0) {
this.reason = res.data.reason;
} else if (res.code == -1) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.redirectToLoginOnce();
this.$store.commit('setToken', '');
this.$store.commit('setMemberInfo', '');
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});

View File

@@ -49,10 +49,7 @@
onLoad(option) {
// 判断登录
if (!this.storeToken) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.getStatus();
}
@@ -73,8 +70,8 @@
}
if (res.code == -1) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});

View File

@@ -19,10 +19,7 @@
components: {},
data() {
return {
state: '',
// 防抖锁: 防止 onLoad 的 getStatus 异步回调 和 用户点击 success 按钮
// 短时间内连发 reLaunch, 触发微信小程序 "appLaunch with non-empty page stack" native 错误
_navigated: false
state: ''
};
},
onLoad(option) {
@@ -31,8 +28,7 @@
// 判断登录
if (!this.storeToken) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.getStatus();
}
@@ -44,17 +40,16 @@
success: res => {
if (res.code >= 0) {
if (res.data && res.data.status == 1) {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
// 跟 success 按钮共享防抖, 避免同时触发
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
}
});
},
success() {
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
};

View File

@@ -113,32 +113,6 @@ export default {
},
onShow() {
this.$util.hideHomeButton();
},
// 覆写 mixin 的分享钩子:优先使用推荐码组件存储的分享数据
onShareAppMessage() {
try {
var shareDataStr = uni.getStorageSync('_recommendShareData');
if (shareDataStr) {
uni.removeStorageSync('_recommendShareData');
return JSON.parse(shareDataStr);
}
} catch(e) {}
return this.mpShareData?.appMessage;
},
onShareTimeline() {
try {
var shareDataStr = uni.getStorageSync('_recommendShareData');
if (shareDataStr) {
uni.removeStorageSync('_recommendShareData');
var d = JSON.parse(shareDataStr);
var query = '';
if (d.path && d.path.indexOf('?') >= 0) {
query = d.path.split('?')[1];
}
return { title: d.title, query: query, imageUrl: d.imageUrl || '' };
}
} catch(e) {}
return this.mpShareData?.timeLine;
},
methods: {

View File

@@ -1,6 +1,6 @@
export default {
methods: {
// 清除登录态(token / 会员信息 / 购物车 / 本地存储)
// 清除登录态(token / 用户信息 / 购物车 / 本地存储)
clearLoginState() {
this.$store.commit('setToken', '');
this.$store.commit('setMemberInfo', '');
@@ -9,48 +9,6 @@ export default {
uni.removeStorageSync('privacyAgreed');
},
// 跨平台登录入口:小程序下用 ns-login 原生授权弹窗 (走 wx.login / getPhoneNumber),
// H5 / 快应用 / APP 下用原生登录页 /pages_tool/login/login (手机号+动态码表单).
//
// 修复背景: 之前所有跳转统一用 LOGIN_PAGE_URL (/pages_tool/login/login),
// 这个原生登录页在微信小程序下是 form 提交 (像 H5), 没有走 wx.login 授权。
// 期望行为:
// - 微信小程序 / 鸿蒙元服务 / 其他小程序: 跳到首页 /pages/index/index,
// 首页挂载了 <ns-login ref="login">, 用户后续操作触发 _requireHomeAuth
// 时会自动弹 ns-login 弹窗, 走原生 wx.login / getPhoneNumber 授权.
// - H5 / 快应用 / APP: 保持跳 LOGIN_PAGE_URL (原生登录页), 表单流程正常.
getLoginEntryUrl() {
// #ifdef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
return this.$util.INDEX_PAGE_URL;
// #endif
// #ifndef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
return this.$util.LOGIN_PAGE_URL;
// #endif
},
// 统一的"跳登录入口"封装, 包含组件级防抖锁 + 清登录态 + reLaunch
//
// 为什么需要防抖: 微信小程序下, 短时间内连发 reLaunch 会触发
// "appLaunch with non-empty page stack" native 错误 (页面销毁未完成, 新 launch 时栈非空).
// 注销/退出流程中常见并发场景:
// - cancelsuccess.vue onLoad 调 getStatus 异步回调 + 用户点 "完成" 按钮
// - 多个 submitCancelApply 同时被触发
// 防抖策略: 同一组件实例 800ms 内只能跳一次, 之后忽略
// (util.js reLaunch 分支还有 300ms 全局锁作为第二道防线)
redirectToLoginOnce() {
// 组件级防抖: 800ms 内重复调用直接忽略
const now = Date.now();
if (this._lastLoginRedirectAt && now - this._lastLoginRedirectAt < 800) {
console.warn('[member-public] redirectToLoginOnce 防抖: 800ms 内重复调用已忽略');
return;
}
this._lastLoginRedirectAt = now;
this.clearLoginState();
// 小程序下 getLoginEntryUrl() 返回首页 (走 ns-login 原生授权弹窗),
// H5 / APP 下返回原生登录页 (form 提交)
this.$util.redirectTo(this.getLoginEntryUrl(), {}, 'reLaunch');
},
// 退出登录
logout() {
uni.showModal({
@@ -58,8 +16,8 @@ export default {
content: this.$lang('logoutContent'),
success: (res) => {
if (res.confirm) {
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});
@@ -92,8 +50,8 @@ export default {
this.$util.redirectTo('/pages_tool/member/cancellation');
}
} else if (res.code == -1) {
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});
@@ -111,15 +69,11 @@ export default {
url: '/membercancel/api/membercancel/apply',
success: rres => {
if (rres.code >= 0) {
// 注销申请提交成功后立刻清空登录态,
// 避免用户通过标题栏/导航栏返回到会员中心等页面时仍能看到账号信息
if (options.clearStateAndRedirect) {
// 跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
} else {
// 跳 cancelstatus (审核中), 也走 reLaunch 清栈
this.clearLoginState();
this.$util.redirectTo('/pages_tool/member/cancelstatus', {}, 'reLaunch');
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.$util.redirectTo('/pages_tool/member/cancelstatus');
}
} else {
this.$util.showToast({

View File

@@ -77,7 +77,7 @@ export default {
setPublicShare() {
let shareUrl = this.$config.h5Domain + '/pages_tool/notice/list';
this.$util.setPublicShare({
title: this.$lang('notice.list.title'),
title: '公告',
desc: '',
link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
@@ -85,7 +85,7 @@ export default {
}
},
onShareAppMessage(res) {
var title = this.$lang('notice.list.title');
var title = '公告';
var path = '/pages_tool/notice/list';
return {
title: title,

View File

@@ -51,7 +51,7 @@ export default {
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
} else {
this.$util.showToast({
title: this.$lang('pay.index.payInfoNotFound')
title: '未获取到支付信息!'
});
setTimeout(() => {
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);

View File

@@ -1,6 +1,9 @@
<template>
<view :style="themeColor" class="vr-page">
<web-view :src="src" class="vr-webview" @titleUpdate="forceCustomTitle"></web-view>
<view :style="themeColor">
<web-view :src="src"></web-view>
<!-- 顶部导航覆盖层:使用 cover-view 兼容微信小程序的 web-view 原生组件 -->
<cover-view class="vr-nav-back icondiy icon-system-arrow-go-back-line" @click="navigateBack"></cover-view>
<cover-view class="vr-nav-home icondiy icon-system-home-2-line" @click="goHome"></cover-view>
</view>
</template>
@@ -8,73 +11,72 @@
export default {
data() {
return {
src: '',
title: '',
customTitle: '',
titleTimer: null
src: ''
};
},
onLoad(option) {
this.customTitle = option?.title || '工厂展示';
this.title = this.customTitle;
uni.setNavigationBarTitle({ title: this.customTitle });
// this.src = decodeURIComponent(option.src);
this.$api.sendRequest({
url: '/api/config/defaultvr',
success: res => {
if (res.code == 0) {
this.customTitle = res.data.title;
this.title = this.customTitle;
uni.setNavigationBarTitle({ title: this.customTitle });
this.src = res.data.url;
this.startTitleGuard();
uni.setNavigationBarTitle({
title:res.data.title
})
this.src = res.data.url
}
},
fail: res => {
}
});
},
onShow() {
if (this.customTitle) {
uni.setNavigationBarTitle({ title: this.customTitle });
}
},
onUnload() {
this.clearTitleGuard();
},
methods: {
forceCustomTitle() {
uni.setNavigationBarTitle({ title: this.customTitle });
},
startTitleGuard() {
this.clearTitleGuard();
let count = 0;
this.titleTimer = setInterval(() => {
uni.setNavigationBarTitle({ title: this.customTitle });
count++;
if (count >= 10) {
this.clearTitleGuard();
}
}, 100);
},
clearTitleGuard() {
if (this.titleTimer) {
clearInterval(this.titleTimer);
this.titleTimer = null;
navigateBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack({
delta: 1
});
} else {
// 直接进入的场景(分享链接 / 外部链接 / 扫码等),页面栈只有当前页,
// navigateBack 会失败。重定向到首页,保证用户能离开 VR 页面。
uni.reLaunch({
url: this.$util.PAGE_KEY_MAP.PAGE_INDEX
});
}
},
goHome() {
uni.reLaunch({
url: this.$util.PAGE_KEY_MAP.PAGE_INDEX
});
}
}
};
</script>
<style lang="scss">
.vr-page {
height: 100vh;
width: 100%;
background: #fff;
.vr-nav-back,
.vr-nav-home {
position: absolute;
top: 34rpx;
z-index: 5;
width: 72rpx;
height: 72rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
text-align: center;
box-sizing: border-box;
}
.vr-webview {
width: 100%;
height: 100%;
.vr-nav-back {
left: 34rpx;
line-height: 60rpx;
}
.vr-nav-home {
right: 34rpx;
font-size: 40rpx;
line-height: 72rpx;
}
</style>

View File

@@ -1,62 +0,0 @@
$ErrorActionPreference = 'Stop'
$target = 'C:\Program Files\Huawei QuickApp IDE\resources\app\extensions\deveco-debug\lib\toolkit\nodeTool'
$before = [Environment]::GetEnvironmentVariable('Path', 'Machine')
Write-Host '=== Before: target entries in Machine PATH ==='
$before -split ';' | ForEach-Object {
if ($_.Trim() -ieq $target) {
Write-Host (" [" + $_ + "]")
}
}
$entries = $before -split ';'
$kept = New-Object System.Collections.Generic.List[string]
$removed = New-Object System.Collections.Generic.List[string]
foreach ($e in $entries) {
$t = $e.Trim()
if ($t -ieq $target) {
$removed.Add($e)
} elseif ($t -ne '') {
$kept.Add($e)
}
}
$after = [string]::Join(';', $kept.ToArray())
if ($removed.Count -eq 0) {
Write-Host 'Target entry not found, nothing to do.'
exit 0
}
try {
[Environment]::SetEnvironmentVariable('Path', $after, 'Machine')
Write-Host ''
Write-Host '=== Result ==='
Write-Host (" Removed entries: " + $removed.Count)
foreach ($r in $removed) { Write-Host (" - " + $r) }
Write-Host (" Remaining entries: " + $kept.Count)
Write-Host ' Write OK.'
} catch {
Write-Host ''
Write-Host (' Write FAILED (admin required?): ' + $_.Exception.Message)
exit 1
}
Write-Host ''
Write-Host '=== Verify after write ==='
$verify = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$stillThere = 0
foreach ($e in ($verify -split ';')) {
if ($e.Trim() -ieq $target) { $stillThere++ }
}
if ($stillThere -gt 0) {
Write-Host ' FAIL: target still present.'
exit 1
} else {
Write-Host ' OK: target no longer in Machine PATH.'
}
Write-Host ''
Write-Host 'Note: env var change does NOT affect already-running processes.'
Write-Host 'Reopen cmd / Git Bash / HBuilderX to see updated where node.'

View File

@@ -1,209 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* scripts/check-subpackage-sync.js
*
* 检查 mp-harmony 项目的子包 HAR 配置在 4 个地方是否同步:
* 1. mp-harmony/build-profile.json5 的 modules 数组
* 2. entry/oh-package.json5 的 dependencies
* 3. entry/har-deps-required.json5 的 requiredHarDeps (单一事实源)
* 4. ascf_build 实际目录
*
* 任何一处不一致 → 立即报错并指出哪一处需要修改。
* 防止新增/删除子包时漏改某处导致 build/runtime 故障。
*
* 使用:
* node scripts/check-subpackage-sync.js
* node scripts/check-subpackage-sync.js --strict # 不一致即 exit 1 (CI 用)
*
* 输出:
* ✓ 5 个子包配置完全一致
* 或
* ✘ 缺失 [pages_extra] 在以下位置:
* - build-profile.json5 (mp-harmony)
* - entry/oh-package.json5
* 建议操作: ...
*/
'use strict';
const fs = require('fs');
const path = require('path');
const JSON5 = require('json5');
function loadJson5(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON5.parse(raw);
}
function exists(p) {
try {
fs.accessSync(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
function listDirs(p) {
try {
return fs.readdirSync(p, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [];
}
}
function diff(a, b) {
const aSet = new Set(a);
const bSet = new Set(b);
const onlyA = [...aSet].filter(x => !bSet.has(x));
const onlyB = [...bSet].filter(x => !aSet.has(x));
return { onlyA, onlyB };
}
function color(s, c) {
if (!process.stdout.isTTY) return s;
const codes = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', gray: '\x1b[90m', reset: '\x1b[0m' };
return codes[c] + s + codes.reset;
}
function main() {
const ROOT = path.resolve(__dirname, '..');
const strict = process.argv.includes('--strict');
// === 单一事实源: har-deps-required.json5 ===
const configPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'har-deps-required.json5');
if (!exists(configPath)) {
console.error(color(`✘ 缺少白名单配置: ${configPath}`, 'red'));
process.exit(strict ? 1 : 0);
}
const config = loadJson5(configPath);
const canonical = (config.requiredHarDeps || []).slice().sort();
console.log(color('=== mp-harmony 子包配置一致性检查 ===', 'cyan'));
console.log(`白名单 (har-deps-required.json5): ${canonical.join(', ')}\n`);
const errors = [];
const warnings = [];
// === 检查 1: mp-harmony/build-profile.json5 的 modules 数组 ===
const buildProfilePath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'build-profile.json5');
const buildProfileModules = [];
if (!exists(buildProfilePath)) {
errors.push(`build-profile.json5 不存在: ${buildProfilePath}`);
} else {
try {
const bp = loadJson5(buildProfilePath);
// modules 是顶层数组, 包含 entry + 5 个子包
// entry 也算一个 module, 但它不在我们的白名单里
const moduleNames = (bp.modules || []).map(m => m.name).filter(n => n && n !== 'entry');
buildProfileModules.push(...moduleNames);
const d = diff(canonical, buildProfileModules);
if (d.onlyA.length) {
errors.push(`build-profile.json5 缺失子包: [${d.onlyA.join(', ')}]`);
console.log(` ${color('✘', 'red')} build-profile.json5: 缺失 [${d.onlyA.join(', ')}]`);
} else if (d.onlyB.length) {
errors.push(`build-profile.json5 多了子包: [${d.onlyB.join(', ')}]`);
console.log(` ${color('✘', 'red')} build-profile.json5: 多了 [${d.onlyB.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} build-profile.json5: ${buildProfileModules.length} 个子包模块与白名单一致`);
}
} catch (e) {
errors.push(`build-profile.json5 解析失败: ${e.message}`);
}
}
// === 检查 2: entry/oh-package.json5 的 dependencies ===
const ohPkgPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'oh-package.json5');
const ohPkgDeps = [];
if (!exists(ohPkgPath)) {
errors.push(`entry/oh-package.json5 不存在: ${ohPkgPath}`);
} else {
try {
const op = loadJson5(ohPkgPath);
const deps = op.dependencies || {};
// 提取 5 个 file: 依赖 (key 是包名)
ohPkgDeps.push(...Object.keys(deps).filter(k => deps[k] && deps[k].startsWith('file:')));
const d = diff(canonical, ohPkgDeps);
if (d.onlyA.length) {
errors.push(`entry/oh-package.json5 缺失 file: 依赖: [${d.onlyA.join(', ')}]`);
console.log(` ${color('✘', 'red')} entry/oh-package.json5: 缺失 [${d.onlyA.join(', ')}]`);
} else if (d.onlyB.length) {
warnings.push(`entry/oh-package.json5 多了 file: 依赖: [${d.onlyB.join(', ')}] (可能是新的子包还未更新白名单)`);
console.log(` ${color('⚠', 'yellow')} entry/oh-package.json5: 多了 [${d.onlyB.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} entry/oh-package.json5: ${ohPkgDeps.length} 个 file: 依赖与白名单一致`);
}
} catch (e) {
errors.push(`entry/oh-package.json5 解析失败: ${e.message}`);
}
}
// === 检查 3: ascf_build 实际目录 ===
const ascfBuildRoot = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_build');
const ascfBuildDirs = listDirs(ascfBuildRoot);
const ascfBuildD = diff(canonical, ascfBuildDirs);
if (ascfBuildD.onlyA.length) {
errors.push(`ascf_build/ 目录缺失子包: [${ascfBuildD.onlyA.join(', ')}]`);
console.log(` ${color('✘', 'red')} ascf_build/: 缺失 [${ascfBuildD.onlyA.join(', ')}]`);
} else if (ascfBuildD.onlyB.length) {
warnings.push(`ascf_build/ 多了子包: [${ascfBuildD.onlyB.join(', ')}] (可能是历史遗留)`);
console.log(` ${color('⚠', 'yellow')} ascf_build/: 多了 [${ascfBuildD.onlyB.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} ascf_build/: ${ascfBuildDirs.length} 个子包目录与白名单一致`);
}
// === 检查 4: 每个子包的关键文件是否齐全 ===
console.log(color('--- 子包关键文件检查 ---', 'cyan'));
for (const pkg of canonical) {
const pkgRoot = path.join(ascfBuildRoot, pkg);
const requiredFiles = [
'oh-package.json5',
'build-profile.json5',
'hvigorfile.ts',
'obfuscation-rules.txt',
'src/main/module.json5',
];
const missing = requiredFiles.filter(f => !exists(path.join(pkgRoot, f)));
if (missing.length) {
errors.push(`${pkg} 缺失文件: [${missing.join(', ')}]`);
console.log(` ${color('✘', 'red')} ${pkg}: 缺失 [${missing.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} ${pkg}: 关键文件齐备`);
}
}
// === 总结 ===
console.log();
if (warnings.length) {
console.log(color(`警告 (${warnings.length}):`, 'yellow'));
for (const w of warnings) console.log(' - ' + w);
}
if (errors.length) {
console.log();
console.log(color(`错误 (${errors.length}):`, 'red'));
for (const e of errors) console.log(' - ' + e);
console.log();
console.log(color('✘ 子包配置不一致, 鸿蒙构建可能失败。', 'red'));
console.log(color(' 修复步骤:', 'gray'));
console.log(color(' 1. 修改 entry/har-deps-required.json5 的 requiredHarDeps (单一事实源)', 'gray'));
console.log(color(' 2. 在 ascf_build/<pkg>/ 创建新子包模板 (含 oh-package.json5 / build-profile.json5 / ...) ', 'gray'));
console.log(color(' 3. 在 mp-harmony/build-profile.json5 的 modules 数组加新模块', 'gray'));
console.log(color(' 4. 在 entry/oh-package.json5 的 dependencies 加 "file:../ascf/ascf_build/<pkg>"', 'gray'));
console.log(color(' 5. 在 DevEco Sync 项目 (让 ohpm 处理 file: 依赖)', 'gray'));
console.log(color(' 6. 跑 node scripts/check-subpackage-sync.js --strict 验证', 'gray'));
process.exit(strict ? 1 : 0);
}
console.log(color(`✔ 5 个子包配置完全一致 (${canonical.length} 个包)`, 'green'));
process.exit(0);
}
try {
main();
} catch (e) {
console.error(color(`脚本异常: ${e.stack || e.message}`, 'red'));
process.exit(1);
}

View File

@@ -1,58 +0,0 @@
// 检查 20 个硬编码导航标题的页面,其语言包是否都有 title key
const paths = [
['pages_goods/category','商品分类'],
['pages_goods/cart','购物车'],
['pages_goods/detail','商品详情'],
['pages_goods/list','商品列表'],
['pages_promotion/merch/merchcategory','入驻商户'],
['pages_promotion/merch/detail','商户首页'],
['pages_promotion/fenxiao/ranking_list','排行榜'],
['pages_promotion/fenxiao/child_fenxiao','分销商'],
['pages_tool/ai-chat/index','AI智能客服'],
['pages_tool/contact/contact','电子名片'],
['pages_tool/member/info','个人资料'],
['pages_tool/order/activist','退款'],
['pages_tool/login/login','登录'],
['pages_tool/weapp/order_shipping','小程序发货'],
['pages_tool/seal/structure','结构选型'],
['pages_tool/seal/medium/search','介质材料搜索'],
['pages_tool/seal/medium/detail','搜索结果'],
['pages_tool/cases/index','案例展示'],
['pages_tool/files/list','查看文件'],
['pages_tool/file-preview/file-preview','文件预览'],
];
function toLang(p) {
const parts = p.split('/');
if (parts[0] === 'pages_tool') return parts.slice(1).join('/');
if (parts[0] === 'pages_goods') return 'goods/' + parts.slice(1).join('/');
if (parts[0] === 'pages_member') return 'member/' + parts.slice(1).join('/');
if (parts[0] === 'pages_order') return 'order/' + parts.slice(1).join('/');
if (parts[0] === 'pages_promotion') {
if (['point','fenxiao','merch'].includes(parts[1])) return parts.slice(1).join('/');
return parts.slice(1).join('/');
}
return p;
}
const fs = require('fs');
const path = require('path');
for (const [p, hardcoded] of paths) {
const lp = toLang(p);
for (const loc of ['zh-cn','en-us']) {
const fp = path.join('lang', loc, lp + '.js');
if (!fs.existsSync(fp)) {
console.log(`MISSING ${loc}/${lp}`);
continue;
}
try {
const m = require(path.resolve(fp)).lang;
const title = m && m.title;
const status = title === undefined ? 'NO title' : (title === hardcoded ? 'ZH-only?' : 'OK');
console.log(`${loc}/${lp}.title = ${JSON.stringify(title)} [${status}]`);
} catch (e) {
console.log(`ERR ${loc}/${lp}: ${e.message}`);
}
}
}

View File

@@ -1,429 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* scripts/fix-subpackage-module-names.js
*
* 修正 ascf_build/<pkg>/ 子包 HAR 元数据中的 name 字段,确保鸿蒙工具链
* (ohpm install / hvigor schema / 运行时依赖解析) 一致通过。
*
* 涉及两个文件:
* 1. ascf_build/<pkg>/oh-package.json5 — top-level `name`
* ohpm install 会读取这个字段,空字符串会报:
* "00604007 Field IS Empty Error: The name of package .../pages_tool cannot be empty"
*
* 2. ascf_build/<pkg>/src/main/module.json5 — `module.name`
* hvigor build 时 schema 校验要求匹配 ^[a-zA-Z][0-9a-zA-Z_.]*$,
* 空字符串会报:
* "Schema validate failed: must match pattern ^[a-zA-Z][0-9a-zA-Z_.]*$"
*
* 修复策略:
* 遍历 SUBPACKAGE_NAMES 数组里的包, 用包目录名覆盖两处的 name,
* 保留其他字段 (version / description / dependencies / deviceTypes 等)。
* 如果文件不存在, 按合理默认值新建一份。
*
* 使用方式:
* # 命令 1: 直接跑这个脚本
* node scripts/fix-subpackage-module-names.js
*
* # 命令 2: 作为模块 require
* node -e "require('./scripts/fix-subpackage-module-names').fixSubpackageModuleNames()"
*
* # 命令 3: 由 vue.config.js 顶层 IIFE 自动调用
* # (HBuilderX / vue-cli-service / node -e 触发时, 自动执行)
*
* # CI 兜底校验:
* node stub-app-projects/mp-harmony/entry/scripts/check-har-deps.js --strict
*/
'use strict';
const fs = require('fs');
const path = require('path');
const SUBPACKAGE_NAMES = [
'components_diy',
'pages_goods',
'pages_order',
'pages_promotion',
'pages_tool',
];
/**
* 修正 ascf_build/<pkg>/ 子包的 name 字段 (oh-package.json5 + module.json5)
*
* @param {object} [opts]
* @param {string} [opts.ascfBuildRoot] - ascf_build 根目录, 默认基于本脚本位置推算
* @param {boolean} [opts.silent=false] - true 时不打日志
* @returns {{fixedCount: number, results: Array<{pkg: string, file: string, status: 'created'|'fixed'|'unchanged'|'error', message?: string}>}}
*/
function fixSubpackageModuleNames({ ascfBuildRoot, silent = false } = {}) {
const root = ascfBuildRoot
|| path.resolve(__dirname, '..', 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_build');
const log = silent ? () => {} : console.log;
log(`\n🔧 [fix-subpackage-module-names] 修正 ascf_build 子包 name (oh-package.json5 + module.json5)...`);
const results = [];
for (const pkg of SUBPACKAGE_NAMES) {
results.push(...fixOhPackageJson5({ pkg, root, silent: true, log }));
results.push(...fixModuleJson5({ pkg, root, silent: true, log }));
}
const fixedCount = results.filter(r => r.status === 'fixed' || r.status === 'created').length;
log(` 📊 修正完成: ${fixedCount} 个文件被更新\n`);
return { fixedCount, results };
}
/**
* 修正 ascf_build/<pkg>/oh-package.json5 的 top-level `name` 字段
*
* 默认内容参考项目内已有 HAR (components_diy / pages_goods 等):
* {
* "name": "<pkg>",
* "version": "1.0.0",
* "description": "Please describe the basic information.",
* "main": "Index.ets",
* "author": "",
* "license": "Apache-2.0",
* "packageType": "InterfaceHar",
* "dependencies": {}
* }
*/
function fixOhPackageJson5({ pkg, root, silent, log }) {
const ohPkgJson5Path = path.join(root, pkg, 'oh-package.json5');
const defaultDoc = {
name: pkg,
version: '1.0.0',
description: 'Please describe the basic information.',
main: 'Index.ets',
author: '',
license: 'Apache-2.0',
packageType: 'InterfaceHar',
dependencies: {},
};
let current = null;
if (fs.existsSync(ohPkgJson5Path)) {
try {
// oh-package.json5 是 JSON5, 可能含 // 注释。Node 内置 JSON.parse 不能处理,
// 用 stripJson5Comments + JSON.parse 处理最常见情况。
const raw = fs.readFileSync(ohPkgJson5Path, 'utf8');
const cleaned = stripJson5Comments(raw);
current = JSON.parse(cleaned);
} catch (e) {
if (!silent) log(` ⚠️ ${pkg}/oh-package.json5 解析失败, 使用默认: ${e.message}`);
current = null;
}
}
const relPath = `${pkg}/oh-package.json5`;
if (!current || typeof current !== 'object') {
try {
fs.mkdirSync(path.dirname(ohPkgJson5Path), { recursive: true });
fs.writeFileSync(ohPkgJson5Path, JSON.stringify(defaultDoc, null, 2) + '\n', 'utf8');
if (!silent) log(` 🆕 ${relPath}: 已创建 (name="${pkg}")`);
return [{ pkg, file: 'oh-package.json5', status: 'created' }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 创建失败: ${e.message}`);
return [{ pkg, file: 'oh-package.json5', status: 'error', message: e.message }];
}
}
const oldName = current.name;
if (oldName !== pkg) {
current.name = pkg;
try {
fs.writeFileSync(ohPkgJson5Path, JSON.stringify(current, null, 2) + '\n', 'utf8');
if (!silent) log(` ✅ ${relPath}: name "${oldName || '(空)'}" → "${pkg}"`);
return [{ pkg, file: 'oh-package.json5', status: 'fixed', oldName }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 写入失败: ${e.message}`);
return [{ pkg, file: 'oh-package.json5', status: 'error', message: e.message }];
}
}
if (!silent) log(` ✔️ ${relPath}: name = "${pkg}" (无须修正)`);
return [{ pkg, file: 'oh-package.json5', status: 'unchanged' }];
}
/**
* 修正 ascf_build/<pkg>/src/main/module.json5 的 `module.name` 字段
*/
function fixModuleJson5({ pkg, root, silent, log }) {
const moduleJson5Path = path.join(root, pkg, 'src', 'main', 'module.json5');
const defaultDoc = {
module: {
name: pkg,
type: 'shared',
description: 'subpackage',
deviceTypes: ['phone', 'tablet'],
deliveryWithInstall: true,
installationFree: true,
},
};
let current = null;
if (fs.existsSync(moduleJson5Path)) {
try {
const raw = fs.readFileSync(moduleJson5Path, 'utf8');
const cleaned = stripJson5Comments(raw);
current = JSON.parse(cleaned);
} catch (e) {
if (!silent) log(` ⚠️ ${pkg}/src/main/module.json5 解析失败, 使用默认: ${e.message}`);
current = null;
}
}
const relPath = `${pkg}/src/main/module.json5`;
if (!current || !current.module || typeof current.module !== 'object') {
try {
fs.mkdirSync(path.dirname(moduleJson5Path), { recursive: true });
fs.writeFileSync(moduleJson5Path, JSON.stringify(defaultDoc, null, 2) + '\n', 'utf8');
if (!silent) log(` 🆕 ${relPath}: 已创建 (name="${pkg}")`);
return [{ pkg, file: 'module.json5', status: 'created' }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 创建失败: ${e.message}`);
return [{ pkg, file: 'module.json5', status: 'error', message: e.message }];
}
}
const oldName = current.module.name;
if (oldName !== pkg) {
current.module.name = pkg;
try {
fs.writeFileSync(moduleJson5Path, JSON.stringify(current, null, 2) + '\n', 'utf8');
if (!silent) log(` ✅ ${relPath}: module.name "${oldName || '(空)'}" → "${pkg}"`);
return [{ pkg, file: 'module.json5', status: 'fixed', oldName }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 写入失败: ${e.message}`);
return [{ pkg, file: 'module.json5', status: 'error', message: e.message }];
}
}
if (!silent) log(` ✔️ ${relPath}: module.name = "${pkg}" (无须修正)`);
return [{ pkg, file: 'module.json5', status: 'unchanged' }];
}
/**
* 简单 JSON5 注释剥离
* 处理 // 单行注释 和 /* 块注释 *\/, 跳过字符串字面量内的 //
* 注意: 不处理裸 key、单引号字符串、Unicode 转义等 JSON5 扩展
* (本项目实际遇到的文件都是标准 JSON + 注释, 足够覆盖)
*/
function stripJson5Comments(input) {
let out = '';
let i = 0;
const n = input.length;
while (i < n) {
const c = input[i];
const next = i + 1 < n ? input[i + 1] : '';
if (c === '/' && next === '*') {
i += 2;
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
i += 2;
continue;
}
if (c === '/' && next === '/') {
i += 2;
while (i < n && input[i] !== '\n') i++;
continue;
}
if (c === '"' || c === "'") {
const quote = c;
out += c;
i++;
while (i < n) {
const sc = input[i];
if (sc === '\\') {
out += sc + (input[i + 1] || '');
i += 2;
continue;
}
out += sc;
i++;
if (sc === quote) break;
}
continue;
}
out += c;
i++;
}
return out;
}
// 顶层 IIFE: 直接执行脚本时自动跑 (CLI 行为)
function main() {
try {
fixSubpackageModuleNames();
} catch (e) {
console.error('[fix-subpackage-module-names] 执行失败:', e.message);
process.exit(1);
}
}
/**
* 同步子包配置一致性检查 (被 vue.config.js 顶层 IIFE 调用)
*
* 用途: HBuilderX 触发 require('./vue.config.js') 时, 在修完 name 之后
* 跑一次 4 处一致性检查, 立即告诉用户哪些地方没改。
*
* 检查项 (复用 SUBPACKAGE_NAMES 数组):
* 1. mp-harmony/build-profile.json5 的 modules 数组 (排除 entry)
* 2. entry/oh-package.json5 的 file: 依赖
* 3. ascf_build 实际目录
*
* 与 scripts/check-subpackage-sync.js 的区别:
* - check-subpackage-sync.js: CLI/CI 用, --strict 可 exit 1
* - runSyncCheck: vue.config.js IIFE 用, 默认不抛错, 仅打印
*
* @param {object} [opts]
* @param {boolean} [opts.silent=false] - true 时不打日志
* @param {boolean} [opts.exitOnError=false] - true 时不一致即抛错 (CLI 模式)
* @returns {{ok: boolean, errors: string[], warnings: string[]}}
*/
function runSyncCheck({ silent = false, exitOnError = false } = {}) {
const log = silent ? () => {} : console.log;
const ROOT = path.resolve(__dirname, '..');
// 读取白名单 (从 har-deps-required.json5 拿, 与 check-subpackage-sync.js 保持一致)
// 注: 优先用 json5 npm 包 (项目已装), 失败则回退到内置 SUBPACKAGE_NAMES
const configPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'har-deps-required.json5');
let canonical = SUBPACKAGE_NAMES.slice().sort();
try {
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf8');
let cfg = null;
try {
// 优先: json5 包 (支持裸 key, 注释, 单引号字符串)
cfg = require('json5').parse(raw);
} catch (e1) {
// 回退: 手写 JSON5 解析 (剥注释后用 JSON.parse)
try {
const cleaned = stripJson5Comments(raw);
cfg = JSON.parse(cleaned);
} catch (e2) {
if (!silent) console.warn(` ⚠️ har-deps-required.json5 解析失败 (json5: ${e1.message}; json: ${e2.message}), 用内置 SUBPACKAGE_NAMES`);
}
}
if (cfg && Array.isArray(cfg.requiredHarDeps) && cfg.requiredHarDeps.length) {
canonical = cfg.requiredHarDeps.slice().sort();
}
}
} catch (e) {
if (!silent) console.warn(' ⚠️ 读取 har-deps-required.json5 失败, 用内置 SUBPACKAGE_NAMES:', e.message);
}
const canonicalSet = new Set(canonical);
const errors = [];
const warnings = [];
// 工具: 列出某目录下的子目录
function listSubdirs(p) {
try {
return fs.readdirSync(p, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [];
}
}
function diff(b) {
const bSet = new Set(b);
return {
onlyA: [...canonicalSet].filter(x => !bSet.has(x)),
onlyB: [...bSet].filter(x => !canonicalSet.has(x)),
};
}
// === 检查 1: build-profile.json5 ===
const buildProfilePath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'build-profile.json5');
let buildProfileModules = [];
if (!fs.existsSync(buildProfilePath)) {
warnings.push(`build-profile.json5 不存在: ${buildProfilePath}`);
} else {
try {
const raw = fs.readFileSync(buildProfilePath, 'utf8').replace(/\/\/.*$/gm, '');
const bp = JSON.parse(raw);
buildProfileModules = (bp.modules || []).map(m => m.name).filter(n => n && n !== 'entry');
} catch (e) {
errors.push(`build-profile.json5 解析失败: ${e.message}`);
}
}
const d1 = diff(buildProfileModules);
if (d1.onlyA.length) errors.push(`build-profile.json5 缺失子包: [${d1.onlyA.join(', ')}]`);
if (d1.onlyB.length) errors.push(`build-profile.json5 多了子包: [${d1.onlyB.join(', ')}]`);
// === 检查 2: entry/oh-package.json5 ===
const ohPkgPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'oh-package.json5');
let ohPkgDeps = [];
if (!fs.existsSync(ohPkgPath)) {
warnings.push(`entry/oh-package.json5 不存在: ${ohPkgPath}`);
} else {
try {
const raw = fs.readFileSync(ohPkgPath, 'utf8').replace(/\/\/.*$/gm, '');
const op = JSON.parse(raw);
const deps = op.dependencies || {};
ohPkgDeps = Object.keys(deps).filter(k => deps[k] && deps[k].startsWith('file:'));
} catch (e) {
errors.push(`entry/oh-package.json5 解析失败: ${e.message}`);
}
}
const d2 = diff(ohPkgDeps);
if (d2.onlyA.length) errors.push(`entry/oh-package.json5 缺失 file: 依赖: [${d2.onlyA.join(', ')}]`);
if (d2.onlyB.length) warnings.push(`entry/oh-package.json5 多了 file: 依赖: [${d2.onlyB.join(', ')}] (可能是新子包未更新白名单)`);
// === 检查 3: ascf_build 实际目录 ===
const ascfBuildRoot = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_build');
const ascfBuildDirs = listSubdirs(ascfBuildRoot);
const d3 = diff(ascfBuildDirs);
if (d3.onlyA.length) errors.push(`ascf_build/ 目录缺失子包: [${d3.onlyA.join(', ')}]`);
if (d3.onlyB.length) warnings.push(`ascf_build/ 多了子包: [${d3.onlyB.join(', ')}] (可能是历史遗留)`);
// === 输出 ===
if (silent) {
// 静默模式仅返回结果
return { ok: errors.length === 0, errors, warnings };
}
const c = (s, color) => {
if (!process.stdout.isTTY) return s;
const codes = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', gray: '\x1b[90m', reset: '\x1b[0m' };
return codes[color] + s + codes.reset;
};
if (errors.length === 0 && warnings.length === 0) {
log(c(`\n🔍 [sync check] ✓ ${canonical.length} 个子包在 3 处配置完全一致 (build-profile / oh-package / ascf_build)`, 'green'));
} else {
log(c(`\n🔍 [sync check] ${errors.length === 0 ? '⚠️' : '✘'} 4 处子包配置不一致:`, errors.length === 0 ? 'yellow' : 'red'));
for (const w of warnings) log(c(` ⚠️ ${w}`, 'yellow'));
for (const e of errors) log(c(` ✘ ${e}`, 'red'));
if (errors.length > 0) {
log(c(`\n 修复: 同步更新以下 4 个文件 (whitelist 是 har-deps-required.json5):`, 'gray'));
log(c(` 1. entry/har-deps-required.json5 (whitelist, 单一事实源)`, 'gray'));
log(c(` 2. mp-harmony/build-profile.json5 modules 数组`, 'gray'));
log(c(` 3. entry/oh-package.json5 dependencies 加 "file:../ascf/ascf_build/<pkg>"`, 'gray'));
log(c(` 4. ascf_build/<pkg>/ 实际目录`, 'gray'));
log(c(` DevEco Build 仍会因 ohpm 校验而失败, 修完再发`, 'gray'));
}
}
log('');
if (exitOnError && errors.length > 0) {
throw new Error(`[sync check] ${errors.length} 个不一致`);
}
return { ok: errors.length === 0, errors, warnings };
}
// 判断是否作为 CLI 直接执行 (vs 作为模块被 require)
if (require.main === module) {
main();
}
module.exports = {
fixSubpackageModuleNames,
runSyncCheck,
SUBPACKAGE_NAMES,
};

View File

@@ -1,264 +0,0 @@
{
"uni_modules/uni-calendar/components/uni-calendar/uni-calendar.vue": [
{
"kind": "template",
"tag": "text",
"text": "取消",
"line": 6
},
{
"kind": "template",
"tag": "text",
"text": "确定",
"line": 7
},
{
"kind": "template",
"tag": "text",
"text": "{{ (nowDate.year || '') + '年' + (nowDate.month || '') + '月' }}",
"line": 15
},
{
"kind": "template",
"tag": "text",
"text": "日",
"line": 27
},
{
"kind": "template",
"tag": "text",
"text": "一",
"line": 28
},
{
"kind": "template",
"tag": "text",
"text": "二",
"line": 29
},
{
"kind": "template",
"tag": "text",
"text": "三",
"line": 30
},
{
"kind": "template",
"tag": "text",
"text": "四",
"line": 31
},
{
"kind": "template",
"tag": "text",
"text": "五",
"line": 32
},
{
"kind": "template",
"tag": "text",
"text": "六",
"line": 33
}
],
"pages_promotion/fenxiao/withdraw_list.vue": [
{
"kind": "js-text",
"match": "已转账",
"line": 45
},
{
"kind": "js-text",
"match": "待审核",
"line": 49
},
{
"kind": "js-text",
"match": "已审核",
"line": 53
},
{
"kind": "js-text",
"match": "已拒绝",
"line": 57
}
],
"uni_modules/mp-html/components/mp-html/node/node.vue": [
{
"kind": "js-text",
"match": "uni.showActionSheet({\n itemList: ['保存图片'],\n success: () => {\n const save = path => {\n uni.saveImageToPhotosAlbum({\n ",
"line": 289
},
{
"kind": "js-text",
"match": "uni.showToast({\n title: '链接已复制'\n })",
"line": 368
},
{
"kind": "js-text",
"match": "保存成功",
"line": 297
},
{
"kind": "js-text",
"match": "链接已复制",
"line": 369
}
],
"components-diy/diy-form.vue": [
{
"kind": "js-text",
"match": "uni.showToast({\n\t\t\t\t\t\t\ttitle: '提交成功',\n\t\t\t\t\t\t\ticon: 'success'\n\t\t\t\t\t\t})",
"line": 305
},
{
"kind": "js-text",
"match": "uni.showToast({\n\t\t\t\t\t\t\ttitle: res.message || '提交失败',\n\t\t\t\t\t\t\ticon: 'none'\n\t\t\t\t\t\t})",
"line": 314
},
{
"kind": "js-text",
"match": "提交成功",
"line": 306
}
],
"uni_modules/uni-count-down/components/uni-count-down/uni-count-down.vue": [
{
"kind": "template",
"tag": "view",
"text": "{{ showColon ? ':' : '时' }}",
"line": 19
},
{
"kind": "template",
"tag": "view",
"text": "{{ showColon ? ':' : '分' }}",
"line": 27
},
{
"kind": "template",
"tag": "view",
"text": "秒",
"line": 35
}
],
"components/ns-goods-recommend/ns-goods-recommend.vue": [
{
"kind": "js-text",
"match": "猜你喜欢",
"line": 13
},
{
"kind": "js-text",
"match": "购买",
"line": 20
}
],
"components-diy/diy-channel-list.vue": [
{
"kind": "js-text",
"match": "uni.showToast({\n\t\t\t\t\t\t\t\ttitle: '视频号仅支持在微信小程序环境中播放',\n\t\t\t\t\t\t\t\ticon: 'none',\n\t\t\t\t\t\t\t\tduration: 2000\n\t\t\t\t\t\t\t})",
"line": 207
},
{
"kind": "js-text",
"match": "视频号仅支持在微信小程序环境中播放",
"line": 208
}
],
"pages/index/public/js/index.js": [
{
"kind": "js-text",
"match": "uni.showModal({\n\t\t\t\t\ttitle: '版本信息',\n\t\t\t\t\tcontent: `提交 ID: ${v.commitId}\\n构建时间: ${v.buildTime}\\n${Config.baseUrl}`,\n\t\t\t\t\tshowCancel: false\n\t\t\t\t})",
"line": 160
},
{
"kind": "js-text",
"match": "版本信息",
"line": 161
}
],
"uni_modules/uni-datetime-picker/components/uni-datetime-picker/calendar.vue": [
{
"kind": "template",
"tag": "text",
"text": "{{ (nowDate.year||'') + ' 年 ' + ( nowDate.month||'') +' 月'}}",
"line": 13
},
{
"kind": "template",
"tag": "view",
"text": "确认",
"line": 87
}
],
"uni_modules/uni-number-box/components/uni-number-box/uni-number-box.vue": [
{
"kind": "js-text",
"match": "商品库存不足",
"line": 131
},
{
"kind": "js-text",
"match": "商品最少购买",
"line": 135
}
],
"common/js/mp-share-default.js": [
{
"kind": "js-text",
"match": "小程序分享",
"line": 85
}
],
"common/js/splash-state.js": [
{
"kind": "js-text",
"match": "uni.showLoading({ title: opts.title || '加载中...', mask: false })",
"line": 111
}
],
"pages/index/index.vue": [
{
"kind": "template",
"tag": "text",
"text": "{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}",
"line": 8
}
],
"pages_goods/public/js/list.js": [
{
"kind": "js-text",
"match": "购买",
"line": 40
}
],
"pages_tool/help/list.vue": [
{
"kind": "js-text",
"match": "帮助中心",
"line": 82
}
],
"pages_tool/notice/list.vue": [
{
"kind": "js-text",
"match": "公告",
"line": 80
}
],
"pages_tool/pay/index.vue": [
{
"kind": "js-text",
"match": "未获取到支付信息!",
"line": 54
}
],
"uni_modules/uni-datetime-picker/components/uni-datetime-picker/time-picker.vue": [
{
"kind": "js-text",
"match": "日期和时间",
"line": 112
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,347 +35,6 @@ async function syncSiteToStub(mode = 'production') {
}
}
/**
* 同步 manifest.json 的 mp-harmony.privacy 配置到 stub 项目的 app.json
*
* 背景:
* 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl 等字段,
* 实际生效的是编译产物里 ascf/ascf_src/app.json (ascf_build 拷贝自此)。
* 但当前 build 流程不会自动从 manifest.json 注入 privacy 字段,
* 如果只改 manifest.json 而忘记改 app.json, 审核时拿到的是旧 URL。
*
* 同步策略:
* 1. 读取 manifest.json 的 mp-harmony.privacy, 期望里面含占位符 YOUR_DOMAIN / YOUR_UNIACID
* 2. 从 site.js 解析 baseUrl (域名) + uniacid
* 3. 把占位符替换成 site.js 里的真实值, 写入 stub app.json
* 4. 若 manifest.json 中无 mp-harmony.privacy → 保留 app.json 现有配置 (向后兼容)
* 5. 若 site.js 缺 baseUrl 或 uniacid → 跳过同步, 警告用户
*
* 设计取舍:
* - manifest.json 用占位符占位 (避免每个 uniacid 切换都要改一次 manifest)
* - site.js 永远是真实值来源 (这是项目里已经存在的部署配置)
* - 同步在构建时执行, 一份配置适配所有 uniacid
*
* 依赖: syncSiteToStub 必须先于本函数执行 (在 main() 中已保证)
*/
/**
* 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释)
* 原因: manifest.json 顶部有 /* ... *\/ 注释, 用正则 `/\*[\s\S]*?\*\//g` 简单剥离时,
* 会跨过字符串字面量, 把"https://..."注释中的字符拼到真实 URL 后面,
* 引入换行符导致 JSON.parse 失败 (Bad control character)。
* 必须逐字符扫描, 跳过字符串内的 /*、//。
*/
function stripJsonComments(input) {
let out = '';
let i = 0;
const n = input.length;
while (i < n) {
const c = input[i];
const next = i + 1 < n ? input[i + 1] : '';
if (c === '/' && next === '*') {
i += 2;
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
i += 2;
continue;
}
if (c === '/' && next === '/') {
i += 2;
while (i < n && input[i] !== '\n') i++;
continue;
}
if (c === '"') {
out += c;
i++;
while (i < n) {
const sc = input[i];
if (sc === '\\') {
out += sc + (input[i + 1] || '');
i += 2;
continue;
}
out += sc;
i++;
if (sc === '"') break;
}
continue;
}
out += c;
i++;
}
return out;
}
/**
* 从 site.js 提取 baseUrl + uniacid
*
* site.js 形如:
* module.exports = {
* baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名
* uniacid: 2482,//后台对应uniacid
* };
*
* 不直接 require, 因为:
* 1. site.js 在前端构建产物中用, 可能依赖一些运行时变量 (uni/Config/...)
* 2. 同步逻辑要在 vue.config.js 触发, 不希望引入额外依赖
* 3. regex 提取足够安全, 避免执行任意 JS
*
* @returns {{baseUrl: string, uniacid: string}|null} 解析失败返回 null
*/
function parseSiteConfig(siteJsSource) {
// baseUrl: 单/双引号字符串, 可带尾斜杠
const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/)
// uniacid: 数字或字符串
const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/)
if (!baseUrlMatch || !uniacidMatch) {
return null
}
return {
baseUrl: baseUrlMatch[1],
uniacid: uniacidMatch[1],
}
}
/**
* 把 baseUrl 拆成 protocol + host (无尾斜杠)
* 因为 site.js 的 baseUrl 通常带尾斜杠, 占位符 YOUR_DOMAIN
* 在 URL 中间出现 (例: https://YOUR_DOMAIN/api/xxx), 直接拼接
* 会产生双斜杠. 这里规范化为"协议://域名(无尾斜杠)".
*
* @param {string} baseUrl 例: "https://xcx.aigc-quickapp.com/"
* @returns {string} 例: "https://xcx.aigc-quickapp.com"
*/
function normalizeBaseUrlForPlaceholder(baseUrl) {
// 去掉尾斜杠
let url = baseUrl.replace(/\/+$/, '')
// 若没有协议, 加 https:// (避免出现 //xcx... 这种相对协议)
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url
}
return url
}
/**
* 递归替换隐私配置对象中的占位符
* @param {*} value 任意 JSON 值
* @param {object} siteConfig {baseUrl, uniacid}
* @returns {*} 替换后的新值 (不修改原对象)
*/
function replacePlaceholders(value, siteConfig) {
if (typeof value === 'string') {
return value
.replace(/YOUR_DOMAIN/g, siteConfig.baseUrl)
.replace(/YOUR_UNIACID/g, siteConfig.uniacid)
}
if (Array.isArray(value)) {
return value.map(v => replacePlaceholders(v, siteConfig))
}
if (value !== null && typeof value === 'object') {
const out = {}
for (const k of Object.keys(value)) {
out[k] = replacePlaceholders(value[k], siteConfig)
}
return out
}
return value
}
/**
* 自定义错误: 验证 app.json 写后内容失败时抛出
* 上层 try/catch 会捕获并 process.exitCode = 1, 让 vue.config.js / npm run build 失败
*/
class AppJsonVerificationError extends Error {
constructor(messages) {
super('app.json 写后验证失败:\n - ' + messages.join('\n - '))
this.name = 'AppJsonVerificationError'
this.messages = messages
}
}
/**
* 验证写完的 app.json 内容正确
*
* 关键字段:
* - 顶级 privacy 块存在
* - privacyContractUrl / serviceContractUrl 都以 https:// + baseUrl 开头
* - URL 不含占位符 YOUR_DOMAIN / YOUR_UNIACID
* - URL 不含双协议头 (https://https://)
* - URL 不含双斜杠 (https://x.com//api)
* - URL 的 uniacid= 参数值 == siteConfig.uniacid
* - JSON 仍能正确 parse (没有序列化失败)
*
* 失败行为: 抛 AppJsonVerificationError, 包含所有不通过项
*
* @param {string} appJsonPath
* @param {{baseUrl: string, uniacid: string}} siteConfig
*/
async function verifyWrittenAppJson(appJsonPath, siteConfig) {
const errors = []
// 1. 重新读取 + parse
let written
try {
const raw = await fsp.readFile(appJsonPath, 'utf8')
written = JSON.parse(raw)
} catch (e) {
throw new AppJsonVerificationError([`文件读取/解析失败: ${e.message}`])
}
// 2. 顶级 privacy 块
const privacy = written.privacy
if (!privacy || typeof privacy !== 'object') {
throw new AppJsonVerificationError(['privacy 块缺失或不是对象'])
}
// 3. URL 字段检查
const urlFields = ['privacyContractUrl', 'serviceContractUrl']
for (const field of urlFields) {
const url = privacy[field]
if (typeof url !== 'string') {
errors.push(`${field} 缺失或不是字符串 (实际: ${typeof url})`)
continue
}
// 不含占位符
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(url)) {
errors.push(`${field} 仍含占位符: ${url}`)
}
// 不含双协议头
if (/^https:\/\/https:|^http:\/\/http:/i.test(url)) {
errors.push(`${field} 含双协议头: ${url}`)
}
// 不含双斜杠 (协议头之后, 路径开始之前)
// 关键: 截到第一个 ? 或 # 之前, 检查是否含 // (避免误报 query 里的 //)
const afterProtocol = url.replace(/^https?:\/\//i, '')
const queryStart = afterProtocol.search(/[?#]/)
const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart)
if (beforeQuery.includes('//')) {
errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`)
}
// 必须以 https:// + siteConfig.baseUrl 开头
const expectedPrefix = siteConfig.baseUrl + '/'
if (!url.startsWith(expectedPrefix)) {
errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`)
}
// uniacid= 参数值 == siteConfig.uniacid
const uniacidMatch = url.match(/[?&]uniacid=([^&]+)/)
if (!uniacidMatch) {
errors.push(`${field} 缺少 uniacid= 参数: ${url}`)
} else if (uniacidMatch[1] !== siteConfig.uniacid) {
errors.push(`${field} 的 uniacid=${uniacidMatch[1]} 不等于 site.js 的 uniacid=${siteConfig.uniacid}`)
}
}
// 4. contractName 字段未被破坏 (manifest 里的 zh/en name 应该原样保留)
const nameFields = ['privacyContractName', 'serviceContractName']
for (const field of nameFields) {
const name = privacy[field]
if (!name || typeof name !== 'object' || !name.zh || !name.en) {
errors.push(`${field} 结构损坏 (应有 zh/en): ${JSON.stringify(name)}`)
}
}
if (errors.length > 0) {
throw new AppJsonVerificationError(errors)
}
}
/**
* 同步 privacy 到**任意**目标 app.json 路径
*
* @param {string} appJsonPath 目标 app.json 文件绝对路径
* @param {string} [label] 标识 (用于日志, 例 "stub" / "build")
* @returns {Promise<{written: boolean, siteConfig?: object}>}
*
* 应用场景:
* - syncPrivacyToPath('stub-app-projects/.../app.json', 'stub')
* → 写 stub ascf_src/app.json (HBuilderX 之外的源)
* - syncPrivacyToPath('unpackage/dist/build/.mp-harmony/app.json', 'build')
* → 写 HBuilderX 构建产物的 app.json, **必须**在打 ZIP 之前
* → 否则发行 ZIP 里的 app.json 仍是占位符
*/
async function syncPrivacyToPath(appJsonPath, label = '') {
try {
const cwd = path.join(__dirname, '..');
const manifestPath = path.join(cwd, 'manifest.json');
const siteJsPath = path.join(cwd, 'site.js');
if (!await exists(manifestPath)) {
console.warn('[harmony.patch] manifest.json 不存在, 跳过 privacy 同步');
return { written: false };
}
if (!await exists(appJsonPath)) {
console.warn(`[harmony.patch] app.json 不存在 (${label || appJsonPath}), 跳过 privacy 同步`);
return { written: false };
}
// 1. 读 manifest.json, 提取 mp-harmony.privacy 配置 (含占位符)
const manifestRaw = await fsp.readFile(manifestPath, 'utf8');
const manifestClean = stripJsonComments(manifestRaw);
const manifest = JSON.parse(manifestClean);
const privacyConfigTemplate = manifest && manifest['mp-harmony'] && manifest['mp-harmony'].privacy;
if (!privacyConfigTemplate) {
return { written: false };
}
// 2. 读 site.js, 提取真实 baseUrl + uniacid
if (!await exists(siteJsPath)) {
console.warn('[harmony.patch] site.js 不存在, 跳过 privacy 同步');
return { written: false };
}
const siteJsSource = await fsp.readFile(siteJsPath, 'utf8');
const siteConfigRaw = parseSiteConfig(siteJsSource);
if (!siteConfigRaw) {
console.warn('[harmony.patch] ⚠️ site.js 解析失败 (找不到 baseUrl / uniacid), 跳过 privacy 同步');
return { written: false };
}
const siteConfig = {
baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl),
uniacid: siteConfigRaw.uniacid,
};
// 3. 占位符替换
const privacyConfig = replacePlaceholders(privacyConfigTemplate, siteConfig)
// 4. 校验替换结果
const finalStr = JSON.stringify(privacyConfig)
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(finalStr)) {
console.warn('[harmony.patch] ⚠️ 替换后仍含占位符, 跳过写入. 请检查 site.js / manifest.json 配置')
return { written: false };
}
// 5. 写目标 app.json
const appJsonRaw = await fsp.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(appJsonRaw);
appJson.privacy = privacyConfig;
await fsp.writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8');
const labelSuffix = label ? ` (${label})` : '';
console.log(`[harmony.patch] privacy 配置已同步到 app.json${labelSuffix} (baseUrl=${siteConfig.baseUrl}, uniacid=${siteConfig.uniacid})`);
// 6. 写后验证
await verifyWrittenAppJson(appJsonPath, siteConfig)
return { written: true, siteConfig }
} catch (err) {
console.error(`[harmony.patch] 同步 privacy 配置失败 (${label || appJsonPath}):`, err);
process.exitCode = 1;
return { written: false }
}
}
/**
* 同步 privacy 到 stub ascf_src/app.json (兼容旧调用)
*/
async function syncPrivacyToStub() {
const cwd = path.join(__dirname, '..');
const stubAppJsonPath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'app.json');
return syncPrivacyToPath(stubAppJsonPath, 'stub');
}
async function copyAssets(mode = 'production') {
try {
const cwd = path.join(__dirname, '..');
@@ -437,8 +96,7 @@ async function main() {
const argv = process.argv.slice(2);
const options = {
noZip: argv.includes('--no-zip'),
mode: 'production',
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
mode: 'production'
};
const modeIndex = argv.indexOf('--mode');
@@ -446,46 +104,18 @@ async function main() {
options.mode = argv[modeIndex + 1];
}
// 子命令: --sync-privacy-only
// vue.config.js _copyMpHarmonyBuild() 后, 构建产物的 app.json
// 会覆盖 stub ascf_src/app.json (因为产物里 app.json 含占位符)。
// vue.config.js 此时再 exec 这个子命令, 强制重新同步一次。
if (options.syncPrivacyOnly) {
console.log('[harmony.patch] --sync-privacy-only: 重新同步 privacy (覆盖 _copyMpHarmonyBuild 的拷贝)');
await syncPrivacyToStub();
return;
}
// 同步 site.js 到 stub 项目源码目录,确保编译时能动态导入
await syncSiteToStub(options.mode);
// 同步 manifest.json 的 mp-harmony.privacy 到 stub app.json (防止上架时 URL 不同步)
await syncPrivacyToStub();
await copyAssets(options.mode);
// 关键: 在打 ZIP 之前, 同步一次 **构建产物** 的 app.json
// HBuilderX 构建产物的 app.json 含占位符, 不修正就直接打 ZIP,
// 发行包里的 app.json 还是占位符, 上架会被审核驳回。
// 这步必须在 ZIP 之前, 必须在 _copyMpHarmonyBuild 之后 (vue.config.js 顺序)。
if (!options.noZip && options.mode === 'production') {
const cwd = path.join(__dirname, '..');
const buildAppJsonPath = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony', 'app.json');
if (await exists(buildAppJsonPath)) {
console.log('[harmony.patch] 同步构建产物的 app.json (打 ZIP 前)');
await syncPrivacyToPath(buildAppJsonPath, 'build');
} else {
console.warn(`[harmony.patch] 构建产物的 app.json 不存在: ${buildAppJsonPath}`);
}
}
if (!options.noZip && options.mode === 'production') {
const cwd = path.join(__dirname, '..');
const sourceDir = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony');
const destDir = path.join(cwd, 'unpackage', 'dist', 'build');
const zipFilePath = await createZipWithSystemCommand(sourceDir, destDir, 'mp-harmony');
console.log(`ZIP 文件路径: ${zipFilePath}`);
await openFileDirectory(zipFilePath);
} else {
console.log('跳过创建 ZIP 文件和打开目录');

View File

@@ -1,77 +1,14 @@
{
"bundle_version": "1784767380",
"bundle_version": "1782901586",
"bundle_lang": "zh-cn",
"bundle_leaves": 2659,
"code_calls_total": 2693,
"code_unique_keys": 2097,
"found_in_bundle": 2086,
"bundle_leaves": 2649,
"code_calls_total": 2677,
"code_unique_keys": 2083,
"found_in_bundle": 2082,
"missing_summary": {
"TRULY_MISSING": 12,
"SYNTAX_BUG": 1
},
"missing_classified": {
"TRULY_MISSING": [
{
"key": "common.buyNow",
"file": "components\\ns-goods-recommend\\ns-goods-recommend.vue",
"reason": "not in bundle, not local"
},
{
"key": "wechatChannel.wechatMpOnly",
"file": "components-diy\\diy-channel-list.vue",
"reason": "not in bundle, not local"
},
{
"key": "common.submitSuccess",
"file": "components-diy\\diy-form.vue",
"reason": "not in bundle, not local"
},
{
"key": "submitFail",
"file": "components-diy\\diy-form.vue",
"reason": "not in bundle, not local"
},
{
"key": "diy.networkError",
"file": "pages\\index\\index.vue",
"reason": "not in bundle, not local"
},
{
"key": "versionInfo",
"file": "pages\\index\\public\\js\\index.js",
"reason": "not in bundle, not local"
},
{
"key": "commitId",
"file": "pages\\index\\public\\js\\index.js",
"reason": "not in bundle, not local"
},
{
"key": "buildTime",
"file": "pages\\index\\public\\js\\index.js",
"reason": "not in bundle, not local"
},
{
"key": "diy.networkError",
"file": "pages_goods\\category.vue",
"reason": "not in bundle, not local"
},
{
"key": "goods.detail.formEmptyTip",
"file": "pages_goods\\detail.vue",
"reason": "not in bundle, not local"
},
{
"key": "common.buyNow",
"file": "pages_goods\\public\\js\\list.js",
"reason": "not in bundle, not local"
},
{
"key": "pay.index.payInfoNotFound",
"file": "pages_tool\\pay\\index.vue",
"reason": "not in bundle, not local"
}
],
"SYNTAX_BUG": [
{
"key": "order.list.status.",
@@ -82,8 +19,18 @@
},
"placeholder_issues": [],
"data_lang_issues": {
"components\\ns-payment\\ns-payment.vue": [
"components.nsPayment.alipay"
],
"pages_promotion\\fenxiao\\apply.vue": [
"fenxiao.apply.pleaseEnterName"
],
"pages_promotion\\merch\\public\\js\\list.js": [
"merch.list.buyText"
],
"pages_tool\\ai-chat\\ai-chat-message.vue": [
"ai-chat.index.loadMoreHistory",
"ai-chat.index.defaultNickname"
]
},
"hardcoded_navbar_titles": [
@@ -174,11 +121,6 @@
"common.tabBar.cart",
"common.tabBar.member"
],
"local_only_keys": [
"buildTime",
"commitId",
"submitFail",
"versionInfo"
],
"local_only_keys": [],
"bundle_only_keys_in_unique": []
}

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env python3
# 精筛 v2:剥离 HTML 注释和 JS 注释后,再扫硬编码中文(只关注真实会被渲染的)
import os, re, json
from collections import defaultdict
ROOT = r'D:\projects\shop-projects\frontend'
SKIP_DIR = {'node_modules', '.unpackage', 'unpackage', '.git', 'dist', 'stub-app-projects', 'lang', 'tests', 'docs', 'scripts', 'common/mixin'}
CN = re.compile(r'[\u4e00-\u9fff]')
LANG_RE = re.compile(r'\$lang\(')
TPL_RE = re.compile(r'<template[^>]*>([\s\S]*?)</template>', re.I)
TAG_RE = re.compile(r'<([a-zA-Z][\w-]*)\b[^>]*>([^<>\n]+)</\1>')
HTML_COMMENT_RE = re.compile(r'<!--[\s\S]*?-->')
JS_TRIGGERS = [
re.compile(r'uni\.show(?:Toast|Modal|ActionSheet|Loading)\s*\(\s*\{[\s\S]*?\}\s*\)', re.S),
re.compile(r'wx\.show(?:Toast|Modal|ActionSheet|Loading)\s*\(\s*\{[\s\S]*?\}\s*\)', re.S),
re.compile(r'\btitle\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\bcontent\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\btext\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\bplaceholder\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\balert\s*\(\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\bconfirm\s*\(\s*[\'"]([^\'"]*?)[\'"]'),
]
def strip_all_comments(text, is_vue=False):
text = HTML_COMMENT_RE.sub(lambda m: ' ' * len(m.group()), text)
text = re.sub(r'/\*[\s\S]*?\*/', lambda m: ' ' * len(m.group()), text)
text = re.sub(r'//[^\n]*', lambda m: ' ' * len(m.group()), text)
return text
results = defaultdict(list)
for dirpath, dirnames, filenames in os.walk(ROOT):
if any(s in dirpath.replace('\\', '/') for s in SKIP_DIR):
continue
for fn in filenames:
if not fn.endswith(('.vue', '.js')):
continue
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, ROOT).replace('\\', '/')
try:
content = open(fp, 'r', encoding='utf-8').read()
except UnicodeDecodeError:
continue
stripped = strip_all_comments(content)
if fn.endswith('.vue'):
for m in TPL_RE.finditer(stripped):
tpl = m.group(1)
for tag in TAG_RE.finditer(tpl):
inner = tag.group(2).strip()
if CN.search(inner) and not LANG_RE.search(inner):
line_no = stripped[:m.start(1) + tpl.find(inner)].count('\n') + 1
results[rel].append({'kind': 'template', 'tag': tag.group(1), 'text': inner[:160], 'line': line_no})
for trig in JS_TRIGGERS:
for m in trig.finditer(stripped):
text = (m.group(1) if m.lastindex else m.group(0))
if not CN.search(text):
continue
if LANG_RE.search(text):
continue
line_no = stripped[:m.start()].count('\n') + 1
results[rel].append({'kind': 'js-text', 'match': text[:160], 'line': line_no})
ranked = sorted(results.items(), key=lambda kv: -len(kv[1]))
print(f'total files: {len(ranked)}')
print(f'total hits: {sum(len(v) for v in ranked)}')
out = []
for f, hits in ranked[:60]:
out.append({'file': f, 'hits': len(hits), 'samples': hits[:3]})
print(json.dumps(out, ensure_ascii=False, indent=2))
with open(os.path.join(ROOT, 'scripts', 'hardcoded_cn_real.json'), 'w', encoding='utf-8') as f:
json.dump({k: v for k, v in ranked}, f, ensure_ascii=False, indent=2)
print('saved hardcoded_cn_real.json')

View File

@@ -1,4 +1,4 @@
module.exports = {
baseUrl: "https://5g.aigc-quickapp.com/",//修改域名
uniacid: 248,//后台对应uniacid
baseUrl: "https://xcx2.aigc-quickapp.com/",//修改域名
uniacid: 2482,//后台对应uniacid
};

View File

@@ -1,6 +1,6 @@
{
"app": {
"bundleName": "com.atomicservice.6917610986330791161",
"bundleName": "com.atomicservice.6917600524822452448",
"bundleType": "atomicService",
"vendor": "example",
"versionCode": 1000000,

View File

@@ -1,25 +0,0 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -1,6 +0,0 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -1,18 +0,0 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -1,10 +0,0 @@
{
"name": "components_diy",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -1,12 +0,0 @@
{
"module": {
"name": "components_diy",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

View File

@@ -1,8 +0,0 @@
{
"string": [
{
"name": "shared_desc",
"value": "description"
}
]
}

View File

@@ -1,25 +0,0 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -1,6 +0,0 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -1,18 +0,0 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -1,10 +0,0 @@
{
"name": "pages_goods",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -1,12 +0,0 @@
{
"module": {
"name": "pages_goods",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

View File

@@ -1,8 +0,0 @@
{
"string": [
{
"name": "shared_desc",
"value": "description"
}
]
}

View File

@@ -1,25 +0,0 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -1,6 +0,0 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -1,18 +0,0 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -1,10 +0,0 @@
{
"name": "pages_order",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -1,12 +0,0 @@
{
"module": {
"name": "pages_order",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

View File

@@ -1,8 +0,0 @@
{
"string": [
{
"name": "shared_desc",
"value": "description"
}
]
}

View File

@@ -1,25 +0,0 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -1,6 +0,0 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -1,18 +0,0 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -1,10 +0,0 @@
{
"name": "pages_promotion",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -1,12 +0,0 @@
{
"module": {
"name": "pages_promotion",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

Some files were not shown because too many files have changed in this diff Show More