Compare commits
216 Commits
yuanlaide2
...
temp2797
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b81ecdf98 | |||
| c536fac4d2 | |||
| 475449f04e | |||
| e40b46a888 | |||
| a81386d336 | |||
| b69eeba74b | |||
| 82139e616d | |||
| ed6d9f563d | |||
| 3a8ad7d871 | |||
| ec960a29c4 | |||
| 8312de5d09 | |||
| 5833a244de | |||
| 92fe95d131 | |||
| 07718235c3 | |||
| 183181a15d | |||
| a4f2c81528 | |||
| 30b66ad7b3 | |||
| 077fac40c2 | |||
| 10d45b1c3b | |||
| 60dc054383 | |||
| ef8c7568ac | |||
| 551d6c103d | |||
| 4a92d9bec2 | |||
| ff4340c3a9 | |||
| 4303a93278 | |||
| 12e4493a17 | |||
| c94e214d05 | |||
| afede79047 | |||
| ab727eef1d | |||
| ee050f5df9 | |||
| 855fbb2fe5 | |||
| b706e67e82 | |||
| 557addb76b | |||
| dacc25b281 | |||
| ec4fb3e688 | |||
| 75e40c752b | |||
| 24df6f0dd5 | |||
| a8ab093eae | |||
| 7f4b1d6d13 | |||
| 1420b7435e | |||
| 8b331e53d3 | |||
| 10fd785b4c | |||
| 2c0b8ed076 | |||
| ab8625ebaf | |||
| bbef0ecf55 | |||
| 671c350d95 | |||
| 2d19a653c9 | |||
| 5f60504c75 | |||
| 289e582ba1 | |||
| c26280a4d3 | |||
| 619ca3a236 | |||
| c35d0e2505 | |||
| a6e9176dbf | |||
| f4517be6c3 | |||
| 408c40fbe9 | |||
| b46dc248d5 | |||
| 1fb2b470da | |||
| 81ff205d2c | |||
| 1b3dd41a4b | |||
| 670d3f99fc | |||
| eb2839eeb8 | |||
| b0f7c7a15c | |||
| f1487e60bb | |||
| 74c24d084e | |||
| 5e8a1a48dd | |||
| 66deecdcf8 | |||
| 37d72381c7 | |||
| be40bb19b3 | |||
| 157a7b4e16 | |||
| 9d187bb8a4 | |||
| d4326bc827 | |||
| 24cb3a096d | |||
| 1610f3f75d | |||
| 837bfbfd77 | |||
| e24a6b25f2 | |||
| 318ed4028e | |||
| 1cda06573e | |||
| be1e175471 | |||
| 0598f97efe | |||
| cd91ff9d49 | |||
| e01ba29604 | |||
| ca60948fcd | |||
| beaa330651 | |||
| a891e4abf9 | |||
| de6a76e91b | |||
| eed766366d | |||
| ed944ee192 | |||
| 270f5f4eff | |||
| 0cb5c1a4df | |||
| 4f7f4f5117 | |||
| 51274b70ce | |||
| 9d8324bb44 | |||
| d1c58f2ec8 | |||
| 6f0d6a9f59 | |||
| 8ebd7918fb | |||
| 3dd059a6f6 | |||
| 414a0e4438 | |||
| df9d45fc1d | |||
| 3bbf8c9531 | |||
| f6cc866aa7 | |||
| d5d3c2b7be | |||
| c5134c1eb2 | |||
| 7c51841dba | |||
| a70a29eda1 | |||
| 4bd36db70f | |||
| eab5a97ba5 | |||
| 3d4941865e | |||
| 9da07f1566 | |||
| 8cef7b775b | |||
| 791d0bb623 | |||
| dbc6002248 | |||
| 6cf8261298 | |||
| 9eb42c4a0c | |||
| ed8a316bc0 | |||
| daab99fd88 | |||
| dac27ca43b | |||
| f5f893b445 | |||
| 45c0c2afb1 | |||
| f4a770e246 | |||
| 352c359326 | |||
| 1cc9fec69c | |||
| 2c89235f15 | |||
| 1d8d18922b | |||
| 18dad6c112 | |||
| 3debab7291 | |||
| a2449ddd91 | |||
| ff0d26908c | |||
| c4675deb1e | |||
| c99d808051 | |||
| acbac773db | |||
| b9cb6bf75b | |||
| 14e0fb9969 | |||
| 3d2a982718 | |||
| eefcc9fe57 | |||
| f51b601ecb | |||
| 70a01bfd2b | |||
| 94142268a0 | |||
| 4b0aa0ac40 | |||
| 6ee4de94a2 | |||
| 658396c9a3 | |||
| 0a6abd87ae | |||
| 25fc8311f8 | |||
| d4a249d744 | |||
| f7efbfd4b7 | |||
| e9b96df758 | |||
| 75f70138ed | |||
| c9aa4c75ae | |||
| a1db706072 | |||
| 276120162a | |||
| f523122889 | |||
| cea0e36d5d | |||
| 0169f25ca5 | |||
| 3939d15f2a | |||
| fd4edc177d | |||
| f96fee60d3 | |||
| d57b69d7cb | |||
| d875642d99 | |||
| 0bcb5e7f4a | |||
| 65013a35f0 | |||
| 31daebc83b | |||
| b8894ea2dc | |||
| ff130bbf15 | |||
| 3a88941828 | |||
| b8c813e43b | |||
| 73776841d8 | |||
| cd2705e5b9 | |||
| 02b8966a91 | |||
| 2adb574dd5 | |||
| 5b7351dcd2 | |||
| 63e2cb2ff7 | |||
| d65f27b256 | |||
| ce7cbc75df | |||
| a6091f6b68 | |||
| 9d6c3218fb | |||
| 71ea945743 | |||
| c8d92d8100 | |||
| 5831189e5e | |||
| 109cba6b6c | |||
| f01a1d7efc | |||
| ae5b878822 | |||
| 7d4ecb9731 | |||
| b5e9940934 | |||
| 018616dd0e | |||
| 74e53a9ca6 | |||
| fdc0b54fbd | |||
| 8e31b9e327 | |||
| 460dbcb447 | |||
| 81ecd45de4 | |||
| 059630ba2c | |||
| 1ac0fb2484 | |||
| d8816bbe42 | |||
| b410597d9c | |||
| 1143ef7b72 | |||
| 987e75c65a | |||
| ac8c461546 | |||
| f3032a6004 | |||
| a4d8023e1f | |||
| bb0adb0542 | |||
| a4c218fecc | |||
| 0d820b04f7 | |||
| 70c9c49b94 | |||
| 6d1bdb5d8b | |||
| 73b7b2a8df | |||
| b2063418fb | |||
| 21c342e389 | |||
| cbf91fa905 | |||
| 61495b5e90 | |||
| 185d4914a6 | |||
| e3bbb7c77a | |||
| 4044070867 | |||
| 41b0d15570 | |||
| aafb75bad3 | |||
| 6e19bc7aff | |||
| 94e852032b | |||
| 5ccbc5d4f4 | |||
| cf6d54e2bf |
78
.gitignore
vendored
78
.gitignore
vendored
@@ -7,8 +7,80 @@
|
||||
.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/ascf/ascf_build
|
||||
stub-app-projects/mp-harmony/entry/src/main/resources
|
||||
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, 留本地复用)
|
||||
# 内容: Playwright 截图 / 测试脚本 / 对比图 / 临时提交草稿
|
||||
# 复用方式: cd _test_archive && python xxx.py
|
||||
_test_archive/
|
||||
|
||||
# 兜底:防止任何临时测试文件被遗漏在根目录
|
||||
# 名称以 _test_/_compare_/_auth_ 开头, 或 check_button.py 等
|
||||
_test_*.png
|
||||
_test_*.html
|
||||
_compare*.png
|
||||
_compare*.py
|
||||
_auth_*.json
|
||||
_commit_msg.txt
|
||||
/check_button.py
|
||||
# 鸿蒙签名材料 (敏感, 绝不能提交到公共 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
|
||||
@@ -45,7 +45,11 @@ const localDevConfig = ({
|
||||
'11-test': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
},
|
||||
'18-test': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'local-2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'http://localhost:8050/',
|
||||
@@ -55,10 +59,15 @@ 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/',
|
||||
@@ -79,6 +88,10 @@ 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/',
|
||||
@@ -135,17 +148,38 @@ const localDevConfig = ({
|
||||
uniacid: 48,
|
||||
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/',
|
||||
},
|
||||
'228': { // 商品分类,没有立即咨询文字
|
||||
uniacid: 228,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2797': { //
|
||||
uniacid: 2797,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
// })['2821']; // 离型纸硅油纸
|
||||
})['48']; // 离型纸硅油纸
|
||||
// })['2482']; // 低空经济
|
||||
// })['48']; // 离型纸硅油纸
|
||||
// })['2485']; // 验证电子表单前后端数据一致性
|
||||
// })['5g-11']; // 数码
|
||||
// })['5g-213']; // 选择要使用的环境配置
|
||||
// })['5g-213']; // 选择要使用的环境配置
|
||||
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||
// })['2724']; // 生物菌肥
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['local-1']; // 本地
|
||||
})['2797']; // 本地
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
// })['test-12']; // test
|
||||
export default localDevConfig;
|
||||
33
App.vue
33
App.vue
@@ -271,27 +271,56 @@
|
||||
// 隐私协议弹窗: 启动期关键资源 (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) {
|
||||
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
|
||||
// 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
|
||||
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
|
||||
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
|
||||
// 避免 splash 提前关导致白屏
|
||||
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
|
||||
}
|
||||
// #endif
|
||||
|
||||
|
||||
152
common/js/diy.js
152
common/js/diy.js
@@ -1,6 +1,7 @@
|
||||
import WxMap from 'common/js/map-wx-jssdk.js';
|
||||
import Config from '@/common/js/config.js';
|
||||
import util from '@/common/js/util.js';
|
||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
|
||||
@@ -51,10 +52,15 @@ export default {
|
||||
lastDiyInfoTime: 0, // 上次成功加载的时间戳
|
||||
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
|
||||
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
|
||||
|
||||
|
||||
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
|
||||
diyLoadError: false,
|
||||
diyLoadErrorMsg: '',
|
||||
diyLoadErrorUrl: '', // 出错的网络请求地址
|
||||
|
||||
//启动广告
|
||||
adv:{},
|
||||
|
||||
adv: {},
|
||||
|
||||
};
|
||||
},
|
||||
onLoad(option) {
|
||||
@@ -117,10 +123,10 @@ export default {
|
||||
// 关闭定位
|
||||
this.enterDefaultStore();
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
onShow() {
|
||||
|
||||
|
||||
this.init();
|
||||
},
|
||||
onHide() {
|
||||
@@ -205,12 +211,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) => {
|
||||
// 拒绝定位,进入默认总店
|
||||
@@ -226,6 +232,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
|
||||
await this.getDiyInfo();
|
||||
//获取启动广告
|
||||
await this.getDiyAdv();
|
||||
@@ -283,7 +290,7 @@ export default {
|
||||
}, 50);
|
||||
// #endif
|
||||
},
|
||||
async getDiyAdv(){
|
||||
async getDiyAdv() {
|
||||
//启动广告
|
||||
let res = await this.$api.sendRequest({
|
||||
url: '/api/diyview/getstartadv',
|
||||
@@ -292,13 +299,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);
|
||||
}
|
||||
@@ -308,25 +315,75 @@ 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) return;
|
||||
if (this.diyInfoLoading) {
|
||||
console.log('[DIY][getDiyInfo] skip: already loading');
|
||||
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: '/api/diyview/info',
|
||||
url: diyInfoUrl,
|
||||
data: {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
@@ -335,19 +392,30 @@ export default {
|
||||
async: false
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('getDiyInfo request failed', e);
|
||||
console.error('[DIY][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
|
||||
@@ -357,9 +425,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$util.showToast({
|
||||
title: this.$lang('diy.noDiyData')
|
||||
});
|
||||
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
|
||||
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
|
||||
// errorType='business': 业务失败 (code!=0 / 消息提示)
|
||||
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
|
||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||
return;
|
||||
@@ -367,10 +436,20 @@ export default {
|
||||
|
||||
this.lastDiyInfoTime = now;
|
||||
this.lastDiyInfoLang = currentLang;
|
||||
// 成功加载后清除错误提示
|
||||
this.diyLoadError = false;
|
||||
this.diyLoadErrorMsg = '';
|
||||
this.diyLoadErrorUrl = '';
|
||||
|
||||
let diyDataValue = res.data;
|
||||
if (diyDataValue.value) {
|
||||
this.diyData = JSON.parse(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);
|
||||
|
||||
// 导航栏标题要根据语言环境切换
|
||||
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
|
||||
@@ -379,22 +458,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)) {
|
||||
/* 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) {
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||
}
|
||||
} else if (this.diyData.global.popWindow.count == 0) {
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||
}
|
||||
}, 500);
|
||||
}*/
|
||||
}, 500);
|
||||
}*/
|
||||
|
||||
// 修改diy数据结构排序
|
||||
let searchIndex = -1;
|
||||
@@ -458,6 +537,9 @@ 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() {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class EventSafety {
|
||||
// 安全的方法
|
||||
matches: (selector) => EventSafety.safeMatches(target, selector),
|
||||
// 数据集
|
||||
dataset: target.dataset || {}
|
||||
dataset: target?.dataset || {}
|
||||
}
|
||||
|
||||
return safeTarget
|
||||
|
||||
112
common/js/form-platform-mixin.js
Normal file
112
common/js/form-platform-mixin.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* form-platform-mixin.js
|
||||
*
|
||||
* 表单组件共用的平台检测 mixin
|
||||
*
|
||||
* 集中维护 3 个 computed:
|
||||
* - platform 当前运行平台 key(与后端 formConfig.terminal 键名对齐)
|
||||
* - platformAliases 后端键名 → 内部 platform 名 兼容层
|
||||
* - platformConfigKeys 当前平台的有效配置键集合(用于从 formConfig.terminal 查找)
|
||||
*
|
||||
* 消费方:
|
||||
* - components/ns-form/ns-form.vue
|
||||
* - components/ns-newform/ns-newform.vue
|
||||
* - 任何需要按端读取 formConfig.terminal.{h5|weapp|harmony|app} 的表单组件
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 单一真值:避免 ns-form / ns-newform 各自维护导致漂移
|
||||
* 2. 纯 computed mixin:不依赖 props/data/methods,便于跨组件复用
|
||||
* 3. 不依赖 util.js 等其它模块,避免循环依赖
|
||||
* 4. 通过 mixins: [...] 引入,组件侧不再写平台判断代码
|
||||
*
|
||||
* 用法:
|
||||
* import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
* export default {
|
||||
* mixins: [formPlatformMixin],
|
||||
* computed: {
|
||||
* config() {
|
||||
* const cur = this.platform;
|
||||
* const keys = this.platformConfigKeys;
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
/**
|
||||
* 当前运行平台标识(与后端 formConfig.terminal 键名对齐)
|
||||
* 后端 terminal 键:h5 / weapp / harmony / app / mp-alipay / mp-baidu / mp-toutiao
|
||||
*/
|
||||
platform() {
|
||||
// #ifdef H5
|
||||
return 'h5';
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'weapp';
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
return 'mp-alipay';
|
||||
// #endif
|
||||
// #ifdef MP-BAIDU
|
||||
return 'mp-baidu';
|
||||
// #endif
|
||||
// #ifdef MP-TOUTIAO
|
||||
return 'mp-toutiao';
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
return 'app';
|
||||
// #endif
|
||||
// #ifdef MP-HARMONY
|
||||
return 'harmony';
|
||||
// #endif
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
return 'quickapp';
|
||||
// #endif
|
||||
return 'h5';
|
||||
},
|
||||
/**
|
||||
* 后端键名 → 内部 platform 名 兼容层
|
||||
* canonical 与 platform() 返回值一致;别名为兼容 DB 旧 app_type(upgrade-to-ai-agent.sql)
|
||||
* 及 uniapp / 设计器的历史命名。键名严格区分大小写。
|
||||
*/
|
||||
platformAliases() {
|
||||
return {
|
||||
'h5': 'h5',
|
||||
'weapp': 'weapp',
|
||||
'mp-weixin': 'weapp',
|
||||
'wechat': 'weapp',
|
||||
'harmony': 'harmony',
|
||||
'app': 'app',
|
||||
'app-plus': 'app',
|
||||
'mp-alipay': 'mp-alipay',
|
||||
'aliapp': 'mp-alipay',
|
||||
'alipay': 'mp-alipay',
|
||||
'mp-baidu': 'mp-baidu',
|
||||
'baiduapp': 'mp-baidu',
|
||||
'baidu': 'mp-baidu',
|
||||
'mp-toutiao': 'mp-toutiao',
|
||||
'toutiao': 'mp-toutiao'
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 当前平台的有效配置键集合
|
||||
* 用于 config() 中查找 formConfig.terminal 下的平台差异化配置:
|
||||
* - 后端格式:formConfig.terminal = { h5: {...}, weapp: {...}, harmony: {...} }
|
||||
* - 旧式扁平:formConfig[platformKey] = { ... }
|
||||
* 返回所有映射到当前 platform 的后端键名,方便两种结构都能命中。
|
||||
*/
|
||||
platformConfigKeys() {
|
||||
const cur = this.platform;
|
||||
const aliases = this.platformAliases;
|
||||
const keys = [];
|
||||
Object.keys(aliases).forEach(k => {
|
||||
if (aliases[k] === cur) keys.push(k);
|
||||
});
|
||||
// 加入当前平台名(兼容 formConfig[platform] 这种扁平写法)
|
||||
if (keys.indexOf(cur) === -1) keys.push(cur);
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -79,114 +79,127 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
// 处理商品详情数据
|
||||
handleGoodsSkuData() {
|
||||
// this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
|
||||
//设置标题
|
||||
// this.$langConfig.title(this.navbarData.title);
|
||||
if (this.goodsSkuDetail.config) {
|
||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||
}
|
||||
|
||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||
|
||||
this.modifyGoodsInfo();
|
||||
|
||||
// 初始化商品详情视图数据
|
||||
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
else this.goodsSkuDetail.sku_images = [];
|
||||
}
|
||||
|
||||
// 多规格时合并主图
|
||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||
|
||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||
}
|
||||
|
||||
let maxHeight = '';
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||
if (typeof item.pic_spec == "string")
|
||||
item.pic_spec = item.pic_spec.split('*');
|
||||
|
||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||
|
||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||
maxHeight = item.pic_spec[1];
|
||||
async handleGoodsSkuData() {
|
||||
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
|
||||
try {
|
||||
if (this.goodsSkuDetail.config) {
|
||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||
}
|
||||
});
|
||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||
|
||||
// 当前商品SKU规格
|
||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||
this.modifyGoodsInfo();
|
||||
|
||||
// 商品属性
|
||||
if (this.goodsSkuDetail.goods_attr_format) {
|
||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||
// 初始化商品详情视图数据
|
||||
try {
|
||||
if (this.$refs && this.$refs.goodsDetailView) {
|
||||
this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('goodsDetailView.init failed:', e);
|
||||
}
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
else this.goodsSkuDetail.sku_images = [];
|
||||
}
|
||||
|
||||
// 多规格时合并主图
|
||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||
|
||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||
}
|
||||
|
||||
let maxHeight = '';
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||
if (typeof item.pic_spec == "string")
|
||||
item.pic_spec = item.pic_spec.split('*');
|
||||
|
||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||
|
||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||
maxHeight = item.pic_spec[1];
|
||||
}
|
||||
});
|
||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||
|
||||
// 当前商品SKU规格
|
||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||
|
||||
// 商品属性
|
||||
if (this.goodsSkuDetail.goods_attr_format) {
|
||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品SKU格式
|
||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||
// 商品SKU格式
|
||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||
|
||||
// 商品详情
|
||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||
// 商品详情
|
||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||
|
||||
//商品服务
|
||||
if (this.goodsSkuDetail.goods_service) {
|
||||
for (let i in this.goodsSkuDetail.goods_service) {
|
||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||
//商品服务
|
||||
if (this.goodsSkuDetail.goods_service) {
|
||||
for (let i in this.goodsSkuDetail.goods_service) {
|
||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.contactData = {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
path: this.shareUrl,
|
||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
this.contactData = {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
path: this.shareUrl,
|
||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
try {
|
||||
if (this.$refs && this.$refs.goodsPromotion) {
|
||||
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
}
|
||||
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
|
||||
|
||||
if (this.goodsRoute != '/pages_goods/detail') this.setPublicShare();
|
||||
|
||||
// this.getBarrageData();
|
||||
if (this.addonIsExist.form) {
|
||||
this.getGoodsForm();
|
||||
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
|
||||
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
|
||||
try {
|
||||
this._goodsFormPromise = this.getGoodsForm();
|
||||
} catch (e) {
|
||||
console.error('getGoodsForm kicked off failed:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('handleGoodsSkuData failed:', e);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -321,17 +334,76 @@ export default {
|
||||
},
|
||||
/**
|
||||
* 获取商品表单
|
||||
* 后端 goodsform 接口与 info() 对齐,返回 {json_data, form_config(camelCase),
|
||||
* form_name, form_tip, form_banner, show_banner, show_tip}。
|
||||
* 全部字段都落到 goodsSkuDetail 上:表单字段 / 样式 / banner / tip 元数据,
|
||||
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
|
||||
*/
|
||||
getGoodsForm() {
|
||||
this.$api.sendRequest({
|
||||
url: "/form/api/form/goodsform",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) this.$set(this.goodsSkuDetail, 'goods_form', res.data);
|
||||
}
|
||||
});
|
||||
async getGoodsForm() {
|
||||
// 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
|
||||
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
|
||||
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
|
||||
const emptyFn = () => {
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
||||
};
|
||||
emptyFn();
|
||||
return;
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await this.$api.sendRequest({
|
||||
url: "/form/api/form/goodsform",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
format: 'object'
|
||||
},
|
||||
async: false
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('getGoodsForm request failed', e);
|
||||
res = null;
|
||||
}
|
||||
const empty = () => {
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
||||
};
|
||||
if (!res || res.code != 0 || !res.data) {
|
||||
empty();
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(res.data)) {
|
||||
// 旧版扁平数组兼容
|
||||
empty();
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', res.data);
|
||||
return;
|
||||
}
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', res.data.json_data || []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', res.data.form_config || {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', res.data.form_name || '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', res.data.form_tip || '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', res.data.form_banner || '');
|
||||
// show_banner/show_tip 后端存 0/1,归一化 boolean
|
||||
this.$set(
|
||||
this.goodsSkuDetail,
|
||||
'goods_form_show_banner',
|
||||
res.data.show_banner == 1 ? true : (res.data.show_banner === 0 ? false : true)
|
||||
);
|
||||
this.$set(
|
||||
this.goodsSkuDetail,
|
||||
'goods_form_show_tip',
|
||||
res.data.show_tip == 1 ? true : (res.data.show_tip === 0 ? false : true)
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
101
common/js/http-error.js
Normal file
101
common/js/http-error.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 通用网络/接口错误分类器
|
||||
*
|
||||
* 适用场景:
|
||||
* - uniapp 各端统一错误分类(diy.js / 商品 / 订单 / 自定义页面等)
|
||||
* - 任意传入 {statusCode, errMsg, message} 形式错误对象的位置
|
||||
*
|
||||
* 设计要点:
|
||||
* 1) 与 Vue / $lang 解耦:不调用任何 i18n 函数,只返回分类结果 + 原始错误字段
|
||||
* 调用方拿到 type 后,自行决定使用 $lang('http.timeout') / $lang('http.serverError')
|
||||
* 或其它命名空间的文案,完全保持各模块原有的 key 命名习惯
|
||||
* 2) 通用网络/接口错误统一映射到 common.http.* 命名空间:
|
||||
* - http.networkError: 通用网络异常(兜底)
|
||||
* - http.timeout: 请求超时
|
||||
* - http.serverError: 服务器错误(5xx)
|
||||
* - http.notFound: 接口不存在(404)
|
||||
* - http.auth: 无访问权限(401/403)
|
||||
* - http.unknown: 其他非 2xx
|
||||
* 3) 兼容多种错误对象形态:
|
||||
* - uniapp request: { statusCode, errMsg, data }
|
||||
* - axios: { response: { status }, message }
|
||||
* - fetch / 原生: { message, name }
|
||||
*
|
||||
* @param {Object|string} err 错误对象或后端业务消息字符串
|
||||
* @returns {{ type: string, statusCode?: number, errMsg?: string, raw?: any }}
|
||||
* - type 取值:
|
||||
* 'business' - 后端返回的业务消息字符串(已透传到 raw)
|
||||
* 'empty' - 错误对象为空
|
||||
* 'notFound' - HTTP 404
|
||||
* 'server' - HTTP 5xx
|
||||
* 'auth' - HTTP 401 / 403
|
||||
* 'request' - 其它非 2xx 状态码
|
||||
* 'timeout' - errMsg 含 timeout
|
||||
* 'network' - errMsg 含 connect / network / fail
|
||||
* 'unknown' - 无法分类
|
||||
* - statusCode: 若有则原样返回
|
||||
* - errMsg: 归一后的原始消息
|
||||
* - raw: 原始输入,方便调用方进一步处理
|
||||
*/
|
||||
export function classifyHttpError(err) {
|
||||
// 1) 无错误对象
|
||||
if (!err) {
|
||||
return { type: 'empty', raw: err };
|
||||
}
|
||||
|
||||
// 2) 后端直接返回的业务消息(字符串)
|
||||
if (typeof err === 'string') {
|
||||
return { type: 'business', raw: err };
|
||||
}
|
||||
|
||||
// 3) 字段归一(兼容 uni / axios / 原生 Error)
|
||||
const statusCode = err.statusCode
|
||||
?? err.response?.status
|
||||
?? err.status;
|
||||
const errMsg = err.errMsg || err.message || '';
|
||||
|
||||
if (statusCode) {
|
||||
if (statusCode === 404) return { type: 'notFound', statusCode, errMsg, raw: err };
|
||||
if (statusCode >= 500) return { type: 'server', statusCode, errMsg, raw: err };
|
||||
if (statusCode === 401 || statusCode === 403) return { type: 'auth', statusCode, errMsg, raw: err };
|
||||
return { type: 'request', statusCode, errMsg, raw: err };
|
||||
}
|
||||
|
||||
if (/timeout/i.test(errMsg)) {
|
||||
return { type: 'timeout', errMsg, raw: err };
|
||||
}
|
||||
if (/(connect|network|fail)/i.test(errMsg)) {
|
||||
return { type: 'network', errMsg, raw: err };
|
||||
}
|
||||
|
||||
return { type: 'unknown', errMsg, raw: err };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 classifyHttpError 的结果,映射到 common.http.* 命名空间的 i18n key
|
||||
*
|
||||
* 通用网络/接口错误统一走 http.*,调用方通过 $lang(key) 解析文案。
|
||||
* 原 diy.errTimeout 这类 key 已合并到 http.*,保持项目国际化更专业。
|
||||
*
|
||||
* @param {string} type classifyHttpError 返回的 type
|
||||
* @returns {string|null} 对应的 http.* key;type='business' 时返回 null(透传 raw)
|
||||
*/
|
||||
export function httpErrorKey(type) {
|
||||
switch (type) {
|
||||
case 'empty': return 'http.networkError';
|
||||
case 'business': return null; // 透传,不用 key
|
||||
case 'notFound': return 'http.notFound';
|
||||
case 'server': return 'http.serverError';
|
||||
case 'auth': return 'http.auth';
|
||||
case 'request': return 'http.unknown';
|
||||
case 'timeout': return 'http.timeout';
|
||||
case 'network': return 'http.networkError';
|
||||
case 'unknown':
|
||||
default: return 'http.networkError';
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
classifyHttpError,
|
||||
httpErrorKey,
|
||||
};
|
||||
@@ -44,6 +44,15 @@ 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,
|
||||
@@ -54,6 +63,7 @@ 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;
|
||||
@@ -73,6 +83,7 @@ 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);
|
||||
}
|
||||
});
|
||||
@@ -87,15 +98,20 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +223,7 @@ export default {
|
||||
}
|
||||
},
|
||||
uploadBase64(params) {
|
||||
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
|
||||
uni.request({
|
||||
url: Config.baseUrl + '/api/upload/headimgBase64',
|
||||
method: 'POST',
|
||||
@@ -221,14 +238,17 @@ 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',
|
||||
@@ -243,14 +263,17 @@ 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,
|
||||
@@ -264,9 +287,11 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,10 +1256,6 @@ export default {
|
||||
if (hasDefault) {
|
||||
return arguments[1];
|
||||
}
|
||||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||||
if (!__DEV__) {
|
||||
return '';
|
||||
}
|
||||
// 开发环境 → 返回 raw key(便于发现问题)
|
||||
return value;
|
||||
}
|
||||
@@ -1350,9 +1346,6 @@ export default {
|
||||
// 有默认值 → 使用默认值
|
||||
if (hasDefault) {
|
||||
value = arguments[1];
|
||||
} else if (!__DEV__) {
|
||||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||||
value = '';
|
||||
}
|
||||
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
||||
} else if (arguments.length > 1) {
|
||||
@@ -1367,7 +1360,7 @@ export default {
|
||||
if (value == undefined) value = '';
|
||||
|
||||
// 多语言调试
|
||||
if (__DEV__ && field == value) {
|
||||
if (field == value) {
|
||||
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,20 +147,13 @@ function getQuickAppPrivacySetting() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 鸿蒙元服务: 查询平台级隐私设置
|
||||
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
|
||||
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
|
||||
* @returns {Promise<{needAuthorization: boolean}>}
|
||||
*/
|
||||
function getHarmonyPrivacySetting() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
|
||||
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
// #ifndef MP-HARMONY
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,7 +181,7 @@ async function checkPrivacy() {
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
||||
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
|
||||
// H5 / 其他平台: 仅查 storage
|
||||
return checkPrivacySync();
|
||||
}
|
||||
@@ -730,8 +723,9 @@ 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;
|
||||
@@ -741,7 +735,18 @@ 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;
|
||||
@@ -757,18 +762,28 @@ 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()) {
|
||||
|
||||
@@ -33,7 +33,16 @@ function useNativeLoading() {
|
||||
// #endif
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
|
||||
// 总兜底超时档位 (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 MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
|
||||
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
|
||||
|
||||
@@ -71,6 +80,111 @@ 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 = {}) {
|
||||
@@ -111,7 +225,34 @@ class SplashState {
|
||||
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
|
||||
}
|
||||
|
||||
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
||||
// 业务方可通过 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) {
|
||||
if (this._timeoutId) {
|
||||
clearTimeout(this._timeoutId);
|
||||
this._timeoutId = null;
|
||||
@@ -125,6 +266,55 @@ 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
|
||||
@@ -136,15 +326,22 @@ 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 = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||
const mode = opts.mode === 'fast' ? 'fast' : 'default';
|
||||
const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||
// strict 模式复用 fast 动画
|
||||
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
|
||||
|
||||
if (useSoft) {
|
||||
this._softHide({ reason, mode });
|
||||
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
|
||||
this._softHide(opts);
|
||||
} else {
|
||||
this._doHide({ reason, mode });
|
||||
}
|
||||
@@ -154,10 +351,17 @@ 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) {
|
||||
@@ -165,6 +369,12 @@ class SplashState {
|
||||
return;
|
||||
}
|
||||
|
||||
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
|
||||
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
|
||||
if (useStrict) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底: 1s 内没 mount, 强制隐藏
|
||||
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = setTimeout(() => {
|
||||
@@ -177,15 +387,27 @@ 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(() => {
|
||||
@@ -204,6 +426,11 @@ 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; // 防重入
|
||||
@@ -225,6 +452,8 @@ 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',
|
||||
@@ -264,6 +493,60 @@ 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 组件, 不会动态增减
|
||||
|
||||
@@ -368,6 +368,186 @@ export function normalizeUrl(urlString) {
|
||||
return schemeAndHost + normalizedPath + normalizedQuery + hash;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// extractInternalAppPath
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 从一个 URL 中提取"本 App 内部页面路径"。
|
||||
*
|
||||
* 背景 / 修复的 bug:
|
||||
* 后台装修数据里,内部页面常被存成【绝对 URL】,例如
|
||||
* https://host/hwappx/common/pages_tool/form/formdata?id=130
|
||||
* diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是:
|
||||
* - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失
|
||||
* - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view
|
||||
* 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径,
|
||||
* 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。
|
||||
*
|
||||
* 判定规则(双闸门,避免误伤真正的外部站点):
|
||||
* 闸门 1 —— host 校验(相对路径直接放行):
|
||||
* 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感);
|
||||
* 否则返回 null(视为真外链, 维持原逻辑)。
|
||||
* ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链,
|
||||
* 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。
|
||||
* 闸门 2 —— 路径校验:
|
||||
* pathname 中出现以 App 页面根开头的段(pages / pages_xxx),
|
||||
* 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回;
|
||||
* 未命中返回 null。
|
||||
*
|
||||
* @example
|
||||
* const H = ['xcx6.aigc-quickapp.com']
|
||||
* extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H)
|
||||
* // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原)
|
||||
* extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H)
|
||||
* // null (子域不同 → 维持外链)
|
||||
* extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H)
|
||||
* // null (外域名 → 维持外链,不误伤)
|
||||
* extractInternalAppPath('/pages_goods/detail?id=9', H)
|
||||
* // '/pages_goods/detail?id=9' (相对路径 → 内部)
|
||||
*
|
||||
* @param {string} url 绝对或相对 URL
|
||||
* @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取);
|
||||
* 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。
|
||||
* @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null
|
||||
*/
|
||||
export function extractInternalAppPath(url, allowedHosts) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
let rest = url;
|
||||
// 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部)
|
||||
const hostMatch = rest.match(/^https?:\/\/([^/]*)/i);
|
||||
if (hostMatch) {
|
||||
if (allowedHosts !== true) {
|
||||
const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, '');
|
||||
const allow = _normalizeHostList(allowedHosts);
|
||||
if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改
|
||||
}
|
||||
rest = rest.substring(hostMatch[0].length);
|
||||
}
|
||||
// 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx)
|
||||
const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i);
|
||||
if (!pageMatch) return null;
|
||||
const internal = rest.substring(pageMatch.index);
|
||||
return internal || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。
|
||||
* 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。
|
||||
* @param {string|string[]} hosts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function _normalizeHostList(hosts) {
|
||||
if (!hosts) return [];
|
||||
const arr = Array.isArray(hosts) ? hosts : [hosts];
|
||||
const out = [];
|
||||
for (const h of arr) {
|
||||
if (!h || typeof h !== 'string') continue;
|
||||
let s = h.trim().toLowerCase();
|
||||
const m = s.match(/^https?:\/\/([^/]*)/i);
|
||||
if (m) s = m[1] || '';
|
||||
else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path
|
||||
s = s.replace(/:\d+$/, ''); // 去端口
|
||||
if (s) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。
|
||||
*
|
||||
* 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode):
|
||||
* - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致)
|
||||
* - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view)
|
||||
* - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上
|
||||
*
|
||||
* @param {Object} link
|
||||
* @returns {'internal'|'online'|'auto'}
|
||||
*/
|
||||
export function resolveNavMode(link) {
|
||||
if (!link || typeof link !== 'object') return 'auto';
|
||||
const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||||
const raw = link.open_mode != null ? link.open_mode
|
||||
: (link.openMode != null ? link.openMode
|
||||
: (pp.open_mode != null ? pp.open_mode : pp.openMode));
|
||||
const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : '';
|
||||
if (m === 'internal' || m === 'spa') return 'internal';
|
||||
if (m === 'online' || m === 'web') return 'online';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl
|
||||
// =============================================================
|
||||
// pickCurrentUniacid
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 从两个候选 uniacid 源中选出"系统当前 uniacid"。
|
||||
*
|
||||
* 用法(当前 diyRedirectTo 中的实际调用, 必须与 http.js 行 168 保持一致):
|
||||
* pickCurrentUniacid(
|
||||
* uni.getStorageSync('uniacid'), // 持久化, 登录/初始化写入
|
||||
* Config.uniacid, // 编译期兜底
|
||||
* )
|
||||
*
|
||||
* 优先级: 第一个参数非 0 → 选第一个; 否则选第二个非 0 值; 都没有 → 0。
|
||||
* 0 视为"无 uniacid": 业务上 uniacid 不会是 0, parseInt 后为 0 也算无效。
|
||||
*
|
||||
* ★ 注意: 故意【不读 store.state.uniacid】。当前代码库里 store.state 上从未设置
|
||||
* uniacid 字段(http.js 只用 storage/Config), 若引入 store 分支会出现
|
||||
* "守卫用 A 真值, http 请求用 B 真值"的不一致。
|
||||
*
|
||||
* @param {*} primaryValue 第一候选(运行时持久化值)
|
||||
* @param {*} secondaryValue 第二候选(编译期兜底)
|
||||
* @returns {number} 选出的 uniacid; 都无效时返回 0
|
||||
*/
|
||||
export function pickCurrentUniacid(primaryValue, secondaryValue) {
|
||||
const a = parseInt(primaryValue, 10);
|
||||
if (a) return a; // 非 0 真值
|
||||
const b = parseInt(secondaryValue, 10);
|
||||
if (b) return b;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 检测 link.wap_url 中【URL 原生】的 uniacid query 与系统当前 uniacid 是否一致。
|
||||
*
|
||||
* 用途: uniacid 是平台租户 ID, 会随租户迁移 / 平台调整而变化。
|
||||
* - 如果数据里的 wap_url 自带的 uniacid 与当前系统不一致,
|
||||
* 说明这份装修数据可能是历史遗留或别的租户的(脏数据),
|
||||
* 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
|
||||
* - 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
|
||||
*
|
||||
* 关键: 必须【在 resolveLinkUrl 抽走 query 之前】判断, 否则 URL 里的 uniacid 已经
|
||||
* 被搬到 page_params 里, 分不清"URL 原生"还是"外加的"。
|
||||
*
|
||||
* 比较规则: 两侧 parseInt 后比较, 兼容 '2482' vs 2482、URL 编码、空值。
|
||||
*
|
||||
* @param {string} wap_url link.wap_url (resolveLinkUrl 之前的原始值)
|
||||
* @param {string|number|null} currentUniacid 系统当前的 uniacid
|
||||
* (0 / null / '' / undefined 一律视为"无当前 uniacid", 返回 false — 让上游决定怎么做)
|
||||
* @returns {boolean} true = URL 原生 uniacid 与当前不一致(脏数据, 该强制走线上)
|
||||
*/
|
||||
export function hasStaleUniacidInUrl(wap_url, currentUniacid) {
|
||||
if (!wap_url || typeof wap_url !== 'string') return false;
|
||||
if (currentUniacid == null || currentUniacid === '' || currentUniacid === 0) return false;
|
||||
const m = wap_url.match(/[?&]uniacid=([^&#]*)/i);
|
||||
if (!m) return false;
|
||||
let urlUniacid;
|
||||
try { urlUniacid = decodeURIComponent(m[1]); } catch (e) { urlUniacid = m[1]; }
|
||||
const cur = String(currentUniacid);
|
||||
const a = parseInt(urlUniacid, 10);
|
||||
const b = parseInt(cur, 10);
|
||||
// 两侧都能 parse 成数字时按数字比; 否则按字符串比
|
||||
if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b;
|
||||
return urlUniacid !== cur;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// appendQueryParams
|
||||
// =============================================================
|
||||
@@ -484,29 +664,30 @@ export function shouldEncodeUrl() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 跨平台 safeEncode(decode roundtrip + encode 一次)
|
||||
* 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样
|
||||
*
|
||||
* 4 平台编码行为:
|
||||
* | 平台 | 宿主 | 内部自动 encode? | JS 侧需要 encode? |
|
||||
* |-----------|---------------|------------------|------------------|
|
||||
* | h5 | 浏览器 | 否 | 必须 |
|
||||
* | mp-weixin | 微信原生 | 否 | 必须(否则微信把 & 当分隔符丢参数) |
|
||||
* | mp-harmony| 鸿蒙原生 | 否 | 必须 |
|
||||
* | quickapp | 华为快应用 Webview (本质 H5) | 否 | 必须 |
|
||||
* 解决问题:
|
||||
* - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值
|
||||
* 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏),
|
||||
* 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。
|
||||
* - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。
|
||||
* 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。
|
||||
* - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。
|
||||
*
|
||||
* 关键 bug 修复(2026-07-14):
|
||||
* 微信小程序下, src 内嵌 URL 含 `&` `?` `=` 空格 反引号等特殊字符时,
|
||||
* 若 JS 不主动 encode, 微信底层路由会把这些字符当成 top-level query 分隔符,
|
||||
* 导致后续 key(id/uniacid/page_params) 全部丢失。
|
||||
*
|
||||
* @param {*} v
|
||||
* @param {string} s 已 decode roundtrip 后的原文
|
||||
* @returns {string}
|
||||
*/
|
||||
function _encodeUrlDelimiters(s) {
|
||||
// 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样
|
||||
return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c));
|
||||
}
|
||||
|
||||
export function _safeEncodeForPlatform(v) {
|
||||
const s = _toParamValueString(v);
|
||||
|
||||
// decode roundtrip: 兼容上游已 encode 过的入参
|
||||
// decode roundtrip: 兼容上游已 encode 过的入参(无论平台都做,避免双编码)
|
||||
let once = s;
|
||||
try {
|
||||
if (/%[0-9a-fA-F]{2}/.test(s)) {
|
||||
@@ -516,7 +697,16 @@ export function _safeEncodeForPlatform(v) {
|
||||
once = s;
|
||||
}
|
||||
|
||||
// 4 平台都主动 encode 一次
|
||||
const platform = getPlatform();
|
||||
|
||||
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
|
||||
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
|
||||
if (['mp-weixin','mp-harmony'].includes(platform)) {
|
||||
return _encodeUrlDelimiters(once);
|
||||
}
|
||||
|
||||
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
|
||||
return encodeURIComponent(once);
|
||||
}
|
||||
|
||||
@@ -616,6 +806,7 @@ export function createNavigateApi(opts = {}) {
|
||||
cleanParamUrls,
|
||||
normalizeUrl,
|
||||
appendQueryParams,
|
||||
extractInternalAppPath,
|
||||
parsePageParams,
|
||||
resolveLinkUrl,
|
||||
decodeEntity,
|
||||
|
||||
3936
common/js/util.js
3936
common/js/util.js
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,35 @@
|
||||
<template>
|
||||
<view data-component-name="diy-bottom-nav" v-if="shouldShowTabBar">
|
||||
<view class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
||||
<view class="tabbar-border"></view>
|
||||
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
||||
<view class="bd">
|
||||
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
||||
:animation="isCartItem(item.link) ? cartAnimation : null"
|
||||
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
||||
<block v-if="verify(item.link)">
|
||||
<image v-if="item.selected_icon_type == 'img'"
|
||||
:src="$util.img(item.selectedIconPath)" />
|
||||
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
||||
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
||||
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
||||
:value="item.style ? item.style : null"></diy-icon>
|
||||
</block>
|
||||
<view class="cart-count-mark font-size-activity-tag"
|
||||
:class="{ max: cartNumber > 99 }"
|
||||
:style="{ background: 'var(--price-color)' }"
|
||||
v-if="isCartItem(item.link) && cartNumber > 0">
|
||||
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
||||
<!-- 修复底部 TabBar 频闪:
|
||||
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
|
||||
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
|
||||
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
|
||||
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
|
||||
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
|
||||
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
||||
<view class="tabbar-border"></view>
|
||||
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
||||
<view class="bd">
|
||||
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
||||
:animation="isCartItem(item.link) ? cartAnimation : null"
|
||||
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
||||
<block v-if="verify(item.link)">
|
||||
<image v-if="item.selected_icon_type == 'img'"
|
||||
:src="$util.img(item.selectedIconPath)" />
|
||||
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
||||
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
||||
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
||||
:value="item.style ? item.style : null"></diy-icon>
|
||||
</block>
|
||||
<view class="cart-count-mark font-size-activity-tag"
|
||||
:class="{ max: cartNumber > 99 }"
|
||||
:style="{ background: 'var(--price-color)' }"
|
||||
v-if="isCartItem(item.link) && cartNumber > 0">
|
||||
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="label"
|
||||
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
|
||||
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
|
||||
@@ -38,7 +43,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 解决fixed定位后底部导航栏塌陷问题 -->
|
||||
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
|
||||
<view class="tab-bar-placeholder"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<view data-component-name="diy-category" :class="['category-page-wrap', 'category-template-' + value.template]"
|
||||
:style="{ height: 'calc(100vh - ' + tabBarHeight + ')' }">
|
||||
<!-- 模板4: 顶部搜索框(图片样式) -->
|
||||
<view class="search-box search-box-template-4" v-if="value.template == 4 && value.search"
|
||||
@tap.stop="onClickSearch()">
|
||||
<view class="search-content">
|
||||
<input type="text" class="uni-input" maxlength="50" :placeholder="$lang('components-diy.diy-search.searchBtn')"
|
||||
confirm-type="search" @tap.stop="onClickSearch()" disabled="true" />
|
||||
<text class="iconfont icon-sousuo3"></text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- <block v-if="value.template == 4">
|
||||
<view class="search-box" v-if="value.search" @tap.stop="$util.redirectTo('/pages_tool/goods/search')" :style="navbarInnerStyle">
|
||||
@@ -28,7 +37,7 @@
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || MP-HARMONY -->
|
||||
|
||||
<view class="search-box" v-if="value.search" @tap.stop="onClickSearch()">
|
||||
<view class="search-box" v-if="value.search && value.template != 4" @tap.stop="onClickSearch()">
|
||||
<view class="search-content">
|
||||
<input type="text" class="uni-input" maxlength="50" :placeholder="$lang('components-diy.diy-search.searchBtn')" confirm-type="search"
|
||||
@tap.stop="onClickSearch()" disabled="true" />
|
||||
@@ -77,16 +86,44 @@
|
||||
</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">
|
||||
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
|
||||
{ select: select == index },
|
||||
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
||||
{ 'border-top': value.template == 4 && select - 1 === index }
|
||||
]" @tap.stop="switchOneCategory(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
<!-- 模板4: 左侧显示一级分类(顶部轮播中的分类) -->
|
||||
<view v-if="value.template == 4">
|
||||
<view class="category-item" v-for="(item, index) in templateFourData" :key="index" :class="[
|
||||
{ select: oneCategorySelect == index },
|
||||
{ 'border-bottom': oneCategorySelect + 1 === index },
|
||||
{ 'border-top': oneCategorySelect - 1 === index }
|
||||
]" @tap.stop="templateFourOneFn(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 其他模板: 左侧显示二级分类 -->
|
||||
<view v-else>
|
||||
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
|
||||
{ select: select == index },
|
||||
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
||||
{ 'border-top': value.template == 4 && select - 1 === index },
|
||||
{ 'first-category-item': index == 0 && value.template == 99 }
|
||||
]" @tap.stop="switchOneCategory(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -102,23 +139,37 @@
|
||||
:lastSecondCategoryName="lastSecondCategoryName"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, 0)" @addCart="addCartPoint"
|
||||
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</scroll-view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 1 || value.template == 99 || loadType == 'all'"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
||||
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
|
||||
@refresherrestore="onRestore">
|
||||
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index" :style="{ display: value.template == 99 && select != index ? 'none' : 'block' }">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||
</scroll-view>
|
||||
<!-- 模板1: 仿京东/淘宝商品分类 - 按 template 2 的 part-load 逻辑, 切换一级分类时只渲染选中分类的子分类网格 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 1"
|
||||
:refresher-enabled="true" refresher-default-style="none"
|
||||
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
|
||||
<view class="child-category-wrap" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index" :style="{ display: select == index ? 'block' : 'none' }">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
||||
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
|
||||
@refresherrestore="onRestore">
|
||||
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index" :style="{ display: value.template == 99 && select != index ? 'none' : 'block' }">
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
|
||||
:currentOneCategory="categoryTree && categoryTree[select] ? categoryTree[select] : null"
|
||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="content-wrap"
|
||||
v-if="(value.template == 2 || value.template == 3 || value.template == 4) && loadType == 'part'">
|
||||
@@ -127,6 +178,7 @@
|
||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||
:last="index == categoryTree.length - 1 ? true : false" :select="select"
|
||||
:oneCategorySelect="oneCategorySelect" :isList="isList" @tologin="toLogin"
|
||||
:currentOneCategory="value.template == 4 && templateFourData[oneCategorySelect] ? templateFourData[oneCategorySelect] : null"
|
||||
@selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||
@switch="switchOneCategory"></diy-category-item>
|
||||
</view>
|
||||
@@ -138,14 +190,13 @@
|
||||
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="category-empty" v-else>
|
||||
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="cart-box" v-if="(value.template == 2 || value.template == 4) && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
||||
<!-- 模板4: 底部购物车栏 -->
|
||||
<view class="cart-box cart-box-template-4"
|
||||
v-if="value.template == 4 && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
||||
<view class="left-wrap">
|
||||
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation" @tap.stop="$util.redirectTo('/pages_goods/cart')">
|
||||
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation"
|
||||
@tap.stop="$util.redirectTo('/pages_goods/cart')">
|
||||
<text class="iconfont icon-ziyuan1"></text>
|
||||
<view class="num" v-if="cartNumber">{{ cartNumber < 99 ? cartNumber : '99+' }}</view>
|
||||
</view>
|
||||
@@ -156,12 +207,13 @@
|
||||
<text class="unit font-size-tag price-font">.{{ cartTotalMoney[1] ? cartTotalMoney[1] : '00' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="right-wrap"><button type="primary" class="settlement-btn" @tap.stop="settlement">去结算</button>
|
||||
<view class="right-wrap"><button type="primary" class="settlement-btn"
|
||||
@tap.stop="settlement">去结算</button>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="cart-point" :style="{ left: item.left + 'px', top: item.top + 'px' }" :key="index"
|
||||
v-for="(item, index) in carIconList"></view>
|
||||
<view class="cart-point" v-for="(item, index) in carIconList" :key="index"
|
||||
:style="{ left: item.left + 'px', top: item.top + 'px' }"></view>
|
||||
|
||||
<ns-goods-sku-category ref="skuSelect" @refresh="refreshData" @addCart="addCartPoint"></ns-goods-sku-category>
|
||||
</view>
|
||||
@@ -209,12 +261,21 @@ export default {
|
||||
isList: false,
|
||||
lang: uni.getStorageSync("lang"),
|
||||
lastSecondCategoryName: '',
|
||||
template99Category: null
|
||||
template99Category: null,
|
||||
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
|
||||
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
|
||||
// 显示骨架占位而非空态文案
|
||||
categoryTreeLoading: true,
|
||||
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getCategoryTree();
|
||||
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
|
||||
// 模板4: 默认单列列表布局(图片样式)
|
||||
if (this.value.template == 4) {
|
||||
this.isList = true;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
query = uni.createSelectorQuery().in(this);
|
||||
@@ -242,7 +303,14 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
componentRefresh: function (nval) { },
|
||||
categoryTreeLoading: function (nval, oval) {
|
||||
if (nval === false && oval === true) {
|
||||
this.$nextTick(() => {
|
||||
if (!this.heightArea.length) this.getHeightArea(-1);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cartTotalMoney() {
|
||||
@@ -316,6 +384,7 @@ export default {
|
||||
this.$store.dispatch('getCartNumber');
|
||||
if (!this.heightArea.length) this.getHeightArea(-1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取高度区间
|
||||
*
|
||||
@@ -323,6 +392,13 @@ export default {
|
||||
* 加防御性检查,避免上线后第一次进入崩溃
|
||||
*/
|
||||
getHeightArea(index) {
|
||||
// 节流: 500ms 内的重复请求复用上次结果, 避免每次 onShow 都跑 selectAll 触发频闪
|
||||
const now = Date.now();
|
||||
if (this._lastHeightAreaTime && now - this._lastHeightAreaTime < 500 && this.heightArea && this.heightArea.length) {
|
||||
if (index != -1 && index < this.categoryTree.length - 1 && Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[index + 1]) this.$refs.categoryItem[index + 1].getGoodsList();
|
||||
return;
|
||||
}
|
||||
this._lastHeightAreaTime = now;
|
||||
let heightArea = [];
|
||||
if (!query || typeof query.selectAll !== 'function') {
|
||||
query = uni.createSelectorQuery && uni.createSelectorQuery().in(this);
|
||||
@@ -352,6 +428,7 @@ export default {
|
||||
* 获取全部分类
|
||||
*/
|
||||
getCategoryTree() {
|
||||
this.categoryTreeLoading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goodscategory/tree',
|
||||
data: {
|
||||
@@ -381,6 +458,14 @@ export default {
|
||||
this.loadGlobalSecondCategoryBtns();
|
||||
}
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 失败也保证 loading 关闭, 让骨架替换为"暂无分类"空态
|
||||
this.categoryTree = this.categoryTree || [];
|
||||
},
|
||||
complete: () => {
|
||||
// 无论成功失败都关闭 loading 骨架
|
||||
this.categoryTreeLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -655,11 +740,12 @@ export default {
|
||||
}
|
||||
|
||||
.content-box {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.tree-wrap {
|
||||
.tree-wrap {
|
||||
width: 170rpx;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
@@ -906,8 +992,219 @@ export default {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 全屏骨架占位 — 不画 navbar/TabBar (由外层 category.vue 渲染), 避免错位
|
||||
// 兼容鸿蒙元服务 (JSVM 对 gap 支持不一): 用 margin 兜底替代 gap
|
||||
.category-page-skeleton {
|
||||
flex: 1;
|
||||
height: 0; // 与 .content-box 一致, 让 flex 子项撑开父容器剩余高度
|
||||
display: flex;
|
||||
background-color: #f4f6fa;
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
.skeleton-tree {
|
||||
width: 170rpx;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20rpx 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// #ifndef MP-HARMONY
|
||||
gap: 18rpx;
|
||||
// #endif
|
||||
flex-shrink: 0;
|
||||
|
||||
.skeleton-tree-item {
|
||||
height: 40rpx;
|
||||
// #ifndef MP-HARMONY
|
||||
margin: 0 28rpx;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
margin: 9rpx 28rpx; // 18rpx 间距 上下各 9rpx, 替代 gap:18rpx
|
||||
// #endif
|
||||
|
||||
border-radius: 12rpx;
|
||||
background: linear-gradient(90deg, #ececec 25%, #f5f5f5 37%, #ececec 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: category-skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
// #ifndef MP-HARMONY
|
||||
gap: 16rpx;
|
||||
// #endif
|
||||
background-color: #f4f6fa;
|
||||
min-width: 0;
|
||||
|
||||
.skeleton-card {
|
||||
width: calc((100% - 32rpx) / 3);
|
||||
height: 220rpx;
|
||||
// #ifdef MP-HARMONY
|
||||
margin: 8rpx;
|
||||
// #endif
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(90deg, #ececec 25%, #f5f5f5 37%, #ececec 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: category-skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes category-skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// 风格四
|
||||
.category-template-4 {
|
||||
// 模板4 顶部搜索框(图片样式)
|
||||
.search-box-template-4 {
|
||||
padding: 20rpx 30rpx 16rpx;
|
||||
background: #fff;
|
||||
|
||||
.search-content {
|
||||
height: 64rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
|
||||
input {
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
padding: 0 30rpx 0 30rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
color: #89899a;
|
||||
width: 70rpx;
|
||||
text-align: center;
|
||||
right: 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板4 底部购物车栏(图片样式)
|
||||
.cart-box-template-4 {
|
||||
height: 110rpx;
|
||||
padding: 0 30rpx;
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
.left-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cart-icon {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
position: relative;
|
||||
margin-top: -10rpx;
|
||||
|
||||
.iconfont {
|
||||
color: var(--btn-text-color, #fff);
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background-color: $base-color;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
|
||||
}
|
||||
|
||||
.num {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(20%, 0);
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
padding: 0 8rpx;
|
||||
min-width: 32rpx;
|
||||
border-radius: 18rpx;
|
||||
background-color: var(--price-color, #FF6B35);
|
||||
border: 2rpx solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
line-height: 1;
|
||||
|
||||
.title {
|
||||
color: #333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.unit,
|
||||
.money {
|
||||
color: var(--price-color, #FF6B35);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.money {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settlement-btn {
|
||||
margin: 0;
|
||||
width: 220rpx;
|
||||
font-weight: bold;
|
||||
border-radius: 50rpx;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
font-size: 30rpx;
|
||||
background: $base-color;
|
||||
color: var(--btn-text-color, #fff);
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
.search-content input {
|
||||
background-color: #f1f1f1;
|
||||
@@ -1135,6 +1432,125 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板1 仿京东/淘宝商品分类样式 =====================
|
||||
.category-template-1 {
|
||||
.search-box {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
|
||||
.search-content {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
|
||||
input {
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
right: 16rpx;
|
||||
color: #89899a;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-box {
|
||||
background: #fff;
|
||||
|
||||
.tree-wrap {
|
||||
width: 170rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.right-flex-wrap {
|
||||
background: #fff;
|
||||
|
||||
.content-wrap {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
|
||||
::v-deep .category-adv {
|
||||
padding: 0 20rpx;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
::v-deep .category-list {
|
||||
margin: 0 20rpx;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep .category-title-with-lines {
|
||||
padding: 30rpx 0 20rpx;
|
||||
}
|
||||
|
||||
// 穿透修复: 图片不需要灰色背景框,与目标图保持一致
|
||||
::v-deep .img-box {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
width: 70% !important;
|
||||
padding-bottom: 70% !important;
|
||||
margin-bottom: 16rpx !important;
|
||||
}
|
||||
|
||||
::v-deep .img-box image {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
// 左侧分类栏样式
|
||||
.tree-wrap .category-item {
|
||||
padding: 28rpx 16rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 0;
|
||||
|
||||
view {
|
||||
color: #666;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.border-top,
|
||||
&.border-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: #fff;
|
||||
|
||||
view {
|
||||
color: var(--base-color, #FF6B35);
|
||||
font-weight: bold;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
width: 6rpx;
|
||||
height: 32rpx;
|
||||
background: var(--base-color, #FF6B35);
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0 6rpx 6rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板99 商品分类样式 =====================
|
||||
.category-template-99 {
|
||||
.search-box {
|
||||
@@ -1188,18 +1604,21 @@ 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.3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
view {
|
||||
color: #FF6B35;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
@@ -1208,7 +1627,7 @@ export default {
|
||||
content: ' ';
|
||||
width: 6rpx;
|
||||
height: 36rpx;
|
||||
background: #FF6B35;
|
||||
background: #FF4646;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -1219,9 +1638,32 @@ 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 {
|
||||
|
||||
@@ -265,7 +265,7 @@ export default {
|
||||
// #ifndef MP-WEIXIN
|
||||
if (item?.channelType === 'wechat') {
|
||||
uni.showToast({
|
||||
title: '视频号仅支持在微信小程序环境中播放',
|
||||
title: this.$lang('wechatChannel.wechatMpOnly'),
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
|
||||
<!-- Banner -->
|
||||
<view v-if="value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
|
||||
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
|
||||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
||||
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
||||
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
||||
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
|
||||
</view>
|
||||
<!-- 提示语 -->
|
||||
<view v-if="value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
|
||||
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
@@ -17,13 +22,18 @@
|
||||
<!-- 提交按钮(外层 = 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="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
<view @click.stop="handleSubmit" 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'
|
||||
|
||||
@@ -47,6 +57,12 @@ export default {
|
||||
showSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
|
||||
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
@@ -70,13 +86,15 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle === 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
var v = this.value || {};
|
||||
css += 'background-color:' + (v.contentBgColor || '') + ';';
|
||||
if (v.elementAngle === 'round') {
|
||||
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
},
|
||||
@@ -141,6 +159,10 @@ export default {
|
||||
submitText() {
|
||||
return this.mergedFormConfig.submitText || '提交';
|
||||
},
|
||||
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
|
||||
hasCustomSubmit() {
|
||||
return typeof this.onSubmit === 'function';
|
||||
},
|
||||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||||
formCfg() {
|
||||
@@ -180,8 +202,22 @@ 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'),
|
||||
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
|
||||
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
|
||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||||
// 输入框视觉增强(与 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'),
|
||||
@@ -269,24 +305,26 @@ export default {
|
||||
return true;
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
submitForm() {
|
||||
var self = this;
|
||||
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
|
||||
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
|
||||
handleSubmit() {
|
||||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||||
if (!this.verify()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
|
||||
if (this.$listeners && this.$listeners.submit) {
|
||||
this.$emit('submit', this.getFormData());
|
||||
return;
|
||||
const formData = this.getFormData();
|
||||
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
|
||||
if (this.hasCustomSubmit) {
|
||||
return this.onSubmit(formData);
|
||||
}
|
||||
// 兜底:内置 sendRequest 流程
|
||||
this.defaultSubmit(formData);
|
||||
},
|
||||
|
||||
// 获得保单数据
|
||||
const submitFormData = this.getFormData();
|
||||
|
||||
// 提交到后端
|
||||
// 公共兜底实现:默认的 /form/api/form/create 流程;
|
||||
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
|
||||
defaultSubmit(submitFormData) {
|
||||
var self = this;
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
@@ -298,7 +336,7 @@ export default {
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
uni.showToast({
|
||||
title: '提交成功',
|
||||
title: self.$lang('submitSuccess'),
|
||||
icon: 'success'
|
||||
});
|
||||
// 重置表单
|
||||
@@ -307,7 +345,7 @@ export default {
|
||||
}
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.message || '提交失败',
|
||||
title: res.message || self.$lang('submitFail'),
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,7 +159,10 @@
|
||||
<!-- 商品推荐 -->
|
||||
<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>
|
||||
@@ -306,9 +309,11 @@ 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();
|
||||
}
|
||||
@@ -349,11 +354,13 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
_notifyLoaded() {
|
||||
if (this._loadedTimer) clearTimeout(this._loadedTimer);
|
||||
this._loadedTimer = setTimeout(() => {
|
||||
this.$emit('loaded');
|
||||
}, 300);
|
||||
},
|
||||
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);
|
||||
},
|
||||
processData() {
|
||||
if (!this.diyGlobalData || !this.diyGlobalData.value) {
|
||||
this.processedData = [];
|
||||
|
||||
1929
components-diy/diy-recommend-code.vue
Normal file
1929
components-diy/diy-recommend-code.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,17 +54,21 @@
|
||||
<!-- 视频播放弹窗 -->
|
||||
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
||||
<view class="video-container" :style="popupVideoStyle">
|
||||
<video
|
||||
<video
|
||||
:key="video_url"
|
||||
ref="popupVideoRef"
|
||||
class="adaptive-video"
|
||||
:autoPauseIfNavigate="true"
|
||||
class="adaptive-video"
|
||||
:autoPauseIfNavigate="true"
|
||||
:autoPauseIfOpenNative="true"
|
||||
:autoplay="false"
|
||||
:enableAutoRotation="true"
|
||||
id="myVideo"
|
||||
:src="video_url"
|
||||
:autoplay="false"
|
||||
:enableAutoRotation="true"
|
||||
id="myVideo"
|
||||
:src="video_url"
|
||||
:controls="true"
|
||||
object-fit="contain"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
@loadedmetadata="onPopupVideoLoaded"
|
||||
@error="onPopupVideoError"
|
||||
></video>
|
||||
@@ -144,41 +148,21 @@ export default {
|
||||
|
||||
// 弹窗视频容器样式
|
||||
popupVideoStyle() {
|
||||
let style = 'position:fixed;width:100%;left:0;';
|
||||
const defaultRatio = 9 / 16;
|
||||
|
||||
let ratio = defaultRatio;
|
||||
if (this.popupVideoWidth && this.popupVideoHeight) {
|
||||
ratio = this.popupVideoHeight / this.popupVideoWidth;
|
||||
}
|
||||
|
||||
const isPortrait = ratio > 1;
|
||||
|
||||
if (isPortrait) {
|
||||
style += 'top:50%;transform:translateY(-50%);';
|
||||
} else {
|
||||
style += 'top:20%;';
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
|
||||
// #endif
|
||||
|
||||
// 容器横向铺满,黑色背景填满左右边界
|
||||
// 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
|
||||
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
|
||||
return style;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 显示视频播放弹窗
|
||||
showVideo(item) {
|
||||
this.video_url = item.videoUrl;
|
||||
let url = item.videoUrl || '';
|
||||
// 处理视频URL:完整链接直接使用,否则通过 $util.img() 转换
|
||||
if (url && url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
url = this.$util.img(url);
|
||||
}
|
||||
this.video_url = url;
|
||||
this.popupVideoWidth = 0;
|
||||
this.popupVideoHeight = 0;
|
||||
this.$refs.videoPopup.open();
|
||||
@@ -625,22 +609,6 @@ export default {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
.adaptive-video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* #ifdef MP-HARMONY */
|
||||
.adaptive-video {
|
||||
position: static;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
.video-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
:style="videoWarpCss"
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
@click="handlerClick(videoUrl)"
|
||||
></video>
|
||||
@@ -106,11 +109,11 @@ export default {
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
|
||||
@@ -42,82 +42,92 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.loading-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 997;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.loading-anim {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 40%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.loading-anim > .item {
|
||||
position: relative;
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
perspective: 1600rpx;
|
||||
transform-style: preserve-3d;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.loading-anim .border {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid;
|
||||
}
|
||||
|
||||
.loading-anim .out {
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
|
||||
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
|
||||
border-left-color: var(--base-color, #FF4646);
|
||||
border-right-color: #C5C5C5 !important;
|
||||
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
border-top-color: var(--base-color, #FF4646);
|
||||
border-bottom-color: #C5C5C5 !important;
|
||||
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
animation: spin 0.6s linear normal infinite;
|
||||
}
|
||||
|
||||
.loading-anim .in {
|
||||
top: 25%;
|
||||
left: 25%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-anim .mid {
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
<style lang="scss">
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.loading-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 997;
|
||||
background: #f8f8f8;
|
||||
// 修复: 强制合成层, 让 spin 动画只在自身合成层跑, 不触发兄弟节点 (尤其是底部 TabBar) 重绘
|
||||
// 兼容环境: 微信小程序 / 鸿蒙元服务 / H5 都受益
|
||||
// - 微信小程序: spin 动画每帧导致兄弟 fixed 元素 TabBar 重绘
|
||||
// - 鸿蒙元服务 JSVM: 同上, JSVM 重绘更明显
|
||||
// - H5: will-change 提示浏览器持续合成
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.loading-anim {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 40%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.loading-anim > .item {
|
||||
position: relative;
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
perspective: 1600rpx;
|
||||
transform-style: preserve-3d;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.loading-anim .border {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid;
|
||||
}
|
||||
|
||||
.loading-anim .out {
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
|
||||
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
|
||||
border-left-color: var(--base-color, #FF4646);
|
||||
border-right-color: #C5C5C5 !important;
|
||||
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
border-top-color: var(--base-color, #FF4646);
|
||||
border-bottom-color: #C5C5C5 !important;
|
||||
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
|
||||
animation: spin 0.6s linear normal infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.loading-anim .in {
|
||||
top: 25%;
|
||||
left: 25%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
animation: spin 0.8s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.loading-anim .mid {
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
border-left-color: transparent;
|
||||
animation: spin 0.6s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
<template>
|
||||
<view class="form-wrap form-component">
|
||||
<block v-for="(item, index) in formData">
|
||||
<view class="order-wrap" v-if="item.controller == 'Text'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="form-wrap form-component" :style="formWrapStyle">
|
||||
<block v-for="(item, index) in formData" :key="index">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<input type="text" :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val" />
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Textarea'">
|
||||
<view class="order-cell flex-box textarea">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
||||
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<textarea :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val"></textarea>
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
|
||||
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
|
||||
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
|
||||
<textarea
|
||||
:rows="item.value.rows || textareaRowsValue"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<picker mode="selector" :range="item.value.options" v-if="item.controller == 'Select'" @change="pickerChange($event, index)">
|
||||
<view class="order-wrap">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
@@ -41,18 +57,18 @@
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Checkbox'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box check-group-box">
|
||||
<checkbox-group @change="checkboxChange($event, index)">
|
||||
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<checkbox :value="v.value" :checked="v.checked" />
|
||||
<view class="checkbox">
|
||||
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }"></text>
|
||||
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }" :style="{ color: v.checked ? checkedColor : '' }"></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -61,18 +77,18 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Radio'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box radio-group-box">
|
||||
<radio-group @change="radioChange($event, index)">
|
||||
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<radio :value="v.value" :checked="item.val == v.value" />
|
||||
<view class="radio-box">
|
||||
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }"></text>
|
||||
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }" :style="{ color: item.val == v.value ? checkedColor : '' }"></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -81,11 +97,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Img'">
|
||||
<view class="order-cell flex-box">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box img-boxs">
|
||||
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
||||
@@ -97,11 +113,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Date'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
@@ -112,11 +128,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Datelimit'">
|
||||
<view class="order-cell flex-box">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -138,11 +154,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Time'">
|
||||
<view class="order-cell">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
@@ -153,11 +169,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'Timelimit'">
|
||||
<view class="order-cell flex-box">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -179,11 +195,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex">
|
||||
<view class="name">
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
@@ -200,39 +216,314 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-form',
|
||||
mixins: [formPlatformMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
default: {}
|
||||
default: () => []
|
||||
},
|
||||
customAttr: {
|
||||
type: Object,
|
||||
default: function() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 表单样式配置(由后端 designer 通过 API 传入)。
|
||||
* 字段名同时支持 camelCase(如 formPaddingX / inputHeight / labelColor)
|
||||
* 与 snake_case(如 form_padding_x / input_height / label_color),camelCase 优先。
|
||||
* 不传则使用组件内置默认(与重构前硬编码值一致,无视觉回归)。
|
||||
*
|
||||
* 支持的键:
|
||||
* formPaddingX, formBgColor, rowPaddingY, rowMinHeight, labelGap,
|
||||
* inputHeight, inputRadius, labelWidthMode ('auto'|'fixed'), labelFixedWidth,
|
||||
* labelFontSize, labelColor, requiredColor, checkedColor,
|
||||
* placeholderColor, showRowDivider, rowDividerColor
|
||||
*
|
||||
* 平台差异化:formConfig.terminal.{h5|weapp|harmony|app|quickapp} 只放差异字段
|
||||
*/
|
||||
formConfig: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formData: this.data
|
||||
formData: [],
|
||||
maxLabelWidth: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 合并配置:内置默认 < 全局 formConfig < 当前平台 formConfig.terminal[platform]
|
||||
// 同时兼容 snake_case 与 camelCase 字段名
|
||||
config() {
|
||||
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
||||
const defaults = {
|
||||
formPaddingX: 0,
|
||||
formBgColor: '#ffffff',
|
||||
rowPaddingY: 20,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 16,
|
||||
inputHeight: 60,
|
||||
inputRadius: 0,
|
||||
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 600,
|
||||
labelAutoMaxWidth: 200,
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#909399',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-newform 对齐)
|
||||
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
|
||||
// line-height 用于在 textareaStyle 里算出对应的 px 高度;不再写死 176rpx。
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 44,
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#f9fafb',
|
||||
inputFocusBorderColor: '#ff4544',
|
||||
inputFocusBgColor: '#ffffff',
|
||||
showRowDivider: true,
|
||||
rowDividerColor: '#f0f0f0'
|
||||
};
|
||||
const fc = this.formConfig || {};
|
||||
|
||||
// 同时读 camelCase 与 snake_case,camelCase 优先
|
||||
const pick = (camel, snake) => {
|
||||
const v1 = fc[camel];
|
||||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||||
return fc[snake];
|
||||
};
|
||||
const base = {
|
||||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||||
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
|
||||
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
||||
labelGap: pick('labelGap', 'label_gap'),
|
||||
inputHeight: pick('inputHeight', 'input_height'),
|
||||
inputRadius: pick('inputRadius', 'input_radius'),
|
||||
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
|
||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||
labelColor: pick('labelColor', 'label_color'),
|
||||
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'),
|
||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||||
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')
|
||||
};
|
||||
// 移除 undefined / 空字符串(保留 0 / false)
|
||||
Object.keys(base).forEach(k => {
|
||||
if (base[k] === undefined || base[k] === null || base[k] === '') delete base[k];
|
||||
});
|
||||
// showRowDivider 在前后端可能传数字 0/1,统一转 boolean
|
||||
if ('showRowDivider' in base) {
|
||||
base.showRowDivider = base.showRowDivider === true || base.showRowDivider === 1 || base.showRowDivider === '1';
|
||||
}
|
||||
|
||||
// 2) 当前平台差异化配置(最高优先级),从 formConfig.terminal 取出当前平台 + 所有别名键,合并
|
||||
const termBlock = fc.terminal || {};
|
||||
const platformKeys = this.platformConfigKeys || [];
|
||||
const platformCfg = Object.assign(
|
||||
{},
|
||||
...platformKeys.map(k => termBlock[k] || fc[k] || {})
|
||||
);
|
||||
// 平台块可能也是 snake_case,统一转 camelCase
|
||||
const platformCfgCamel = {};
|
||||
if (this.$util && typeof this.$util.snakeToCamel === 'function') {
|
||||
Object.keys(platformCfg).forEach(k => {
|
||||
platformCfgCamel[this.$util.snakeToCamel(k)] = platformCfg[k];
|
||||
});
|
||||
} else {
|
||||
Object.assign(platformCfgCamel, platformCfg);
|
||||
}
|
||||
|
||||
// 3) 三层合并:defaults < base < platformCfgCamel
|
||||
return Object.assign({}, defaults, base, platformCfgCamel);
|
||||
},
|
||||
// 容器样式:内边距 + 背景 + 16 个 CSS 自定义属性
|
||||
formWrapStyle() {
|
||||
const cfg = this.config;
|
||||
const num = (v, fb) => (typeof v === 'number' ? v : parseInt(v) || fb);
|
||||
// 后端 formConfig 一律以 rpx 传递;
|
||||
// _rpx() 按端分支:H5 输出 'Npx'(uni.upx2px),其它端输出 'Nrpx'
|
||||
return this.$util.objToStyle({
|
||||
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
|
||||
background: cfg.formBgColor,
|
||||
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
|
||||
'--form-bg-color': cfg.formBgColor,
|
||||
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
|
||||
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
|
||||
'--label-gap': this.$util.rpx(cfg.labelGap),
|
||||
'--input-height': this.$util.rpx(cfg.inputHeight),
|
||||
'--input-radius': this.$util.rpx(cfg.inputRadius),
|
||||
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
||||
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
||||
'--label-color': cfg.labelColor,
|
||||
'--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)),
|
||||
'--textarea-rows': num(cfg.textareaRows, 4),
|
||||
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
|
||||
// 输入框视觉增强(与 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'
|
||||
});
|
||||
},
|
||||
rowWrapStyle() {
|
||||
return this.$util.objToStyle({
|
||||
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
|
||||
});
|
||||
},
|
||||
rowWrapClass() {
|
||||
return this.config.showRowDivider === false ? 'order-wrap no-divider' : 'order-wrap';
|
||||
},
|
||||
rowCellStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
|
||||
nameWidthStyle() {
|
||||
const cfg = this.config;
|
||||
if (cfg.labelWidthMode === 'fixed') {
|
||||
return { width: this.$util.rpx(cfg.labelFixedWidth) };
|
||||
}
|
||||
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
|
||||
},
|
||||
// 输入框样式(input / textarea 共用)
|
||||
inputStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': this.$util.rpx(this.config.inputHeight),
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius),
|
||||
'font-size': this.$util.rpx(this.config.labelFontSize),
|
||||
'color': this.config.labelColor,
|
||||
'line-height': this.$util.rpx(this.config.inputHeight)
|
||||
});
|
||||
},
|
||||
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
|
||||
textareaRowsValue() {
|
||||
const n = parseInt(this.config.textareaRows, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 4;
|
||||
},
|
||||
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出固定可视高度)
|
||||
textareaLineHeightValue() {
|
||||
const n = parseInt(this.config.textareaLineHeight, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 44;
|
||||
},
|
||||
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
|
||||
// 设计要点:
|
||||
// 1) 不复用 inputStyle 的 min-height / line-height:那两个值取自 inputHeight(默认 60rpx),
|
||||
// 对 textarea 太小,会让原生 textarea 单行过高、可视区域过小;
|
||||
// 2) 不使用 auto-height:原生 textarea 上 auto-height 会让组件随内容无限扩展,
|
||||
// 遮挡后续组件,导致"看不到下面的其他组件";
|
||||
// 3) 高度 = rows × lineHeight(两个值都由 formConfig 动态下发,不再写死 176rpx):
|
||||
// - H5 浏览器 / 微信原生层:原生 <textarea> 同时识别 :rows 和 CSS line-height,
|
||||
// 高度由浏览器自然算出,与 :rows 完全一致;
|
||||
// - 鸿蒙 ArkUI:原生 TextArea 只识别 :rows,不识别 CSS height,所以我们在 JS 里
|
||||
// 显式算 height = rows × lineHeight 反向写出,保证三端可视高度像素级一致。
|
||||
textareaStyle() {
|
||||
const lh = this.$util.rpx(this.textareaLineHeightValue);
|
||||
const rows = this.textareaRowsValue;
|
||||
// 加上 padding(12rpx 上下) 和 border(1rpx 上下) 的占用,避免与 :rows 算出的高度不一致时被截断
|
||||
const paddings = this.$util.rpx(24) + this.$util.rpx(2);
|
||||
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
|
||||
return this.$util.objToStyle({
|
||||
'height': h,
|
||||
'min-height': h,
|
||||
'line-height': lh,
|
||||
'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;
|
||||
},
|
||||
// 复选 / 单选 选中色
|
||||
checkedColor() {
|
||||
return this.config.checkedColor;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.setFormData();
|
||||
this.initFormData();
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
},
|
||||
watch: {
|
||||
data: function() {
|
||||
this.setFormData();
|
||||
data: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.initFormData();
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
}
|
||||
},
|
||||
formConfig: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 预计算每项的 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.formData = this.data;
|
||||
|
||||
this.initFormData();
|
||||
this.$nextTick(() => this.calculateMaxLabelWidth());
|
||||
},
|
||||
processFormData() {
|
||||
this.formData.forEach(item => {
|
||||
if (!item.val) item.val = item.value.default ? item.value.default : '';
|
||||
// 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.value.options) {
|
||||
item.option_lists = [];
|
||||
@@ -356,6 +647,14 @@
|
||||
}
|
||||
});
|
||||
},
|
||||
// 计算最长 label 宽度(封装自 util.calculateMaxLabelWidth)
|
||||
// 用于 labelWidthMode === 'auto' 时把 .name 的 width 撑开,避免长短 label 错位
|
||||
calculateMaxLabelWidth() {
|
||||
this.maxLabelWidth = this.$util.calculateMaxLabelWidth(
|
||||
this.formData,
|
||||
this.config.labelFontSize
|
||||
);
|
||||
},
|
||||
verify() {
|
||||
let verify = true;
|
||||
|
||||
@@ -643,9 +942,45 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
/* CSS 自定义属性:默认值,与组件内 config.defaults 一一对应,
|
||||
formConfig 未传时作为兜底;运行时会被 formWrapStyle 注入的 inline style 覆盖。
|
||||
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
||||
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
||||
.form-wrap {
|
||||
--form-padding-x: 0rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-padding-y: 20rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 16rpx;
|
||||
--input-height: 60rpx;
|
||||
--input-radius: 0rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 28rpx;
|
||||
--label-color: #1f2937;
|
||||
--label-font-weight: 600;
|
||||
--required-color: #ef4444;
|
||||
--placeholder-font-size: 28rpx;
|
||||
--checked-color: #ff4544;
|
||||
--placeholder-color: #909399;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 44rpx;
|
||||
// 输入框视觉增强(与 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;
|
||||
}
|
||||
|
||||
.order-wrap {
|
||||
padding: 20rpx 0;
|
||||
padding: var(--row-padding-y, 20rpx) 0;
|
||||
|
||||
&.no-divider {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -658,9 +993,10 @@
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
min-height: var(--row-min-height, 60rpx);
|
||||
|
||||
&.textarea {
|
||||
align-items: unset;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.clear-flex {
|
||||
@@ -674,7 +1010,8 @@
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: solid 1px #eee;
|
||||
// calc + 0/1 控制分割线显隐(formConfig.showRowDivider = false 时整条隐藏)
|
||||
border-bottom: calc(var(--show-row-divider, 1) * 2rpx) solid var(--row-divider-color, #f0f0f0);
|
||||
}
|
||||
|
||||
&.align-top {
|
||||
@@ -682,28 +1019,31 @@
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 80vw;
|
||||
width: var(--label-fixed-width, 180rpx);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: var(--label-gap, 16rpx);
|
||||
}
|
||||
|
||||
.tit {
|
||||
text-align: left;
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
font-weight: var(--label-font-weight, 600);
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
font-size: 28rpx;
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
margin-left: 4rpx;
|
||||
width: 14rpx;
|
||||
text-align: left;
|
||||
@@ -716,17 +1056,91 @@
|
||||
line-height: inherit;
|
||||
text-align: left;
|
||||
|
||||
// H5 端 <input> 可能被 uni-app 编译为 <uni-input> 包装组件,::v-deep 穿透 scoped 命中真实原生 input
|
||||
::v-deep input,
|
||||
input {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
text-align: left;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||
// 避免 placeholder / 文字下半部分被裁切
|
||||
line-height: var(--input-height, 60rpx);
|
||||
height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 0 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);
|
||||
}
|
||||
|
||||
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||
.placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
}
|
||||
|
||||
::v-deep textarea,
|
||||
textarea {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 44rpx;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 行高由 formConfig.textareaLineHeight(兜底 44rpx)控制;
|
||||
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
||||
// 真正做到了"rows 改 → 高度自动改"。
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.uni-input {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
// 输入框视觉增强(与 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 {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
}
|
||||
|
||||
.color-tip {
|
||||
color: var(--placeholder-color, #909399);
|
||||
}
|
||||
|
||||
checkbox-group {
|
||||
@@ -739,6 +1153,15 @@
|
||||
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;
|
||||
@@ -847,7 +1270,7 @@
|
||||
|
||||
.iconfont {
|
||||
color: $color-tip;
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
|
||||
.box-flex picker {
|
||||
|
||||
@@ -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,10 +67,13 @@
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.auto) {
|
||||
this.getLikeList();
|
||||
}
|
||||
},
|
||||
// 初始化 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();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.list = [];
|
||||
|
||||
@@ -56,10 +56,18 @@
|
||||
|
||||
<!-- 手机号授权按钮 -->
|
||||
<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">
|
||||
@@ -67,6 +75,27 @@
|
||||
</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') }}
|
||||
@@ -109,11 +138,20 @@
|
||||
|
||||
<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>
|
||||
@@ -170,12 +208,16 @@ export default {
|
||||
isSend: false
|
||||
},
|
||||
// 小程序获取手机号所需数据
|
||||
authMobileData: {
|
||||
iv: '',
|
||||
encryptedData: ''
|
||||
},
|
||||
site_name: ''
|
||||
};
|
||||
authMobileData: {
|
||||
iv: '',
|
||||
encryptedData: ''
|
||||
},
|
||||
site_name: '',
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙专属:控制手机号+验证码表单显示
|
||||
showHarmonyMobileForm: false,
|
||||
// #endif
|
||||
};
|
||||
},
|
||||
options: {
|
||||
styleIsolation: 'shared'
|
||||
@@ -203,7 +245,7 @@ export default {
|
||||
if (this.forceBindingMobileControl) {
|
||||
if (this.formData.mobile.length == 0) return true;
|
||||
|
||||
// #ifdef H5
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
// 验证码
|
||||
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
|
||||
// 动态码
|
||||
@@ -343,7 +385,7 @@ export default {
|
||||
openCompleteInfoPop() {
|
||||
this.getRegisterConfig();
|
||||
|
||||
// #ifdef H5
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
if (!this.storeToken) this.getCaptchaConfig();
|
||||
// #endif
|
||||
|
||||
@@ -676,6 +718,26 @@ 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 === '') {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
let menuButtonInfo = {};
|
||||
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
|
||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
||||
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
|
||||
// #endif
|
||||
|
||||
|
||||
@@ -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 || $lang('components.ns-newform.inputPlaceholder')"
|
||||
placeholder-class="placeholder color-tip"
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
/>
|
||||
</view>
|
||||
@@ -27,10 +27,16 @@
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
|
||||
<textarea
|
||||
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
|
||||
placeholder-class="placeholder color-tip"
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
- H5 / 微信:原生 textarea 识别 :rows 属性;
|
||||
- 鸿蒙:渲染为 ArkUI 原生 TextArea,CSS height 不生效,必须靠 :rows 控制。
|
||||
rows 全部由 formConfig 动态下发,与 ns-form 行为保持一致。 -->
|
||||
<textarea
|
||||
:rows="item.value.rows || textareaRowsValue"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
</view>
|
||||
</view>
|
||||
@@ -64,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', 'align-left']">
|
||||
<checkbox-group @change="checkboxChange($event, index)">
|
||||
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||
<checkbox :value="v.value" :checked="v.checked"></checkbox>
|
||||
<view class="checkbox">
|
||||
@@ -92,8 +98,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']">
|
||||
<radio-group @change="radioChange($event, index)">
|
||||
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
||||
<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">
|
||||
@@ -280,8 +286,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-newform',
|
||||
mixins: [formPlatformMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
@@ -349,61 +358,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 当前运行平台标识(与后端 formConfig.terminal 键名对齐:h5 / wechat / weapp / harmony)
|
||||
platform() {
|
||||
// 优先使用后端在 formConfig.terminal 中使用的键名
|
||||
// #ifdef H5
|
||||
return 'h5';
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'weapp';
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
return 'mp-alipay';
|
||||
// #endif
|
||||
// #ifdef MP-BAIDU
|
||||
return 'mp-baidu';
|
||||
// #endif
|
||||
// #ifdef MP-TOUTIAO
|
||||
return 'mp-toutiao';
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
return 'app';
|
||||
// #endif
|
||||
// #ifdef MP-HARMONY
|
||||
return 'harmony';
|
||||
// #endif
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
return 'quickapp';
|
||||
// #endif
|
||||
return 'h5';
|
||||
},
|
||||
// 平台配置兼容层:后端键名(h5/wechat/weapp/harmony)→ 内部 platform 名
|
||||
platformAliases() {
|
||||
return {
|
||||
'h5': 'h5',
|
||||
'weapp': 'weapp',
|
||||
'mp-weixin': 'weapp',
|
||||
'wechat': 'wechat',
|
||||
'harmony': 'harmony',
|
||||
'app': 'app',
|
||||
'app-plus': 'app'
|
||||
};
|
||||
},
|
||||
// 当前平台的有效配置键集合(用于 config() 中查找 platformCfg)
|
||||
platformConfigKeys() {
|
||||
const cur = this.platform;
|
||||
const aliases = this.platformAliases;
|
||||
// 找出所有映射到当前平台的后端键名
|
||||
const keys = [];
|
||||
Object.keys(aliases).forEach(k => {
|
||||
if (aliases[k] === cur) keys.push(k);
|
||||
});
|
||||
// 加入当前平台名(兼容 formConfig[platform] 这种写法)
|
||||
if (keys.indexOf(cur) === -1) keys.push(cur);
|
||||
return keys;
|
||||
},
|
||||
// 合并配置:内置默认 < 全局 < 当前平台
|
||||
// 合并配置:内置默认 < 全局 < 当前平台
|
||||
config() {
|
||||
// 内置默认值(最低优先级)- 与后端 D:\projects\shop-projects\backend\src\app\component\view\form\js\design.js getDefaultFormData().formConfig 对齐
|
||||
const defaults = {
|
||||
@@ -422,8 +377,22 @@ export default {
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 600,
|
||||
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#ddd',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
|
||||
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 44,
|
||||
// 输入框视觉增强(可由后端 form_config 覆盖)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#f9fafb',
|
||||
inputFocusBorderColor: '#ff4544',
|
||||
inputFocusBgColor: '#ffffff',
|
||||
labelFontWeight: 600,
|
||||
// 提交按钮
|
||||
submitColor: '#ffffff',
|
||||
submitBgColor: '#ff4544',
|
||||
@@ -455,9 +424,9 @@ export default {
|
||||
}
|
||||
let width;
|
||||
if (this.config.labelWidthMode === 'fixed') {
|
||||
width = `${this.config.labelFixedWidth}rpx`;
|
||||
width = this.$util.rpx(this.config.labelFixedWidth);
|
||||
} else {
|
||||
width = this.maxLabelWidth ? `${this.maxLabelWidth}rpx` : 'auto';
|
||||
width = this.maxLabelWidth ? this.$util.rpx(this.maxLabelWidth) : 'auto';
|
||||
}
|
||||
return {
|
||||
width: width
|
||||
@@ -470,30 +439,41 @@ export default {
|
||||
|
||||
return this.$util.objToStyle({
|
||||
// 仅保留左右内边距 = form-padding-x;上下由每行 row-padding-y 承担
|
||||
padding: `0 ${cfg.formPaddingX}rpx`,
|
||||
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
|
||||
background: cfg.formBgColor,
|
||||
// 通过 CSS 变量传递:与后端 design.css 的 :root 变量一一对应
|
||||
'--form-padding-x': `${cfg.formPaddingX}rpx`,
|
||||
'--banner-padding-x': `${cfg.bannerPaddingX}rpx`,
|
||||
'--tip-padding-x': `${cfg.tipPaddingX}rpx`,
|
||||
'--tip-font-size': `${cfg.tipFontSize}rpx`,
|
||||
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
|
||||
'--banner-padding-x': this.$util.rpx(cfg.bannerPaddingX),
|
||||
'--tip-padding-x': this.$util.rpx(cfg.tipPaddingX),
|
||||
'--tip-font-size': this.$util.rpx(cfg.tipFontSize),
|
||||
'--tip-color': cfg.tipColor,
|
||||
'--tip-bg-color': cfg.tipBgColor,
|
||||
'--row-padding-y': `${cfg.rowPaddingY}rpx`,
|
||||
'--row-min-height': `${cfg.rowMinHeight}rpx`,
|
||||
'--label-gap': `${cfg.labelGap}rpx`,
|
||||
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
|
||||
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
|
||||
'--label-gap': this.$util.rpx(cfg.labelGap),
|
||||
'--form-bg-color': cfg.formBgColor,
|
||||
'--row-divider-color': cfg.rowDividerColor,
|
||||
'--input-height': `${cfg.inputHeight}rpx`,
|
||||
'--input-radius': `${cfg.inputRadius}rpx`,
|
||||
'--label-fixed-width': `${cfg.labelFixedWidth}rpx`,
|
||||
'--label-font-size': `${num(cfg.labelFontSize, 28)}rpx`,
|
||||
'--input-height': this.$util.rpx(cfg.inputHeight),
|
||||
'--input-radius': this.$util.rpx(cfg.inputRadius),
|
||||
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
||||
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
||||
'--label-color': cfg.labelColor,
|
||||
'--required-color': cfg.requiredColor,
|
||||
'--checked-color': cfg.checkedColor,
|
||||
'--placeholder-color': cfg.placeholderColor,
|
||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||
// textarea 行数 / 行高(CSS 兜底 + JS 算高度都用得到)
|
||||
'--textarea-rows': num(cfg.textareaRows, 4),
|
||||
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
|
||||
// 输入框视觉增强(边框 / 背景 / 聚焦 / 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': `${cfg.submitMarginTop || 0}rpx`,
|
||||
'--submit-margin-top': this.$util.rpx(cfg.submitMarginTop || 0),
|
||||
// 分割线显隐
|
||||
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
||||
});
|
||||
@@ -501,7 +481,7 @@ export default {
|
||||
// 行间距样式:仅上下内边距,水平由外层 form-wrap 控制(与后端 form-gorup 对齐)
|
||||
rowWrapStyle() {
|
||||
return this.$util.objToStyle({
|
||||
padding: `${this.config.rowPaddingY}rpx 0`
|
||||
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
|
||||
});
|
||||
},
|
||||
// 行容器 class:用于控制分割线显隐
|
||||
@@ -511,7 +491,7 @@ export default {
|
||||
// 行最小高度
|
||||
rowCellStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': `${this.config.rowMinHeight}rpx`
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签与输入框间距
|
||||
@@ -520,26 +500,45 @@ export default {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'margin-right': `${this.config.labelGap}rpx`
|
||||
'margin-right': this.$util.rpx(this.config.labelGap)
|
||||
};
|
||||
},
|
||||
// 输入框样式(全局,未指定 item 时使用)
|
||||
inputStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': `${this.config.inputHeight}rpx`,
|
||||
'border-radius': `${this.config.inputRadius}rpx`
|
||||
'min-height': this.$util.rpx(this.config.inputHeight),
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius)
|
||||
});
|
||||
},
|
||||
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
|
||||
textareaRowsValue() {
|
||||
const n = parseInt(this.config.textareaRows, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 4;
|
||||
},
|
||||
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出可视高度)
|
||||
textareaLineHeightValue() {
|
||||
const n = parseInt(this.config.textareaLineHeight, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 44;
|
||||
},
|
||||
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
|
||||
// 高度 = rows × lineHeight + padding + border,由 formConfig 动态控制,与 ns-form 同公式
|
||||
textareaStyle() {
|
||||
const lh = this.$util.rpx(this.textareaLineHeightValue);
|
||||
const rows = this.textareaRowsValue;
|
||||
const paddings = this.$util.rpx(24) + this.$util.rpx(2); // padding 12rpx 上下 + border 1rpx 上下
|
||||
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
|
||||
return this.$util.objToStyle({
|
||||
'height': h,
|
||||
'min-height': h,
|
||||
'line-height': lh,
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius)
|
||||
});
|
||||
},
|
||||
labelStyle() {
|
||||
let style = {};
|
||||
let fontSize = this.config.labelFontSize;
|
||||
if (typeof fontSize === 'number') {
|
||||
fontSize = `${fontSize}rpx`;
|
||||
} else if (typeof fontSize === 'string' && !fontSize.endsWith('rpx') && !fontSize.endsWith('px')) {
|
||||
fontSize = `${fontSize}rpx`;
|
||||
}
|
||||
style.fontSize = fontSize;
|
||||
style.color = this.config.labelColor;
|
||||
const style = {
|
||||
fontSize: this.$util.rpx(this.config.labelFontSize),
|
||||
color: this.config.labelColor
|
||||
};
|
||||
return this.$util.objToStyle(style);
|
||||
},
|
||||
requiredColor() {
|
||||
@@ -559,21 +558,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 处理 rpx 字号:与后端 label_font_size 字段语义对齐
|
||||
_resolveFontSize(value, fallback) {
|
||||
let v = value;
|
||||
if (v === '' || v === null || v === undefined) {
|
||||
v = fallback;
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return `${v}rpx`;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
if (v.endsWith('rpx') || v.endsWith('px')) return v;
|
||||
return `${v}rpx`;
|
||||
}
|
||||
return v;
|
||||
},
|
||||
// 预计算每项的样式(写到 item._labelStyle / item._boxAlignStyle),
|
||||
// 避免在模板里以方法形式调用:微信小程序 WXML 不支持 :style="getXxx(item)" 语法。
|
||||
// 触发时机:processFormData 末尾 / calculateMaxLabelWidth 末尾 / config 变化 / mode 变化。
|
||||
@@ -589,10 +573,7 @@ export default {
|
||||
|
||||
// item 级别 label 字号 / 颜色覆盖(与原 getItemLabelStyle 逻辑一致)
|
||||
if (itemValue.label_font_size !== undefined && itemValue.label_font_size !== null && itemValue.label_font_size !== '') {
|
||||
let fs = itemValue.label_font_size;
|
||||
if (typeof fs === 'number') fs = `${fs}rpx`;
|
||||
else if (typeof fs === 'string' && !fs.endsWith('rpx') && !fs.endsWith('px')) fs = `${fs}rpx`;
|
||||
style['--item-label-font-size'] = fs;
|
||||
style['--item-label-font-size'] = this.$util.rpx(itemValue.label_font_size);
|
||||
}
|
||||
if (itemValue.label_color) {
|
||||
style['--item-label-color'] = itemValue.label_color;
|
||||
@@ -606,6 +587,23 @@ 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 使用)
|
||||
@@ -629,8 +627,13 @@ export default {
|
||||
if (len > maxChars) maxChars = len;
|
||||
});
|
||||
// 与后端公式一致:chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2)
|
||||
const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
|
||||
this.maxLabelWidth = Math.round(widthPx * 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;
|
||||
// 宽度变了,需要把全局 nameWidthStyle 重新合到每项 _labelStyle 里
|
||||
this.recomputeItemStyles();
|
||||
},
|
||||
@@ -648,7 +651,9 @@ export default {
|
||||
|
||||
processFormData() {
|
||||
this.formData.forEach((item) => {
|
||||
if (!item.val) {
|
||||
// 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 : '';
|
||||
}
|
||||
|
||||
@@ -1028,6 +1033,16 @@ export default {
|
||||
--submit-color: #ffffff;
|
||||
--submit-bg-color: #ff4544;
|
||||
--placeholder-color: #ddd;
|
||||
--placeholder-font-size: 28rpx;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 44rpx;
|
||||
// 输入框视觉增强(边框 / 背景 / 聚焦 / 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 对齐:整组隐藏所有行分割线
|
||||
@@ -1079,13 +1094,13 @@ export default {
|
||||
&.vertical-layout {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
|
||||
.name {
|
||||
width: 100%;
|
||||
margin-bottom: 8rpx;
|
||||
margin-bottom: var(--label-gap, 16rpx);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
|
||||
.box {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1135,7 +1150,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: 500;
|
||||
font-weight: var(--item-label-font-weight, var(--label-font-weight, 600));
|
||||
letter-spacing: 0.2rpx;
|
||||
|
||||
text {
|
||||
@@ -1203,52 +1218,110 @@ 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: none;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
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: transparent;
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
::v-deep textarea {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
width: 100%;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
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;
|
||||
|
||||
&: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;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(与 ::v-deep textarea 同步)
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: none;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
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: transparent;
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #ff4544);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1308,6 +1381,16 @@ 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;
|
||||
|
||||
@@ -70,37 +70,11 @@ export default {
|
||||
return {
|
||||
isIphoneX: false,
|
||||
payIndex: 0,
|
||||
// #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'
|
||||
}
|
||||
],
|
||||
payTypeList: [],
|
||||
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,
|
||||
// 预售页面判断
|
||||
@@ -108,6 +82,30 @@ 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
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
<view class="video-popup-body">
|
||||
<video
|
||||
v-if="videoUrl"
|
||||
id="popupVideoPlayer"
|
||||
:src="videoUrl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
class="video-player"
|
||||
></video>
|
||||
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
||||
@@ -22,6 +26,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { playVideoInContext } from '@/common/js/video-helper.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-video-player-popup',
|
||||
props: {
|
||||
@@ -76,6 +82,10 @@ export default {
|
||||
* @param {Object} e - 弹窗状态变化事件
|
||||
*/
|
||||
onVideoPopupChange(e) {
|
||||
// H5 端弹窗显示后触发播放:避免 autoplay 被浏览器拦截 / 弹窗动画未完成被 pause() 打断
|
||||
if (e && e.show) {
|
||||
playVideoInContext('popupVideoPlayer', this);
|
||||
}
|
||||
this.$emit('popup-change', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@
|
||||
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,
|
||||
// 预售页面判断
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<template>
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!--
|
||||
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
||||
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
|
||||
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
|
||||
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
|
||||
-->
|
||||
<!-- #endif-->
|
||||
<!-- #ifndef MP-HARMONY -->
|
||||
<view>
|
||||
<view class="privacy-mask" v-if="showPop">
|
||||
<view class="privacy-wrap">
|
||||
@@ -6,7 +15,7 @@
|
||||
<view class="privacy-desc">
|
||||
{{ i18n.descPrefix }}
|
||||
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
|
||||
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY -->
|
||||
<!-- #ifdef QUICKAPP-WEBVIEW || H5 -->
|
||||
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
|
||||
<!-- #endif -->
|
||||
{{ i18n.descSuffix }}
|
||||
@@ -17,13 +26,15 @@
|
||||
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
|
||||
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || MP-HARMONY -->
|
||||
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||
<!-- #ifdef H5 -->
|
||||
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
|
||||
}}</button>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -48,6 +59,12 @@ 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() 完成,
|
||||
@@ -70,10 +87,22 @@ 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
|
||||
@@ -85,13 +114,28 @@ 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
|
||||
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
|
||||
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
|
||||
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
|
||||
// 立即 resolve, 让 popUp 的 await 不永久阻塞
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// H5 无平台隐私 API, 立即 resolve
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
// #endif
|
||||
|
||||
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
||||
@@ -179,14 +223,14 @@ export default {
|
||||
* 统一弹窗 + 关闭其他页面的弹窗
|
||||
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
||||
*/
|
||||
showPrivacyPopup() {
|
||||
async showPrivacyPopup() {
|
||||
// 先通知其他实例关闭
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
||||
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
||||
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
||||
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
||||
this.popUp()
|
||||
await this.popUp()
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -265,7 +309,8 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
|
||||
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
|
||||
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
|
||||
// #endif
|
||||
},
|
||||
|
||||
@@ -284,22 +329,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);
|
||||
@@ -307,8 +352,8 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 使用应用内协议页面
|
||||
this.$util.redirectTo(privacyContractPage);
|
||||
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
||||
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -320,8 +365,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);
|
||||
@@ -329,7 +374,7 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
this.$util.redirectTo(privacyServicePage);
|
||||
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -344,7 +389,7 @@ export default {
|
||||
this.privacyResolves.clear()
|
||||
|
||||
// 先通知其他实例关闭, 再关闭自己, 避免叠层
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||
this.disPopUp()
|
||||
|
||||
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
|
||||
@@ -369,7 +414,7 @@ export default {
|
||||
this.privacyResolves.clear()
|
||||
|
||||
// 先通知其他实例关闭, 再关闭自己
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||
this.disPopUp()
|
||||
|
||||
// 由 PrivacyManager 管理 storage
|
||||
@@ -390,9 +435,9 @@ export default {
|
||||
*/
|
||||
i18nPrepareFn() {
|
||||
let appName, privacyContractName, privacyServiceName;
|
||||
|
||||
|
||||
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
// #ifdef H5
|
||||
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
||||
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
||||
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
||||
@@ -416,20 +461,33 @@ export default {
|
||||
return result;
|
||||
},
|
||||
|
||||
popUp() {
|
||||
async popUp() {
|
||||
// 弹窗前**一次性**计算所有 i18n 文案
|
||||
// boot 阶段已用 isBundleComplete() 保证 bundle 完整
|
||||
// $lang() 返回真实翻译(效果与 H5 一致)
|
||||
// 必须先等两类数据准备好:
|
||||
// 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 默认文案)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -438,6 +496,11 @@ 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>
|
||||
@@ -491,7 +554,7 @@ export default {
|
||||
.privacy-button-btn {
|
||||
color: #FFF;
|
||||
font-size: 30rpx;
|
||||
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
|
||||
/* #ifdef QUICKAPP-WEBVIEW || H5 */
|
||||
font-size: 28rpx;
|
||||
/* #endif */
|
||||
font-weight: 500;
|
||||
|
||||
5168
lang-bundle.json
5168
lang-bundle.json
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@ export const lang = {
|
||||
|
||||
// 底部选项卡
|
||||
tabBar: {
|
||||
home: 'index',
|
||||
category: 'category',
|
||||
cart: 'cart',
|
||||
member: 'member',
|
||||
home: 'Home',
|
||||
category: 'Category',
|
||||
cart: 'Cart',
|
||||
member: 'Me',
|
||||
},
|
||||
|
||||
//公共
|
||||
@@ -54,11 +54,15 @@ export const lang = {
|
||||
serviceUnavailable: 'Navigation service temporarily unavailable',
|
||||
},
|
||||
|
||||
// diy custom page
|
||||
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
|
||||
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
|
||||
@@ -108,6 +112,7 @@ 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
|
||||
@@ -134,12 +139,18 @@ export const lang = {
|
||||
requestFailed: '{url} request failed. {err}',
|
||||
// network timeout
|
||||
timeout: 'Network request timeout, please try again later',
|
||||
// network connection failed
|
||||
networkError: 'Network connection failed, please check your network',
|
||||
// network connection failed / generic network error (unified copy, reused by diy and other generic scenarios)
|
||||
networkError: 'Network error, please try again later',
|
||||
// server error
|
||||
serverError: 'Service temporarily unavailable, please try again later',
|
||||
serverError: 'Server error, please try again later',
|
||||
// API not found (HTTP 404)
|
||||
notFound: 'API not found',
|
||||
// no access permission (HTTP 401 / 403)
|
||||
auth: 'No access permission',
|
||||
// request aborted
|
||||
aborted: 'Request cancelled',
|
||||
// unknown error (errorType fallback, e.g. non-5xx/404/401/403 HTTP status)
|
||||
unknown: 'Request failed, please try again later',
|
||||
// unknown error
|
||||
unknownError: 'Request failed, please try again later',
|
||||
},
|
||||
@@ -247,7 +258,14 @@ 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',
|
||||
|
||||
@@ -115,6 +115,7 @@ export const lang = {
|
||||
|
||||
// Product Message
|
||||
productMessage: 'Product Message',
|
||||
formEmptyTip: 'No message fields to fill',
|
||||
|
||||
// AI Customer Service
|
||||
aiCustomerService: 'AI Assistant',
|
||||
|
||||
@@ -3,5 +3,6 @@ export const lang = {
|
||||
title: 'Payment Method',
|
||||
paymentAmount: 'Payment Amount',
|
||||
confirmPayment: 'Confirm Payment',
|
||||
seeOrder: 'View Order'
|
||||
seeOrder: 'View Order',
|
||||
payInfoNotFound: 'Payment info not found!'
|
||||
}
|
||||
@@ -61,11 +61,15 @@ export const lang = {
|
||||
weChatWorkKefu: '企业微信客服',
|
||||
},
|
||||
|
||||
// diy 自定义页面
|
||||
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
|
||||
diy: {
|
||||
networkErrorTitle: '提示',
|
||||
networkError: '网络异常,请稍后重试',
|
||||
retry: '重试',
|
||||
noDiyData: '未配置自定义页面数据',
|
||||
locationNotAvailable: '未获取到定位',
|
||||
// 数据加载失败详细文案(diy 专属模板,内部 {reason} 由通用 http.* 填充)
|
||||
loadFailTpl: '页面数据加载失败:{reason},请稍后重试',
|
||||
},
|
||||
|
||||
// 客服服务
|
||||
@@ -108,6 +112,7 @@ export const lang = {
|
||||
notSupported: '当前环境不支持微信视频号',
|
||||
missingFields: '缺少必要字段: {field}',
|
||||
playFailed: '微信视频播放失败!',
|
||||
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
|
||||
},
|
||||
|
||||
// uniapp 工具函数
|
||||
@@ -134,12 +139,18 @@ export const lang = {
|
||||
requestFailed: '{url}请求失败. {err}',
|
||||
// 网络超时
|
||||
timeout: '网络请求超时,请稍后重试',
|
||||
// 网络连接失败
|
||||
networkError: '网络连接失败,请检查网络设置',
|
||||
// 网络连接失败 / 通用网络异常(统一文案,供 diy 等通用场景复用)
|
||||
networkError: '网络异常,请稍后重试',
|
||||
// 服务器错误
|
||||
serverError: '服务暂时不可用,请稍后重试',
|
||||
serverError: '服务器异常,请稍后重试',
|
||||
// 接口不存在(HTTP 404)
|
||||
notFound: '接口不存在',
|
||||
// 无访问权限(HTTP 401 / 403)
|
||||
auth: '无访问权限',
|
||||
// 请求被中断
|
||||
aborted: '请求已取消',
|
||||
// 未知错误(errorType 分类兜底,如非 5xx/404/401/403 的 HTTP 状态码)
|
||||
unknown: '请求失败,请稍后重试',
|
||||
// 未知错误
|
||||
unknownError: '请求失败,请稍后重试',
|
||||
},
|
||||
@@ -245,6 +256,14 @@ export const lang = {
|
||||
addToCartFailMin: '已达到最小购买数量',
|
||||
addToCartFailStock: '库存不足',
|
||||
|
||||
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
|
||||
submitSuccess: '提交成功',
|
||||
submitFail: '提交失败',
|
||||
versionInfo: '版本信息',
|
||||
commitId: '提交 ID',
|
||||
buildTime: '构建时间',
|
||||
loading: '加载中...',
|
||||
|
||||
consult: '立即咨询',
|
||||
consultSuccess: '咨询成功',
|
||||
consultFail: '咨询失败',
|
||||
|
||||
@@ -108,6 +108,7 @@ export const lang = {
|
||||
|
||||
// 商品留言
|
||||
productMessage: '商品留言',
|
||||
formEmptyTip: '暂无需填写的留言字段',
|
||||
|
||||
// 智能客服
|
||||
aiCustomerService: '智能客服',
|
||||
|
||||
@@ -3,5 +3,6 @@ export const lang = {
|
||||
title: '支付方式',
|
||||
paymentAmount: '支付金额',
|
||||
confirmPayment: '确认支付',
|
||||
seeOrder: '查看订单'
|
||||
seeOrder: '查看订单',
|
||||
payInfoNotFound: '未获取到支付信息!'
|
||||
}
|
||||
|
||||
8
main.js
8
main.js
@@ -80,10 +80,18 @@ 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 失败不影响主流程 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,7 +136,23 @@
|
||||
"sassImplementationName" : "dart-sass",
|
||||
"mp-harmony" : {
|
||||
"distribute" : {
|
||||
"bundleName" : "com.atomicservice.6917577984608507983"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
package.json
164
package.json
@@ -1,72 +1,92 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
pages.json
194
pages.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
@@ -51,6 +51,7 @@
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-recommend-code": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
@@ -147,13 +148,13 @@
|
||||
"diy-store-label": "view",
|
||||
"diy-tab": "view",
|
||||
"diy-text": "view",
|
||||
"diy-video": "view",
|
||||
"diy-video-list": "view"
|
||||
"diy-video": "view",
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "cart",
|
||||
},
|
||||
{
|
||||
"path": "cart",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
@@ -363,7 +364,7 @@
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "",
|
||||
"navigationBarTitleText": "",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
@@ -635,7 +636,7 @@
|
||||
//****************** 商户 ******************
|
||||
{
|
||||
"path": "merch/merchcategory",
|
||||
"style": {
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
@@ -960,10 +961,10 @@
|
||||
{
|
||||
"path": "fenxiao/withdrawal_detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
// #ifdef APP-PLUS
|
||||
,"navigationStyle": "custom"
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1084,8 +1085,8 @@
|
||||
"pages": [
|
||||
{
|
||||
"path": "ai-chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能客服"
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能客服"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1093,11 +1094,14 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "vr/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "工厂展示"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1291,6 +1295,7 @@
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-recommend-code": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
@@ -2147,7 +2152,9 @@
|
||||
{
|
||||
"path": "form/formdata",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
|
||||
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
|
||||
// #ifdef APP-PLUS || MP
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "",
|
||||
@@ -2208,7 +2215,7 @@
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "weapp/order_shipping",
|
||||
"style": {
|
||||
@@ -2314,78 +2321,78 @@
|
||||
},
|
||||
//******************文件模块******************
|
||||
{
|
||||
"path": "files/list",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "查看文件",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
"diy-bargain": "view",
|
||||
"diy-bottom-nav": "view",
|
||||
"diy-category": "view",
|
||||
"diy-category-item": "view",
|
||||
"diy-channel-list": "view",
|
||||
"diy-channel-video": "view",
|
||||
"diy-comp-extend": "view",
|
||||
"diy-coupon": "view",
|
||||
"diy-digit": "view",
|
||||
"diy-fenxiao-goods-list": "view",
|
||||
"diy-float-btn": "view",
|
||||
"diy-follow-official-account": "view",
|
||||
"diy-form": "view",
|
||||
"diy-goods-brand": "view",
|
||||
"diy-goods-list": "view",
|
||||
"diy-goods-recommend": "view",
|
||||
"diy-graphic-nav": "view",
|
||||
"diy-group": "view",
|
||||
"diy-groupbuy": "view",
|
||||
"diy-horz-blank": "view",
|
||||
"diy-horz-line": "view",
|
||||
"diy-hot-area": "view",
|
||||
"diy-icon": "view",
|
||||
"diy-image-nav": "view",
|
||||
"diy-img-ads": "view",
|
||||
"diy-index-page": "view",
|
||||
"diy-kefu": "view",
|
||||
"diy-listmenu": "view",
|
||||
"diy-live": "view",
|
||||
"diy-many-goods-list": "view",
|
||||
"diy-map": "view",
|
||||
"diy-member-info": "view",
|
||||
"diy-member-my-order": "view",
|
||||
"diy-merch-list": "view",
|
||||
"diy-notes": "view",
|
||||
"diy-notice": "view",
|
||||
"diy-payment-qrcode": "view",
|
||||
"diy-picture": "view",
|
||||
"diy-pinfan": "view",
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
"diy-seckill": "view",
|
||||
"diy-store": "view",
|
||||
"diy-store-label": "view",
|
||||
"diy-tab": "view",
|
||||
"diy-text": "view",
|
||||
"diy-video": "view",
|
||||
"diy-video-list": "view"
|
||||
"path": "files/list",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "查看文件",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
"diy-bargain": "view",
|
||||
"diy-bottom-nav": "view",
|
||||
"diy-category": "view",
|
||||
"diy-category-item": "view",
|
||||
"diy-channel-list": "view",
|
||||
"diy-channel-video": "view",
|
||||
"diy-comp-extend": "view",
|
||||
"diy-coupon": "view",
|
||||
"diy-digit": "view",
|
||||
"diy-fenxiao-goods-list": "view",
|
||||
"diy-float-btn": "view",
|
||||
"diy-follow-official-account": "view",
|
||||
"diy-form": "view",
|
||||
"diy-goods-brand": "view",
|
||||
"diy-goods-list": "view",
|
||||
"diy-goods-recommend": "view",
|
||||
"diy-graphic-nav": "view",
|
||||
"diy-group": "view",
|
||||
"diy-groupbuy": "view",
|
||||
"diy-horz-blank": "view",
|
||||
"diy-horz-line": "view",
|
||||
"diy-hot-area": "view",
|
||||
"diy-icon": "view",
|
||||
"diy-image-nav": "view",
|
||||
"diy-img-ads": "view",
|
||||
"diy-index-page": "view",
|
||||
"diy-kefu": "view",
|
||||
"diy-listmenu": "view",
|
||||
"diy-live": "view",
|
||||
"diy-many-goods-list": "view",
|
||||
"diy-map": "view",
|
||||
"diy-member-info": "view",
|
||||
"diy-member-my-order": "view",
|
||||
"diy-merch-list": "view",
|
||||
"diy-notes": "view",
|
||||
"diy-notice": "view",
|
||||
"diy-payment-qrcode": "view",
|
||||
"diy-picture": "view",
|
||||
"diy-pinfan": "view",
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
"diy-seckill": "view",
|
||||
"diy-store": "view",
|
||||
"diy-store-label": "view",
|
||||
"diy-tab": "view",
|
||||
"diy-text": "view",
|
||||
"diy-video": "view",
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
@@ -2416,14 +2423,12 @@
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"custom": {
|
||||
"x-skeleton": "@/uni_modules/x-skeleton/components/x-skeleton/x-skeleton.vue",
|
||||
"mp-html": "@/uni_modules/mp-html/components/mp-html/mp-html.vue",
|
||||
"mp-html": "@/uni_modules/mp-html/components/mp-html/mp-html.vue",
|
||||
"uni-*(\\W.*)": "@/uni_modules/uni$1/components/uni$1/uni$1.vue",
|
||||
|
||||
"mescroll-uni": "@/components/mescroll/my-list-mescroll.vue",
|
||||
"ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue",
|
||||
|
||||
"ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue",
|
||||
"diy-*(\\W.*)": "@/components-diy/diy$1.vue"
|
||||
}
|
||||
},
|
||||
@@ -2431,8 +2436,9 @@
|
||||
"pages/index/index": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"pages_tool"
|
||||
"pages_tool",
|
||||
"components-diy"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,15 @@
|
||||
<view :style="themeColor">
|
||||
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
|
||||
class="page-img">
|
||||
<!-- diy 数据网络加载失败提示(页面内横幅,各端可靠,悬浮在最上层) -->
|
||||
<view v-if="diyLoadError" class="diy-load-error">
|
||||
<view class="diy-load-error__body">
|
||||
<text class="diy-load-error__msg">{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}</text>
|
||||
<text v-if="diyLoadErrorUrl" class="diy-load-error__url" selectable user-select>{{ diyLoadErrorUrl }}</text>
|
||||
</view>
|
||||
<view class="diy-load-error__btn" @tap="retryDiyInfo">{{ $lang('diy.retry') || '重试' }}</view>
|
||||
</view>
|
||||
|
||||
<view class="site-info-box"
|
||||
v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode">
|
||||
<view class="site-info">
|
||||
@@ -179,6 +188,52 @@ export default {
|
||||
z-index: 122;
|
||||
}
|
||||
|
||||
.diy-load-error {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 28rpx;
|
||||
padding-top: calc(24rpx + var(--status-bar-height, 0px));
|
||||
background-color: #fff5f5;
|
||||
border-bottom: 1rpx solid #ffccc7;
|
||||
}
|
||||
|
||||
.diy-load-error__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diy-load-error__msg {
|
||||
font-size: 26rpx;
|
||||
color: #cf1322;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diy-load-error__url {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diy-load-error__btn {
|
||||
margin-left: 20rpx;
|
||||
padding: 10rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #fff;
|
||||
background-color: #cf1322;
|
||||
border-radius: 32rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.small-bot swiper {
|
||||
width: 560rpx;
|
||||
height: 784rpx;
|
||||
|
||||
@@ -55,7 +55,7 @@ export default {
|
||||
onShow() {
|
||||
// 获取关注公众号二维码
|
||||
this.getFollowQrcode();
|
||||
|
||||
|
||||
// 重新获取店铺信息
|
||||
this.shopInfo = uni.getStorageSync('shopInfo');
|
||||
},
|
||||
@@ -128,12 +128,12 @@ export default {
|
||||
},
|
||||
// diy-group 渲染完毕回调, 用于触发版本水印等延后显示元素
|
||||
// 同时通知 splash-state 页面就绪, 启动屏可以关闭
|
||||
// 延迟 500ms 是为了确保页面内容有足够时间渲染
|
||||
// 注意: diy-group 内部 _notifyLoaded 已有 300ms 渲染 settle,
|
||||
// 这里不再叠加 500ms, 避免吃掉 splash 的 2s page-ready 兜底预算
|
||||
// (叠加延迟会导致弱网/冷启动下 splash 走 page-ready-timeout 兜底路径)
|
||||
onDiyGroupLoaded() {
|
||||
this.diyGroupReady = true;
|
||||
setTimeout(() => {
|
||||
try { splashState.notifyPageReady(); } catch (e) {}
|
||||
}, 500);
|
||||
try { splashState.notifyPageReady(); } catch (e) {}
|
||||
},
|
||||
/**
|
||||
* 版本连击 3 次以上显示构建版本信息(供测试人员使用)
|
||||
@@ -160,10 +160,10 @@ export default {
|
||||
} catch (e) {}
|
||||
if (v && v.commitId) {
|
||||
uni.showModal({
|
||||
title: '版本信息',
|
||||
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
|
||||
showCancel: false
|
||||
});
|
||||
title: this.$lang('versionInfo'),
|
||||
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
|
||||
showCancel: false
|
||||
});
|
||||
} else {
|
||||
console.error('未获取到构建版本信息');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
<template>
|
||||
<view>
|
||||
<view scroll-y="true" class="goods-detail" :class="isIphoneX ? 'active' : ''">
|
||||
<view class="goods-container">
|
||||
<!-- 骨架屏:商品详情加载时按结构分段占位(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">
|
||||
<!-- AI客服悬浮按钮 -->
|
||||
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
|
||||
<image :src="aiAvatarUrl" mode="aspectFill" class="ai-avatar-icon" />
|
||||
@@ -35,7 +59,8 @@
|
||||
<!-- 商品视频 -->
|
||||
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
|
||||
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
|
||||
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"></video>
|
||||
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"
|
||||
playsinline webkit-playsinline x5-playsinline></video>
|
||||
</view>
|
||||
|
||||
<!-- 切换视频、图片 -->
|
||||
@@ -459,7 +484,7 @@
|
||||
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<!-- #ifdef H5 -->
|
||||
<!-- #ifdef H5 || MP-HARMONY -->
|
||||
<view class="share-box" @click="copyUrl">
|
||||
<button class="share-btn" :plain="true">
|
||||
<view class="iconfont icon-fuzhilianjie"></view>
|
||||
@@ -474,11 +499,10 @@
|
||||
</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>
|
||||
@@ -501,11 +525,142 @@ 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);
|
||||
@@ -524,6 +679,11 @@ 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]
|
||||
@@ -532,6 +692,79 @@ 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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<view :style="themeColor">
|
||||
<view v-show="diyData && diyData.value && diyData.value.length">
|
||||
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
|
||||
<view v-if="diyData && diyData.value && diyData.value.length">
|
||||
<block v-for="(item, index) in diyData.value" :key="index">
|
||||
<view v-if="item.componentName == 'GoodsCategory'">
|
||||
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
|
||||
@@ -15,8 +16,8 @@
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
|
||||
<!-- 底部tabBar -->
|
||||
<view id="tab-bar">
|
||||
<!-- 底部tabBar: loading 期间隐藏, 等 loading-cover 关闭后下一 tick 再挂载, 避免 TabBar 与 loading-cover 同时 GPU 合成导致频闪 -->
|
||||
<view id="tab-bar" v-if="!loadingVisible">
|
||||
<diy-bottom-nav></diy-bottom-nav>
|
||||
</view>
|
||||
</view>
|
||||
@@ -27,7 +28,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
diyData: { value: [] },
|
||||
mpShareData: null //小程序分享数据
|
||||
mpShareData: null, //小程序分享数据
|
||||
// 修复底部 TabBar 频闪: 用本地 loadingVisible 状态镜像 <loading-cover> 的可见性,
|
||||
// loading-cover 的 spin 动画与 diy-bottom-nav 都是 fixed/z-index 高 + GPU 合成,
|
||||
// 在 MP-Weixin 下会互相触发重绘, 让用户看到 TabBar 抖动。
|
||||
// 策略: loading-cover 显示期间, 也把 tabBar 先收起来, loading-cover 隐藏后
|
||||
// 下一次 nextTick 再放出 tabBar, 把整段 loading 过程的合成层冲突降到最低。
|
||||
loadingVisible: true
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
@@ -40,8 +47,13 @@ export default {
|
||||
this.$util.hideHomeButton();
|
||||
try {
|
||||
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
|
||||
if (this.$refs.category && this.$refs.category[0]) {
|
||||
// 节流: 避免 onShow 重复触发 pageShow() 中的 getHeightArea(-1) 高耗时选择器查询, 引起频闪和性能损耗
|
||||
const now = Date.now();
|
||||
if (this._lastPageShowTime && now - this._lastPageShowTime < 500) {
|
||||
// 短时间内重复触发, 跳过本次 pageShow
|
||||
} else if (this.$refs.category && this.$refs.category[0]) {
|
||||
this.$refs.category[0].pageShow();
|
||||
this._lastPageShowTime = now;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('onShow pageShow failed', e);
|
||||
@@ -109,6 +121,13 @@ export default {
|
||||
this._diyInfoLoading = false;
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
uni.stopPullDownRefresh();
|
||||
// loading-cover 收起后, 等下一 tick 让出 frame, 再挂载 diy-bottom-nav
|
||||
// (避免两个 fixed 高 z-index GPU 合成层在同帧 mount 触发 MP-Weixin 重绘频闪)
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
this.loadingVisible = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<view :style="themeColor">
|
||||
<!-- 鸿蒙兼容: themeColor 是 CSS 变量字符串, 鸿蒙 JSVM 早期版本对 :style="字符串" 处理可能不稳.
|
||||
兜底: 只有非空字符串才绑定 :style. -->
|
||||
<view :style="themeColor || undefined">
|
||||
<!-- #ifndef H5 -->
|
||||
<!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0">
|
||||
<ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar>
|
||||
</view> -->
|
||||
<!-- #endif -->
|
||||
|
||||
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
|
||||
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
|
||||
<!-- 价格区域 -->
|
||||
<template v-slot:price>
|
||||
<!-- 限时折扣 -->
|
||||
@@ -72,7 +74,9 @@
|
||||
</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>
|
||||
@@ -411,7 +415,17 @@
|
||||
</view> -->
|
||||
<!-- v-if="goodsSkuDetail.goodsForm" -->
|
||||
<view style="padding: 0 30rpx;">
|
||||
<ns-form :data="informationform" ref="form"></ns-form>
|
||||
<!-- 表单暂无字段时的友好提示, 避免"打开弹窗却什么都不显示"困惑用户 -->
|
||||
<view v-if="!formDiyValue || !formDiyValue.jsonData || !formDiyValue.jsonData.length" class="form-empty" style="padding: 60rpx 0; text-align: center; color: #999;">
|
||||
{{ $lang('goods.detail.formEmptyTip') }}
|
||||
</view>
|
||||
<diy-form
|
||||
v-else
|
||||
:value="formDiyValue"
|
||||
dataMode="raw"
|
||||
:show-submit="false"
|
||||
ref="form"
|
||||
></diy-form>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="button-box"><button type="primary" @click="saveubfirnation()">{{ $lang('goods.detail.confirm') }}</button></view>
|
||||
@@ -445,7 +459,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')"
|
||||
@@ -453,9 +467,12 @@
|
||||
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 -->
|
||||
@@ -511,7 +528,9 @@
|
||||
</goods-detail-view>
|
||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||
<ns-login ref="login"></ns-login>
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
|
||||
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
|
||||
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
|
||||
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
@@ -545,6 +564,21 @@ export default {
|
||||
nsGoodsPromotion
|
||||
},
|
||||
mixins: [base, detail, scroll],
|
||||
computed: {
|
||||
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
||||
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
||||
formDiyValue() {
|
||||
const sku = this.goodsSkuDetail || {};
|
||||
return {
|
||||
jsonData: sku.goods_form || [],
|
||||
formConfig: sku.goods_form_config || {},
|
||||
show_banner: sku.goods_form_show_banner === true,
|
||||
form_banner: sku.goods_form_banner || '',
|
||||
show_tip: sku.goods_form_show_tip === true,
|
||||
form_tip: sku.goods_form_tip || ''
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 发送商品信息到智能客服
|
||||
|
||||
@@ -40,7 +40,9 @@ export default {
|
||||
this.isAction = false;
|
||||
},
|
||||
onUnload() {
|
||||
if (!this.storeToken && this.$refs.login) this.$refs.login.cancelCompleteInfo();
|
||||
if (!this.storeToken && this.$refs.login && typeof this.$refs.login.cancelCompleteInfo === 'function') {
|
||||
this.$refs.login.cancelCompleteInfo();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
if (!this.storeToken) {
|
||||
|
||||
@@ -4,7 +4,10 @@ export default {
|
||||
goodsRoute: '/pages_goods/detail',
|
||||
couponList: [], //优惠券列表
|
||||
couponBtnSwitch: false, //获取优惠券防止重复提交
|
||||
|
||||
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
|
||||
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
|
||||
loading: true,
|
||||
|
||||
posterApi: '/api/goods/poster',
|
||||
|
||||
//满减活动
|
||||
@@ -24,9 +27,10 @@ export default {
|
||||
|
||||
membercard: null, // 会员卡信息
|
||||
hackReset: true,
|
||||
cardOff: false,
|
||||
informationform:[]//留言表单
|
||||
|
||||
cardOff: false
|
||||
// 留言表单数据不再在 data() 维护:detail.vue 的 computed formDiyValue
|
||||
// 直接从 goodsSkuDetail.goods_form / goods_form_config / goods_form_* 实时读取并打包成
|
||||
// diy-form raw 模式期望的 value 结构。
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -52,11 +56,19 @@ export default {
|
||||
}
|
||||
return discount.toFixed(2);
|
||||
},
|
||||
//表单高度
|
||||
FormHeight(){
|
||||
let height = 48;
|
||||
if (this.goodsForm && this.goodsForm.length) {
|
||||
height += this.goodsForm.length * 5;
|
||||
// 弹窗高度: 受限于视口, 内部表单 <scroll-view> 自带滚动, 不再按字段数无限加高
|
||||
FormHeight() {
|
||||
// 上限 65vh: 留足顶部状态栏/导航栏 + 底部 home indicator, 也不会显得"一打开就半屏遮脸"
|
||||
// 字段多时通过内部 <scroll-view scroll-y> 自然滚动
|
||||
const formList = (this.goodsSkuDetail && this.goodsSkuDetail.goods_form)
|
||||
|| this.goodsForm;
|
||||
let height = 45;
|
||||
if (formList && formList.length) {
|
||||
// 少量字段: 每个约 5vh; 字段多时递增放缓并封顶, 避免接近屏顶
|
||||
// 5 个字段 → 45 + min(10, 25) = 55vh
|
||||
// 10 个字段 → 45 + min(10, 50) = 55vh (已封顶)
|
||||
const dynamic = Math.min(10, formList.length * 5);
|
||||
height = Math.min(55, height + dynamic);
|
||||
}
|
||||
height += 'vh';
|
||||
return height;
|
||||
@@ -88,8 +100,19 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
async onShow() {
|
||||
//同步获取商品详情
|
||||
await this.getGoodsSkuDetail();
|
||||
// 同步获取商品详情
|
||||
// 鸿蒙元服务兼容: 包 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'
|
||||
});
|
||||
}
|
||||
},
|
||||
onHide() {
|
||||
this.couponBtnSwitch = false;
|
||||
@@ -111,14 +134,28 @@ export default {
|
||||
},
|
||||
// 获取商品详情
|
||||
async getGoodsSkuDetail() {
|
||||
let res = await this.$api.sendRequest({
|
||||
url: '/api/goodssku/detail',
|
||||
async: false,
|
||||
data: {
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsId,
|
||||
let res;
|
||||
try {
|
||||
// 修复鸿蒙元服务: 必须传 async: false !
|
||||
// 当 App.vue 的 boot (lang/theme/remote-config) 未完成时,
|
||||
// sendRequest 在 async !== false 模式下会直接 return undefined (不返回 promise),
|
||||
// 导致 res.code undefined / res.data 抛 TypeError, 整个 onShow 失败.
|
||||
res = await this.$api.sendRequest({
|
||||
url: '/api/goodssku/detail',
|
||||
async: false,
|
||||
data: {
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsId,
|
||||
}
|
||||
});
|
||||
// 防御: 即便 async:false 仍可能在极端情况下返回 undefined
|
||||
if (!res || typeof res !== 'object') {
|
||||
throw new Error('sendRequest returned non-object response (likely boot not complete)');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('getGoodsSkuDetail sendRequest failed:', e);
|
||||
throw e;
|
||||
}
|
||||
let data = res.data;
|
||||
if (data.goods_sku_detail != null) {
|
||||
if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) {
|
||||
@@ -129,7 +166,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name
|
||||
|
||||
|
||||
// 关键改动: 先一次性赋值, 释放事件循环让 MP-Weixin 把首屏 (含媒体/价格/标题) 渲出来,
|
||||
// 再 $nextTick 后才跑同步字段变换 (sku_images split / JSON.parse / 属性去重 等),
|
||||
// 避免连续多次 Vue 响应式写触发同一帧内多次 vdom 重算, 显著改善"打开详情很慢"的体感。
|
||||
this.goodsSkuDetail = data.goods_sku_detail;
|
||||
if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id;
|
||||
if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id;
|
||||
@@ -141,7 +181,10 @@ export default {
|
||||
this.chatRoomParams = {
|
||||
sku_id: this.goodsSkuDetail.sku_id
|
||||
};
|
||||
let typeId = this.goodsSkuDetail.goods_promotion[0];
|
||||
// 鸿蒙兼容: 后端在部分场景下可能不返回 goods_promotion, 缺字段直接 .[0] 会抛 TypeError,
|
||||
// 整个 onShow 走 catch 路径无法关闭骨架屏, 表现为"数据已回来但永远显示骨架屏"
|
||||
const promotionList = this.goodsSkuDetail?.goods_promotion || [];
|
||||
let typeId = promotionList.length > 0 ? promotionList[0] : undefined;
|
||||
if (typeId) {
|
||||
// 限时折扣
|
||||
if (typeId.discount_id) {
|
||||
@@ -155,8 +198,28 @@ export default {
|
||||
goods_id: this.goodsId
|
||||
};
|
||||
|
||||
// 处理商品数据
|
||||
this.handleGoodsSkuData();
|
||||
// 让首屏主区域先绘制
|
||||
// 兼容鸿蒙元服务: $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();
|
||||
|
||||
// 限时折扣
|
||||
if (this.goodsSkuDetail.promotion_type == 1 && this.addonIsExist.discount) {
|
||||
@@ -192,7 +255,7 @@ export default {
|
||||
}
|
||||
|
||||
// 优惠券
|
||||
if (this.goodsSkuDetail.coupon_list) {
|
||||
if (Array.isArray(this.goodsSkuDetail.coupon_list)) {
|
||||
this.couponList = this.goodsSkuDetail.coupon_list;
|
||||
this.couponList.forEach(v => {
|
||||
if (v.count == v.lead_count) v.useState = 2;
|
||||
@@ -207,8 +270,6 @@ export default {
|
||||
// this.handleBundlingData(this.goodsSkuDetail.bundling_list);
|
||||
// }
|
||||
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
|
||||
} else {
|
||||
this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo');
|
||||
}
|
||||
@@ -220,9 +281,35 @@ export default {
|
||||
},
|
||||
|
||||
//商品留言打开
|
||||
showinformation(){
|
||||
this.informationform = this.goodsSkuDetail.goods_form
|
||||
this.$refs.informationPopup.open();
|
||||
async showinformation(){
|
||||
// 修复: 第一次打开时, uni-popup 内部 <scroll-view><diy-form> 还没完成首次渲染,
|
||||
// 直接 open() 会让用户看到空白弹窗。这里:
|
||||
// 1) 若商品表单后台请求尚未完成 (或根本没触发), 先发起并 await 该请求
|
||||
// 2) 用 $nextTick 等组件挂载完成后再打开
|
||||
try {
|
||||
// 若页面已进入但 _goodsFormPromise 还没建 (如 sku 切换/数据未到等场景),
|
||||
// 这里补一个并发请求; 若已存在, 复用旧 promise 不重复请求。
|
||||
if (!this._goodsFormPromise || typeof this._goodsFormPromise.then !== 'function') {
|
||||
const loader = (this.getGoodsForm && typeof this.getGoodsForm === 'function')
|
||||
? this.getGoodsForm()
|
||||
: Promise.resolve();
|
||||
this._goodsFormPromise = loader;
|
||||
await loader;
|
||||
} else {
|
||||
await this._goodsFormPromise;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('await goods form promise failed', e);
|
||||
}
|
||||
// 第一次强制刷新 popup 内部组件布局 (nextTick), 确保表单完整渲染
|
||||
// 兼容鸿蒙元服务: $nextTick 不可靠时降级到 setTimeout
|
||||
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
|
||||
// 微信小程序下 scroll-view 在首次以 transform 隐藏后再显示时, 内部子节点布局可能未刷新
|
||||
// 双 nextTick 兜底, 保证表单完整可见
|
||||
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
|
||||
if (this.$refs.informationPopup && typeof this.$refs.informationPopup.open === 'function') {
|
||||
this.$refs.informationPopup.open();
|
||||
}
|
||||
},
|
||||
//商品留言关闭
|
||||
closeinformationPopup(){
|
||||
@@ -230,18 +317,18 @@ export default {
|
||||
},
|
||||
//保存商品留言
|
||||
saveubfirnation(){
|
||||
//缺少
|
||||
// ref 指向 <diy-form>:diy-form 只暴露 getFormData() 方法,无 formData 属性
|
||||
if (!this.$refs.form.verify()) return;
|
||||
|
||||
|
||||
// uni.setStorageSync('goodFormData', {
|
||||
// goods_id: this.goodsSkuDetail.goods_id,
|
||||
// form_data: this.$refs.form.formData
|
||||
// form_data: this.$refs.form.getFormData()
|
||||
// });
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goods/information',
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
form_data: JSON.stringify(this.$refs.form.formData)
|
||||
form_data: JSON.stringify(this.$refs.form.getFormData())
|
||||
},
|
||||
success: res => {
|
||||
var data = res.data;
|
||||
|
||||
@@ -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.config.text = this.$lang('goods.list.buyText') || this.$lang('buyNow');
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/lucky/status',
|
||||
|
||||
@@ -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[calculateGoodsData.merch_id].coupon_id">
|
||||
<template v-if="(orderCreateData.coupon && orderCreateData.coupon[calculateGoodsData.merch_id] && 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="selectCouponId == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
|
||||
<view class="iconfont" :class="selectCouponMap[merchCoupon.merch_id] == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -55,7 +55,8 @@ export default {
|
||||
merch_id: 0,
|
||||
data: []
|
||||
},
|
||||
selectCouponId:0
|
||||
// 按 merch_id 隔离,避免多商户之间互相污染
|
||||
selectCouponMap: {}
|
||||
}
|
||||
},
|
||||
inject: ['promotion'],
|
||||
@@ -85,7 +86,7 @@ export default {
|
||||
goodsData() {
|
||||
if (this.paymentData) {
|
||||
this.paymentData.goods_list.forEach(item => {
|
||||
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||
})
|
||||
return this.paymentData;
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export default {
|
||||
calculateGoodsData() {
|
||||
if (this.calculateData) {
|
||||
this.calculateData.goods_list.forEach(item => {
|
||||
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||
})
|
||||
return this.calculateData;
|
||||
}
|
||||
@@ -189,7 +190,7 @@ export default {
|
||||
//多商户商品分类
|
||||
goodsSpecFormat(goods_list){
|
||||
goods_list.forEach(item => {
|
||||
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||
})
|
||||
return goods_list
|
||||
},
|
||||
@@ -249,7 +250,11 @@ export default {
|
||||
this.modules = data.modules;
|
||||
|
||||
//记录多商户优惠券
|
||||
this.orderCreateData.coupon = data.coupon
|
||||
// 兜底为对象,避免后续被当作数组处理
|
||||
if (!data.coupon || typeof data.coupon !== 'object' || Array.isArray(data.coupon)) {
|
||||
data.coupon = {};
|
||||
}
|
||||
this.orderCreateData.coupon = data.coupon;
|
||||
|
||||
//查询优惠券
|
||||
this.getCouponList();
|
||||
@@ -365,7 +370,17 @@ export default {
|
||||
}
|
||||
});
|
||||
//多商户
|
||||
this.orderCreateData.coupon = res.data.coupon
|
||||
// 保留用户已选的 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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
|
||||
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
|
||||
|
||||
@@ -731,44 +746,52 @@ 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;
|
||||
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();
|
||||
},
|
||||
/**
|
||||
* 显示店铺优惠券信息
|
||||
* @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();
|
||||
},
|
||||
|
||||
//-------------------
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
fontWeight: false,
|
||||
padding: 0,
|
||||
cartEvent: "detail",
|
||||
text: this.$lang('merch.list.buyText'),
|
||||
text: '',
|
||||
textColor: "#FFFFFF",
|
||||
theme: "default",
|
||||
aroundRadius: 25,
|
||||
@@ -66,6 +66,7 @@ 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;
|
||||
|
||||
@@ -180,7 +180,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -397,7 +397,7 @@ export default {
|
||||
scrollTop: 0,
|
||||
scrollIntoViewId: null,
|
||||
hasMoreHistory: false,
|
||||
loadingText: this.$lang('ai-chat.index.loadMoreHistory'),
|
||||
loadingText: '',
|
||||
voiceInputing: false,
|
||||
audioContext: null,
|
||||
currentAudio: null,
|
||||
@@ -417,7 +417,7 @@ export default {
|
||||
localUserAvatar: '',
|
||||
showAvatarSelector: false, // 头像选择弹窗控制
|
||||
localUserNickname: '', // 当前用户昵称
|
||||
defaultNickname: this.$lang('ai-chat.index.defaultNickname'), // 默认昵称
|
||||
defaultNickname: '', // 默认昵称
|
||||
showNicknameEditor: false, // 昵称编辑弹窗控制
|
||||
// AI头像错误状态
|
||||
avatarError: false,
|
||||
@@ -493,6 +493,9 @@ 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()
|
||||
|
||||
@@ -160,7 +160,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,8 @@
|
||||
detail: null,
|
||||
isRepeat: false,
|
||||
complete: false,
|
||||
scroll:true
|
||||
scroll: true,
|
||||
urlParams: {}
|
||||
};
|
||||
},
|
||||
onLoad(data) {
|
||||
@@ -39,12 +40,27 @@
|
||||
// #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];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -89,7 +105,9 @@
|
||||
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)
|
||||
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
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
@@ -153,7 +171,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
<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 控制。
|
||||
@submit 接管提交逻辑(带 uniacid + showModal) -->
|
||||
<diy-form
|
||||
ref="form"
|
||||
:value="diyValue"
|
||||
dataMode="raw"
|
||||
@submit="create"
|
||||
/>
|
||||
: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" />
|
||||
</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>
|
||||
@@ -25,13 +45,9 @@
|
||||
|
||||
<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 {
|
||||
@@ -43,6 +59,16 @@ 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
|
||||
@@ -50,7 +76,30 @@ 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),
|
||||
@@ -70,6 +119,7 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
console.log("=====表单页面====", options)
|
||||
if (!options.uniacid) {
|
||||
uni.showModal({
|
||||
title: this.$lang('form.formdata.tip'),
|
||||
@@ -90,7 +140,41 @@ export default {
|
||||
this.uniacid = options.uniacid
|
||||
this.id = options.id || 0
|
||||
this.isFromMpWebView = options.from === 'mp-webview';
|
||||
this.hideNavbar = Boolean(options?.hide_navbar);
|
||||
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];
|
||||
}
|
||||
|
||||
if (options.scene) {
|
||||
const scene = decodeURIComponent(options.scene)
|
||||
@@ -99,6 +183,11 @@ 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];
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,7 +202,7 @@ export default {
|
||||
|
||||
this.getData()
|
||||
},
|
||||
|
||||
|
||||
watch: {
|
||||
storeToken(newVal, oldVal) {
|
||||
newVal && this.getData()
|
||||
@@ -123,6 +212,20 @@ 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',
|
||||
@@ -150,22 +253,37 @@ 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 已先做 verify(),仅在通过时才 emit('submit', formData)
|
||||
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
|
||||
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
|
||||
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
|
||||
// 因此内部所有 this.xxx 都指向 formdata 实例,不会漂移到子组件。
|
||||
create(formData) {
|
||||
if (this.isRepeat) return
|
||||
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
|
||||
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
|
||||
uniacid: this.uniacid,
|
||||
url_params: JSON.stringify(this.urlParams),
|
||||
member_id: this.memberInfo ? this.memberInfo.id : 0
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.complete = true
|
||||
uni.showModal({
|
||||
title: this.$lang('form.formdata.tip'),
|
||||
content: this.$lang('form.formdata.submitSuccess'),
|
||||
@@ -178,6 +296,10 @@ export default {
|
||||
title: res.message
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络失败时复位防重复标记,允许用户重试
|
||||
this.isRepeat = false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -185,6 +307,114 @@ 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;
|
||||
@@ -203,7 +433,13 @@ page {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + '/pages_tool/help/list';
|
||||
this.$util.setPublicShare({
|
||||
title: '帮助中心',
|
||||
title: this.$lang('help.list.title'),
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||
|
||||
@@ -100,7 +100,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -95,7 +95,10 @@ export default {
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
|
||||
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
|
||||
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getAccountInfo();
|
||||
}
|
||||
@@ -112,8 +115,8 @@ export default {
|
||||
this.fenxiao_info = res.data.fenxiao_info;
|
||||
}
|
||||
} else if (res.code == -1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
|
||||
<script>
|
||||
import htmlParser from '@/common/js/html-parser';
|
||||
import memberPublic from './public/js/member-public.js';
|
||||
|
||||
export default {
|
||||
mixins: [memberPublic],
|
||||
data() {
|
||||
return {
|
||||
agreement: {},
|
||||
@@ -31,7 +34,10 @@ export default {
|
||||
if (option.back) this.back = option.back;
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
|
||||
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
|
||||
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getCancelAgreement();
|
||||
}
|
||||
@@ -45,9 +51,8 @@ export default {
|
||||
this.agreement = res.data;
|
||||
if (this.agreement.content) this.agreement.content = htmlParser(this.agreement.content);
|
||||
} else if (res.code == -1) {
|
||||
this.$store.commit('setToken', '');
|
||||
this.$store.commit('setMemberInfo', '');
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import memberPublic from './public/js/member-public.js';
|
||||
|
||||
export default {
|
||||
mixins: [memberPublic],
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
@@ -26,7 +29,8 @@ export default {
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
@@ -39,9 +43,8 @@ export default {
|
||||
if (res.code >= 0) {
|
||||
this.reason = res.data.reason;
|
||||
} else if (res.code == -1) {
|
||||
this.$store.commit('setToken', '');
|
||||
this.$store.commit('setMemberInfo', '');
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,7 +49,10 @@
|
||||
onLoad(option) {
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
|
||||
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
|
||||
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
@@ -70,8 +73,8 @@
|
||||
}
|
||||
|
||||
if (res.code == -1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
state: ''
|
||||
state: '',
|
||||
// 防抖锁: 防止 onLoad 的 getStatus 异步回调 和 用户点击 success 按钮
|
||||
// 短时间内连发 reLaunch, 触发微信小程序 "appLaunch with non-empty page stack" native 错误
|
||||
_navigated: false
|
||||
};
|
||||
},
|
||||
onLoad(option) {
|
||||
@@ -28,7 +31,8 @@
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
@@ -40,16 +44,17 @@
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
|
||||
// 跟 success 按钮共享防抖, 避免同时触发
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
success() {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,7 +261,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -432,7 +432,13 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
@@ -113,6 +113,32 @@ 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: {
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
methods: {
|
||||
// 清除登录态(token / 用户信息 / 购物车 / 本地存储)
|
||||
// 清除登录态(token / 会员信息 / 购物车 / 本地存储)
|
||||
clearLoginState() {
|
||||
this.$store.commit('setToken', '');
|
||||
this.$store.commit('setMemberInfo', '');
|
||||
@@ -9,6 +9,48 @@ 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({
|
||||
@@ -16,8 +58,8 @@ export default {
|
||||
content: this.$lang('logoutContent'),
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -50,8 +92,8 @@ export default {
|
||||
this.$util.redirectTo('/pages_tool/member/cancellation');
|
||||
}
|
||||
} else if (res.code == -1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -69,11 +111,15 @@ export default {
|
||||
url: '/membercancel/api/membercancel/apply',
|
||||
success: rres => {
|
||||
if (rres.code >= 0) {
|
||||
// 注销申请提交成功后立刻清空登录态,
|
||||
// 避免用户通过标题栏/导航栏返回到会员中心等页面时仍能看到账号信息
|
||||
if (options.clearStateAndRedirect) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口: 内部带防抖锁, 避免连发 reLaunch
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.$util.redirectTo('/pages_tool/member/cancelstatus');
|
||||
// 跳 cancelstatus (审核中), 也走 reLaunch 清栈
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo('/pages_tool/member/cancelstatus', {}, 'reLaunch');
|
||||
}
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
|
||||
@@ -77,7 +77,7 @@ export default {
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + '/pages_tool/notice/list';
|
||||
this.$util.setPublicShare({
|
||||
title: '公告',
|
||||
title: this.$lang('notice.list.title'),
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
}
|
||||
},
|
||||
onShareAppMessage(res) {
|
||||
var title = '公告';
|
||||
var title = this.$lang('notice.list.title');
|
||||
var path = '/pages_tool/notice/list';
|
||||
return {
|
||||
title: title,
|
||||
|
||||
@@ -51,7 +51,7 @@ export default {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
title: '未获取到支付信息!'
|
||||
title: this.$lang('pay.index.payInfoNotFound')
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<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 :style="themeColor" class="vr-page">
|
||||
<web-view :src="src" class="vr-webview" @titleUpdate="forceCustomTitle"></web-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -11,72 +8,73 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
src: ''
|
||||
src: '',
|
||||
title: '',
|
||||
customTitle: '',
|
||||
titleTimer: null
|
||||
};
|
||||
},
|
||||
onLoad(option) {
|
||||
// this.src = decodeURIComponent(option.src);
|
||||
this.customTitle = option?.title || '工厂展示';
|
||||
this.title = this.customTitle;
|
||||
uni.setNavigationBarTitle({ title: this.customTitle });
|
||||
this.$api.sendRequest({
|
||||
url: '/api/config/defaultvr',
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
uni.setNavigationBarTitle({
|
||||
title:res.data.title
|
||||
})
|
||||
this.src = res.data.url
|
||||
this.customTitle = res.data.title;
|
||||
this.title = this.customTitle;
|
||||
uni.setNavigationBarTitle({ title: this.customTitle });
|
||||
this.src = res.data.url;
|
||||
this.startTitleGuard();
|
||||
}
|
||||
},
|
||||
fail: res => {
|
||||
}
|
||||
});
|
||||
},
|
||||
onShow() {
|
||||
if (this.customTitle) {
|
||||
uni.setNavigationBarTitle({ title: this.customTitle });
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
this.clearTitleGuard();
|
||||
},
|
||||
methods: {
|
||||
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
|
||||
});
|
||||
}
|
||||
forceCustomTitle() {
|
||||
uni.setNavigationBarTitle({ title: this.customTitle });
|
||||
},
|
||||
goHome() {
|
||||
uni.reLaunch({
|
||||
url: this.$util.PAGE_KEY_MAP.PAGE_INDEX
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.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-page {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vr-nav-back {
|
||||
left: 34rpx;
|
||||
line-height: 60rpx;
|
||||
}
|
||||
|
||||
.vr-nav-home {
|
||||
right: 34rpx;
|
||||
font-size: 40rpx;
|
||||
line-height: 72rpx;
|
||||
.vr-webview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
62
remove-huawei-node-from-path.ps1
Normal file
62
remove-huawei-node-from-path.ps1
Normal file
@@ -0,0 +1,62 @@
|
||||
$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.'
|
||||
209
scripts/check-subpackage-sync.js
Normal file
209
scripts/check-subpackage-sync.js
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/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);
|
||||
}
|
||||
58
scripts/check-title-keys.js
Normal file
58
scripts/check-title-keys.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// 检查 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
429
scripts/fix-subpackage-module-names.js
Normal file
429
scripts/fix-subpackage-module-names.js
Normal file
@@ -0,0 +1,429 @@
|
||||
#!/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,
|
||||
};
|
||||
264
scripts/hardcoded_cn_real.json
Normal file
264
scripts/hardcoded_cn_real.json
Normal file
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
7804
scripts/hardcoded_cn_scan.json
Normal file
7804
scripts/hardcoded_cn_scan.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,347 @@ 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, '..');
|
||||
@@ -96,7 +437,8 @@ async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const options = {
|
||||
noZip: argv.includes('--no-zip'),
|
||||
mode: 'production'
|
||||
mode: 'production',
|
||||
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
|
||||
};
|
||||
|
||||
const modeIndex = argv.indexOf('--mode');
|
||||
@@ -104,18 +446,46 @@ 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 文件和打开目录');
|
||||
|
||||
@@ -1,14 +1,77 @@
|
||||
{
|
||||
"bundle_version": "1782901586",
|
||||
"bundle_version": "1784767380",
|
||||
"bundle_lang": "zh-cn",
|
||||
"bundle_leaves": 2649,
|
||||
"code_calls_total": 2677,
|
||||
"code_unique_keys": 2083,
|
||||
"found_in_bundle": 2082,
|
||||
"bundle_leaves": 2659,
|
||||
"code_calls_total": 2693,
|
||||
"code_unique_keys": 2097,
|
||||
"found_in_bundle": 2086,
|
||||
"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.",
|
||||
@@ -19,18 +82,8 @@
|
||||
},
|
||||
"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": [
|
||||
@@ -121,6 +174,11 @@
|
||||
"common.tabBar.cart",
|
||||
"common.tabBar.member"
|
||||
],
|
||||
"local_only_keys": [],
|
||||
"local_only_keys": [
|
||||
"buildTime",
|
||||
"commitId",
|
||||
"submitFail",
|
||||
"versionInfo"
|
||||
],
|
||||
"bundle_only_keys_in_unique": []
|
||||
}
|
||||
80
scripts/scan-hardcoded-cn.py
Normal file
80
scripts/scan-hardcoded-cn.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/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')
|
||||
4
site.js
4
site.js
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
baseUrl: "https://xcx2.aigc-quickapp.com/",//修改域名
|
||||
uniacid: 2482,//后台对应uniacid
|
||||
baseUrl: "https://5g.aigc-quickapp.com/",//修改域名
|
||||
uniacid: 248,//后台对应uniacid
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.atomicservice.6917600524822452448",
|
||||
"bundleName": "com.atomicservice.6917610986330791161",
|
||||
"bundleType": "atomicService",
|
||||
"vendor": "example",
|
||||
"versionCode": 1000000,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": true,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hspTasks,
|
||||
plugins: []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "components_diy",
|
||||
"version": "1.0.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"main": "Index.ets",
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"packageType": "InterfaceHar",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "components_diy",
|
||||
"type": "shared",
|
||||
"description": "subpackage",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "shared_desc",
|
||||
"value": "description"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": true,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hspTasks,
|
||||
plugins: []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "pages_goods",
|
||||
"version": "1.0.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"main": "Index.ets",
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"packageType": "InterfaceHar",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "pages_goods",
|
||||
"type": "shared",
|
||||
"description": "subpackage",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "shared_desc",
|
||||
"value": "description"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": true,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hspTasks,
|
||||
plugins: []
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user