Compare commits
261 Commits
yuanlaide2
...
a026adaec7
| Author | SHA1 | Date | |
|---|---|---|---|
| a026adaec7 | |||
| 278d8c94f9 | |||
| f66b70c087 | |||
| c1407a84bb | |||
| 6ffbae0f33 | |||
| f6038575cc | |||
| 5cdafb6f7f | |||
| 7ebbdaf996 | |||
| ecb0789780 | |||
| e693e4273c | |||
| e3171ef7ad | |||
| 3ca1ca8cd7 | |||
| fcbbb05afe | |||
| dd94e53d81 | |||
| b8ff67aaa3 | |||
| 40a6f739d0 | |||
| 58a5a7d535 | |||
| 0e8db6f821 | |||
| e6a45f6b7d | |||
| 1b7cc4b679 | |||
| 876f639bb5 | |||
| 02c9fa602e | |||
| b5b27039ee | |||
| 8901e302e4 | |||
| 68bcd3ae7c | |||
| 9f89172829 | |||
| ddd1ec3056 | |||
| d305268cb8 | |||
| 1efb866e41 | |||
| b8182a0f97 | |||
| 37463a173c | |||
| 422ca8cdb5 | |||
| f92b1fb5b7 | |||
| a0043418c8 | |||
| 927f763d0a | |||
| 69ad814985 | |||
| 92599453c2 | |||
| 5d7facc94d | |||
| f6eb1fad11 | |||
| 83b253bced | |||
| 19cb332645 | |||
| e5406a27ba | |||
| ffa170ff13 | |||
| 3b81ecdf98 | |||
| c536fac4d2 | |||
| 475449f04e | |||
| e40b46a888 | |||
| 8a95b0310d | |||
| a81386d336 | |||
| b69eeba74b | |||
| f656be789d | |||
| 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
|
||||
117
.local.config.js
117
.local.config.js
@@ -21,7 +21,7 @@ const localDevConfig = ({
|
||||
'2881': { // 防爆电器
|
||||
uniacid: 2881,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'2724': { // 生物菌肥
|
||||
uniacid: 2724,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
@@ -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/',
|
||||
@@ -74,11 +83,15 @@ const localDevConfig = ({
|
||||
'5g-11': {
|
||||
uniacid: 11,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-206': {
|
||||
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/',
|
||||
@@ -86,27 +99,27 @@ const localDevConfig = ({
|
||||
'145': { // 表单链接转义问题
|
||||
uniacid: 145,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-213': { // 单图组点击链接无法跳转
|
||||
uniacid: 213,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-4': { // 公告点击链接无法跳转
|
||||
uniacid: 4,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
uniacid: 97,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-50': { // 燃气安全产品机器人,购物车无反应
|
||||
uniacid: 50,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-237': { // 商品分类,没有立即咨询文字
|
||||
uniacid: 237,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'2847': { //
|
||||
uniacid: 2847,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
@@ -119,33 +132,79 @@ const localDevConfig = ({
|
||||
uniacid: 2485,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2821': { // 离型纸硅油纸
|
||||
uniacid: 2821,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'18': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'48': { // 测试平台
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
'2821': { // 离型纸硅油纸
|
||||
uniacid: 2821,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'18': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'48': { // 测试平台
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'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': { //气雾栽培机,视频组件安卓ios播放正常
|
||||
uniacid: 2797,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-95': { // 商品分类,没有立即咨询文字
|
||||
uniacid: 95,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-160': { // 过瘤胃瘤胃保护,商品详情视频
|
||||
uniacid: 160,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-268': { // 合成孔径雷达,一级分类背景字体颜色
|
||||
uniacid: 268,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'225-test': { // 测试平台
|
||||
uniacid: 225,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'192.168.8.118-321': { // 测试平台
|
||||
uniacid: 321,
|
||||
domain: 'http://192.168.8.118:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'5g-59': { // 合成孔径雷达,一级分类背景字体颜色
|
||||
uniacid: 59,
|
||||
domain: 'https://5g.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']; // 本地
|
||||
})['2811']; // 本地
|
||||
// })['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
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
.goods-video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.goods-video .uni-video-cover {
|
||||
@@ -1223,6 +1225,7 @@
|
||||
}
|
||||
|
||||
.popup-layer {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
.head-wrap {
|
||||
font-size: $font-size-toolbar;
|
||||
|
||||
@@ -4,8 +4,56 @@ import store from '@/store/index.js'
|
||||
import lang from './lang.js'
|
||||
|
||||
let currentConversationId = null;
|
||||
|
||||
// ====== AI Agent Generalization (customer service is the first agent) ======
|
||||
// setAgentId updates currentAgentId; default is agent_aikefu.
|
||||
// Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically.
|
||||
//
|
||||
// PR-7: 按 agent_id 路由 endpoint
|
||||
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
|
||||
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
|
||||
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
|
||||
|
||||
export const DEFAULT_AGENT_ID = 'agent_aikefu';
|
||||
let currentAgentId = DEFAULT_AGENT_ID;
|
||||
export const CONVERSATION_KEY = 'ai_conversation_id';
|
||||
|
||||
/**
|
||||
* PR-7: agent_id -> 后端 addon 路由映射
|
||||
*
|
||||
* 规则:
|
||||
* - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*)
|
||||
* - 其他 agent -> aiagent addon (新路径 /api/aiagent/*)
|
||||
*
|
||||
* 后端两个 addon 完全独立:
|
||||
* - aikefu: lucky_aikefu_config / conversation / message
|
||||
* - aiagent: lucky_ai_agent_config / conversation / message
|
||||
*/
|
||||
function resolveAddonName(agentId) {
|
||||
if (!agentId || agentId === 'agent_aikefu') return 'aikefu';
|
||||
return 'aiagent';
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-7: agent_id -> API endpoint URL
|
||||
* agent_aikefu -> '/api/kefu/...' (兼容老路径)
|
||||
* 其他 -> '/api/aiagent/...' (ai-agent 新路径)
|
||||
*/
|
||||
function resolveApiUrl(apiName) {
|
||||
var addon = resolveAddonName(currentAgentId);
|
||||
if (addon === 'aikefu') {
|
||||
return '/api/kefu/' + apiName;
|
||||
}
|
||||
return '/api/aiagent/' + apiName;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-7: WebSocket URL 路径(/ws/{addonName})
|
||||
*/
|
||||
function resolveWsPath() {
|
||||
return '/ws/' + resolveAddonName(currentAgentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取域名配置参数(避免重复代码)
|
||||
*/
|
||||
@@ -35,12 +83,12 @@ const GUEST_ID_KEY = 'ai_guest_user_id';
|
||||
function getUserId() {
|
||||
const memberId = store.state.memberInfo?.id;
|
||||
if (memberId) return memberId;
|
||||
|
||||
|
||||
let guestId;
|
||||
try {
|
||||
guestId = uni.getStorageSync(GUEST_ID_KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
|
||||
if (!guestId) {
|
||||
guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
|
||||
try {
|
||||
@@ -50,6 +98,20 @@ function getUserId() {
|
||||
return guestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 AI Agent。
|
||||
*/
|
||||
function getAgentId() {
|
||||
return currentAgentId || DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前 AI Agent。
|
||||
*/
|
||||
function setAgentId(agentId) {
|
||||
if (agentId && typeof agentId === 'string') currentAgentId = agentId;
|
||||
}
|
||||
|
||||
// #ifdef MP
|
||||
// ================================
|
||||
// WebSocket 长连接管理(微信小程序)
|
||||
@@ -91,7 +153,9 @@ function ensureWebSocketConnected() {
|
||||
return;
|
||||
}
|
||||
|
||||
var wssUrl = 'wss://' + domain + '/ws/aikefu';
|
||||
// PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
|
||||
var wsPath = resolveWsPath();
|
||||
var wssUrl = 'wss://' + domain + wsPath;
|
||||
wsConnected = false;
|
||||
wsAuthenticated = false;
|
||||
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
|
||||
@@ -106,6 +170,7 @@ function ensureWebSocketConnected() {
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'auth',
|
||||
agent_id: getAgentId(),
|
||||
uniacid: _uniacid,
|
||||
token: _token,
|
||||
user_id: _userId
|
||||
@@ -233,19 +298,35 @@ export default {
|
||||
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-7: 获取当前 agent 对应的 addon 名称 (aikefu / aiagent)
|
||||
*/
|
||||
getAddonName() {
|
||||
return resolveAddonName(currentAgentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-7: 获取当前 agent 对应的 API endpoint 前缀
|
||||
*/
|
||||
getApiPrefix() {
|
||||
return resolveAddonName(currentAgentId) === 'aikefu' ? '/api/kefu' : '/api/aiagent';
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送普通消息(blocking 模式)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async sendMessage(message, options = {}) {
|
||||
try {
|
||||
const params = {
|
||||
url: '/api/kefu/chat',
|
||||
url: resolveApiUrl('chat'),
|
||||
data: {
|
||||
agent_id: getAgentId(),
|
||||
user_id: getUserId(),
|
||||
stream: false,
|
||||
inputs: {},
|
||||
query: message,
|
||||
response_mode: 'blocking', // 强制 blocking
|
||||
response_mode: 'blocking',
|
||||
user: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
...getDomainParams()
|
||||
@@ -268,7 +349,6 @@ export default {
|
||||
} catch (error) {
|
||||
console.error('[AI Service] AiChat API请求失败,详细错误:', error);
|
||||
console.error('[AI Service] 错误堆栈:', error.stack);
|
||||
// 打印更多信息帮助调试
|
||||
console.error('[AI Service] store 状态:', {
|
||||
memberInfo: store.state.memberInfo,
|
||||
uniacid: this.getUniacid(),
|
||||
@@ -284,7 +364,6 @@ export default {
|
||||
*/
|
||||
async sendStreamMessage(message, onChunk, onComplete) {
|
||||
// #ifdef MP
|
||||
// 先确保连接已就绪
|
||||
try {
|
||||
await ensureWebSocketConnected();
|
||||
} catch (e) {
|
||||
@@ -292,7 +371,6 @@ export default {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 设置当前消息回调
|
||||
wsMessageContent = '';
|
||||
wsMessageActive = true;
|
||||
wsMessageOnChunk = onChunk || null;
|
||||
@@ -305,6 +383,7 @@ export default {
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'chat',
|
||||
agent_id: getAgentId(),
|
||||
uniacid: this.getUniacid(),
|
||||
query: message,
|
||||
user_id: getUserId(),
|
||||
@@ -315,13 +394,16 @@ export default {
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP 流式请求(仅 H5 使用)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async sendHttpStream(message, onChunk, onComplete) {
|
||||
// #ifdef H5
|
||||
try {
|
||||
const url = Config.baseUrl + `/api/kefu/chat`;
|
||||
var chatUrl = resolveApiUrl('chat');
|
||||
var url = Config.baseUrl + chatUrl;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -334,6 +416,7 @@ export default {
|
||||
inputs: {},
|
||||
query: message,
|
||||
response_mode: 'streaming',
|
||||
agent_id: getAgentId(),
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
...getDomainParams()
|
||||
@@ -356,9 +439,8 @@ export default {
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 按行分割,保留不完整的最后一行
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 未完成的行留到下次
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
@@ -369,14 +451,12 @@ export default {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.text || '';
|
||||
// 兼容嵌套 data.data 格式
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
}
|
||||
content += text;
|
||||
if (onChunk) onChunk(text);
|
||||
}
|
||||
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
|
||||
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
if (cid) {
|
||||
conversationId = cid;
|
||||
@@ -392,7 +472,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后残留的 buffer(如果有)
|
||||
if (buffer.trim().startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = buffer.trim().slice(5);
|
||||
@@ -479,11 +558,13 @@ export default {
|
||||
|
||||
/**
|
||||
* 生成新会话(如需)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async generateConversationId() {
|
||||
const params = {
|
||||
url: '/api/kefu/createConversation',
|
||||
url: resolveApiUrl('createConversation'),
|
||||
data: {
|
||||
agent_id: getAgentId(),
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || 'test_token',
|
||||
user_id: getUserId(),
|
||||
@@ -503,11 +584,12 @@ export default {
|
||||
|
||||
/**
|
||||
* 获取服务状态
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async getServiceStatus() {
|
||||
try {
|
||||
const response = await http.sendRequest({
|
||||
url: '/api/kefu/health',
|
||||
url: resolveApiUrl('health'),
|
||||
async: false,
|
||||
data: {}
|
||||
});
|
||||
@@ -527,12 +609,14 @@ export default {
|
||||
|
||||
/**
|
||||
* 获取服务配置信息
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async getServiceConfig() {
|
||||
try {
|
||||
const params = {
|
||||
url: '/api/kefu/info',
|
||||
url: resolveApiUrl('info'),
|
||||
data: {
|
||||
agent_id: getAgentId(),
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || 'test_token',
|
||||
user_id: getUserId(),
|
||||
@@ -542,7 +626,7 @@ export default {
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
};
|
||||
const response = await http.sendRequest({
|
||||
...params,
|
||||
async: false,
|
||||
@@ -557,12 +641,14 @@ export default {
|
||||
|
||||
/**
|
||||
* 清除会话历史(调用后端接口)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async clearConversation(conversationId) {
|
||||
try {
|
||||
const response = await http.sendRequest({
|
||||
url: '/api/kefu/clear-conversation',
|
||||
url: resolveApiUrl('clear-conversation'),
|
||||
data: {
|
||||
agent_id: getAgentId(),
|
||||
conversation_id: conversationId,
|
||||
...getDomainParams()
|
||||
},
|
||||
@@ -577,6 +663,7 @@ export default {
|
||||
|
||||
/**
|
||||
* 获取会话历史
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async getConversationHistory(params = {}) {
|
||||
try {
|
||||
@@ -585,6 +672,7 @@ export default {
|
||||
}
|
||||
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
|
||||
const requestData = {
|
||||
agent_id: params.agent_id || getAgentId(),
|
||||
uniacid: params.uniacid || this.getUniacid(),
|
||||
conversation_id: params.conversation_id,
|
||||
user_id: params.user_id || getUserId(),
|
||||
@@ -595,7 +683,7 @@ export default {
|
||||
...getDomainParams()
|
||||
};
|
||||
const response = await http.sendRequest({
|
||||
url: '/api/kefu/getHistory',
|
||||
url: resolveApiUrl('getHistory'),
|
||||
data: requestData,
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -625,6 +713,7 @@ export default {
|
||||
|
||||
/**
|
||||
* [H5 Only] EventSource 流式聊天(备用)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
|
||||
// #ifdef H5
|
||||
@@ -632,6 +721,7 @@ export default {
|
||||
window.currentEventSource.close();
|
||||
}
|
||||
const paramsObj = {
|
||||
agent_id: getAgentId(),
|
||||
uniacid: this.getUniacid(),
|
||||
user_id: getUserId(),
|
||||
member_id: getUserId(),
|
||||
@@ -642,7 +732,8 @@ export default {
|
||||
...getDomainParams()
|
||||
};
|
||||
const params = new URLSearchParams(paramsObj);
|
||||
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`;
|
||||
var chatUrl = resolveApiUrl('chat');
|
||||
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
|
||||
try {
|
||||
const eventSource = new EventSource(url);
|
||||
window.currentEventSource = eventSource;
|
||||
@@ -687,7 +778,6 @@ export default {
|
||||
*/
|
||||
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
|
||||
// #ifdef H5
|
||||
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
|
||||
return this.sendHttpStream(message, onMessage, (res) => {
|
||||
onComplete?.({ message: res.content, conversation_id: res.conversation_id });
|
||||
});
|
||||
@@ -711,4 +801,26 @@ export default {
|
||||
// H5 无 WebSocket 长连接,无需操作
|
||||
},
|
||||
// #endif
|
||||
};
|
||||
|
||||
/**
|
||||
* Set current AI Agent (default aikefu; link system passes agent_id).
|
||||
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
|
||||
* @param {string} agentId
|
||||
*/
|
||||
setAgentId(agentId) {
|
||||
var oldAddon = resolveAddonName(currentAgentId);
|
||||
setAgentId(agentId);
|
||||
var newAddon = resolveAddonName(currentAgentId);
|
||||
if (oldAddon !== newAddon) {
|
||||
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current AI Agent.
|
||||
* @returns {string}
|
||||
*/
|
||||
getAgentId() {
|
||||
return getAgentId();
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import IMAGE_CONFIG from './image-config.js';
|
||||
import Platform from './platform.js';
|
||||
import PAGE_KEY_MAP from './util.js';
|
||||
|
||||
export class CustomerService {
|
||||
constructor(vueInstance, externalConfig = null) {
|
||||
@@ -118,6 +119,16 @@ export class CustomerService {
|
||||
|
||||
/**
|
||||
* 跳转到 AI智能客服页面(AiChat)
|
||||
*
|
||||
* 注意:不在此处注入 __dyu=FloatBtn。
|
||||
* 该标记原本是 diy-float-btn.vue 用来告诉 ai-chat "我是从浮动按钮来的,请隐藏底部 tabBar"。
|
||||
* 此方法是 customer-service.js 的**通用 AI 客服入口**,被多处复用:
|
||||
* - hover-nav(悬浮客服按钮)
|
||||
* - 商品详情页 / 会员中心 / 个人中心 等的"联系客服"按钮
|
||||
* - DIY 浮动按钮(diy-float-btn 内部已自行注入 __dyu=FloatBtn,这里再注入会重复)
|
||||
* 若在此处统一注入,会让所有 AI 客服入口都隐藏底部 tabBar,语义错误且侵入性过强。
|
||||
* hover-nav 需要隐藏 tabBar 的场景,由 hover-nav.vue#handleUnifiedKefuClick
|
||||
* 自行注入 __dyu=FloatBtn(与 diy-float-btn.vue#onFloatBtnClick 一致)。
|
||||
*/
|
||||
openAiChatService() {
|
||||
try {
|
||||
@@ -127,7 +138,7 @@ export class CustomerService {
|
||||
}
|
||||
|
||||
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
||||
|
||||
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
||||
uni.navigateTo({
|
||||
@@ -551,6 +562,9 @@ export class CustomerService {
|
||||
|
||||
/**
|
||||
* 打开 AI 智能助手
|
||||
*
|
||||
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
|
||||
* 由调用方(hover-nav.vue 等)按需注入。
|
||||
*/
|
||||
openAIChat() {
|
||||
try {
|
||||
@@ -558,7 +572,7 @@ export class CustomerService {
|
||||
this.vm.setAiUnreadCount(0);
|
||||
}
|
||||
|
||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
||||
|
||||
uni.navigateTo({
|
||||
url: aiChatUrl,
|
||||
|
||||
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 组件, 不会动态增减
|
||||
|
||||
@@ -71,6 +71,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -264,6 +268,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -456,6 +464,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -648,6 +660,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -840,6 +856,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -1032,6 +1052,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -1224,6 +1248,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -1416,6 +1444,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
|
||||
@@ -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,
|
||||
|
||||
4013
common/js/util.js
4013
common/js/util.js
File diff suppressed because it is too large
Load Diff
2090
common/js/util.js.bak
Normal file
2090
common/js/util.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
92
common/js/video-helper.js
Normal file
92
common/js/video-helper.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// iOS 微信小程序 <video> 兼容工具
|
||||
// 问题背景:
|
||||
// 1) 弹窗内的 <video> 用 autoplay 不可靠——popup 初始隐藏,autoplay 在隐藏态不触发,
|
||||
// 等 popup 显示后也不会重新 autoplay,导致"安卓有时能播、iOS 不能";
|
||||
// 2) iOS 对同一个 <video> 实例在 src 重绑 / 弹窗重开 / 播放结束后,不会自动重新加载,
|
||||
// 需要靠 :key 重建实例或 seek(0) 才能重播;
|
||||
// 3) iOS 微信不支持 webm / vp9 / hevc / ogv 等封装或编码,会直接黑屏;
|
||||
// 4) H5 端(含微信内置浏览器)$nextTick 调 play() 太快,弹窗动画未完时浏览器内部
|
||||
// 会自动 pause(),导致 "The play() request was interrupted by a call to pause()" 错误。
|
||||
// 这里统一提供"格式检测"与"在视频可见后触发播放"的能力,供各视频组件复用。
|
||||
//
|
||||
// 重要:微信小程序的 <video>.play() 不要求用户手势(与 H5 不同),只要在视频元素
|
||||
// 已经渲染、popup 已经显示后调用即可。因此请务必在 uni-popup 的 @change(show=true)
|
||||
// 回调里调用本方法,而不是在 open() 里紧跟着 $nextTick 调用(那时元素尚未可见)。
|
||||
|
||||
// iOS 微信 <video> 已知不支持的封装/编码(仅按扩展名做保守判断,用于给出友好提示)
|
||||
const UNSUPPORTED_EXT_ON_IOS = ['webm', 'ogv', 'ogg', 'mov', 'm4v', 'hevc', 'av1', '3gp'];
|
||||
|
||||
// 取 URL 中的小写扩展名(忽略查询参数与 hash)
|
||||
export function getVideoExt(url) {
|
||||
if (!url || typeof url !== 'string') return '';
|
||||
const clean = url.split('?')[0].split('#')[0];
|
||||
const idx = clean.lastIndexOf('.');
|
||||
if (idx < 0) return '';
|
||||
return clean.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
// 该地址在 iOS 上是否很可能无法播放(基于扩展名的保守判断)
|
||||
export function isLikelyUnsupportedOnIOS(url) {
|
||||
const ext = getVideoExt(url);
|
||||
if (!ext) return false;
|
||||
return UNSUPPORTED_EXT_ON_IOS.indexOf(ext) !== -1;
|
||||
}
|
||||
|
||||
// 在"视频已经渲染、popup 已经显示"的前提下触发播放。
|
||||
// 必须在 uni-popup 的 @change(show=true) 回调里调用,此时 <video> 元素已可见,
|
||||
// createVideoContext 才能拿到有效实例。
|
||||
// videoId: <video> 的 id;instance: 当前组件/页面实例(组件内必传)。
|
||||
export function playVideoInContext(videoId, instance) {
|
||||
if (!videoId || !instance) return;
|
||||
|
||||
// H5 端需要更长延迟:等 uni-popup 动画播完(~350ms),否则浏览器内部会自动
|
||||
// pause() 导致 "play() interrupted by pause()" 错误;小程序端 $nextTick 即可。
|
||||
// #ifdef H5
|
||||
const delay = 400;
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
const delay = 0;
|
||||
// #endif
|
||||
|
||||
const doPlay = () => {
|
||||
let ctx = null;
|
||||
try {
|
||||
ctx = uni.createVideoContext(videoId, instance);
|
||||
} catch (e) {
|
||||
console.error('[video-helper] createVideoContext failed:', e);
|
||||
return;
|
||||
}
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
ctx.play();
|
||||
} catch (e) {
|
||||
console.error('[video-helper] play() failed, will retry:', e);
|
||||
// H5 端首次 play() 常被浏览器自动 pause() 打断,延迟重试一次
|
||||
// #ifdef H5
|
||||
setTimeout(() => {
|
||||
try { ctx.play(); } catch (retryErr) {
|
||||
console.error('[video-helper] retry play() also failed:', retryErr);
|
||||
}
|
||||
}, 300);
|
||||
// #endif
|
||||
}
|
||||
};
|
||||
|
||||
if (delay > 0) {
|
||||
// H5:先等 nextTick(DOM 更新),再等 delay(弹窗动画完成)
|
||||
if (typeof instance.$nextTick === 'function') {
|
||||
instance.$nextTick(() => setTimeout(doPlay, delay));
|
||||
} else {
|
||||
setTimeout(doPlay, delay);
|
||||
}
|
||||
} else {
|
||||
// 小程序端:nextTick 即可
|
||||
if (typeof instance.$nextTick === 'function') {
|
||||
instance.$nextTick(doPlay);
|
||||
} else {
|
||||
doPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -698,10 +784,10 @@ export default {
|
||||
padding: 26rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||
|
||||
view {
|
||||
color: #222222;
|
||||
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||
width: 100%;
|
||||
white-space: break-word;
|
||||
overflow: hidden;
|
||||
@@ -718,10 +804,10 @@ export default {
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: #fff;
|
||||
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||
|
||||
view {
|
||||
color: #333;
|
||||
color: var(--diy-category-tree-select-text-color, #333);
|
||||
font-weight: bold;
|
||||
font-size: var(--diy-category-tree-select-size, 28rpx);
|
||||
}
|
||||
@@ -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: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||
border-radius: 0;
|
||||
|
||||
view {
|
||||
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.border-top,
|
||||
&.border-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||
|
||||
view {
|
||||
color: var(--diy-category-tree-select-text-color, #333);
|
||||
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 {
|
||||
@@ -1187,19 +1603,22 @@ export default {
|
||||
|
||||
.tree-wrap .category-item {
|
||||
padding: 30rpx 16rpx;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
|
||||
view {
|
||||
color: #333;
|
||||
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&.select {
|
||||
background: #fff;
|
||||
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
view {
|
||||
color: #FF6B35;
|
||||
color: var(--diy-category-tree-select-text-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
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
|
||||
:style="style">
|
||||
<block v-for="(item, index) in value.list" :key="index">
|
||||
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)"
|
||||
<view class="button-box" @tap.stop="onFloatBtnClick(item)"
|
||||
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
|
||||
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
|
||||
show-menu-by-longpress />
|
||||
@@ -44,7 +44,44 @@ export default {
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
components: {},
|
||||
methods: {},
|
||||
methods: {
|
||||
/**
|
||||
* 浮动按钮点击处理:
|
||||
* 在跳转前给 link 注入 __dyu=FloatBtn 来源标记,
|
||||
* 让目标页面(尤其是 ai-chat)能识别"来自浮动按钮"从而隐藏底部 tabBar,
|
||||
* 与"从底部导航栏点击进入时显示并高亮 tabBar"的场景并存。
|
||||
*
|
||||
* 为什么用 __dyu 这种 3 字符命名:
|
||||
* - 双下划线 __ 前缀 + 3 位字母(dy-ui 框架) 是前端约定俗成的"系统内部保留字段"标志
|
||||
* (如 __proto__, __esModule),业务方配置 link 时几乎不可能使用这种短命名 → 与业务参数零冲突
|
||||
* - URL 中占用空间极小,不污染业务 query 字符串
|
||||
* - 未来如需扩展其他组件的入口来源,可继续使用 __dyu 前缀(如 __dyu=Banner / __dyu=Nav)
|
||||
*
|
||||
* 实现要点:
|
||||
* - 浅拷贝 item.link 避免污染原数据(可能被 v-for 复用)
|
||||
* - 把标记写入 page_params,diyRedirectTo → redirectTo 会自动拼到 URL query,
|
||||
* 目标页 onLoad(options) 可直接读取
|
||||
* - 同时在 wap_url 末尾追加/合并 __dyu=FloatBtn 兜底:某些路径不走 page_params 拼接
|
||||
*/
|
||||
onFloatBtnClick(item) {
|
||||
if (!item || !item.link) return;
|
||||
const link = { ...item.link };
|
||||
link.page_params = Object.assign({}, link.page_params || {}, {
|
||||
__dyu: 'FloatBtn'
|
||||
});
|
||||
|
||||
// 兜底:直接把标记拼到 wap_url query,防止 page_params 在某些分支未被透传
|
||||
if (link.wap_url && typeof link.wap_url === 'string' && link.wap_url.indexOf('http') === -1) {
|
||||
const sep = link.wap_url.indexOf('?') === -1 ? '?' : '&';
|
||||
// 避免重复追加(精确匹配 __dyu= 而不是 __dyu,避免误伤可能的 __dyu_other)
|
||||
if (link.wap_url.indexOf('__dyu=') === -1) {
|
||||
link.wap_url = link.wap_url + sep + '__dyu=FloatBtn';
|
||||
}
|
||||
}
|
||||
|
||||
this.$util.diyRedirectTo(link);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
let style = {},
|
||||
|
||||
@@ -1,29 +1,59 @@
|
||||
<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 is-card-overlap" :style="bannerStyle + bannerBleedStyle">
|
||||
<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>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'horizontal'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
<!-- 提交按钮(外层 = 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>
|
||||
<!-- 卡片容器:仅在后台配置了 banner 图片时启用,让表单浮在 banner 之上(参考左图布局)。
|
||||
无 banner 时不渲染 .form-card,保持原有平铺行为,零外溢。 -->
|
||||
<view v-if="hasBannerMode" class="form-card" :style="cardWrapStyle">
|
||||
<!-- 提示语 -->
|
||||
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'vertical'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
|
||||
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
|
||||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 无 banner 时仍按旧布局直接渲染 tip / ns-newform / submit,避免影响其他使用方(diy-group / diy-article 等) -->
|
||||
<template v-else>
|
||||
<!-- 提示语 -->
|
||||
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'vertical'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
</view>
|
||||
</template>
|
||||
</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 +77,20 @@ export default {
|
||||
showSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 父级容器左右内边距(rpx)。卡片模式下让 banner 图片突破这个内边距左右铺满(如 system-form-wrap 的 padding-x)。
|
||||
// 仅在 hasBannerMode=true 时生效;默认 0(不铺满)。
|
||||
// 引入原因:formdata.vue 的 .system-form-wrap 默认 padding: 0 50rpx,banner 图片被左右各 50rpx 收缩,
|
||||
// 后台配 form_banner 后图片应跨越屏幕宽度,而不是被容器内缩。
|
||||
containerInset: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
|
||||
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
@@ -70,13 +114,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;
|
||||
},
|
||||
@@ -90,6 +136,52 @@ export default {
|
||||
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
||||
});
|
||||
},
|
||||
// 后台配了 banner 图片时启用卡片浮起布局(与参考设计左图一致);无 banner 走原平铺,零外溢
|
||||
hasBannerMode() {
|
||||
const v = this.value || {};
|
||||
return !!(v.show_banner && v.form_banner);
|
||||
},
|
||||
// 卡片容器内联样式:maring-top(负偏移让卡片向上压住 banner 底部)+ border-radius + padding-x + box-shadow + background。
|
||||
// 数值由 formConfig 走 camelCase/snake_case 双读,缺省走默认值,使后端零配置时也能看到卡片浮起效果。
|
||||
// 关键:卡片必须自带白底 + z-index(见 SCSS .form-card)—— 只有卡片自己的背景才能"盖住"banner 图片底部,
|
||||
// 父级容器(如 formdata.vue 的 .system-form-wrap)是 banner 的祖先,其背景会被 banner 覆盖,无法形成浮起效果。
|
||||
cardWrapStyle() {
|
||||
const cfg = this.mergedFormConfig || {};
|
||||
// 父级传了 containerInset(即 formdata.vue 场景),外层 system-form-wrap 已给左右 padding-x,这里默认 0 不重复
|
||||
// 其他场景(diy-group / diy-article 等未传 containerInset),回退到 bannerPaddingX 或 28rpx 保证卡片模式视觉正常
|
||||
const parentInset = this._num(this.containerInset, 0);
|
||||
const defaultSidePad = parentInset > 0 ? 0 : this._num(cfg.bannerPaddingX, 28);
|
||||
const sidePad = this._num(cfg.cardSidePad, defaultSidePad);
|
||||
// overlap:负值,让卡片向上"压"住 banner 底部(与设计图一致)
|
||||
const overlap = -Math.abs(this._num(cfg.cardOverlap, 48));
|
||||
// 卡片圆角:缺省 24rpx(浮起卡片的圆角,比外层卡片的 16rpx 更明显,突出浮起感)
|
||||
const radius = this._num(cfg.cardRadius, 24);
|
||||
// 阴影:缺省中等阴影(与设计方案一致)—— 由本容器承担浮起感
|
||||
const shadow = cfg.cardShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)';
|
||||
// 卡片底色:纯白,与设计方案一致;可由后台 formConfig.cardBgColor 覆盖
|
||||
const bgColor = cfg.cardBgColor || '#ffffff';
|
||||
return this.$util.objToStyle({
|
||||
marginTop: overlap + 'rpx',
|
||||
paddingLeft: sidePad + 'rpx',
|
||||
paddingRight: sidePad + 'rpx',
|
||||
borderRadius: radius + 'rpx',
|
||||
boxShadow: shadow,
|
||||
backgroundColor: bgColor
|
||||
});
|
||||
},
|
||||
// Banner 容器破壳样式:仅在卡片模式下 + 父级传了 containerInset>0 时启用,让 banner 突破父容器 padding 跨越屏幕宽度。
|
||||
// 计算逻辑:负 margin-left/right + 等量扩宽 width,使 banner 元素左/右边界对齐到屏幕边缘。
|
||||
// 父级未传 containerInset 或传 0 时返回空样式,banner 保持原尺寸(零外溢)。
|
||||
bannerBleedStyle() {
|
||||
const inset = this._num(this.containerInset, 0);
|
||||
if (!this.hasBannerMode || inset <= 0) return '';
|
||||
const expand = inset * 2;
|
||||
return this.$util.objToStyle({
|
||||
marginLeft: -inset + 'rpx',
|
||||
marginRight: -inset + 'rpx',
|
||||
width: 'calc(100% + ' + expand + 'rpx)'
|
||||
});
|
||||
},
|
||||
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
|
||||
tipStyle() {
|
||||
const cfg = this.mergedFormConfig || {};
|
||||
@@ -131,7 +223,7 @@ export default {
|
||||
width: widthPct + '%',
|
||||
padding: `${submitPaddingY}rpx 0`,
|
||||
color: cfg.submitColor || '#ffffff',
|
||||
'background-color': cfg.submitBgColor || '#ff4544',
|
||||
'background-color': cfg.submitBgColor || '#3b82f6',
|
||||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||||
});
|
||||
@@ -141,6 +233,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 +276,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'),
|
||||
@@ -193,7 +303,13 @@ export default {
|
||||
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
||||
tipColor: pick('tipColor', 'tip_color'),
|
||||
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
||||
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
|
||||
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
|
||||
// 卡片浮起(仅 show_banner && form_banner 启用)
|
||||
cardOverlap: pick('cardOverlap', 'card_overlap'),
|
||||
cardRadius: pick('cardRadius', 'card_radius'),
|
||||
cardShadow: pick('cardShadow', 'card_shadow'),
|
||||
cardSidePad: pick('cardSidePad', 'card_side_pad'),
|
||||
cardBgColor: pick('cardBgColor', 'card_bg_color')
|
||||
};
|
||||
// 移除 undefined / 空字符串(保留 0/false)
|
||||
Object.keys(base).forEach(function(k) {
|
||||
@@ -201,9 +317,33 @@ export default {
|
||||
delete base[k];
|
||||
}
|
||||
});
|
||||
// 前端视觉默认:强制覆盖后端 formConfig 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
|
||||
// 与 formdata.vue 的 styleDefaults 保持一致,使所有 diy-form(含 diy-group 内的表单)统一呈现新设计稿样式。
|
||||
// 后端仍保留行为/文案类配置(behavior / submitText / banner / tip 等),本合并仅覆盖视觉字段。
|
||||
const styleDefaults = {
|
||||
formLayout: 'vertical',
|
||||
formPaddingX: 28,
|
||||
rowPaddingY: 24,
|
||||
labelGap: 20,
|
||||
labelFontSize: 32,
|
||||
labelFontWeight: 700,
|
||||
labelColor: '#1f2937',
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
inputBgColor: '#ffffff',
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputFocusBorderColor: '#3b82f6',
|
||||
inputFocusBgColor: '#ffffff',
|
||||
placeholderColor: '#9ca3af',
|
||||
placeholderFontSize: 28,
|
||||
checkedColor: '#3b82f6',
|
||||
showRowDivider: false,
|
||||
submitColor: '#ffffff',
|
||||
submitBgColor: '#3b82f6'
|
||||
};
|
||||
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
||||
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
||||
const result = Object.assign({}, base);
|
||||
const result = Object.assign({}, base, styleDefaults);
|
||||
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
||||
if (termBlock && typeof termBlock === 'object') {
|
||||
Object.keys(termBlock).forEach(function(platform) {
|
||||
@@ -269,24 +409,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 +440,7 @@ export default {
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
uni.showToast({
|
||||
title: '提交成功',
|
||||
title: self.$lang('submitSuccess'),
|
||||
icon: 'success'
|
||||
});
|
||||
// 重置表单
|
||||
@@ -307,7 +449,7 @@ export default {
|
||||
}
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.message || '提交失败',
|
||||
title: res.message || self.$lang('submitFail'),
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
@@ -330,12 +472,37 @@ export default {
|
||||
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
||||
margin: 0;
|
||||
|
||||
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
|
||||
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
|
||||
&.is-card-overlap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片容器:浮在 banner 图片上方(参考设计左图)。
|
||||
// 仅在 hasBannerMode=true 时渲染(见 template v-if);
|
||||
// 阴影 + margin-top(负偏移)+ 圆角 + 白底 全部由 cardWrapStyle 内联写入(读取后台 formConfig.card*)。
|
||||
// 关键:卡片必须自带白底并 z-index 高于 banner,才能真正"盖住"banner 图片底部形成浮起;
|
||||
// 父级容器(formdata.vue 的 .system-form-wrap)虽也是白底,但它是 banner 的祖先、背景会被 banner 覆盖,
|
||||
// 因此本容器的白底不可省略(与 .system-form-wrap 同色,视觉上从卡片向下自然延续到页面底部)。
|
||||
.form-card {
|
||||
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||||
// 兜底 padding-bottom:让卡片与后面内容(如推荐码、其它 diy 组件)有间距
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
||||
padding: 0;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -137,8 +137,8 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
|
||||
2033
components-diy/diy-recommend-code.vue
Normal file
2033
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
|
||||
|
||||
@@ -743,7 +743,53 @@ export default {
|
||||
this.$langConfig.change(targetLang, url);
|
||||
},
|
||||
|
||||
/**
|
||||
* 客服按钮点击的统一入口。
|
||||
*
|
||||
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
|
||||
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
|
||||
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
|
||||
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
|
||||
*
|
||||
* 为什么不在 customer-service.js#openAiChatService 统一注入:
|
||||
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
|
||||
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
|
||||
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
|
||||
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
|
||||
* 其他入口保持原样。
|
||||
*
|
||||
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
|
||||
*/
|
||||
handleUnifiedKefuClick() {
|
||||
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
|
||||
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
|
||||
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
|
||||
// 与底部 tabBar 无关,继续走原统一入口。
|
||||
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
|
||||
// 清除未读数(与 openAiChatService 行为保持一致)
|
||||
if (typeof this.setAiUnreadCount === 'function') {
|
||||
this.setAiUnreadCount(0);
|
||||
} else if (this.$store && typeof this.$store.commit === 'function') {
|
||||
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
|
||||
}
|
||||
|
||||
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
|
||||
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
|
||||
uni.navigateTo({
|
||||
url: aiChatUrl,
|
||||
fail: (err) => {
|
||||
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
|
||||
// H5 兜底
|
||||
// #ifdef H5
|
||||
window.location.href = aiChatUrl;
|
||||
// #endif
|
||||
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
|
||||
this.customerService.handleUnifiedKefuClick({
|
||||
sendMessageTitle: '来自悬浮按钮的咨询',
|
||||
sendMessagePath: '/pages/index/index'
|
||||
|
||||
@@ -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,58 +1,74 @@
|
||||
<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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ 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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ 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">
|
||||
<view :class="['box', 'box-flex']">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ 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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ 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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ 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,26 +113,26 @@
|
||||
</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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -138,26 +154,26 @@
|
||||
</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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</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="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -179,60 +195,332 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex">
|
||||
<view class="name">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required">{{ item.value.required ? '*' : '' }}</text>
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
|
||||
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||
</view>
|
||||
</pick-regions>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="box">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
|
||||
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||
</view>
|
||||
</pick-regions>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</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: 28,
|
||||
formBgColor: '#ffffff',
|
||||
rowPaddingY: 24,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 20,
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 32,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 700,
|
||||
labelAutoMaxWidth: 200,
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#3b82f6',
|
||||
placeholderColor: '#9ca3af',
|
||||
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)
|
||||
});
|
||||
},
|
||||
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
|
||||
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
|
||||
nameWidthStyle() {
|
||||
return {};
|
||||
},
|
||||
// 输入框样式(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 +644,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 +939,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: 28rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-padding-y: 24rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 20rpx;
|
||||
--input-height: 84rpx;
|
||||
--input-radius: 16rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 32rpx;
|
||||
--label-color: #1f2937;
|
||||
--label-font-weight: 700;
|
||||
--required-color: #ef4444;
|
||||
--placeholder-font-size: 28rpx;
|
||||
--checked-color: #3b82f6;
|
||||
--placeholder-color: #9ca3af;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 48rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #ffffff;
|
||||
--input-focus-border-color: #3b82f6;
|
||||
--input-focus-bg-color: #ffffff;
|
||||
--row-divider-color: #f0f0f0;
|
||||
--show-row-divider: 0;
|
||||
}
|
||||
|
||||
.order-wrap {
|
||||
padding: 20rpx 0;
|
||||
padding: var(--row-padding-y, 20rpx) 0;
|
||||
|
||||
&.no-divider {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -655,12 +987,14 @@
|
||||
|
||||
.order-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
min-height: var(--row-min-height, 60rpx);
|
||||
|
||||
&.textarea {
|
||||
align-items: unset;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.clear-flex {
|
||||
@@ -674,7 +1008,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,61 +1017,165 @@
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 80vw;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--input-height, 60rpx);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--label-gap, 20rpx);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.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, 700);
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
font-size: 28rpx;
|
||||
margin-left: 4rpx;
|
||||
width: 14rpx;
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
margin-right: 6rpx;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.box {
|
||||
flex: 1;
|
||||
padding: 0 10rpx;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-size: $font-size-base;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||
// 避免 placeholder / 文字下半部分被裁切
|
||||
line-height: var(--input-height, 84rpx);
|
||||
height: var(--input-height, 84rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 44rpx;
|
||||
padding: 0 24rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||
.placeholder {
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
}
|
||||
|
||||
::v-deep textarea,
|
||||
textarea {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
width: 100%;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
|
||||
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
||||
// 真正做到了"rows 改 → 高度自动改"。
|
||||
line-height: var(--textarea-line-height, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 12rpx 24rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.uni-input {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
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, #9ca3af);
|
||||
}
|
||||
|
||||
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
|
||||
picker,
|
||||
.pick-regions {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 在 box-flex(带右侧箭头)场景下,picker 不应占满,留出右侧箭头空间
|
||||
&.box-flex {
|
||||
picker,
|
||||
.pick-regions {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.iconfont.icon-right {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -746,6 +1185,39 @@
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
||||
// 注意:option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 会选不中
|
||||
&.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.option-layout-wrap {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-right: 30rpx;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.img-boxs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -821,10 +1293,11 @@
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,16 +1311,17 @@
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
// 动态码
|
||||
@@ -283,14 +325,69 @@ export default {
|
||||
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
this.getCode(authData => {
|
||||
this.authLogin(authData, 'authOnlyLogin');
|
||||
}, (err) => {
|
||||
callback && callback(err);
|
||||
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
|
||||
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
|
||||
// "privacy permission is not authorized or buttonId is invalid"。
|
||||
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
|
||||
this.ensureWxPrivacyAuthorized(() => {
|
||||
this.getCode(authData => {
|
||||
this.authLogin(authData, 'authOnlyLogin');
|
||||
}, (err) => {
|
||||
callback && callback(err);
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
|
||||
ensureWxPrivacyAuthorized(done) {
|
||||
try {
|
||||
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
|
||||
// 低版本基础库无隐私 API, 直接放行
|
||||
done();
|
||||
return;
|
||||
}
|
||||
wx.getPrivacySetting({
|
||||
success: (res) => {
|
||||
if (!res || !res.needAuthorization) {
|
||||
// 已授权或无隐私指引配置, 直接放行
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
|
||||
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
|
||||
const popup = this.$refs.privacyPopup;
|
||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
||||
const onAgreed = () => {
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
done();
|
||||
};
|
||||
const onDisagreed = () => {
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
|
||||
};
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
uni.$on('privacy:agreed', onAgreed);
|
||||
uni.$on('privacy:disagreed', onDisagreed);
|
||||
popup.showPrivacyPopup();
|
||||
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
|
||||
// 无 privacy-popup 实例时退回微信官方隐私弹窗
|
||||
wx.requirePrivacyAuthorize({
|
||||
success: () => done(),
|
||||
fail: () => { }
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => done()
|
||||
});
|
||||
} catch (e) {
|
||||
done();
|
||||
}
|
||||
},
|
||||
|
||||
// 跳转去登录页
|
||||
toLoginH5Page(mode = 'navigateTo') {
|
||||
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
||||
@@ -343,7 +440,7 @@ export default {
|
||||
openCompleteInfoPop() {
|
||||
this.getRegisterConfig();
|
||||
|
||||
// #ifdef H5
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
if (!this.storeToken) this.getCaptchaConfig();
|
||||
// #endif
|
||||
|
||||
@@ -572,8 +669,24 @@ export default {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const errMsg = (e.detail && e.detail.errMsg) || '';
|
||||
// #ifdef MP-WEIXIN
|
||||
// 隐私协议未授权时 getPhoneNumber 会直接失败:
|
||||
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
|
||||
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
|
||||
if (errMsg.indexOf('privacy') !== -1) {
|
||||
this.$util.showToast({
|
||||
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
|
||||
});
|
||||
const popup = this.$refs.privacyPopup;
|
||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
||||
popup.showPrivacyPopup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
this.$util.showToast({
|
||||
title: e.detail.errMsg
|
||||
title: errMsg
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -676,6 +789,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
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<view v-if="item.controller == 'Text'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</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>
|
||||
@@ -23,14 +23,20 @@
|
||||
<view v-if="item.controller == 'Textarea'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', 'textarea', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</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>
|
||||
@@ -45,8 +51,8 @@
|
||||
>
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
@@ -61,22 +67,23 @@
|
||||
<view v-if="item.controller == 'Checkbox'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</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 || 'vertical')]">
|
||||
<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">
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
!v.checked ? 'icon-fuxuankuang2' : '',
|
||||
v.checked ? 'icon-fuxuankuang1' : ''
|
||||
]"
|
||||
:style="{ color: v.checked ? checkedColor : '' }"
|
||||
></text>
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
'check-icon',
|
||||
!v.checked ? 'icon-fuxuankuang2' : '',
|
||||
v.checked ? 'icon-fuxuankuang1' : ''
|
||||
]"
|
||||
:style="{ color: v.checked ? checkedColor : '' }"
|
||||
></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -89,22 +96,23 @@
|
||||
<view v-if="item.controller == 'Radio'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</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 || 'vertical')]">
|
||||
<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">
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
item.val != v.value ? 'icon-yuan_checkbox' : '',
|
||||
item.val == v.value ? 'icon-yuan_checked' : ''
|
||||
]"
|
||||
:style="{ color: item.val == v.value ? checkedColor : '' }"
|
||||
></text>
|
||||
<text
|
||||
:class="[
|
||||
'iconfont',
|
||||
'radio-icon',
|
||||
item.val != v.value ? 'icon-yuan_checkbox' : '',
|
||||
item.val == v.value ? 'icon-yuan_checked' : ''
|
||||
]"
|
||||
:style="{ color: item.val == v.value ? checkedColor : '' }"
|
||||
></text>
|
||||
{{ v.value }}
|
||||
</view>
|
||||
</label>
|
||||
@@ -117,8 +125,8 @@
|
||||
<view v-if="item.controller == 'Img'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'img-boxs', 'align-left']" :style="item._boxAlignStyle">
|
||||
<view v-for="(v, k) in item.img_lists" :key="k" class="img-box" @tap="uploadImg(index)">
|
||||
@@ -136,8 +144,8 @@
|
||||
<view v-if="item.controller == 'Date'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<picker
|
||||
@@ -158,8 +166,8 @@
|
||||
<view v-if="item.controller == 'Datelimit'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
|
||||
<view class="date-box">
|
||||
@@ -197,8 +205,8 @@
|
||||
<view v-if="item.controller == 'Time'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<picker
|
||||
@@ -219,8 +227,8 @@
|
||||
<view v-if="item.controller == 'Timelimit'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
|
||||
<view class="date-box">
|
||||
@@ -258,8 +266,8 @@
|
||||
<view v-if="item.controller == 'City'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
|
||||
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
|
||||
<view class="name" :style="item._labelStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex', 'align-right']">
|
||||
<pick-regions
|
||||
@@ -280,8 +288,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-newform',
|
||||
mixins: [formPlatformMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
@@ -293,7 +304,7 @@ export default {
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
default: 'vertical',
|
||||
validator: (value) => !value || ['horizontal', 'vertical'].includes(value)
|
||||
},
|
||||
// 表单配置(按平台差异化)
|
||||
@@ -349,84 +360,44 @@ 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 = {
|
||||
formPaddingX: 14,
|
||||
formPaddingX: 28,
|
||||
bannerPaddingX: 0,
|
||||
tipPaddingX: 14,
|
||||
rowPaddingY: 4,
|
||||
tipPaddingX: 28,
|
||||
rowPaddingY: 24,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 16,
|
||||
labelGap: 20,
|
||||
formBgColor: '#ffffff',
|
||||
rowDividerColor: '#f0f0f0',
|
||||
showRowDivider: true,
|
||||
inputHeight: 60,
|
||||
inputRadius: 10,
|
||||
showRowDivider: false,
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
labelWidthMode: 'auto',
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelFontSize: 32,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 700,
|
||||
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
checkedColor: '#3b82f6',
|
||||
placeholderColor: '#9ca3af',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
|
||||
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 48,
|
||||
// 输入框视觉增强(可由后端 form_config 覆盖)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#ffffff',
|
||||
inputFocusBorderColor: '#3b82f6',
|
||||
inputFocusBgColor: '#ffffff',
|
||||
labelFontWeight: 700,
|
||||
// 提交按钮
|
||||
submitColor: '#ffffff',
|
||||
submitBgColor: '#ff4544',
|
||||
submitBgColor: '#3b82f6',
|
||||
submitMarginTop: 0, // 提交按钮顶部外边距(rpx):调整表单与按钮之间的距离
|
||||
// 提示语
|
||||
tipFontSize: 32,
|
||||
@@ -455,9 +426,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 +441,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 +483,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 +493,7 @@ export default {
|
||||
// 行最小高度
|
||||
rowCellStyle() {
|
||||
return this.$util.objToStyle({
|
||||
'min-height': `${this.config.rowMinHeight}rpx`
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签与输入框间距
|
||||
@@ -520,26 +502,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 +560,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 +575,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 +589,24 @@ export default {
|
||||
// item 级别 box 对齐方式(与原 getItemBoxAlignStyle 逻辑一致)
|
||||
const align = itemValue.input_align;
|
||||
this.$set(item, '_boxAlignStyle', align ? { 'text-align': align } : {});
|
||||
|
||||
// 选项排列样式:默认 vertical(每项一行),仅当后端显式配置 wrap 时横向换行
|
||||
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
|
||||
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
|
||||
const optLayout = itemValue.option_layout;
|
||||
const optStyle = {};
|
||||
if (optLayout === 'wrap') {
|
||||
// wrap 模式:横向自动换行
|
||||
optStyle['flex-direction'] = 'row';
|
||||
optStyle['align-items'] = 'center';
|
||||
optStyle['gap'] = '12rpx';
|
||||
} else {
|
||||
// vertical 模式(默认):每项独立一行
|
||||
optStyle['flex-direction'] = 'column';
|
||||
optStyle['align-items'] = 'flex-start';
|
||||
optStyle['gap'] = '20rpx';
|
||||
}
|
||||
this.$set(item, '_optionLayoutStyle', optStyle);
|
||||
});
|
||||
},
|
||||
// 容器样式(提供给外部 diy-form 使用)
|
||||
@@ -629,8 +630,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 +654,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 : '';
|
||||
}
|
||||
|
||||
@@ -1007,28 +1015,38 @@ export default {
|
||||
.form-wrap {
|
||||
background: #fff;
|
||||
/* 默认值兜底:与 ns-newform.config 默认值一致 */
|
||||
--form-padding-x: 14rpx;
|
||||
--form-padding-x: 28rpx;
|
||||
--banner-padding-x: 0rpx;
|
||||
--tip-padding-x: 14rpx;
|
||||
--tip-font-size: 24rpx;
|
||||
--tip-padding-x: 28rpx;
|
||||
--tip-font-size: 32rpx;
|
||||
--tip-color: #666666;
|
||||
--tip-bg-color: #ffffff;
|
||||
--row-padding-y: 4rpx;
|
||||
--row-padding-y: 24rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 16rpx;
|
||||
--label-gap: 20rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-divider-color: #f0f0f0;
|
||||
--input-height: 60rpx;
|
||||
--input-radius: 10rpx;
|
||||
--input-height: 84rpx;
|
||||
--input-radius: 16rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 28rpx;
|
||||
--label-font-size: 32rpx;
|
||||
--label-color: #1f2937;
|
||||
--required-color: #ef4444;
|
||||
--checked-color: #ff4544;
|
||||
--checked-color: #3b82f6;
|
||||
--submit-color: #ffffff;
|
||||
--submit-bg-color: #ff4544;
|
||||
--placeholder-color: #ddd;
|
||||
--show-row-divider: 1;
|
||||
--submit-bg-color: #3b82f6;
|
||||
--placeholder-color: #9ca3af;
|
||||
--placeholder-font-size: 28rpx;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 48rpx;
|
||||
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #ffffff;
|
||||
--input-focus-border-color: #3b82f6;
|
||||
--input-focus-bg-color: #ffffff;
|
||||
--label-font-weight: 700;
|
||||
--show-row-divider: 0;
|
||||
|
||||
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
|
||||
&.no-divider-all {
|
||||
@@ -1079,13 +1097,14 @@ export default {
|
||||
&.vertical-layout {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
|
||||
.name {
|
||||
width: 100%;
|
||||
margin-bottom: 8rpx;
|
||||
min-height: auto;
|
||||
margin-bottom: var(--label-gap, 20rpx);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
|
||||
.box {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1135,7 +1154,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 {
|
||||
@@ -1146,7 +1165,7 @@ export default {
|
||||
.required {
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
|
||||
margin-left: 4rpx;
|
||||
margin-right: 6rpx;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@@ -1203,74 +1222,135 @@ 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, 84rpx);
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
line-height: calc(var(--input-height, 84rpx) - 2rpx);
|
||||
padding: 0 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
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, #3b82f6);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
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-radius: var(--input-radius, 10rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
height: var(--input-height, 84rpx);
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
line-height: calc(var(--input-height, 84rpx) - 2rpx);
|
||||
padding: 0 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
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, #3b82f6);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// H5 端 <textarea> 被 uni-app 编译为 <uni-textarea> 自定义组件,
|
||||
// 真实 <textarea> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
|
||||
::v-deep textarea {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
width: 100%;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
|
||||
line-height: var(--textarea-line-height, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
padding: 12rpx 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
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, #3b82f6);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
// 非 H5 端(小程序 / 鸿蒙 / App)<textarea> 是直接原生元素,普通选择器即可
|
||||
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, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
padding: 12rpx 24rpx;
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
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, #3b82f6);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.uni-input, .select-address {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--label-color, #1f2937);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
transition: all 0.3s ease;
|
||||
min-height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
box-sizing: border-box;
|
||||
line-height: var(--input-height, 60rpx);
|
||||
|
||||
line-height: var(--input-height, 84rpx);
|
||||
|
||||
&.color-tip {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
|
||||
|
||||
&.empty {
|
||||
color: var(--placeholder-color, #ddd);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1308,6 +1388,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap = 横向自动换行
|
||||
// option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 选不中实际 DOM
|
||||
&.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1333,7 +1430,14 @@ export default {
|
||||
.radio-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.check-icon,
|
||||
.radio-icon {
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
&.img-boxs {
|
||||
@@ -1426,7 +1530,7 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.placeholder {
|
||||
color: #909399;
|
||||
color: #9ca3af;
|
||||
font-size: 28rpx;
|
||||
|
||||
// #ifdef H5
|
||||
@@ -1435,6 +1539,6 @@ export default {
|
||||
}
|
||||
|
||||
.color-tip {
|
||||
color: #909399;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -237,18 +281,18 @@ export default {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (wx.onNeedPrivacyAuthorization) {
|
||||
wx.onNeedPrivacyAuthorization((resolve) => {
|
||||
// 检查是否已同意
|
||||
if (!PrivacyManager.needShow()) {
|
||||
// 已同意, 直接放行
|
||||
resolve({ event: 'agree' })
|
||||
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
|
||||
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
|
||||
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
|
||||
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
|
||||
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
|
||||
if (typeof this.privacyAuthorization === 'function') {
|
||||
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
|
||||
this.privacyAuthorization(resolve)
|
||||
} else {
|
||||
// 需要弹窗, 存 resolve 等用户操作
|
||||
if (typeof this.privacyAuthorization === 'function') {
|
||||
this.privacyAuthorization(resolve)
|
||||
} else {
|
||||
// 兜底: 组件未就绪, 直接放行避免 API 卡死
|
||||
resolve({ event: 'agree' })
|
||||
}
|
||||
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
|
||||
// (disagree 无需 buttonId, 不会产生非法 resolve)
|
||||
resolve({ event: 'disagree' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
5169
lang-bundle.json
5169
lang-bundle.json
File diff suppressed because it is too large
Load Diff
@@ -136,4 +136,8 @@ export const lang = {
|
||||
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
||||
logChatComponentNotInit: 'Chat component not initialized',
|
||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
||||
}
|
||||
|
||||
// ====== AI Agent general copy (customer service is the first agent) ======
|
||||
aiAgentDefaultTitle: 'AI Agent',
|
||||
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
|
||||
aiAgentNotConfigured: 'This AI Agent is not enabled'}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const lang = {
|
||||
loggingIn: 'Logging in',
|
||||
loginFailed: 'Login failed',
|
||||
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
||||
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
|
||||
pleaseEnterNickname: 'Please enter nickname',
|
||||
avatarPullFailed: 'Avatar pull failed',
|
||||
}
|
||||
|
||||
@@ -7,5 +7,18 @@ export const lang = {
|
||||
submitSuccess: 'Submitted successfully',
|
||||
// Form information not available
|
||||
noFormInfo: 'Form information not available',
|
||||
// Behavior settings
|
||||
// Captcha input placeholder
|
||||
captchaPlaceholder: 'Please enter the captcha',
|
||||
// Captcha required
|
||||
captchaRequired: 'Please enter the captcha',
|
||||
// Captcha error
|
||||
captchaError: 'Incorrect captcha, please try again',
|
||||
// Submit limit reached
|
||||
submitLimit: 'Submission limit reached',
|
||||
// Form not open
|
||||
notOpen: 'The form is not open yet',
|
||||
// Login required
|
||||
loginRequired: 'Please log in first',
|
||||
|
||||
}
|
||||
|
||||
@@ -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!'
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export const lang = {
|
||||
//title为每个页面的标题
|
||||
title: 'AI智能客服',
|
||||
title: 'AI智能',
|
||||
|
||||
// ====== 通用 ======
|
||||
cancel: '取消',
|
||||
@@ -136,4 +136,8 @@ export const lang = {
|
||||
logInitNavbarFailed: '初始化导航栏失败',
|
||||
logChatComponentNotInit: '聊天组件未初始化',
|
||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
||||
}
|
||||
|
||||
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
||||
aiAgentDefaultTitle: 'AI 智能体',
|
||||
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
|
||||
aiAgentNotConfigured: '该 AI 智能体暂未启用'}
|
||||
|
||||
@@ -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: '咨询失败',
|
||||
|
||||
@@ -45,6 +45,7 @@ export const lang = {
|
||||
loggingIn: '登录中',
|
||||
loginFailed: '登录失败',
|
||||
pleaseAuthorizeMobile: '请授权您的手机号码',
|
||||
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
|
||||
pleaseEnterNickname: '请输入昵称',
|
||||
avatarPullFailed: '头像拉取失败',
|
||||
}
|
||||
|
||||
@@ -6,4 +6,17 @@ export const lang = {
|
||||
submitSuccess: '提交成功',
|
||||
// 未获取到表单信息
|
||||
noFormInfo: '未获取到表单信息',
|
||||
// 行为设置相关
|
||||
// 请输入验证码
|
||||
captchaPlaceholder: '请输入验证码',
|
||||
// 请输入验证码(校验提示)
|
||||
captchaRequired: '请输入验证码',
|
||||
// 验证码错误
|
||||
captchaError: '验证码错误,请重试',
|
||||
// 提交次数已达上限
|
||||
submitLimit: '提交次数已达上限',
|
||||
// 表单未开放
|
||||
notOpen: '表单未开放',
|
||||
// 请先登录
|
||||
loginRequired: '请先登录',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
252
pages.json
252
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,65 @@
|
||||
"pages": [
|
||||
{
|
||||
"path": "ai-chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能客服"
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能客服",
|
||||
"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-recommend-code": "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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1093,11 +1151,14 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "vr/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "工厂展示"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1291,6 +1352,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 +2209,9 @@
|
||||
{
|
||||
"path": "form/formdata",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
|
||||
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
|
||||
// #ifdef APP-PLUS || MP
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "",
|
||||
@@ -2208,7 +2272,7 @@
|
||||
"diy-video-list": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "weapp/order_shipping",
|
||||
"style": {
|
||||
@@ -2314,78 +2378,79 @@
|
||||
},
|
||||
//******************文件模块******************
|
||||
{
|
||||
"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": "文件预览",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
@@ -2416,14 +2481,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 +2494,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" />
|
||||
@@ -32,10 +56,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品视频 -->
|
||||
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
|
||||
<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="contain"
|
||||
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 {
|
||||
|
||||
@@ -384,5 +384,14 @@ export default {
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
}
|
||||
|
||||
// 购物车页面SKU弹窗底部按钮上移,避开底部导航栏
|
||||
::v-deep .goods-sku .sku-info .footer {
|
||||
bottom: calc(30rpx + env(safe-area-inset-bottom) + 112rpx) !important;
|
||||
}
|
||||
|
||||
::v-deep .goods-sku .sku-info .body-item {
|
||||
padding-bottom: 112rpx;
|
||||
}
|
||||
|
||||
@import './public/css/cart.scss';
|
||||
</style>
|
||||
@@ -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: {
|
||||
/**
|
||||
* 发送商品信息到智能客服
|
||||
@@ -569,7 +603,7 @@ export default {
|
||||
uni.setStorageSync('selectedProduct', productInfo);
|
||||
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/ai-chat/index'
|
||||
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
|
||||
<view class="chat-content">
|
||||
<view class="chat-content" :style="{ bottom: tabBarHeight }">
|
||||
<ai-chat-message
|
||||
ref="chat"
|
||||
:initial-messages="initialMessages"
|
||||
@@ -68,6 +68,15 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部导航栏(仅在 tabBar 场景下显示):
|
||||
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
|
||||
组件内部已绑定 data-component-name="diy-bottom-nav"。
|
||||
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
|
||||
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
|
||||
→ shouldShowTabBar=false → tabBar 隐藏
|
||||
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
|
||||
<diy-bottom-nav></diy-bottom-nav>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -87,6 +96,9 @@ export default {
|
||||
return {
|
||||
initialMessages: [],
|
||||
fallbackWelcome: '',
|
||||
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
|
||||
agentId: 'agent_aikefu',
|
||||
pageTitle: '',
|
||||
|
||||
// --- 系统导航栏/状态栏相关 ---
|
||||
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
|
||||
@@ -105,7 +117,15 @@ export default {
|
||||
keyboardHeight: 0,
|
||||
|
||||
// 事件处理器引用(用于清理)
|
||||
safeEventHandlers: new Map()
|
||||
safeEventHandlers: new Map(),
|
||||
|
||||
// 是否从浮动按钮进入
|
||||
// 用来在运行时区分两种场景:
|
||||
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
|
||||
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
|
||||
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
|
||||
// 并在 onUnload 还原,避免影响其他页面。
|
||||
isFromFloatBtn: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -127,6 +147,21 @@ export default {
|
||||
async onLoad(options) {
|
||||
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
|
||||
|
||||
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
|
||||
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
|
||||
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
|
||||
// 当前 onShow/initChat 不区分 agent 行为,后续按业务扩展按 agentId 路由即可。
|
||||
try {
|
||||
this.agentId = (options && options.agent_id) || 'agent_aikefu';
|
||||
const rawTitle = (options && options.title) || this.$lang('ai-chat.index.title');
|
||||
this.pageTitle = decodeURIComponent(rawTitle);
|
||||
// 同步 agentId 给 ai-service,后续 sendMessage / WS auth 自动带新 agent_id
|
||||
try { aiService.setAgentId(this.agentId); } catch (e) { /* 兼容旧 ai-service 版本 */ }
|
||||
} catch (e) {
|
||||
this.agentId = 'agent_aikefu';
|
||||
this.pageTitle = this.$lang('ai-chat.index.title');
|
||||
}
|
||||
|
||||
// ✅ 修改:取消权限校验,允许所有客服类型访问此页面
|
||||
// const hasAccess = this.checkAccessPermission();
|
||||
// if (!hasAccess) {
|
||||
@@ -134,7 +169,27 @@ export default {
|
||||
// return;
|
||||
// }
|
||||
|
||||
this.$langConfig.title(this.$lang('ai-chat.index.title'));
|
||||
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
|
||||
|
||||
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
|
||||
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
|
||||
// (见 diy-float-btn.vue#onFloatBtnClick)
|
||||
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
|
||||
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
|
||||
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
|
||||
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
|
||||
// (业务方不会使用 __ 开头的短变量名)。
|
||||
const dyuEntry = (options && options.__dyu) || '';
|
||||
if (dyuEntry === 'FloatBtn') {
|
||||
this.isFromFloatBtn = true;
|
||||
// 临时隐藏底部导航栏;onUnload 会还原。
|
||||
try {
|
||||
this.$store.commit('setBottomNavHidden', true);
|
||||
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
|
||||
} else {
|
||||
this.isFromFloatBtn = false;
|
||||
}
|
||||
|
||||
this.initChat();
|
||||
},
|
||||
|
||||
@@ -180,6 +235,15 @@ export default {
|
||||
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
|
||||
this.cleanup();
|
||||
aiService.disconnectWebSocket();
|
||||
|
||||
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
|
||||
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
|
||||
if (this.isFromFloatBtn) {
|
||||
try {
|
||||
this.$store.commit('setBottomNavHidden', false);
|
||||
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
|
||||
this.isFromFloatBtn = false;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -647,7 +711,9 @@ export default {
|
||||
/* #endif */
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* bottom 由模板动态绑定到 tabBarHeight:
|
||||
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
|
||||
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
|
||||
}
|
||||
|
||||
/* 底部tabBar占位样式 */
|
||||
|
||||
@@ -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>
|
||||
@@ -1,35 +1,93 @@
|
||||
<template>
|
||||
<view class="file-preview-container" :style="themeColor">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序:web-view 是原生组件,必须用 cover-view 盖在上面;cover-view 不支持 iconfont,改用 cover-image PNG -->
|
||||
<cover-view class="mp-header">
|
||||
<cover-view class="mp-header-inner">
|
||||
<cover-view class="nav-btn back-btn" @click="goBack">
|
||||
<cover-image src="/static/img/icon-back.png" class="nav-icon"></cover-image>
|
||||
</cover-view>
|
||||
<cover-view class="header-title">{{ headerTitle }}</cover-view>
|
||||
<button class="nav-btn share-btn" open-type="share" hover-class="none">
|
||||
<cover-image src="/static/img/icon-share.png" class="nav-icon"></cover-image>
|
||||
</button>
|
||||
</cover-view>
|
||||
<!-- 状态栏占位(navigationStyle custom 时,避免被时间/电量遮挡) -->
|
||||
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
|
||||
</cover-view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<!-- 非小程序:普通 view + iconfont 即可 -->
|
||||
<view class="header">
|
||||
<view class="back-btn" @click="goBack">
|
||||
<view class="nav-btn back-btn" @click="goBack">
|
||||
<text class="iconfont icon-back"></text>
|
||||
</view>
|
||||
<view class="header-title">{{ fileName }}</view>
|
||||
<view class="placeholder"></view>
|
||||
<view class="header-title">{{ headerTitle }}</view>
|
||||
<button class="nav-btn share-btn" open-type="share" hover-class="none" @click.stop>
|
||||
<text class="iconfont icon-fenxiang"></text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序 web-view 是全屏原生组件,父容器 padding/margin 对它无效,必须在它前面放一个等高的占位 view 把它顶下去 -->
|
||||
<view class="mp-content-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="content">
|
||||
<!-- PDF 文件预览 -->
|
||||
<view v-if="fileType === 'pdf'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<!-- H5 端:web-view 渲染为 iframe,若被 X-Frame-Options/CORS 拦截则显示空白;额外提供"新标签页打开"与"下载"按钮兜底 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">如页面空白,请使用下方按钮:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
|
||||
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- Word 文件预览 -->
|
||||
<view v-else-if="fileType === 'word'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">浏览器可能无法直接内嵌预览 Word,建议下载后查看:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
|
||||
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 视频文件预览 -->
|
||||
<view v-else-if="fileType === 'video'" class="video-viewer">
|
||||
<video v-if="fileUrl" :src="fileUrl" controls autoplay class="video-player"></video>
|
||||
<video v-if="fileUrl" id="filePreviewVideo" :src="fileUrl" controls class="video-player"></video>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.videoUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 其他文件类型 -->
|
||||
<view v-else class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<view v-if="fileUrl" class="h5-action-bar">
|
||||
<view class="h5-action-tip">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
|
||||
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<view class="error-message">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -41,32 +99,114 @@ export default {
|
||||
return {
|
||||
fileName: '',
|
||||
fileUrl: '',
|
||||
fileType: ''
|
||||
fileType: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
|
||||
headerTotalHeightPx() {
|
||||
const rpxToPx = this.windowWidth / 750;
|
||||
return this.statusBarHeight + Math.round(88 * rpxToPx);
|
||||
},
|
||||
// #endif
|
||||
// 页面标题栏显示的短标题(避免超长挤压分享按钮;对用户无意义的tmp名统一显示通用"文件预览")
|
||||
headerTitle() {
|
||||
const name = (this.fileName || '').trim();
|
||||
// 如果是临时文件名(tmp_ 开头 或 带路径hash的长串),统一显示通用"文件预览"
|
||||
if (/^tmp_/i.test(name) || (name.length > 30 && /[a-f0-9]{20,}/i.test(name))) {
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
}
|
||||
// 否则显示原始文件名(列表里传入的,如"乐瑞固企业介绍",本身就短)
|
||||
if (name) {
|
||||
// JS 层保险截断(超过16字 + …),应对 cover-view CSS 省略号不生效
|
||||
if (name.length > 16) return name.slice(0, 16) + '…';
|
||||
return name;
|
||||
}
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
},
|
||||
// 分享的真实标题(好友收到的卡片显示真实文件名,不截断)
|
||||
shareTitle() {
|
||||
const name = (this.fileName || '').trim();
|
||||
if (name && !/^tmp_/i.test(name)) return name;
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
},
|
||||
// 分享路径:只带文件三要素,不挂推荐码/关系
|
||||
sharePath() {
|
||||
const query = [];
|
||||
if (this.fileName) query.push(`fileName=${encodeURIComponent(this.fileName)}`);
|
||||
if (this.fileUrl) query.push(`fileUrl=${encodeURIComponent(this.fileUrl)}`);
|
||||
if (this.fileType) query.push(`fileType=${encodeURIComponent(this.fileType)}`);
|
||||
return `/pages_tool/file-preview/file-preview${query.length ? '?' + query.join('&') : ''}`;
|
||||
},
|
||||
themeColor() {
|
||||
if (this.$shop && this.$shop.themeColor) {
|
||||
return `--theme-color: ${this.$shop.themeColor};`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onLoad(option) {
|
||||
// 解析传递的参数
|
||||
if (option.fileName) {
|
||||
this.fileName = decodeURIComponent(option.fileName);
|
||||
}
|
||||
|
||||
if (option.fileUrl) {
|
||||
this.fileUrl = decodeURIComponent(option.fileUrl);
|
||||
}
|
||||
|
||||
if (option.fileType) {
|
||||
this.fileType = decodeURIComponent(option.fileType);
|
||||
} else {
|
||||
// 根据文件名后缀推断文件类型
|
||||
this.inferFileType();
|
||||
}
|
||||
// #ifdef MP-WEIXIN
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.statusBarHeight = sys.statusBarHeight || 20;
|
||||
this.windowWidth = sys.windowWidth || 375;
|
||||
// #endif
|
||||
},
|
||||
onReady() {
|
||||
// 视频加载后手动触发一次播放占位(去掉 autoplay 防自动播放被系统拦截)
|
||||
// #ifndef H5
|
||||
if (this.fileType === 'video' && this.fileUrl) {
|
||||
try {
|
||||
const ctx = uni.createVideoContext('filePreviewVideo', this);
|
||||
if (ctx && typeof ctx.play === 'function') { /* 留给用户点击,不自动播 */ }
|
||||
} catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.shareTitle,
|
||||
path: this.sharePath
|
||||
};
|
||||
},
|
||||
// 微信小程序:分享到朋友圈
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: this.shareTitle,
|
||||
query: this.sharePath.split('?')[1] || ''
|
||||
};
|
||||
},
|
||||
// #endif
|
||||
methods: {
|
||||
/**
|
||||
* 根据文件名后缀推断文件类型
|
||||
*/
|
||||
inferFileType() {
|
||||
if (this.fileName) {
|
||||
if (this.fileUrl) {
|
||||
const clean = String(this.fileUrl).split('?')[0];
|
||||
const ext = clean.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
} else if (this.fileName) {
|
||||
const ext = this.fileName.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
@@ -77,13 +217,48 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 返回上一页
|
||||
*/
|
||||
goBack() {
|
||||
uni.navigateBack();
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.switchTab({ url: '/pages/index/index' }).catch(() => {
|
||||
uni.navigateBack();
|
||||
});
|
||||
}
|
||||
},
|
||||
// #ifdef H5
|
||||
// H5 端:新标签页打开文件(绕过 iframe X-Frame-Options/CORS 限制)
|
||||
openInNewTab() {
|
||||
if (!this.fileUrl) return;
|
||||
try {
|
||||
window.open(this.fileUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '打开失败,请稍后重试', icon: 'none' });
|
||||
}
|
||||
},
|
||||
// H5 端:通过 <a download> 触发浏览器下载(最稳定的跨浏览器方案)
|
||||
triggerDownload() {
|
||||
if (!this.fileUrl) return;
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = this.fileUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
if (this.fileName) {
|
||||
// 仅提示浏览器用这个名字;受 CORS 影响,跨域文件时 download 属性可能被忽略,
|
||||
// 此时浏览器会直接按服务器返回的文件名/直接显示,但不会丢失文件本身
|
||||
link.download = this.fileName;
|
||||
}
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
// 兜底:新标签页打开
|
||||
this.openInNewTab();
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -92,7 +267,10 @@ export default {
|
||||
.file-preview-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* =================== 非 MP 端 header =================== */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -101,22 +279,34 @@ export default {
|
||||
padding: 0 30rpx;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.back-btn {
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
.icon-back {
|
||||
font-size: 32rpx;
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
@@ -124,17 +314,91 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 60rpx;
|
||||
|
||||
.share-btn {
|
||||
.icon-fenxiang {
|
||||
font-size: 34rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================== MP 端 header (cover-view 浮层) =================== */
|
||||
/* #ifdef MP-WEIXIN */
|
||||
.mp-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.mp-status-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 20rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin: 0 16rpx;
|
||||
/* cover-view 下 ellipsis 支持有限,JS 层(headerTitle computed)已经做了截断,这里再冗余防守 */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* =================== 内容区 =================== */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
/* 给浮层 header(statusBar+88rpx)留位置,避免内容顶上去 */
|
||||
padding-top: calc(var(--mp-header-h, 120rpx));
|
||||
/* #endif */
|
||||
padding-left: 20rpx;
|
||||
padding-right: 20rpx;
|
||||
padding-bottom: 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.file-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
@@ -142,7 +406,7 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
|
||||
.error-message {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
@@ -152,8 +416,52 @@ export default {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
/* H5 兜底操作栏(PDF/Word 被 X-Frame-Options 拦截时显示)*/
|
||||
.h5-action-bar {
|
||||
padding: 24rpx 28rpx 20rpx;
|
||||
background: linear-gradient(180deg, #fffbe6 0%, #ffffff 100%);
|
||||
border-bottom: 1rpx solid #f0e9c8;
|
||||
|
||||
.h5-action-tip {
|
||||
font-size: 26rpx;
|
||||
color: #8a6d1b;
|
||||
margin-bottom: 18rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.h5-action-btns {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
|
||||
.h5-action-btn {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
padding: 0;
|
||||
font-size: 28rpx;
|
||||
border-radius: 36rpx;
|
||||
background: #f2f3f5;
|
||||
color: #333;
|
||||
border: 1rpx solid #e2e4e8;
|
||||
margin: 0;
|
||||
|
||||
&::after { border: none; }
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
.video-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
@@ -161,12 +469,12 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
|
||||
.error-message {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
|
||||
@@ -13,12 +13,18 @@
|
||||
<view class="article-wrap">
|
||||
<ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv>
|
||||
<block v-for="(item, index) in list" :key="index">
|
||||
<view class="item" @tap="toDetail(item.$orig)">
|
||||
<view class="article-img">
|
||||
<image class="cover-img" :src="item.g1" mode="widthFix" @error="imgError(index)" show-menu-by-longpress></image>
|
||||
<view class="item" @tap="toDetail(item)">
|
||||
<view class="pdf-icon">
|
||||
<text class="pdf-icon-p">P</text>
|
||||
<view class="pdf-icon-fold"></view>
|
||||
<view class="pdf-icon-lines">
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="title">{{ item.$orig.files_title }}</view>
|
||||
<view class="title">{{ item.files_title }}</view>
|
||||
<view class="read-wrap">
|
||||
<text>{{ $lang('files.list.viewPdf') }}</text>
|
||||
</view>
|
||||
@@ -84,8 +90,10 @@ export default {
|
||||
if (res.code === 0 && res.data) {
|
||||
list = res.data.list
|
||||
if (!this.title && res.data.list.length > 0) {
|
||||
const first = res.data.list[0]
|
||||
const firstOrig = (first && first.$orig) ? first.$orig : first
|
||||
uni.setNavigationBarTitle({
|
||||
title: res.data.list[0].category_name
|
||||
title: firstOrig.category_name || ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -98,9 +106,18 @@ export default {
|
||||
this.list = []
|
||||
}
|
||||
this.list = this.list.concat(list.map(item => {
|
||||
const orig = item
|
||||
const g1 = this.$util.img("addon/personnel/shop/view/enterprise/fileicon.png")
|
||||
return { $orig: orig, g1: g1 }
|
||||
// 兼容两种后端结构:自带 $orig(如 cases 接口)或扁平结构
|
||||
const src = (item && item.$orig) ? item.$orig : item
|
||||
return {
|
||||
files_id: src.files_id,
|
||||
files_title: src.files_title,
|
||||
files_url: src.files_url,
|
||||
files_desc: src.files_desc,
|
||||
category_id: src.category_id,
|
||||
category_name: src.category_name,
|
||||
// 保留完整原对象引用,避免后续扩展字段丢失
|
||||
$orig: src
|
||||
}
|
||||
}))
|
||||
if (this.$refs.loadingCover) {
|
||||
this.$refs.loadingCover.hide()
|
||||
@@ -115,41 +132,121 @@ export default {
|
||||
})
|
||||
},
|
||||
toDetail(item) {
|
||||
uni.showLoading({
|
||||
title: this.$lang('files.list.loading')
|
||||
})
|
||||
try {
|
||||
uni.downloadFile({
|
||||
url: item.files_url,
|
||||
success: res => {
|
||||
if (res.statusCode === 200) {
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
},
|
||||
fail: () => {
|
||||
},
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
},
|
||||
complete: res => {
|
||||
if (res.statusCode === 404) {
|
||||
uni.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
const title = (item.files_title || '文件').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
// 根据 URL 推断后缀(若无扩展名则默认 pdf)
|
||||
let ext = 'pdf';
|
||||
if (item.files_url) {
|
||||
const clean = item.files_url.split('?')[0];
|
||||
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
|
||||
if (matched) ext = matched[1].toLowerCase();
|
||||
}
|
||||
const fileName = `${title}.${ext}`;
|
||||
const fileType =
|
||||
['pdf'].includes(ext) ? 'pdf'
|
||||
: ['doc', 'docx'].includes(ext) ? 'word'
|
||||
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext) ? 'video'
|
||||
: 'pdf';
|
||||
const previewPage = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(fileName)}&fileUrl=${encodeURIComponent(item.files_url)}&fileType=${encodeURIComponent(fileType)}`;
|
||||
|
||||
// ========== H5 端:跳转预览页(web-view 走 iframe);失败则新标签页打开原链接 ==========
|
||||
// #ifdef H5
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
try { window.open(item.files_url, '_blank'); } catch (e) {
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// ========== 非 H5 端:下载文件后按平台分支打开 ==========
|
||||
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
|
||||
uni.downloadFile({
|
||||
url: item.files_url,
|
||||
success: res => {
|
||||
if (res.statusCode !== 200) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- 微信小程序:saveFile 重命名 + openDocument(短文件名标题)-----
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager();
|
||||
// 重命名临时文件到用户目录,用短文件名 —— openDocument 的标题会取 filePath 的文件名部分,从而显示短名"乐瑞固企业介绍"而非 tmp_xxx
|
||||
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||
try {
|
||||
// saveFile:复制临时文件到持久目录并改名(临时文件在某些基础库上禁止直接 rename)
|
||||
fs.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
filePath: targetPath,
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: r.savedFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: () => {
|
||||
// 预览结束后清理,避免占用持久空间(失败忽略)
|
||||
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) {}
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
// saveFile 失败(配额等),直接用临时文件 openDocument 兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// 极端异常兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ----- App / 其他小程序:直接 openDocument 打开临时文件;失败兜底跳转预览页 -----
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: res => {
|
||||
if (res && res.statusCode === 404) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
imgError(index) {
|
||||
if (this.list[index]) {
|
||||
@@ -224,18 +321,70 @@ export default {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .article-img {
|
||||
.article-wrap .item .pdf-icon {
|
||||
margin-right: 20rpx;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: #e64340;
|
||||
border-radius: 10rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
|
||||
}
|
||||
|
||||
.article-wrap .item .article-img image {
|
||||
.article-wrap .item .pdf-icon .pdf-icon-p {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 8rpx;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
|
||||
border-bottom-left-radius: 4rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-lines {
|
||||
position: absolute;
|
||||
right: 6rpx;
|
||||
bottom: 6rpx;
|
||||
width: 36rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line {
|
||||
width: 100%;
|
||||
height: 3rpx;
|
||||
background-color: #ffffff;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(2) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(3) {
|
||||
width: 60%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap {
|
||||
@@ -243,16 +392,21 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap .title {
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 24rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap .read-wrap {
|
||||
|
||||
@@ -6,11 +6,28 @@
|
||||
</view>
|
||||
<view class="system-form-wrap">
|
||||
<view class="form-title">{{ $lang('form.form.title') }}</view>
|
||||
<ns-form :data="detail.json_data" :formConfig="detail.form_config" ref="form"></ns-form>
|
||||
<ns-form :data="detail.json_data" :formConfig="mergedFormConfig" ref="form"></ns-form>
|
||||
<!-- 行为设置:提交验证码 -->
|
||||
<view class="captcha-wrap" v-if="behavior.captcha == 1">
|
||||
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
|
||||
<!-- #ifdef H5 -->
|
||||
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
|
||||
<!-- #endif -->
|
||||
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
|
||||
</view>
|
||||
<button type="primary" size="mini" class="button mini" @click="create()">{{ $lang('common.submit') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<ns-empty :text="complete ? $lang('form.form.submitSuccess') : $lang('form.form.noFormInfo')" v-else></ns-empty>
|
||||
<!-- 空态:除 ns-empty 文案外,追加一个可手动点的登录按钮——解决一切自动弹窗时序问题后的最后兜底 -->
|
||||
<view v-else>
|
||||
<ns-empty :text="emptyText"></ns-empty>
|
||||
<view v-if="!complete" style="padding: 20rpx 40rpx 40rpx; display: flex; justify-content: center;">
|
||||
<button type="primary" size="mini" @click="forceOpenLogin">点击登录查看表单</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<ns-login ref="login"></ns-login>
|
||||
@@ -29,9 +46,56 @@
|
||||
detail: null,
|
||||
isRepeat: false,
|
||||
complete: false,
|
||||
scroll:true
|
||||
scroll: true,
|
||||
urlParams: {},
|
||||
// 登录后回跳地址(供 ns-login / H5 重定向使用)
|
||||
backUrl: '',
|
||||
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
|
||||
behavior: {},
|
||||
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
|
||||
formError: '',
|
||||
// 图形验证码(behavior.captcha=1 时展示)
|
||||
captcha: { id: '', img: '', code: '' },
|
||||
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
|
||||
captchaLoaded: false,
|
||||
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
|
||||
pendingSubmit: null,
|
||||
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
|
||||
pendingLoginRequired: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 前端视觉默认:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
|
||||
// 与 diy-form 路径(formdata.vue 的 styleDefaults)保持一致,使系统表单页也统一呈现新设计稿样式。
|
||||
// 后端仍保留行为类配置(behavior / submitText 等),本合并仅覆盖视觉字段。
|
||||
mergedFormConfig() {
|
||||
const styleDefaults = {
|
||||
formLayout: 'vertical',
|
||||
formPaddingX: 28,
|
||||
rowPaddingY: 24,
|
||||
labelGap: 20,
|
||||
labelFontSize: 32,
|
||||
labelFontWeight: 700,
|
||||
labelColor: '#1f2937',
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
inputBgColor: '#ffffff',
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputFocusBorderColor: '#3b82f6',
|
||||
inputFocusBgColor: '#ffffff',
|
||||
placeholderColor: '#9ca3af',
|
||||
placeholderFontSize: 28,
|
||||
checkedColor: '#3b82f6',
|
||||
showRowDivider: false,
|
||||
submitColor: '#ffffff',
|
||||
submitBgColor: '#3b82f6'
|
||||
};
|
||||
return Object.assign({}, this.detail ? (this.detail.form_config || {}) : {}, styleDefaults);
|
||||
},
|
||||
emptyText() {
|
||||
return this.complete ? this.$lang('form.form.submitSuccess') : (this.formError || this.$lang('form.form.noFormInfo'));
|
||||
}
|
||||
},
|
||||
onLoad(data) {
|
||||
// #ifdef MP-ALIPAY
|
||||
let options = my.getLaunchOptionsSync();
|
||||
@@ -39,27 +103,115 @@
|
||||
// #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];
|
||||
if (!item) return;
|
||||
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
|
||||
let pair = item.split('=');
|
||||
if (pair.length === 2) {
|
||||
const key = pair[0];
|
||||
const val = pair[1];
|
||||
if (key === 'id') this.id = val;
|
||||
else if (key === 'uniacid') this.uniacid = val;
|
||||
else this.urlParams[key] = val;
|
||||
} else if (item.indexOf('id') === 0 && item.indexOf('-') !== -1) {
|
||||
// 旧格式兜底:id-13
|
||||
this.id = item.split('-')[1];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
// H5:从 window.location.href 解析参数,兼容 history 路由 (?key=val) 与 hash 路由 (#/path?key=val) 两种形式
|
||||
// 保证 recommend_code / uniacid 等参数不丢失
|
||||
try {
|
||||
const href = window.location.href;
|
||||
let queryStr = '';
|
||||
const hashIdx = href.indexOf('#');
|
||||
const qIdx = href.indexOf('?');
|
||||
if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) {
|
||||
// history 模式:? 在 # 之前(或无 #)
|
||||
queryStr = href.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : href.length);
|
||||
} else if (hashIdx !== -1) {
|
||||
// hash 模式:# 之后可能还有 ?
|
||||
const afterHash = href.substring(hashIdx + 1);
|
||||
const hq = afterHash.indexOf('?');
|
||||
if (hq !== -1) queryStr = afterHash.substring(hq + 1);
|
||||
}
|
||||
if (queryStr) {
|
||||
const pairs = queryStr.split('&');
|
||||
for (const p of pairs) {
|
||||
if (!p) continue;
|
||||
const eq = p.indexOf('=');
|
||||
let k, v;
|
||||
if (eq === -1) { k = decodeURIComponent(p); v = ''; }
|
||||
else { k = decodeURIComponent(p.substring(0, eq)); v = decodeURIComponent(p.substring(eq + 1)); }
|
||||
if (k === 'id') { if (!this.id) this.id = v; }
|
||||
else if (k === 'uniacid') { /* reserved, 透传到后端时不进 urlParams */ }
|
||||
else if (k === 'scene') { continue; }
|
||||
else { if (this.urlParams[k] === undefined) this.urlParams[k] = v; }
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
// 构建登录回跳地址:包含 id + 所有 urlParams(尤其 recommend_code),
|
||||
// 供 ns-login 弹窗登录成功或 H5 整页重定向登录后回到本页
|
||||
const backQuery = [];
|
||||
if (this.id) backQuery.push('id=' + encodeURIComponent(this.id));
|
||||
for (const k in this.urlParams) {
|
||||
const v = this.urlParams[k];
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
backQuery.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
|
||||
}
|
||||
}
|
||||
this.backUrl = '/pages_tool/form/form' + (backQuery.length ? '?' + backQuery.join('&') : '');
|
||||
},
|
||||
onShow() {
|
||||
if (!this.storeToken) {
|
||||
this.redirectTologin('/pages_tool/form/form?id=' + this.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开是否需要登录由后端 behavior 决定(info 返回 FORM_LOGIN_REQUIRED 时弹登录)
|
||||
this.getData();
|
||||
},
|
||||
mounted() {
|
||||
// 1) 兜底 A:onLoad→getData 回调比 ns-login 挂载还早时,pendingLoginRequired=true
|
||||
// 挂载完成再最后尝试一次自动弹窗
|
||||
if (this.pendingLoginRequired) {
|
||||
const tryOpen = () => {
|
||||
try {
|
||||
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
|
||||
this.$refs.login.open(this.backUrl);
|
||||
}
|
||||
} catch (e) { console.warn('[FORM] mounted login.open failed:', e); }
|
||||
};
|
||||
this.$nextTick(tryOpen);
|
||||
this.pendingLoginRequired = false;
|
||||
}
|
||||
// 2) 兜底 B:登录态在组件创建之前就已写入(token 提前 race),但 info 因时序没拿到数据
|
||||
// 此时用户实际上已经登录,再拉一次 info 就能拿到真实表单数据
|
||||
if (this.storeToken && !this.detail) {
|
||||
this.$nextTick(() => { this.getData(); });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
storeToken: function(nVal, oVal) {
|
||||
if (nVal) this.getData();
|
||||
if (nVal) {
|
||||
this.getData();
|
||||
// 填写需登录:弹窗登录成功后自动续提交
|
||||
this.resumeSubmitAfterLogin();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -69,38 +221,303 @@
|
||||
data: {
|
||||
form_id: this.id
|
||||
},
|
||||
success: res => { this._handleInfoResponse(res); },
|
||||
fail: err => { this._handleInfoFailure(err); }
|
||||
});
|
||||
},
|
||||
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
|
||||
// 成功分支必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
|
||||
// 只要不满足就是"没拿到有效表单",然后用最宽的条件判定是否是登录拦截:
|
||||
// - 消息包含 FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录 / 登录相关中文
|
||||
// - 或者 data 无效(null/undefined/''/{}/非对象)+ 用户未登录 + 有 form_id
|
||||
// 命中任一 → 一律弹登录 + 友好文案"请先登录",不再误显示"未获取到表单信息"
|
||||
_handleInfoResponse(res) {
|
||||
try {
|
||||
const payload = (res && typeof res === 'object') ? res : {};
|
||||
const dataObj = payload.data;
|
||||
|
||||
// —— 1) 严格判定:是否拿到了真正的表单结构? ——
|
||||
const hasValidFormData =
|
||||
payload.code == 0 &&
|
||||
dataObj !== null && dataObj !== undefined && dataObj !== '' &&
|
||||
typeof dataObj === 'object' &&
|
||||
(dataObj.json_data !== undefined || dataObj.form_config !== undefined);
|
||||
|
||||
if (hasValidFormData) {
|
||||
this.detail = dataObj;
|
||||
this.formError = '';
|
||||
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
|
||||
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
|
||||
this.resumeSubmitAfterLogin();
|
||||
// 本地缓存一份字段 JSON:下次再打开时如果仍被登录拦截,可作为 skeleton 兜底
|
||||
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
|
||||
// 成功分支同样必须关闭 loading 遮罩:
|
||||
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// —— 2) 没拿到有效结构 → 宽网判定:是否属于"需要登录"场景? ——
|
||||
const msg = (payload.message || '').toString();
|
||||
const isFormNotOpen = (msg === 'FORM_NOT_OPEN');
|
||||
|
||||
const isLoginMsg =
|
||||
msg === 'FORM_LOGIN_REQUIRED' ||
|
||||
msg === 'LOGIN_REQUIRED' ||
|
||||
msg.indexOf('请先登录') !== -1 ||
|
||||
(msg.indexOf('登录') !== -1 && msg.indexOf('请') !== -1) ||
|
||||
(msg.toLowerCase().indexOf('login') !== -1 && msg.toLowerCase().indexOf('required') !== -1);
|
||||
|
||||
const dataIsEmpty =
|
||||
!dataObj ||
|
||||
dataObj === '' ||
|
||||
dataObj === null ||
|
||||
dataObj === undefined ||
|
||||
(typeof dataObj === 'object' && !dataObj.json_data && !dataObj.form_config &&
|
||||
((Array.isArray(dataObj) && dataObj.length === 0) ||
|
||||
(!Array.isArray(dataObj) && Object.keys(dataObj).length === 0)));
|
||||
|
||||
// 最宽判定:只要不是"表单未开放",且(显式登录消息 OR 空数据+未登录+有 id)都按登录拦截处理
|
||||
const shouldTriggerLogin =
|
||||
!isFormNotOpen &&
|
||||
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
|
||||
|
||||
if (shouldTriggerLogin && this.storeToken) {
|
||||
// 已登录(storeToken 存在)但后端仍返回登录拦截:
|
||||
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
|
||||
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
|
||||
this.formError = this.$lang('form.form.loginRequired');
|
||||
} else if (shouldTriggerLogin) {
|
||||
this.formError = this.$lang('form.form.loginRequired');
|
||||
this._openLoginWithRetry();
|
||||
} else if (isFormNotOpen) {
|
||||
this.formError = this.$lang('form.form.notOpen');
|
||||
} else {
|
||||
this.formError = msg || this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[FORM] _handleInfoResponse exception:', e && e.message ? e.message : e);
|
||||
this.formError = this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
// info 接口网络失败时也做同样判定:未登录+有 id 就尝试启动登录流程,至少让用户先完成登录
|
||||
// (登录成功后会再次 getData,那时网络一般已经正常)
|
||||
_handleInfoFailure(err) {
|
||||
try {
|
||||
if (this.id && !this.storeToken) {
|
||||
this.formError = this.$lang('form.form.loginRequired');
|
||||
this._openLoginWithRetry();
|
||||
} else {
|
||||
this.formError = this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
} catch (e) {
|
||||
this.formError = this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
|
||||
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
|
||||
_openLoginWithRetry() {
|
||||
// 防重复触发:3 秒内只允许自动弹一次登录
|
||||
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
|
||||
const now = Date.now();
|
||||
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
|
||||
this._lastAutoLoginAt = now;
|
||||
const url = this.backUrl;
|
||||
let attempts = 0;
|
||||
const delays = [0, 60, 320];
|
||||
const tryOnce = () => {
|
||||
try {
|
||||
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
|
||||
this.$refs.login.open(url);
|
||||
this.pendingLoginRequired = false;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[FORM] login.open attempt ' + (attempts + 1) + ' failed:', e);
|
||||
}
|
||||
attempts++;
|
||||
if (attempts < delays.length) {
|
||||
setTimeout(tryOnce, delays[attempts]);
|
||||
} else {
|
||||
// 三次全失败:交给 mounted 钩子最后兜底
|
||||
this.pendingLoginRequired = true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
tryOnce();
|
||||
},
|
||||
// 模板空态按钮的手动入口:用户主动点击也能打开登录弹窗(最后一道防线)
|
||||
forceOpenLogin() {
|
||||
this._openLoginWithRetry();
|
||||
},
|
||||
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
|
||||
getCaptcha() {
|
||||
this.captchaLoaded = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/api/captcha/captcha',
|
||||
data: {
|
||||
captcha_id: this.captcha.id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.detail = res.data;
|
||||
this.captcha.id = res.data.id;
|
||||
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : '';
|
||||
this.captcha.code = '';
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
fail: res => {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
|
||||
savePendingSubmit(formData) {
|
||||
const data = formData || (this.$refs.form && this.$refs.form.formData) || null;
|
||||
const payload = {
|
||||
formId: this.id,
|
||||
urlParams: this.urlParams,
|
||||
formData: data,
|
||||
captchaId: this.captcha.id,
|
||||
captchaCode: this.captcha.code,
|
||||
ts: Date.now()
|
||||
};
|
||||
this.pendingSubmit = payload;
|
||||
try { uni.setStorageSync('form_pending_submit', payload); } catch (e) {}
|
||||
// H5 整页回跳标记:仅当确由登录拦截触发时才允许自动续提交,避免陈旧数据被误提交
|
||||
try { uni.setStorageSync('form_pending_from_login', 1); } catch (e) {}
|
||||
},
|
||||
// 仅在已登录时取回并消费待提交数据(未登录 / 非登录回跳 / 过期不消费,避免误提交);取后清除
|
||||
takePendingSubmit() {
|
||||
let payload = this.pendingSubmit;
|
||||
const fromStorage = !payload;
|
||||
if (fromStorage) {
|
||||
try { payload = uni.getStorageSync('form_pending_submit'); } catch (e) {}
|
||||
}
|
||||
if (!payload || !payload.formData) return null;
|
||||
if (!this.storeToken) return null;
|
||||
// H5 存储路径:必须是因登录拦截触发的回跳,否则视为陈旧数据,清理后不提交
|
||||
if (fromStorage) {
|
||||
let fromLogin = false;
|
||||
try { fromLogin = uni.getStorageSync('form_pending_from_login'); } catch (e) {}
|
||||
if (!fromLogin) {
|
||||
this.pendingSubmit = null;
|
||||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 仅消费 10 分钟内的待提交,避免极旧数据被自动提交
|
||||
if (payload.ts && Date.now() - payload.ts > 10 * 60 * 1000) {
|
||||
this.pendingSubmit = null;
|
||||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||||
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
this.pendingSubmit = null;
|
||||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||||
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
|
||||
return payload;
|
||||
},
|
||||
// 登录成功后:回填已填数据并自动续提交
|
||||
resumeSubmitAfterLogin() {
|
||||
const pending = this.takePendingSubmit();
|
||||
if (!pending) return;
|
||||
// 回填表单字段(nextTick 确保 ns-form 已挂载),避免 H5 整页回跳后好友看到空白表单
|
||||
this.$nextTick(() => this.restoreFormData(pending.formData));
|
||||
// 验证码:MP 登录不丢态可直接提交;H5 回跳后验证码失效,需重新获取并让用户重填
|
||||
if (this.behavior.captcha == 1 && !this.captcha.code) {
|
||||
this.getCaptcha();
|
||||
this.$util.showToast({ title: '请重新输入验证码后提交' });
|
||||
return;
|
||||
}
|
||||
this.create();
|
||||
},
|
||||
// 将保存的 formData 回填到 ns-form
|
||||
restoreFormData(formData) {
|
||||
try {
|
||||
const form = this.$refs.form;
|
||||
if (form && Array.isArray(formData)) {
|
||||
form.formData = JSON.parse(JSON.stringify(formData));
|
||||
if (typeof form.processFormData === 'function') form.processFormData();
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
|
||||
doRedirect(link) {
|
||||
const fallback = () => { this.complete = true; this.detail = null; };
|
||||
if (!link) return fallback();
|
||||
if (link.appid) {
|
||||
// 跳转其他微信小程序
|
||||
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: fallback });
|
||||
} else if (link.mobile) {
|
||||
uni.makePhoneCall({ phoneNumber: String(link.mobile), fail: fallback });
|
||||
fallback();
|
||||
} else if (link.wap_url) {
|
||||
if (/^https?:\/\//i.test(link.wap_url)) {
|
||||
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(link.wap_url), fail: fallback });
|
||||
} else {
|
||||
uni.navigateTo({ url: link.wap_url, fail: fallback });
|
||||
}
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
},
|
||||
create() {
|
||||
if (this.$refs.form.verify()) {
|
||||
if (this.isRepeat) return;
|
||||
this.isRepeat = true;
|
||||
const behavior = this.behavior || {};
|
||||
|
||||
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
|
||||
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
|
||||
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
|
||||
this.savePendingSubmit();
|
||||
this.$refs.login && this.$refs.login.open(this.backUrl);
|
||||
return;
|
||||
}
|
||||
// 验证码必填
|
||||
if (behavior.captcha == 1 && !this.captcha.code) {
|
||||
this.$util.showToast({ title: this.$lang('form.form.captchaRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRepeat = true;
|
||||
const submitCaptchaId = String(this.captcha.id || '');
|
||||
const submitCaptchaCode = String(this.captcha.code || '').toUpperCase();
|
||||
console.log('[FORM-SUBMIT-CAPTCHA]', { captcha_id: submitCaptchaId, captcha_code: submitCaptchaCode, raw_code: this.captcha.code });
|
||||
this.$api.sendRequest({
|
||||
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,
|
||||
captcha_id: submitCaptchaId,
|
||||
captcha_code: submitCaptchaCode
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') })
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
|
||||
const link = behavior.redirect;
|
||||
setTimeout(() => {
|
||||
this.complete = true;
|
||||
this.detail = null;
|
||||
}, 1500)
|
||||
link ? this.doRedirect(link)
|
||||
: (this.complete = true, this.detail = null);
|
||||
}, link ? 800 : 1500);
|
||||
} else {
|
||||
this.isRepeat = false;
|
||||
this.$util.showToast({ title: res.message })
|
||||
if (res.message == 'FORM_LOGIN_REQUIRED') {
|
||||
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
|
||||
this.savePendingSubmit();
|
||||
this.$refs.login && this.$refs.login.open(this.backUrl);
|
||||
} else if (res.message == 'CAPTCHA_ERROR' || res.message == 'CAPTCHA_FAILURE') {
|
||||
this.$util.showToast({ title: this.$lang('form.form.captchaError') });
|
||||
if (behavior.captcha == 1) this.getCaptcha();
|
||||
} else if (res.message == 'FORM_SUBMIT_LIMIT') {
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitLimit') });
|
||||
this.complete = true;
|
||||
this.detail = null;
|
||||
} else if (res.message == 'FORM_NOT_OPEN') {
|
||||
this.$util.showToast({ title: this.$lang('form.form.notOpen') });
|
||||
} else {
|
||||
this.$util.showToast({ title: res.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -135,6 +552,48 @@
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.captcha-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
margin-top: 24rpx;
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
font-size: 28rpx;
|
||||
border: 1rpx solid #e5e7eb;
|
||||
border-radius: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
width: 200rpx;
|
||||
height: 72rpx;
|
||||
margin-left: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx solid #e5e7eb;
|
||||
}
|
||||
|
||||
.captcha-btn {
|
||||
margin-left: 20rpx;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #ff4544;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ff4544;
|
||||
border-radius: 12rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 80rpx;
|
||||
line-height: 80rpx !important;
|
||||
@@ -153,7 +612,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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user