Compare commits
448 Commits
yuanlaide6
...
dev/1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c9a4673203 | |||
| bdd0935fab | |||
| 9d3cd2b65e | |||
| 22b33c45ad | |||
| a2bf8d6c25 | |||
| 8635f9795f | |||
| 166450f6ce | |||
| b4509e3092 | |||
| 8fa16d9b14 | |||
| 9c9d1a412f | |||
| 1f87b23e7b | |||
| 421dad516c | |||
| ab8dff4b93 | |||
| 8999dd3f25 | |||
| 91ce0b582b | |||
| 7daa3afdc2 | |||
| 21b3247547 | |||
| 7909cf2b22 | |||
| 33cd21cf78 | |||
| 336b4d21da | |||
| 77b1a3372c | |||
| 373db1fb73 | |||
| d4457e58d4 | |||
| 550ec72bb7 | |||
| 1276a44b6b | |||
| 9086f1665c | |||
| a9f3c7acc2 | |||
| 53b5b725a5 | |||
| fe3f059f58 | |||
| add384916c | |||
| f2997f5401 | |||
| 4439df7c9b | |||
| 5290271017 | |||
| d358bb55ba | |||
| 2f750dbb99 | |||
| 591ce742a9 | |||
| f41057a9ad | |||
| 5a009130e6 | |||
| e8fc1aca06 | |||
| e60b3e2e52 | |||
| 022a8bb5bb | |||
| 6309a5bb4b | |||
| 67af165c14 | |||
| 36dfbfed67 | |||
| e56b91970c | |||
| 4650d9ea30 | |||
| 52f50eb91e | |||
| b3f6ff7517 | |||
| d0d17fd916 | |||
| 9b540ecfad | |||
| 389d87b82c | |||
| c9a58a5ddf | |||
| 53810114ed | |||
| f2619ae3b6 | |||
| 15b0d2a18a | |||
| 1f44f11248 | |||
| 6e3818dff6 | |||
| f22892968f | |||
| 8c5ed50f7a | |||
| 8f82d97c95 | |||
| 10ed9792a4 | |||
| fc407935d2 | |||
| b92f474ac8 | |||
| 0859765efb | |||
| 8cf70b617d | |||
| df5be6045b | |||
| 571452cd30 | |||
| 053cb00826 | |||
| 9e6dc8665f | |||
| a957679957 | |||
| b49fa378e7 | |||
| 1c0444b1ec | |||
| 09909f0dcf | |||
| b14032149e | |||
| 447f7cd7b5 | |||
| a5a94e31cc | |||
| dd15ff8ea4 | |||
| 3ab78f5369 | |||
| e223e58425 | |||
| 5ef5f275f0 | |||
| 236e56f4e9 | |||
| d170e53082 | |||
| e7dd6aff62 | |||
| 8488aa39bc | |||
| 817d2081d7 | |||
| 0a0ce88d82 | |||
| 34c0df5320 | |||
| c36d8dbce6 | |||
| 356a6d2bbf | |||
| 3b354992e3 | |||
| fe0381a298 | |||
| 733c33a41c | |||
| e8ff0e9be4 | |||
| 0faecf3603 | |||
| e9c8bac60b | |||
| a72e1b733d | |||
| 132ecbb127 | |||
| 5173b5d38e | |||
| 4172da6201 | |||
| 53be343159 | |||
| 1de5f925ee | |||
| 95b5484629 | |||
| 454bc3b522 | |||
| 1223ec9202 | |||
| 4f75347103 | |||
| 6e7dbdde94 | |||
| 04b5ed06a3 | |||
| 0507d600a0 | |||
| cf902e0440 | |||
| f555b8a946 | |||
| ce705d9d61 | |||
| 5f4dc370f8 | |||
| a978858254 | |||
| 10e6402ad6 | |||
| bf5e32df2b | |||
| 9b0a2ce9b6 | |||
| 954e8cad0b | |||
| 166a284359 | |||
| 5efae5c38b | |||
| 4168c24fd6 | |||
| 762401777b | |||
| 24c4e40df9 | |||
| c58734fcda | |||
| bbc9fdd6ea | |||
| 4cd21ade51 | |||
| 0799e47233 | |||
| 306f2f7ff7 | |||
| fff10a2e19 | |||
| 9d990e6aac | |||
| 74468e2c9d | |||
| f40c4b2a69 | |||
| 2dae3adf66 | |||
| e079afc0a2 | |||
| 85b3e4db47 | |||
| 0197224c75 | |||
| 70f8fb867d | |||
| 86b87eeb9d | |||
| 297b545e55 | |||
| 3e2ae9b7ca | |||
| 2016b87b6e | |||
| a42ad4ebb3 | |||
| 158750b933 | |||
| 3abe65de2a | |||
| c0e432cc22 | |||
| f8115f47fd | |||
| 0ee97f902d | |||
| 3b996485e8 | |||
| d18df73849 | |||
| 66b56387e6 | |||
| bac5583ecb | |||
| 3079cf4ba5 | |||
| aefb0c1550 | |||
| 235adb5ebd | |||
| 744981ed93 | |||
| 942d85de19 | |||
| ae5921aa90 | |||
| c104f6c443 | |||
| 747a15f16e | |||
| ee20c214de | |||
| b93752d58c | |||
| 619e1dc9ef | |||
| 8fd68033ed | |||
| 500328ef55 | |||
| 62b4b44f88 | |||
| 6fa0603783 | |||
| 9d599d5f47 | |||
| 2b1f8a4fe2 | |||
| 34c712db32 | |||
| 7a81045290 | |||
| 67ce0f8b39 | |||
| 01df6c9116 | |||
| 480a109114 | |||
| 509fe203f7 | |||
| 0f9cc1087f | |||
| 0f24f47048 | |||
| a3d2ee562e | |||
| 1b1704f7c6 | |||
| 8b2670bf31 | |||
| e66c91acfc | |||
| 29699a66fd | |||
| 3b4051c775 | |||
| fffb5dae18 | |||
| 8cfc144ba9 | |||
| 065fba7cf8 | |||
| f2cb69cb06 | |||
| fe2d3dcfe2 | |||
| 78973c99a3 | |||
| 171190c1cd | |||
| 7952f5a722 | |||
| 3f29116345 | |||
| 3f99a1b9e4 | |||
| 7a5de9a44f | |||
| b4a8415a38 | |||
| e2573cbaef | |||
| ca772295ba | |||
| 0925d28eb0 | |||
| 782a26f446 | |||
| 278645230a | |||
| 21ee661cef | |||
| 3fffab81c8 | |||
| 6993582592 | |||
| 429487b1d1 | |||
| d15724dd2f | |||
| 4cb34bf28e | |||
| 2daaa258a0 | |||
| 3892fb517f | |||
| 659308e116 | |||
| bc127a45e8 | |||
| 596e7cdb03 | |||
| f09fffb7ad | |||
| a01587fc68 | |||
| 04cd90078b | |||
| 45015cc781 | |||
| fce62e988f | |||
| c5f91be93b | |||
| d0960da1d8 | |||
| 8443cadba9 | |||
| 9739eea32a | |||
| 90c77096aa | |||
| 1deb805d29 | |||
| b079442cfa | |||
| 88a0153f90 | |||
| 50cd3081e6 | |||
| f37633525f | |||
| 1893f6d884 | |||
| 661b33b6b6 | |||
| 1d8bcf7d73 | |||
| aaa4faeb57 | |||
| 2288746182 | |||
| 56d6d17b6a | |||
| 53e885164d | |||
| e82355cf9b | |||
| 16f2053927 | |||
| 0fbff1004c | |||
| 75690ba6cc | |||
| 0506e765a9 | |||
| acab1971ab | |||
| f8797b9529 | |||
| 11cc4f6319 | |||
| fc5c7e682c | |||
| b2211edeab | |||
| cd1ba1fbe2 | |||
| d2ad7b4108 | |||
| b4c47fcc26 | |||
| b31c133d8e | |||
| 2787e73126 | |||
| aafd888596 | |||
| ff3c2a0d9b | |||
| e7ad9a3cb5 | |||
| fc2b77135b | |||
| 33f54eb63c | |||
| 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 | |||
| b038175f07 | |||
| f18577a46c | |||
| 9915fb9bb1 | |||
| bb34e32f87 | |||
| 45e997adae | |||
| 2e4f11d108 |
81
.gitignore
vendored
81
.gitignore
vendored
@@ -5,10 +5,83 @@
|
|||||||
/iconfont-preview.html
|
/iconfont-preview.html
|
||||||
|
|
||||||
.codebuddy
|
.codebuddy
|
||||||
|
.workbuddy
|
||||||
.vscode
|
.vscode
|
||||||
.trae
|
.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_src
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build
|
stub-app-projects/mp-harmony/entry/src/main/resources/rawfile
|
||||||
stub-app-projects/mp-harmony/entry/src/main/resources
|
|
||||||
|
# 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
|
||||||
136
.local.config.js
136
.local.config.js
@@ -59,15 +59,16 @@ const localDevConfig = ({
|
|||||||
domain: 'http://192.168.197.1:8050/',
|
domain: 'http://192.168.197.1:8050/',
|
||||||
// domain: 'http://localhost: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': { // 测试平台
|
'local-11': { // 测试平台
|
||||||
uniacid: 11,
|
uniacid: 11,
|
||||||
domain: 'http://localhost:8050/',
|
domain: 'http://localhost:8050/',
|
||||||
},
|
},
|
||||||
'local-18': { // 人工晶体
|
'100': {// 口译教学
|
||||||
uniacid: 18,
|
|
||||||
domain: 'http://localhost:8050/',
|
|
||||||
},
|
|
||||||
'100': {
|
|
||||||
uniacid: 100,
|
uniacid: 100,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
@@ -75,31 +76,35 @@ const localDevConfig = ({
|
|||||||
uniacid: 1,
|
uniacid: 1,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'115': {
|
'115': {// 低压断路器
|
||||||
uniacid: 115,
|
uniacid: 115,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-11': {
|
'5g-11': {// 铝箔袋包装
|
||||||
uniacid: 11,
|
uniacid: 11,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-206': {
|
'5g-206': {// 气体探测
|
||||||
uniacid: 206,
|
uniacid: 206,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'47': { // 修改弹窗
|
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||||
|
uniacid: 165,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
|
||||||
uniacid: 47,
|
uniacid: 47,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'145': { // 表单链接转义问题
|
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
|
||||||
uniacid: 145,
|
uniacid: 145,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-213': { // 单图组点击链接无法跳转
|
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
|
||||||
uniacid: 213,
|
uniacid: 213,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-4': { // 公告点击链接无法跳转
|
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
|
||||||
uniacid: 4,
|
uniacid: 4,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
@@ -111,19 +116,23 @@ const localDevConfig = ({
|
|||||||
uniacid: 50,
|
uniacid: 50,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-237': { // 商品分类,没有立即咨询文字
|
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
|
||||||
uniacid: 237,
|
uniacid: 237,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'2847': { //
|
'5g-248': { //中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||||
|
uniacid: 248,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'2847': { // 家居装饰品
|
||||||
uniacid: 2847,
|
uniacid: 2847,
|
||||||
domain: 'https://xcx.aigc-quickapp.com/',
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'2482': { // 低空经济
|
'2482': { // 低空经济
|
||||||
uniacid: 2482,
|
uniacid: 2482,
|
||||||
domain: 'https://xcx3.aigc-quickapp.com/',
|
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'2485': { // 验证电子表单前后端数据一致性
|
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
|
||||||
uniacid: 2485,
|
uniacid: 2485,
|
||||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
@@ -143,25 +152,108 @@ const localDevConfig = ({
|
|||||||
uniacid: 48,
|
uniacid: 48,
|
||||||
domain: 'https://test.aigc-quickapp.com',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
'54': { // 测试平台
|
'test-12': { // 户外鞋,测试优惠券
|
||||||
uniacid: 54,
|
uniacid: 12,
|
||||||
domain: 'https://test.aigc-quickapp.com',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
|
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
|
||||||
|
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': { // 脱模剂粘合剂乐瑞固Lorechem
|
||||||
|
uniacid: 59,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'5g-204': { // 热固性塑料,公告不跳转
|
||||||
|
uniacid: 204,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'5g-351': { // 洗车机,商品详情的销量,购物车,库存隐藏
|
||||||
|
uniacid: 351,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'2709': { // 中央厨房
|
||||||
|
uniacid: 2709,
|
||||||
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'192.168.8.118-325': { // 测试平台
|
||||||
|
uniacid: 325,
|
||||||
|
domain: 'http://192.168.8.118:8050/',
|
||||||
|
// domain: 'http://localhost:8050/',
|
||||||
|
},
|
||||||
|
'2505': { // 煤矿钻机
|
||||||
|
uniacid: 2505,
|
||||||
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'3007': { // 钎焊材料
|
||||||
|
uniacid: 3007,
|
||||||
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'5g-317': { // 电磁兼容,模板显示不全商品
|
||||||
|
uniacid: 317,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'5g-312': { // 天然植物功效活性物
|
||||||
|
uniacid: 312,
|
||||||
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
|
},
|
||||||
|
'192.168.8.113-1': { // 测试平台
|
||||||
|
uniacid: 1,
|
||||||
|
domain: 'http://192.168.8.113:8050/',
|
||||||
|
// domain: 'http://localhost:8050/',
|
||||||
|
},
|
||||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||||
})['2821']; // 离型纸硅油纸
|
// })['2821']; // 离型纸硅油纸
|
||||||
// })['2482']; // 低空经济
|
// })['2482']; // 低空经济
|
||||||
// })['48']; // 离型纸硅油纸
|
// })['48']; // 离型纸硅油纸
|
||||||
// })['2485']; // 验证电子表单前后端数据一致性
|
// })['2485']; // 验证电子表单前后端数据一致性
|
||||||
// })['5g-11']; // 数码
|
// })['5g-11']; // 数码
|
||||||
// })['5g-213']; // 选择要使用的环境配置
|
// })['5g-213']; // 选择要使用的环境配置
|
||||||
|
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||||
|
// })['2724']; // 生物菌肥
|
||||||
// })['2811']; // 选择要使用的环境配置
|
// })['2811']; // 选择要使用的环境配置
|
||||||
|
// })['5g-351']; //洗车机
|
||||||
|
// })['5g-204']; //热固性塑料
|
||||||
|
// })['5g-59']; //合成孔径雷达
|
||||||
|
// })['2709']; //中央厨房
|
||||||
|
// })['3007']; //钎焊材料
|
||||||
|
// })['2505']; //煤矿钻机
|
||||||
// })['local-11']; // 选择要使用的环境配置
|
// })['local-11']; // 选择要使用的环境配置
|
||||||
// })['local-1']; // 本地
|
// })['2811']; // 本地
|
||||||
// })['local-18']; // 本地-人工晶体-有商品
|
// })['5g-317']; // 电磁兼容
|
||||||
|
// })['5g-351']; //
|
||||||
|
})['2811']; // 本地
|
||||||
|
// })['5g-248']; // 中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||||
|
// })['2881']; // 本地
|
||||||
// })['local-2']; // 本地
|
// })['local-2']; // 本地
|
||||||
// })['local-11']; // 本地
|
// })['local-11']; // 本地
|
||||||
// })['1-test']; // test
|
// })['1-test']; // test
|
||||||
// })['18-test']; // test
|
// })['test-12']; // test
|
||||||
export default localDevConfig;
|
export default localDevConfig;
|
||||||
60
App.vue
60
App.vue
@@ -5,6 +5,15 @@
|
|||||||
import ThemeOverride from 'common/js/theme-override.js';
|
import ThemeOverride from 'common/js/theme-override.js';
|
||||||
import PrivacyManager from 'common/js/privacy.js';
|
import PrivacyManager from 'common/js/privacy.js';
|
||||||
import splashState from 'common/js/splash-state.js';
|
import splashState from 'common/js/splash-state.js';
|
||||||
|
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
|
||||||
|
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
||||||
|
import aiService from 'common/js/ai-service.js';
|
||||||
|
import { markNeedStartupAd } from 'common/js/startup-ad.js';
|
||||||
|
|
||||||
|
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
|
||||||
|
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
|
||||||
|
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
|
||||||
|
let _appHasShown = false;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Weixin
|
Weixin
|
||||||
@@ -271,27 +280,56 @@
|
|||||||
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
||||||
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
||||||
// 只弹一次: 同意后写 storage,后续不再弹
|
// 只弹一次: 同意后写 storage,后续不再弹
|
||||||
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
|
// 异步查询平台隐私状态(微信/快应用), H5 同步返回
|
||||||
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
||||||
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
||||||
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
||||||
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
||||||
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
|
// 二次 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 {
|
try {
|
||||||
await PrivacyManager.show({
|
await PrivacyManager.show({
|
||||||
urlFallback: _buildPrivacyUrlFallback(options),
|
urlFallback: _buildPrivacyUrlFallback(options),
|
||||||
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||||
})
|
})
|
||||||
} catch (e) { console.warn('[privacy] show fail', e); }
|
} catch (e) { console.warn('[privacy] show fail', e); }
|
||||||
|
// #endif
|
||||||
|
|
||||||
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
||||||
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
||||||
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
||||||
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
|
// 只有 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
|
// #ifndef H5
|
||||||
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
||||||
if (_needSplash) {
|
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
|
// #endif
|
||||||
|
|
||||||
@@ -434,6 +472,14 @@
|
|||||||
if (uni.getStorageSync('memberInfo')) {
|
if (uni.getStorageSync('memberInfo')) {
|
||||||
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
|
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
|
||||||
|
|
||||||
|
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
|
||||||
|
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
|
||||||
|
try {
|
||||||
|
if (aiService && typeof aiService.onUserChange === 'function') {
|
||||||
|
aiService.onUserChange();
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
// 查询购物车信息
|
// 查询购物车信息
|
||||||
this.$store.dispatch('getCartNumber');
|
this.$store.dispatch('getCartNumber');
|
||||||
}
|
}
|
||||||
@@ -462,6 +508,16 @@
|
|||||||
},
|
},
|
||||||
onShow: function(options) {
|
onShow: function(options) {
|
||||||
// #ifdef MP
|
// #ifdef MP
|
||||||
|
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
|
||||||
|
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
|
||||||
|
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
|
||||||
|
if (_appHasShown) {
|
||||||
|
try { Lang.onAppReentry(); } catch (e) {}
|
||||||
|
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
|
||||||
|
try { markNeedStartupAd(); } catch (e) {}
|
||||||
|
}
|
||||||
|
_appHasShown = true;
|
||||||
|
|
||||||
// 自动授权登录
|
// 自动授权登录
|
||||||
this.getAuthInfo();
|
this.getAuthInfo();
|
||||||
if (this.$store.state.token) {
|
if (this.$store.state.token) {
|
||||||
|
|||||||
@@ -118,6 +118,8 @@
|
|||||||
.goods-video video {
|
.goods-video video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goods-video .uni-video-cover {
|
.goods-video .uni-video-cover {
|
||||||
@@ -1223,6 +1225,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.popup-layer {
|
.popup-layer {
|
||||||
|
position: relative;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
.head-wrap {
|
.head-wrap {
|
||||||
font-size: $font-size-toolbar;
|
font-size: $font-size-toolbar;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
567
common/js/card-api.js
Normal file
567
common/js/card-api.js
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
/**
|
||||||
|
* 名片云(card addon)API 收口层
|
||||||
|
*
|
||||||
|
* 单一事实来源:后端《名片云(card addon)API 对接文档》CARD_API_REFERENCE.md(2026-10-13)
|
||||||
|
* 文档与源码如有出入,以源码为准;URL 路径一个字符都不能变,只做收口不做改名。
|
||||||
|
*
|
||||||
|
* 收口要求(来自后端 checklist):
|
||||||
|
* 1. 所有 /card/api/* URL 必须集中在本层,页面不得再直连字面量;
|
||||||
|
* 2. 统一走项目请求工具(自动带 token / uniacid),任何接口不传 site_id;
|
||||||
|
* 3. 失败分支统一 toast(message);「不存在」类错误按"内容不存在或已删除"提示;
|
||||||
|
* 4. card/update 只传编辑过的键(键存在才写、空串=清空、键缺席=不擦除);
|
||||||
|
* 5. 详情/列表渲染富化字段用 *_list 解码键,不解析原始 JSON 串;
|
||||||
|
* 6. 编辑页对 locked_fields 命中的字段渲染只读;
|
||||||
|
* 7. recognize 成功后检查 deduped,为 true 时提示"已存在同手机号名片";
|
||||||
|
* 8. 分页参数 page_size 不超过 100;
|
||||||
|
* 9. 名片路由参数用 card_id(字符串),人脉/线索用 id(数字),不要混用。
|
||||||
|
*
|
||||||
|
* 用法(页面内 this.$cardApi):
|
||||||
|
* this.$cardApi.cardLists({ data: { page: 1 }, success: res => {}, fail: () => {} })
|
||||||
|
*
|
||||||
|
* 注意:本层只做"URL 收口 + 通道选择",不做业务加工;
|
||||||
|
* 业务字典(状态/来源/字段白名单)随端口导出,供各页面复用。
|
||||||
|
*/
|
||||||
|
import Http from './http.js';
|
||||||
|
|
||||||
|
/** 所有端点路径(与后端文档逐一比对,改动需两边同步) */
|
||||||
|
export const CARD_API_URL = {
|
||||||
|
// === 名片夹 /card/api/card/* ===
|
||||||
|
CARD_LISTS: '/card/api/card/lists',
|
||||||
|
CARD_DETAIL: '/card/api/card/detail',
|
||||||
|
CARD_UPDATE: '/card/api/card/update',
|
||||||
|
CARD_FAVORITE: '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
|
||||||
|
CARD_CREATE: '/card/api/card/create', // 新建数字名片(后端源码已有, 文档未列; 以源码为准)
|
||||||
|
CARD_CLAIM: '/card/api/card/claim',
|
||||||
|
CARD_RECOGNIZE: '/card/api/card/recognize',
|
||||||
|
CARD_RETRY: '/card/api/card/retry',
|
||||||
|
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
|
||||||
|
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
|
||||||
|
|
||||||
|
// === 跟进完成态 2.11.3 /card/api/card/* ===
|
||||||
|
CARD_FOLLOW_DONE: '/card/api/card/followDone',
|
||||||
|
// === 访客轨迹 2.11.4 /card/api/card/* ===
|
||||||
|
CARD_VIEWS: '/card/api/card/views',
|
||||||
|
|
||||||
|
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
|
||||||
|
APPLY_SUBMIT: '/card/api/apply/submit',
|
||||||
|
APPLY_STATUS: '/card/api/apply/status',
|
||||||
|
|
||||||
|
// === 人脉库 /card/api/contact/* ===
|
||||||
|
CONTACT_LISTS: '/card/api/contact/lists',
|
||||||
|
CONTACT_ADD: '/card/api/contact/add',
|
||||||
|
CONTACT_INFO: '/card/api/contact/info',
|
||||||
|
CONTACT_UPDATE: '/card/api/contact/update',
|
||||||
|
CONTACT_DELETE: '/card/api/contact/delete',
|
||||||
|
CONTACT_TO_LEAD: '/card/api/contact/toLead',
|
||||||
|
|
||||||
|
// === 潜在客户 /card/api/lead/* ===
|
||||||
|
LEAD_LISTS: '/card/api/lead/lists',
|
||||||
|
LEAD_SET_STATUS: '/card/api/lead/setStatus',
|
||||||
|
|
||||||
|
// === 交换名片 /card/api/exchange/* ===
|
||||||
|
EXCHANGE_LISTS: '/card/api/exchange/lists',
|
||||||
|
EXCHANGE_AGREE: '/card/api/exchange/agree',
|
||||||
|
EXCHANGE_IGNORE: '/card/api/exchange/ignore',
|
||||||
|
|
||||||
|
// === 名片设计(员工端可选样式, 后端 v2) /card/api/design/* ===
|
||||||
|
// 预设列表必须与商家后台 CardTemplatePresets 同源(避免前端自建漂移)
|
||||||
|
// ⚠ v2 路径无 /card/ 段: 初版 /card/api/card/design/* 在本框架 action 单段路由下 404
|
||||||
|
CARD_DESIGN_PRESETS: '/card/api/design/presets', // 预设列表(同源后台 CardTemplatePresets)
|
||||||
|
CARD_DESIGN_SAVE: '/card/api/design/save', // 应用预设/保存本卡样式
|
||||||
|
|
||||||
|
// === 企业名片 /card/api/enterprise/* (2.13.6) ===
|
||||||
|
// 业务模型: 企业卡片 owner_id=0(店铺级), 同一时刻仅 1 张 is_active=1 生效;
|
||||||
|
// 名片区三模式 header_mode: official(指定/默认官方企业卡) / self(员工自选个人卡) / staff(员工轮播)
|
||||||
|
ENTERPRISE_DETAIL: '/card/api/enterprise/detail', // GET ?card_id= 不传=当前生效卡
|
||||||
|
ENTERPRISE_SAVE: '/card/api/enterprise/save', // POST {card_id?,fields?,design?,header_mode?,official_card_id?}
|
||||||
|
ENTERPRISE_MINE: '/card/api/enterprise/mine', // GET 自己名下企业卡列表
|
||||||
|
ENTERPRISE_ACTIVATE: '/card/api/enterprise/activate', // POST {card_id} 切换生效(互斥)
|
||||||
|
ENTERPRISE_CHOOSE_SELF: '/card/api/enterprise/chooseSelf', // POST {card_id} 员工自选展示个人卡
|
||||||
|
ENTERPRISE_DELETE: '/card/api/enterprise/delete' // POST {card_id} 删候选卡(生效卡禁删)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 社交媒体类型白名单:必须与后端 CardRichFields::SOCIAL_TYPES 保持一致(增减需两边同步)。
|
||||||
|
* type 非法或 value 为空的条目会被后端静默丢弃。
|
||||||
|
* ⚠️ 2026-10-13:telegram / messenger 由独立标量字段软下线,统一并入本数组;
|
||||||
|
* 旧数据后端读取时自动合并进 socials_list(见 CARD_API_REFERENCE.md 3.3)。
|
||||||
|
*/
|
||||||
|
export const SOCIAL_TYPES = [
|
||||||
|
{ key: 'alipay', label: '支付宝' },
|
||||||
|
{ key: 'douyin', label: '抖音' },
|
||||||
|
{ key: 'wxvideo', label: '视频号' },
|
||||||
|
{ key: 'xiaohongshu', label: '小红书' },
|
||||||
|
{ key: 'weibo', label: '微博' },
|
||||||
|
{ key: 'kuaishou', label: '快手' },
|
||||||
|
{ key: 'bilibili', label: '哔哩哔哩' },
|
||||||
|
{ key: 'qq', label: 'QQ' },
|
||||||
|
{ key: 'linkedin', label: 'LinkedIn' },
|
||||||
|
{ key: 'facebook', label: 'Facebook' },
|
||||||
|
{ key: 'instagram', label: 'Instagram' },
|
||||||
|
{ key: 'x', label: 'X' },
|
||||||
|
{ key: 'telegram', label: 'Telegram' },
|
||||||
|
{ key: 'messenger', label: 'Messenger' },
|
||||||
|
{ key: 'other', label: '其他' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 富化字段上限:与后端 CardRichFields::MAX_* 一致 */
|
||||||
|
export const RICH_FIELD_LIMITS = {
|
||||||
|
MAX_MOBILES: 5,
|
||||||
|
MAX_SOCIALS: 10,
|
||||||
|
MAX_CUSTOM: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 线索跟进状态:与后端 Lead::STATUSES 一致(0-3,其它值报「参数不合法」) */
|
||||||
|
export const LEAD_STATUS = [
|
||||||
|
{ value: 0, label: '新客户' },
|
||||||
|
{ value: 1, label: '已联系' },
|
||||||
|
{ value: 2, label: '已成交' },
|
||||||
|
{ value: 3, label: '无效' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 人脉来源:与后端 Contact::SOURCES 一致(scan/exchange 暂无产生入口) */
|
||||||
|
export const CONTACT_SOURCES = [
|
||||||
|
{ value: 'manual', label: '手工录入' },
|
||||||
|
{ value: 'lead_in', label: '潜在客户转入' },
|
||||||
|
{ value: 'scan', label: '扫描' },
|
||||||
|
{ value: 'exchange', label: '交换' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 名片来源:与后端 relation.source_type 一致 */
|
||||||
|
export const CARD_SOURCE_TYPES = [
|
||||||
|
{ value: 'manual', label: '手动创建' },
|
||||||
|
{ value: 'manual_batch', label: '批量制作' },
|
||||||
|
{ value: 'scan', label: '扫描识别' },
|
||||||
|
{ value: 'import_csv', label: 'CSV 导入' },
|
||||||
|
{ value: 'import_xlsx', label: 'Excel 导入' },
|
||||||
|
{ value: 'clone', label: '克隆' },
|
||||||
|
{ value: 'api', label: '接口创建' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名片编辑标量字段:与后端 BusinessCardFieldNormalizer 输出对齐。
|
||||||
|
* ⚠️ 2026-10-13:telegram / messenger 已软下线为独立标量(传了也被忽略),
|
||||||
|
* 统一走 fields.socials 数组按类型添加;读取时旧值自动合并进 socials_list。
|
||||||
|
*/
|
||||||
|
export const CARD_FIELDS = [
|
||||||
|
'name', 'company', 'job_title', 'avatar',
|
||||||
|
'mobile', 'phone', 'email', 'website', 'wechat',
|
||||||
|
'country', 'province', 'city', 'address', 'postal_code',
|
||||||
|
'bio'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
|
||||||
|
export const CONTACT_FIELDS = [
|
||||||
|
'name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status'
|
||||||
|
];
|
||||||
|
|
||||||
|
const CardApi = {
|
||||||
|
url: CARD_API_URL,
|
||||||
|
|
||||||
|
/* ================= 名片夹 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的名片列表
|
||||||
|
* @param {Object} p { page, page_size(<=100), keyword, success, fail }
|
||||||
|
* 返回 data: { list, count, page_count }
|
||||||
|
*/
|
||||||
|
cardLists(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_LISTS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片详情
|
||||||
|
* @param {Object} p { card_id, success, fail }
|
||||||
|
* 返回 data: { relation, source, share_list }
|
||||||
|
*/
|
||||||
|
cardDetail(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DETAIL }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新卡片(normalize 语义:键存在才写;空串=清空;键缺席=不擦除)
|
||||||
|
* 局部更新时只传用户实际编辑过的键。走 JSON body(fields 为嵌套对象)。
|
||||||
|
* @param {Object} p { card_id, fields, success, fail }
|
||||||
|
*/
|
||||||
|
cardUpdate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_UPDATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建数字名片(商家端批量制作/单张新建入口)
|
||||||
|
* 入参:`fields`(与 card/update 同结构,`name` 必填)+ 可选 `owner_id`(指定归属员工,0=未归属待认领)
|
||||||
|
* 返回 `data`:`{ card_id, ...relation }`(前端用 data.card_id 跳详情)
|
||||||
|
* ⚠️ 该端点后端源码已实现但旧文档未列出,按「文档与源码出入以源码为准」收口。
|
||||||
|
*/
|
||||||
|
cardCreate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_CREATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认领名片(商家后台建的卡未归属 owner_id=0,员工认领到自己名下)
|
||||||
|
* 入参(二选一, claim_code 优先): { data: { claim_code } } 或 { data: { card_id } }
|
||||||
|
* 成功 data.claimed: true=新认领, false=早已是自己的(幂等)
|
||||||
|
* 文档: card_claim_api.md(POST /card/api/card/claim,token 自动带)
|
||||||
|
* @param {Object} p { data: { claim_code | card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
cardClaim(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_CLAIM }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换收藏(双向 toggle,幂等可逆:未收藏→置1,已收藏→置0)
|
||||||
|
* 直接用返回值 data.is_favorite 回填 UI,前端不自行判断状态。
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
* 返回 data: { card_id, is_favorite: 0|1 }
|
||||||
|
* 拒绝(message 直接 toast): 卡不存在 / 非自己名下卡(统一「名片不存在」) / 参数不合法 / 未登录
|
||||||
|
*/
|
||||||
|
cardToggleFavorite(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_FAVORITE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除自己的名片(软删, 2.11.91)
|
||||||
|
* 文档: CARD_DELETE_API.md(POST /card/api/card/delete,token 自动带,不传 site_id)
|
||||||
|
* 通道: 与 cardClaim/cardFollowDone 同款 sendRequest(表单编码);只需 card_id 一个字段
|
||||||
|
* 交互: 调用前页面必须已做二次确认;成功后 toast「已删除」并刷新列表(接口不再返回该卡)
|
||||||
|
* 口径: 「卡片不存在」是故意模糊(他人/已删/不存在同一文案),前端统一按 message toast,不特殊处理
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
cardDelete(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DELETE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别建卡
|
||||||
|
* @param {Object} p { media_path, media_type?, source_type?, success, fail }
|
||||||
|
* 返回 data: { source_id, relation_id, card_id, deduped, fields }
|
||||||
|
* deduped=true 表示站点内已有同手机号名片,未新建,前端提示"已存在同名片"
|
||||||
|
*/
|
||||||
|
cardRecognize(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别失败重试(仅本站 + status=failed 的来源可重试,有次数上限)
|
||||||
|
* @param {Object} p { source_id, success, fail }
|
||||||
|
*/
|
||||||
|
cardRetry(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RETRY }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量识别(1~10 条,逐项独立成败)
|
||||||
|
* @param {Object} p { items: [{ source_type?, media_type?, media_path, media_key? }], success, fail }
|
||||||
|
* 返回 data: { items: [{ ok, message, data }], ok, failed }
|
||||||
|
*/
|
||||||
|
cardRecognizeBatch(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE_BATCH }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记跟进完成(2.11.3 跟进闭环)
|
||||||
|
* 行级归属: 只能标记自己名下的名片(与收藏/认领同一套口径)
|
||||||
|
* 成功 data: { card_id, follow_up_done: 1 }
|
||||||
|
* 失败 message 直接 toast: 卡片不存在 / 只能标记自己名下的名片
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
cardFollowDone(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_FOLLOW_DONE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访客轨迹(2.11.4 谁看了我)
|
||||||
|
* 入参(全部可选): { data: { card_id?, page?, page_size?(<=50, 建议20) } }
|
||||||
|
* 不传 card_id = 当前员工名下全部名片; 成功 data: { list, total, page, page_size,
|
||||||
|
* summary: { today_pv, today_uv, total_pv, total_uv } }
|
||||||
|
* @param {Object} p { data: {...}, success, fail }
|
||||||
|
*/
|
||||||
|
cardViews(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 人脉库 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脉列表
|
||||||
|
* @param {Object} p { page, page_size(<=100), keyword, source?, status?, success, fail }
|
||||||
|
* 返回 data: { list, count, page_count }
|
||||||
|
*/
|
||||||
|
contactLists(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_LISTS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增人脉
|
||||||
|
* @param {Object} p { fields: { name(必填), company?, ... }, success, fail }
|
||||||
|
*/
|
||||||
|
contactAdd(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_ADD }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脉详情(编辑页回填;id 为数字自增主键,注意与名片 card_id 字符串不同)
|
||||||
|
* @param {Object} p { id, success, fail }
|
||||||
|
*/
|
||||||
|
contactInfo(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_INFO }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人脉(兼容 fields 对象或顶层平铺字段)
|
||||||
|
* @param {Object} p { id, fields?, ...平铺字段, success, fail }
|
||||||
|
*/
|
||||||
|
contactUpdate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_UPDATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除人脉(真删不可恢复,前端需二次确认弹窗)
|
||||||
|
* @param {Object} p { id, success, fail }
|
||||||
|
*/
|
||||||
|
contactDelete(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_DELETE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脉 → 潜在客户(不删人脉行;同员工同手机号已转过则报错)
|
||||||
|
* @param {Object} p { id, success, fail }
|
||||||
|
*/
|
||||||
|
contactToLead(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_TO_LEAD }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 潜在客户 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线索列表(自己名下名片收到的线索 + 自己从人脉转入的线索)
|
||||||
|
* @param {Object} p { page, page_size(<=100), status?(0-3), keyword, success, fail }
|
||||||
|
* 返回 data: { list, count, page_count }
|
||||||
|
*/
|
||||||
|
leadLists(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_LISTS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新跟进状态(status 只能是 0/1/2/3)
|
||||||
|
* @param {Object} p { id, status, success, fail }
|
||||||
|
* 返回 data: { id, status }
|
||||||
|
*/
|
||||||
|
leadSetStatus(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_SET_STATUS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 交换名片 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待处理交换请求列表(返回 status=0;已同意/已忽略员工端不可见)
|
||||||
|
* @param {Object} p { page, page_size(<=100), success, fail }
|
||||||
|
* 返回 data: { list:[{ id, from_card_id, name, avatar, job_title, company,
|
||||||
|
* create_time, create_time_txt, status }], count, page_count }
|
||||||
|
* ⚠️ 详情页红点计数技巧: lists?page=1&page_size=1 取 count 即可
|
||||||
|
* ⚠️ 后端契约要求 lists 走 GET(分页在 query); 而 Http.sendRequest 是
|
||||||
|
* 「带 data 就发 POST」的语义, 所以这里把分页拼进 url、不传 data,
|
||||||
|
* 否则会被发成 POST 导致接口不匹配、列表恒为空。
|
||||||
|
*/
|
||||||
|
exchangeLists(p) {
|
||||||
|
const opt = Object.assign({}, p || {});
|
||||||
|
const src = opt.data || opt;
|
||||||
|
const page = src.page || 1;
|
||||||
|
const pageSize = src.page_size || 20;
|
||||||
|
delete opt.data;
|
||||||
|
delete opt.page;
|
||||||
|
delete opt.page_size;
|
||||||
|
opt.url = CARD_API_URL.EXCHANGE_LISTS + '?page=' + page + '&page_size=' + pageSize;
|
||||||
|
Http.sendRequest(opt);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同意交换(后端自动双方建人脉,前端无需再调 contact/add)
|
||||||
|
* 幂等: 同侧已存在同手机号人脉时 receiver_created=0,仍是成功,不当失败
|
||||||
|
* @param {Object} p { data: { id }, success, fail }
|
||||||
|
* 返回 data: { id, status:1, receiver_created, initiator_created }
|
||||||
|
*/
|
||||||
|
exchangeAgree(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_AGREE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忽略交换(仅本方隐藏 status=2,对方不发通知、可再次发起)
|
||||||
|
* @param {Object} p { data: { id }, success, fail }
|
||||||
|
* 返回 data: { id, status:2 }
|
||||||
|
*/
|
||||||
|
exchangeIgnore(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_IGNORE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同意交换后「校验 + 兜底」建人脉(页面点「同意」后必调;幂等,可安全重复调用)。
|
||||||
|
* 背景: 后端声明 CardExchange::agree 会双向互存人脉(我方人脉收对方名片 / 对方人脉收我方名片),
|
||||||
|
* 但实测出现「同意成功、对方名片却没进我的人脉」, 故前端不再只信 receiver_created,
|
||||||
|
* 统一在同意后按「对方名片编号」校验一次: 我的人脉里没有同手机号记录才补建。
|
||||||
|
* 流程: 取对方名片详情 → 按手机号查我的人脉 → 不存在则以 source=exchange 新建人脉。
|
||||||
|
* 幂等: 同手机号人脉已存在(含后端已建)时 created=0, 与后台已建不会重复。
|
||||||
|
* @param {Object} p { fromCardId, success, fail }
|
||||||
|
* 回调 done(data): { code, created, existed } created=1 新建 / 0 已存在或无需建;
|
||||||
|
* existed = 命中的同手机号人脉行(未命中为 null), 供页面提示「人脉中已有该联系人」。
|
||||||
|
*/
|
||||||
|
ensureExchangeContact(p) {
|
||||||
|
const self = this;
|
||||||
|
const done = p.success, fail = p.fail;
|
||||||
|
const finish = (d) => { typeof done === 'function' && done(d); };
|
||||||
|
const fromCardId = p.fromCardId;
|
||||||
|
if (!fromCardId) { finish({ code: 0, created: 0 }); return; }
|
||||||
|
this.cardDetail({
|
||||||
|
data: { card_id: fromCardId },
|
||||||
|
success: res => {
|
||||||
|
if (res.code != 0) { typeof fail === 'function' && fail(res); return; }
|
||||||
|
const r = (res.data && (res.data.relation || res.data)) || {};
|
||||||
|
const mobile = r.mobile || '';
|
||||||
|
const fields = {
|
||||||
|
name: r.name || '',
|
||||||
|
company: r.company || '',
|
||||||
|
job_title: r.job_title || '',
|
||||||
|
mobile: mobile,
|
||||||
|
source: 'exchange'
|
||||||
|
};
|
||||||
|
// 富化字段仅在对方有名片值时带上(空串=清空语义, 新建人脉时不传空键)
|
||||||
|
if (r.wechat) fields.wechat = r.wechat;
|
||||||
|
if (r.email) fields.email = r.email;
|
||||||
|
if (r.website) fields.website = r.website;
|
||||||
|
if (r.address) fields.address = r.address;
|
||||||
|
const add = () => self.contactAdd({
|
||||||
|
data: { source: 'exchange', fields: fields },
|
||||||
|
success: res2 => {
|
||||||
|
// code!=0(如手机号已存在被拒): 交换已同意成功, 兜底不打扰用户, 按无需新建收口
|
||||||
|
const ok = res2 && res2.code == 0;
|
||||||
|
finish({ code: ok ? 0 : ((res2 && res2.code) || 0), created: ok ? 1 : 0, raw: res2 });
|
||||||
|
},
|
||||||
|
fail: err => { typeof fail === 'function' && fail(err); }
|
||||||
|
});
|
||||||
|
// 按手机号去重: 已存在同手机号人脉则不重复建(兼容后台已建 / 重复同意)
|
||||||
|
if (mobile) {
|
||||||
|
self.contactLists({
|
||||||
|
data: { page: 1, page_size: 20, keyword: mobile },
|
||||||
|
success: res3 => {
|
||||||
|
const d3 = res3.data || {};
|
||||||
|
const list = Array.isArray(d3) ? d3 : (d3.list || []);
|
||||||
|
const hit = list.find(c => (c.mobile || '') === mobile);
|
||||||
|
// 命中: 对方已在我的人脉里(可能显示为旧姓名), 不覆盖用户维护过的数据, 只回传命中行
|
||||||
|
if (hit) { finish({ code: 0, created: 0, existed: hit }); return; }
|
||||||
|
add();
|
||||||
|
},
|
||||||
|
fail: () => add()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
add();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: err => { typeof fail === 'function' && fail(err); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 员工建卡申请 2.11.15 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交建卡申请(空态申请 → 商家审批 → 自动建卡归属)
|
||||||
|
* 约束: 同一会员同时只允许一条待审申请(重复提交后端会拒)
|
||||||
|
* 入参: { data: { name, mobile } }
|
||||||
|
* @param {Object} p { data: { name, mobile }, success, fail }
|
||||||
|
*/
|
||||||
|
applySubmit(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_SUBMIT }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询我的建卡申请状态(进入名片夹/空态时调用或轮询)
|
||||||
|
* 成功 data: { status: 0 待审/1 已同意/2 已拒绝, status_text, note, has_card }
|
||||||
|
* @param {Object} p { success, fail }
|
||||||
|
*/
|
||||||
|
applyStatus(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_STATUS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 名片设计(员工端可选样式) ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取名片预设列表(与商家后台 CardTemplatePresets 同一份, 同源)
|
||||||
|
* 成功 data: { list:[{ key, name, desc, thumb,
|
||||||
|
* design:{ theme_color, text_color, bg_color, layout, cover, font_family,
|
||||||
|
* font_scale, card_radius, header_gradient_end, header_gradient_angle, quick_style } }] }
|
||||||
|
* @param {Object} p { success, fail }
|
||||||
|
*/
|
||||||
|
cardDesignPresets(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DESIGN_PRESETS }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用预设 / 保存本卡样式(员工端选择后台预设后落库)
|
||||||
|
* 入参(包在 data 里): { card_id, preset_key } —— 后端按 preset_key 取 CardTemplatePresets
|
||||||
|
* 的 design 合并进本卡 lucky_card_design(非空覆盖), 返回 { code:0 }
|
||||||
|
* @param {Object} p { data: { card_id, preset_key }, success, fail }
|
||||||
|
*/
|
||||||
|
cardDesignSave(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_DESIGN_SAVE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ================= 企业名片 2.13.6 ================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业名片详情(核心)
|
||||||
|
* GET ?card_id= 不传返回当前生效企业名片;传候选卡 id 可预览(is_active=0)
|
||||||
|
* 成功 data: { card_id, is_active, is_owner, fields:{name,company,job_title,mobile,email,
|
||||||
|
* website,address,wechat,avatar,bio,name_en,company_en,job_title_en,bio_en},
|
||||||
|
* design:{theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id},
|
||||||
|
* header:{mode,official_card_id,official,self,staff} }
|
||||||
|
* 渲染: 名片区按 header.mode 取 official/self/staff 数据, 内容区用 fields+design.canvas
|
||||||
|
* 失败「企业名片不存在」→ 前端空态引导去管理端创建
|
||||||
|
* @param {Object} p { data: { card_id? }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseDetail(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_DETAIL }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存企业名片(后台/会员;owner_id=0 的卡会员不可改)
|
||||||
|
* @param {Object} p { data: { card_id?, fields?, design?, header_mode?, official_card_id? }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseSave(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_SAVE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的企业名片列表(自己名下)
|
||||||
|
* 成功 data: { list: [{ card_id, name, is_active }] }
|
||||||
|
* @param {Object} p { success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseMine(p) {
|
||||||
|
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_MINE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换生效企业名片(互斥:全店仅 1 张 is_active=1,仅能切自己名下的卡)
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseActivate(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_ACTIVATE }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工自选展示名片(self 模式):card_id 必须是自己名下的 contact 卡
|
||||||
|
* 选择按会员存储;detail 的 header.self.chosen=1 表示已自选
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseChooseSelf(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_CHOOSE_SELF }, p || {}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除企业名片(会员端;生效卡不可删,删除候选卡会清理官方名片引用)
|
||||||
|
* @param {Object} p { data: { card_id }, success, fail }
|
||||||
|
*/
|
||||||
|
enterpriseDelete(p) {
|
||||||
|
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_DELETE }, p || {}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardApi;
|
||||||
@@ -63,7 +63,7 @@ if (process?.env?.NODE_ENV === 'development') {
|
|||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +80,10 @@ export default {
|
|||||||
//api请求地址
|
//api请求地址
|
||||||
baseUrl: domain,
|
baseUrl: domain,
|
||||||
|
|
||||||
|
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
|
||||||
|
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
|
||||||
|
aikefuConfigUuid: aikefuConfigUuid,
|
||||||
|
|
||||||
// 图片域名
|
// 图片域名
|
||||||
imgDomain: domain,
|
imgDomain: domain,
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import IMAGE_CONFIG from './image-config.js';
|
import IMAGE_CONFIG from './image-config.js';
|
||||||
import Platform from './platform.js';
|
import Platform from './platform.js';
|
||||||
|
import PAGE_KEY_MAP from './util.js';
|
||||||
|
|
||||||
export class CustomerService {
|
export class CustomerService {
|
||||||
constructor(vueInstance, externalConfig = null) {
|
constructor(vueInstance, externalConfig = null) {
|
||||||
@@ -118,6 +119,16 @@ export class CustomerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到 AI智能客服页面(AiChat)
|
* 跳转到 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() {
|
openAiChatService() {
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +138,7 @@ export class CustomerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
||||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
||||||
|
|
||||||
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
@@ -551,6 +562,9 @@ export class CustomerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开 AI 智能助手
|
* 打开 AI 智能助手
|
||||||
|
*
|
||||||
|
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
|
||||||
|
* 由调用方(hover-nav.vue 等)按需注入。
|
||||||
*/
|
*/
|
||||||
openAIChat() {
|
openAIChat() {
|
||||||
try {
|
try {
|
||||||
@@ -558,7 +572,7 @@ export class CustomerService {
|
|||||||
this.vm.setAiUnreadCount(0);
|
this.vm.setAiUnreadCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: aiChatUrl,
|
url: aiChatUrl,
|
||||||
|
|||||||
164
common/js/diy.js
164
common/js/diy.js
@@ -2,6 +2,7 @@ import WxMap from 'common/js/map-wx-jssdk.js';
|
|||||||
import Config from '@/common/js/config.js';
|
import Config from '@/common/js/config.js';
|
||||||
import util from '@/common/js/util.js';
|
import util from '@/common/js/util.js';
|
||||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||||
|
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
|
||||||
|
|
||||||
let systemInfo = util.getDeviceInfo();
|
let systemInfo = util.getDeviceInfo();
|
||||||
|
|
||||||
@@ -232,6 +233,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
|
||||||
await this.getDiyInfo();
|
await this.getDiyInfo();
|
||||||
//获取启动广告
|
//获取启动广告
|
||||||
await this.getDiyAdv();
|
await this.getDiyAdv();
|
||||||
@@ -296,33 +298,99 @@ export default {
|
|||||||
data: {},
|
data: {},
|
||||||
async: false
|
async: false
|
||||||
});
|
});
|
||||||
this.adv = res.value
|
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
|
||||||
// 弹框形式,首次弹出 1,每次弹出 0
|
// 避免取到 undefined 后模板 v-if 抛错导致 uni-popup 不渲染、真机广告弹不出来
|
||||||
|
let advData = (res && (res.data != null ? res.data : res.value)) || {};
|
||||||
|
this.adv = advData;
|
||||||
|
// 后台配置: advshow=1 开启启动广告; advtype 决定弹出策略
|
||||||
|
// advtype == 1: 首次弹出 —— 仅第一次进入小程序弹出(持久化标记, 清缓存后可再弹)
|
||||||
|
// advtype == 0: 每次弹出 —— 每次"重新进入小程序"都弹出(不含 Tab 切换回首页)
|
||||||
if (this.adv.advshow == 1) {
|
if (this.adv.advshow == 1) {
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
if (res.value.advtype == 1) {
|
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
|
||||||
|
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
|
||||||
|
await this._waitPrivacyResolved();
|
||||||
|
if (!this.$refs.uniPopupWindow) return;
|
||||||
|
|
||||||
|
// 先判定本次是否需要弹出, 避免无谓的图片预加载
|
||||||
|
let shouldOpen = false;
|
||||||
|
let isFirstType = false;
|
||||||
|
if (this.adv.advtype == 1) {
|
||||||
|
// 首次弹出: 从未展示过才弹
|
||||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||||
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
if (popwindow_count == '' || popwindow_count == 0) {
|
||||||
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
shouldOpen = true;
|
||||||
|
isFirstType = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 每次弹出: 冷启动首次 或 真正"重新进入小程序"(App 前后台切换)时弹出,
|
||||||
|
// 由 App.onShow 在重新进入时置位的信号驱动, 切 Tab 不会触发 App.onShow 故不弹
|
||||||
|
if (consumeNeedStartupAd() || isColdLaunchPending()) {
|
||||||
|
shouldOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldOpen) return;
|
||||||
|
|
||||||
|
// 关键: 广告图片是网络图, 而 uni-popup 用 v-if 在 open() 时才渲染 slot,
|
||||||
|
// 图片要等弹窗打开后才开始请求/解码, 真机网络慢时就会"一截一截"逐步显示。
|
||||||
|
// 先把所有广告图片预加载进本地缓存, 全部就绪后再 open, 使弹窗一次性完整出现。
|
||||||
|
await this._preloadAdvImages(this.adv.list);
|
||||||
|
if (!this.$refs.uniPopupWindow) return;
|
||||||
|
|
||||||
this.$refs.uniPopupWindow.open();
|
this.$refs.uniPopupWindow.open();
|
||||||
|
if (isFirstType) {
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||||
}
|
}
|
||||||
} else if (res.value.advtype == 0) {
|
|
||||||
this.$refs.uniPopupWindow.open();
|
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 等待隐私协议流程结束后再弹启动广告
|
||||||
|
* - 鸿蒙元服务: 隐私由系统托管, 应用层不弹自建弹窗, 直接放行
|
||||||
|
* - 已同意过(storage privacyAgreed): 无需等待, 直接放行
|
||||||
|
* - 未同意: 监听 PrivacyManager 广播的 privacy:agreed / privacy:disagreed,
|
||||||
|
* 用户点击隐私协议(同意/拒绝)关闭弹窗后放行
|
||||||
|
* - 兜底超时: 隐私协议因规则未弹等情况永不 emit 时也要弹广告, 避免卡死
|
||||||
|
* @param {number} [maxWait=15000]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
_waitPrivacyResolved(maxWait = 15000) {
|
||||||
|
// 鸿蒙元服务: 隐私协议由系统托管, 应用层不弹自建隐私弹窗, 无需等待
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
return Promise.resolve();
|
||||||
|
// #endif
|
||||||
|
// 已经同意过 → 隐私弹窗不会再出现, 直接放行
|
||||||
|
if (uni.getStorageSync('privacyAgreed')) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
uni.$off('privacy:agreed', finish);
|
||||||
|
uni.$off('privacy:disagreed', finish);
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
uni.$on('privacy:agreed', finish);
|
||||||
|
uni.$on('privacy:disagreed', finish);
|
||||||
|
// 兜底: 隐私协议因规则未弹 / 一直未处理时, 超时后也要弹广告
|
||||||
|
const timer = setTimeout(finish, maxWait);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
||||||
|
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
|
||||||
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
|
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
|
||||||
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
|
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
|
||||||
_showDiyLoadError(err, url) {
|
// @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business'
|
||||||
|
_showDiyLoadError(err, url, errorType) {
|
||||||
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
|
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
|
||||||
this.diyLoadErrorUrl = url || '';
|
this.diyLoadErrorUrl = url || '';
|
||||||
this.diyLoadError = true;
|
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 可能返回空串,故统一用 `|| 中文兜底` 保证非空
|
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
|
||||||
@@ -341,8 +409,8 @@ export default {
|
|||||||
// empty / unknown 不走模板,直接返回通用网络异常
|
// empty / unknown 不走模板,直接返回通用网络异常
|
||||||
if (info.type === 'empty' || info.type === 'unknown') return reason;
|
if (info.type === 'empty' || info.type === 'unknown') return reason;
|
||||||
|
|
||||||
const tpl = this.$lang('diy.loadFailTpl');
|
const tpl = this.$lang('diy.loadFailTpl', { reason });
|
||||||
return tpl.replace('{reason}', reason);
|
return tpl;
|
||||||
},
|
},
|
||||||
// 错误横幅「重试」入口
|
// 错误横幅「重试」入口
|
||||||
retryDiyInfo() {
|
retryDiyInfo() {
|
||||||
@@ -355,10 +423,15 @@ export default {
|
|||||||
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
|
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentLang = uni.getStorageSync("lang") || '';
|
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 (!force) {
|
||||||
if (this.diyInfoLoading) return;
|
if (this.diyInfoLoading) {
|
||||||
|
console.log('[DIY][getDiyInfo] skip: already loading');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
|
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
|
||||||
&& this.lastDiyInfoLang === currentLang) {
|
&& this.lastDiyInfoLang === currentLang) {
|
||||||
|
console.log('[DIY][getDiyInfo] skip: throttled by ttl');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,21 +452,25 @@ export default {
|
|||||||
async: false
|
async: false
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getDiyInfo request failed', e);
|
console.error('[DIY][getDiyInfo] request failed', e);
|
||||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
this.diyInfoLoading = false;
|
this.diyInfoLoading = false;
|
||||||
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
|
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
|
||||||
this._showDiyLoadError(e, diyInfoFullUrl);
|
// errorType='network': 网络异常 (uni.request catch)
|
||||||
|
this._showDiyLoadError(e, diyInfoFullUrl, 'network');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.diyInfoLoading = false;
|
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) {
|
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 (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
|
|
||||||
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
|
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
|
||||||
if (!res) {
|
if (!res) {
|
||||||
this._showDiyLoadError(null, diyInfoFullUrl);
|
// errorType='no-response': 完全没有 res 对象
|
||||||
|
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
|
||||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||||
return;
|
return;
|
||||||
@@ -410,7 +487,8 @@ export default {
|
|||||||
|
|
||||||
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
|
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
|
||||||
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
|
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
|
||||||
this._showDiyLoadError(res && res.message, diyInfoFullUrl);
|
// errorType='business': 业务失败 (code!=0 / 消息提示)
|
||||||
|
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
|
||||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||||
return;
|
return;
|
||||||
@@ -425,7 +503,13 @@ export default {
|
|||||||
|
|
||||||
let diyDataValue = res.data;
|
let diyDataValue = res.data;
|
||||||
if (diyDataValue.value) {
|
if (diyDataValue.value) {
|
||||||
|
try {
|
||||||
this.diyData = JSON.parse(diyDataValue.value);
|
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
|
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
|
||||||
@@ -513,11 +597,51 @@ export default {
|
|||||||
this.openBottomNav = this.diyData.global.openBottomNav;
|
this.openBottomNav = this.diyData.global.openBottomNav;
|
||||||
}
|
}
|
||||||
this.isShowCopyRight = true;
|
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));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 预加载启动广告图片
|
||||||
|
* 广告弹窗由 uni-popup 在 open() 时才用 v-if 渲染内容, 图片是网络图,
|
||||||
|
* 若等到弹窗打开后才开始加载, 真机网络慢时会出现"一截一截"逐步显示。
|
||||||
|
* 这里提前把图片下载进本地缓存, 等全部加载完成(或超时)后再弹出,
|
||||||
|
* 使弹窗一次性完整呈现。
|
||||||
|
* @param {Array} list - adv.list, 元素形如 { imageUrl, link }
|
||||||
|
* @param {number} [timeout=4000] - 单张兜底超时, 避免个别图片卡住导致广告永不弹出
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
_preloadAdvImages(list, timeout = 4000) {
|
||||||
|
if (!list || !list.length) return Promise.resolve();
|
||||||
|
const tasks = list.map((item) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = (item && item.imageUrl) ? this.$util.img(item.imageUrl) : '';
|
||||||
|
if (!url) return resolve();
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, timeout);
|
||||||
|
try {
|
||||||
|
// getImageInfo 会下载图片并写入本地缓存, 之后 image 组件可直接命中缓存
|
||||||
|
uni.getImageInfo({
|
||||||
|
src: url,
|
||||||
|
success: finish,
|
||||||
|
fail: finish
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Promise.all(tasks).then(() => {});
|
||||||
|
},
|
||||||
closePopupWindow() {
|
closePopupWindow() {
|
||||||
this.$refs.uniPopupWindow.close();
|
this.$refs.uniPopupWindow.close();
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
|
|
||||||
},
|
},
|
||||||
closeCollectPopupWindow() {
|
closeCollectPopupWindow() {
|
||||||
this.$refs.collectPopupWindow.close();
|
this.$refs.collectPopupWindow.close();
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export default {
|
|||||||
|
|
||||||
hover_nav_bg_color: '#FFFC',
|
hover_nav_bg_color: '#FFFC',
|
||||||
hover_nav_text_color: '#000',
|
hover_nav_text_color: '#000',
|
||||||
|
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
||||||
|
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
||||||
|
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
||||||
|
hover_nav_phone_font_size: '36rpx', // 电话按钮内图标字号基准
|
||||||
|
|
||||||
// ===== DIY 分类页字号 token =====
|
// ===== DIY 分类页字号 token =====
|
||||||
diy_category_tree_select_size: '28rpx',
|
diy_category_tree_select_size: '28rpx',
|
||||||
|
|||||||
134
common/js/floating-button.js
Normal file
134
common/js/floating-button.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 浮动侧边栏配置消费 (字段来源: /api/config/init → data.floating_button)
|
||||||
|
* 纯函数模块, 无副作用, 可直接单测
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 归一化路由: 'pages/index/index' → '/pages/index/index' */
|
||||||
|
export function normalizeRoute(route) {
|
||||||
|
if (!route) return '';
|
||||||
|
return String(route).charAt(0) === '/' ? String(route) : '/' + String(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取当前页面路由 (兼容 H5 / 小程序; route 已含分包 root, 如 'pages_goods/detail') */
|
||||||
|
export function getCurrentRoute() {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const cur = pages[pages.length - 1];
|
||||||
|
return normalizeRoute((cur && (cur.route || (cur.$page && cur.$page.path))) || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前缀命中判定
|
||||||
|
* '/pages/index' 命中 '/pages/index' 与 '/pages/index/xxx'
|
||||||
|
* '/pages_tool/' 命中 '/pages_tool/xxx' (前缀自带尾 /)
|
||||||
|
* '/pages/order/payment' 只命中它自己及其子路径
|
||||||
|
*/
|
||||||
|
export function hitPrefix(route, prefix) {
|
||||||
|
if (!route || !prefix) return false;
|
||||||
|
const p = prefix.charAt(prefix.length - 1) === '/' ? prefix : prefix + '/';
|
||||||
|
return route === prefix || route.indexOf(p) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任一前缀命中 */
|
||||||
|
function hitAny(route, prefixes) {
|
||||||
|
if (!Array.isArray(prefixes)) return false;
|
||||||
|
for (let i = 0; i < prefixes.length; i++) {
|
||||||
|
if (hitPrefix(route, prefixes[i])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找出路由命中的页面分组 key (未命中返回 null) */
|
||||||
|
export function matchPageGroup(route, prefixMap) {
|
||||||
|
if (!prefixMap) return null;
|
||||||
|
const keys = Object.keys(prefixMap);
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
if (hitAny(normalizeRoute(route), prefixMap[keys[i]])) return keys[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧边栏在当前页面是否显示 —— 唯一判定入口
|
||||||
|
*
|
||||||
|
* 优先级: 总开关 → 强制隐藏 → 显示范围
|
||||||
|
* 1) fb 不存在 (老缓存响应) → fallbackVisible (默认 true, 保持老行为)
|
||||||
|
* 2) enabled != 1 → false
|
||||||
|
* 3) 命中 force_hidden_prefixes 任意前缀 → false (最高优先级)
|
||||||
|
* 4) 命中 custom_pages / page_prefixes._custom → true
|
||||||
|
* 5) 命中 pages[组] == 1 → true
|
||||||
|
* 6) 都不命中 (如 /pages_goods/*) → false
|
||||||
|
*/
|
||||||
|
export function shouldShowOnPage(fb, route, fallbackVisible) {
|
||||||
|
if (!fb || typeof fb !== 'object') return fallbackVisible !== false;
|
||||||
|
if (Number(fb.enabled) !== 1) return false;
|
||||||
|
|
||||||
|
const r = normalizeRoute(route);
|
||||||
|
if (!r) return false;
|
||||||
|
|
||||||
|
// 强制隐藏 — 优先级最高
|
||||||
|
if (matchPageGroup(r, fb.force_hidden_prefixes)) return false;
|
||||||
|
|
||||||
|
// 自定义前缀优先判定
|
||||||
|
if (hitAny(r, fb.custom_pages) || hitAny(r, (fb.page_prefixes || {})['_custom'])) return true;
|
||||||
|
|
||||||
|
// 预设分组
|
||||||
|
const group = matchPageGroup(r, fb.page_prefixes);
|
||||||
|
if (group) return Number((fb.pages || {})[group]) === 1;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前页面要渲染的按钮列表 (已按 button_order 排序 + 显隐过滤)
|
||||||
|
* 返回 ['lang','kefu','phone'] 的子集, 空数组 = 整个侧边栏不渲染
|
||||||
|
* 返回 null = 配置缺失, 由调用方决定老行为
|
||||||
|
*/
|
||||||
|
export function resolveVisibleButtons(fb) {
|
||||||
|
if (!fb || typeof fb !== 'object') return null;
|
||||||
|
const order = Array.isArray(fb.button_order) ? fb.button_order : ['lang', 'kefu', 'phone'];
|
||||||
|
const map = fb.buttons || {};
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
const k = order[i];
|
||||||
|
if (Number(map[k]) === 1) out.push(k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言按钮显示文案
|
||||||
|
* @param {Object} fb data.floating_button
|
||||||
|
* @param {Array} langList [{ value:'zh-cn', name:'CN' }, ...]
|
||||||
|
* @param {Number} currentIdx 当前语言下标
|
||||||
|
*/
|
||||||
|
export function resolveLangButtonText(fb, langList, currentIdx) {
|
||||||
|
const list = Array.isArray(langList) ? langList : [];
|
||||||
|
if (!list.length) return '';
|
||||||
|
const style = (fb && fb.lang_switch && fb.lang_switch.style) || 'target';
|
||||||
|
|
||||||
|
if (style === 'text') {
|
||||||
|
const t = (fb && fb.lang_switch && fb.lang_switch.text) || '';
|
||||||
|
if (t) return t; // 空文案兜底走 target
|
||||||
|
}
|
||||||
|
if (style === 'current' || list.length < 2) {
|
||||||
|
return (list[currentIdx] && list[currentIdx].name) || '';
|
||||||
|
}
|
||||||
|
// target: 显示「点下去会切到的那个」语言
|
||||||
|
// 2 种语言 → 另一个; >2 种 → 取第一个不是当前的那个
|
||||||
|
let targetIdx = 0;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
if (i !== currentIdx) { targetIdx = i; break; }
|
||||||
|
}
|
||||||
|
return list[targetIdx] ? (list[targetIdx].name || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语言切换按钮是否可见 (含老「双语模式」fallback) */
|
||||||
|
export function isLangButtonVisible(fb, shopInfo, langCount) {
|
||||||
|
if (Number(langCount) < 2) return false;
|
||||||
|
if (fb && fb.lang_switch) return Number(fb.lang_switch.enabled) === 1;
|
||||||
|
return !!(shopInfo && shopInfo.ischina); // 老行为兜底
|
||||||
|
}
|
||||||
@@ -44,6 +44,15 @@ function _sleep(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: url,
|
url: url,
|
||||||
@@ -54,6 +63,7 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
|
|||||||
responseType: responseType,
|
responseType: responseType,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
success: (res) => {
|
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) {
|
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
|
||||||
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
|
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
|
||||||
return;
|
return;
|
||||||
@@ -73,6 +83,7 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
|
|||||||
resolve(res.data);
|
resolve(res.data);
|
||||||
},
|
},
|
||||||
fail: (res) => {
|
fail: (res) => {
|
||||||
|
console.error('[HTTP][FAIL]', reqId, method, url, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res));
|
||||||
reject(res);
|
reject(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -87,15 +98,20 @@ async function _requestWithRetry(params, url, method, data, header, dataType, re
|
|||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
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);
|
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
lastStatusCode = err && err.statusCode;
|
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)) {
|
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
|
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
|
||||||
|
console.log('[HTTP][RETRY-WAIT]', method, url, 'delay=', delay + 'ms');
|
||||||
await _sleep(delay);
|
await _sleep(delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +223,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
uploadBase64(params) {
|
uploadBase64(params) {
|
||||||
|
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
|
||||||
uni.request({
|
uni.request({
|
||||||
url: Config.baseUrl + '/api/upload/headimgBase64',
|
url: Config.baseUrl + '/api/upload/headimgBase64',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -221,14 +238,17 @@ export default {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
success: res => {
|
success: res => {
|
||||||
|
console.log('[HTTP][RESPONSE] uploadBase64', 'statusCode=', res.statusCode, JSON.stringify(res.data));
|
||||||
typeof params.success == 'function' && params.success(res.data);
|
typeof params.success == 'function' && params.success(res.data);
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
|
console.error('[HTTP][FAIL] uploadBase64', JSON.stringify(res));
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
pullImage(params) {
|
pullImage(params) {
|
||||||
|
console.log('[HTTP][REQUEST] pullImage', Config.baseUrl + '/api/upload/headimgPull', 'path=', params.path);
|
||||||
uni.request({
|
uni.request({
|
||||||
url: Config.baseUrl + '/api/upload/headimgPull',
|
url: Config.baseUrl + '/api/upload/headimgPull',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -243,14 +263,70 @@ export default {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
success: res => {
|
success: res => {
|
||||||
|
console.log('[HTTP][RESPONSE] pullImage', 'statusCode=', res.statusCode, JSON.stringify(res.data));
|
||||||
typeof params.success == 'function' && params.success(res.data);
|
typeof params.success == 'function' && params.success(res.data);
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
|
console.error('[HTTP][FAIL] pullImage', JSON.stringify(res));
|
||||||
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* JSON body 请求通道
|
||||||
|
*
|
||||||
|
* 为什么需要: sendRequest 走表单编码, 嵌套数组(如 fields.mobiles)的序列化
|
||||||
|
* 格式在各端不一致(uni 序列化器的平台差异, 小程序/H5 产出不同),
|
||||||
|
* 后端 input('fields/a') 依赖 PHP 方括号语法, 踩坑风险高。
|
||||||
|
* 本方法统一 JSON.stringify + application/json —— TP6 Request
|
||||||
|
* 原生解析 JSON body(getInputData), 且字符串 body 各端原样发送, 零歧义。
|
||||||
|
* token/uniacid/app_type 与 sendRequest 一样自动补齐。
|
||||||
|
*/
|
||||||
|
sendJson(params) {
|
||||||
|
if (!Config.baseUrl) {
|
||||||
|
uni.showToast({
|
||||||
|
title: getLang().lang('http.baseUrlNotConfigured'),
|
||||||
|
'icon': 'none',
|
||||||
|
'duration': 10000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/');
|
||||||
|
const data = {
|
||||||
|
app_type: appType,
|
||||||
|
app_type_name: appTypeName,
|
||||||
|
token: store.state.token || '',
|
||||||
|
uniacid: uni.getStorageSync('uniacid') || Config.uniacid
|
||||||
|
};
|
||||||
|
if (params.data != undefined) Object.assign(data, params.data);
|
||||||
|
|
||||||
|
const store_info = store.state.globalStoreInfo;
|
||||||
|
if (store_info) data.store_id = store_info.store_id;
|
||||||
|
|
||||||
|
console.log('[HTTP][REQUEST] sendJson', url);
|
||||||
|
uni.request({
|
||||||
|
url: url,
|
||||||
|
method: params.method || 'POST',
|
||||||
|
header: Object.assign({ 'content-type': 'application/json' }, params.header || {}),
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
dataType: params.dataType || 'json',
|
||||||
|
responseType: params.responseType || 'text',
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[HTTP][RESPONSE] sendJson', url, 'statusCode=', res.statusCode);
|
||||||
|
let r = res.data;
|
||||||
|
if (typeof r === 'string') {
|
||||||
|
try { r = JSON.parse(r); } catch (e) { /* 保持原样 */ }
|
||||||
|
}
|
||||||
|
typeof params.success == 'function' && params.success(r);
|
||||||
|
},
|
||||||
|
fail: (res) => {
|
||||||
|
console.error('[HTTP][FAIL] sendJson', url, JSON.stringify(res));
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
upload(params) {
|
upload(params) {
|
||||||
|
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
|
||||||
uni.uploadFile({
|
uni.uploadFile({
|
||||||
url: Config.baseUrl + params.url,
|
url: Config.baseUrl + params.url,
|
||||||
filePath: params.filePath,
|
filePath: params.filePath,
|
||||||
@@ -264,9 +340,11 @@ export default {
|
|||||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||||
},
|
},
|
||||||
success: (res) => {
|
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));
|
typeof params.success == 'function' && params.success(JSON.parse(res.data));
|
||||||
},
|
},
|
||||||
fail: (res) => {
|
fail: (res) => {
|
||||||
|
console.error('[HTTP][FAIL] upload', Config.baseUrl + params.url, JSON.stringify(res));
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) {
|
|||||||
const version = String(payload.version || '');
|
const version = String(payload.version || '');
|
||||||
const sign = payload.sign || '';
|
const sign = payload.sign || '';
|
||||||
|
|
||||||
|
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
|
||||||
|
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
|
||||||
|
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
|
||||||
|
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
|
||||||
|
console.warn(
|
||||||
|
'[lang-loader] bundle tree INCOMPLETE. ' +
|
||||||
|
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
|
||||||
|
' lang=' + lang +
|
||||||
|
' appType=' + appType +
|
||||||
|
' uniacid=' + uniacid +
|
||||||
|
' version=' + version +
|
||||||
|
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4) 签名校验(可选)
|
// 4) 签名校验(可选)
|
||||||
const secret = langConfig.remote.signSecret || '';
|
const secret = langConfig.remote.signSecret || '';
|
||||||
const signValid = validateSign(tree, version, sign, secret);
|
const signValid = validateSign(tree, version, sign, secret);
|
||||||
|
|||||||
@@ -628,6 +628,26 @@ function loadLangPackSync(lang, path) {
|
|||||||
* 获得当前本地语言
|
* 获得当前本地语言
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 语言切换开关钩子(由后台 shop_info.ischina 决定,经 store/index.js 的 setShopInfo 写入)。
|
||||||
|
*
|
||||||
|
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischina
|
||||||
|
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 后台关闭双语/小程序(ischina 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
|
||||||
|
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
|
||||||
|
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
|
||||||
|
* - 后台开启双语(ischina 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
|
||||||
|
*/
|
||||||
|
function setLangSwitchEnabled(enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
// 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文
|
||||||
|
invalidateStorageCache('lang');
|
||||||
|
try { uni.removeStorageSync('lang'); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentLocale() {
|
function getCurrentLocale() {
|
||||||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||||||
const val = cachedStorageGet('lang', null);
|
const val = cachedStorageGet('lang', null);
|
||||||
@@ -973,6 +993,13 @@ export default {
|
|||||||
*/
|
*/
|
||||||
getCurrentLocale,
|
getCurrentLocale,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言切换功能开关(后台 shop_info.ischina 决定)。
|
||||||
|
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
|
||||||
|
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
|
||||||
|
*/
|
||||||
|
setLangSwitchEnabled,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从后端数据中提取当前语言的文案(向后兼容)
|
* 从后端数据中提取当前语言的文案(向后兼容)
|
||||||
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
||||||
@@ -1054,6 +1081,13 @@ export default {
|
|||||||
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
||||||
let isComplete1 = isBundleComplete(result.tree);
|
let isComplete1 = isBundleComplete(result.tree);
|
||||||
if (!isComplete1) {
|
if (!isComplete1) {
|
||||||
|
// [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components
|
||||||
|
try {
|
||||||
|
console.warn('[i18n:boot] bundle incomplete (1st fetch). ' +
|
||||||
|
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
||||||
|
', source=' + (result && result.source) +
|
||||||
|
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
||||||
|
} catch (e) {}
|
||||||
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
||||||
delete remoteTreeCache[_preloadMk];
|
delete remoteTreeCache[_preloadMk];
|
||||||
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
||||||
@@ -1064,6 +1098,13 @@ export default {
|
|||||||
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
||||||
let isComplete2 = isBundleComplete(result.tree);
|
let isComplete2 = isBundleComplete(result.tree);
|
||||||
if (!isComplete2) {
|
if (!isComplete2) {
|
||||||
|
// [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree
|
||||||
|
try {
|
||||||
|
console.warn('[i18n:boot] bundle incomplete (after force retry). ' +
|
||||||
|
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
||||||
|
', source=' + (result && result.source) +
|
||||||
|
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
||||||
|
} catch (e) {}
|
||||||
throw new Error('lang bundle unusable after force retry');
|
throw new Error('lang bundle unusable after force retry');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,10 +1144,14 @@ export default {
|
|||||||
setRemoteStatus('fail', { reason: _reason, lang });
|
setRemoteStatus('fail', { reason: _reason, lang });
|
||||||
bootCompleted = true;
|
bootCompleted = true;
|
||||||
emitUpdated(lang);
|
emitUpdated(lang);
|
||||||
|
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
|
||||||
|
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
|
||||||
|
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: 'cache',
|
source: 'cache',
|
||||||
status: 'fail',
|
status: 'ok',
|
||||||
|
degraded: true,
|
||||||
reason: _errMsg || 'fallback to cache',
|
reason: _errMsg || 'fallback to cache',
|
||||||
lang,
|
lang,
|
||||||
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
||||||
@@ -1176,6 +1221,42 @@ export default {
|
|||||||
loadOverrides(lang);
|
loadOverrides(lang);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用:
|
||||||
|
* 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启),
|
||||||
|
* 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在:
|
||||||
|
* - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid
|
||||||
|
* - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果
|
||||||
|
* - remote-only 模式下远端 bundle 不会被重新拉取
|
||||||
|
* 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前
|
||||||
|
* storage 的 locale 重新读取并合并,不再沿用旧值。
|
||||||
|
* 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。
|
||||||
|
*/
|
||||||
|
onAppReentry() {
|
||||||
|
// [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry
|
||||||
|
try {
|
||||||
|
console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY +
|
||||||
|
', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) +
|
||||||
|
', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null)));
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid)
|
||||||
|
invalidateStorageCache();
|
||||||
|
// 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并
|
||||||
|
invalidateLangCaches();
|
||||||
|
// [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存)
|
||||||
|
try {
|
||||||
|
console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() +
|
||||||
|
', freshUniacid=' + getCurrentUniacid());
|
||||||
|
} catch (e) {}
|
||||||
|
// 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染
|
||||||
|
if (IS_REMOTE_ONLY) {
|
||||||
|
this.boot();
|
||||||
|
} else {
|
||||||
|
this.refresh(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
||||||
* @param {(status:string, detail:object) => void} fn
|
* @param {(status:string, detail:object) => void} fn
|
||||||
@@ -1247,7 +1328,7 @@ export default {
|
|||||||
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
|
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
|
||||||
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
|
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
|
||||||
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
|
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) {
|
if (cachedFieldResult !== undefined) {
|
||||||
let value = cachedFieldResult;
|
let value = cachedFieldResult;
|
||||||
// 没找到翻译
|
// 没找到翻译
|
||||||
@@ -1256,10 +1337,6 @@ export default {
|
|||||||
if (hasDefault) {
|
if (hasDefault) {
|
||||||
return arguments[1];
|
return arguments[1];
|
||||||
}
|
}
|
||||||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
|
||||||
if (!__DEV__) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// 开发环境 → 返回 raw key(便于发现问题)
|
// 开发环境 → 返回 raw key(便于发现问题)
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -1350,9 +1427,6 @@ export default {
|
|||||||
// 有默认值 → 使用默认值
|
// 有默认值 → 使用默认值
|
||||||
if (hasDefault) {
|
if (hasDefault) {
|
||||||
value = arguments[1];
|
value = arguments[1];
|
||||||
} else if (!__DEV__) {
|
|
||||||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
|
||||||
value = '';
|
|
||||||
}
|
}
|
||||||
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
||||||
} else if (arguments.length > 1) {
|
} else if (arguments.length > 1) {
|
||||||
@@ -1367,7 +1441,7 @@ export default {
|
|||||||
if (value == undefined) value = '';
|
if (value == undefined) value = '';
|
||||||
|
|
||||||
// 多语言调试
|
// 多语言调试
|
||||||
if (__DEV__ && field == value) {
|
if (field == value) {
|
||||||
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
768
common/js/media-upload.js
Normal file
768
common/js/media-upload.js
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
// common/js/media-upload.js
|
||||||
|
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
|
||||||
|
//
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
|
||||||
|
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
|
||||||
|
// "Cannot find module '@/common/js/media-upload.js'". 此次按
|
||||||
|
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
|
||||||
|
//
|
||||||
|
// 调用方:
|
||||||
|
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
|
||||||
|
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
|
||||||
|
//
|
||||||
|
// 上传策略:
|
||||||
|
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
|
||||||
|
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
|
||||||
|
//
|
||||||
|
// 端点:
|
||||||
|
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
|
||||||
|
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
|
||||||
|
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
|
||||||
|
|
||||||
|
import aiService from './ai-service.js'
|
||||||
|
import Config from './config.js'
|
||||||
|
import store from '@/store/index.js'
|
||||||
|
|
||||||
|
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
|
||||||
|
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
|
||||||
|
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
|
||||||
|
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
|
||||||
|
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
|
||||||
|
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
|
||||||
|
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
|
||||||
|
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
|
||||||
|
const EXT_MIME_MAP = {
|
||||||
|
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
||||||
|
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
|
||||||
|
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
||||||
|
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||||
|
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
||||||
|
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
||||||
|
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
||||||
|
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
|
||||||
|
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
|
||||||
|
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||||
|
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
||||||
|
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
||||||
|
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
||||||
|
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
|
||||||
|
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
|
||||||
|
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
|
||||||
|
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
|
||||||
|
doc: 'application/msword',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
zip: 'application/zip', rar: 'application/x-rar-compressed',
|
||||||
|
'7z': 'application/x-7z-compressed',
|
||||||
|
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
|
||||||
|
pdf: 'application/pdf', txt: 'text/plain',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片 (type=image)
|
||||||
|
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
|
||||||
|
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
|
||||||
|
*/
|
||||||
|
export async function uploadImage(filePath, opts = {}) {
|
||||||
|
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
|
||||||
|
const config = await aiService.getServiceConfig()
|
||||||
|
const strategy = config?.upload_strategy || 'backend'
|
||||||
|
|
||||||
|
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
|
||||||
|
let compressedPath = filePath
|
||||||
|
let detectedSize = 0
|
||||||
|
try {
|
||||||
|
detectedSize = await getFileSize(filePath)
|
||||||
|
} catch (e) {
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
|
||||||
|
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
|
||||||
|
console.warn('[media-upload] getFileSize failed:', e)
|
||||||
|
}
|
||||||
|
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
|
||||||
|
// #ifdef H5 || MP-WEIXIN
|
||||||
|
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
|
||||||
|
try {
|
||||||
|
const compressed = await uni.compressImage({
|
||||||
|
src: filePath,
|
||||||
|
quality: 80,
|
||||||
|
compressedWidth: 2048,
|
||||||
|
compressedHeight: 2048,
|
||||||
|
})
|
||||||
|
compressedPath = compressed.tempFilePath || filePath
|
||||||
|
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[media-upload] compressImage failed, use original:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
|
||||||
|
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
|
||||||
|
// fileSizeFinal = 探测失败的兜底:
|
||||||
|
// - 第一次探测失败(原始大小未知) → 保守走 STS
|
||||||
|
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
|
||||||
|
let fileSizeFinal = detectedSize
|
||||||
|
if (compressedPath !== filePath) {
|
||||||
|
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
|
||||||
|
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
|
||||||
|
fileSizeFinal = detectedSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const useSts = strategy === 'oss_sts'
|
||||||
|
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
|
||||||
|
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
|
||||||
|
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
|
||||||
|
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
|
||||||
|
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
|
||||||
|
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
|
||||||
|
|
||||||
|
if (finalUseSts) {
|
||||||
|
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
|
||||||
|
} catch (e) {
|
||||||
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
|
||||||
|
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
|
||||||
|
if (e && e.code === 'OSS_STS_REQUIRED') {
|
||||||
|
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
||||||
|
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传 audio / video / file
|
||||||
|
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
|
||||||
|
* 未配云存储 (backend) → 直接 PHP 中转
|
||||||
|
* @param {string} filePath
|
||||||
|
* @param {'audio'|'video'|'file'} kind
|
||||||
|
* @param {{name?:string, mime?:string, size?:number}} [opts]
|
||||||
|
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
|
||||||
|
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
|
||||||
|
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
|
||||||
|
*/
|
||||||
|
export async function uploadMedia(filePath, kind, opts = {}) {
|
||||||
|
if (!['audio', 'video', 'file'].includes(kind)) {
|
||||||
|
throw new Error(`invalid kind: ${kind}`)
|
||||||
|
}
|
||||||
|
const addon = aiService.getAddonName()
|
||||||
|
const config = await aiService.getServiceConfig()
|
||||||
|
const agentId = aiService.getAgentId()
|
||||||
|
const hints = { name: opts.name || '', mime: opts.mime || '' }
|
||||||
|
const size = opts.size || 0
|
||||||
|
const strategy = (config && config.upload_strategy) || 'backend'
|
||||||
|
|
||||||
|
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
|
||||||
|
const doPhpUpload = async (fallbackErr) => {
|
||||||
|
let phpRes = null
|
||||||
|
try {
|
||||||
|
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
|
||||||
|
}
|
||||||
|
if (!phpRes || !phpRes.url) {
|
||||||
|
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
|
||||||
|
}
|
||||||
|
return Object.assign({}, phpRes, { kind })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
||||||
|
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
||||||
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
|
||||||
|
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
|
||||||
|
if (strategy === 'backend') {
|
||||||
|
try {
|
||||||
|
return await doPhpUpload()
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === 'OSS_STS_REQUIRED') {
|
||||||
|
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
||||||
|
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||||
|
} catch (e) {
|
||||||
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
|
||||||
|
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
|
||||||
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
|
||||||
|
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
|
||||||
|
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
|
||||||
|
try {
|
||||||
|
return await doPhpUpload(e)
|
||||||
|
} catch (e2) {
|
||||||
|
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
|
||||||
|
throw e2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP 中转 (multipart/form-data)
|
||||||
|
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
|
||||||
|
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
|
||||||
|
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
|
||||||
|
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
|
||||||
|
*/
|
||||||
|
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||||
|
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
|
||||||
|
+ (kind === 'image' ? 'image' : 'file')
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: Config.baseUrl + url,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: {
|
||||||
|
kind,
|
||||||
|
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
||||||
|
agent_id: agentId || undefined,
|
||||||
|
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||||
|
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||||
|
uniacid: aiService.getUniacid(),
|
||||||
|
user_id: aiService.getUserId?.() || '',
|
||||||
|
},
|
||||||
|
success: (res) => {
|
||||||
|
try {
|
||||||
|
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||||
|
if (data.code === 0 || data.success) {
|
||||||
|
resolve({
|
||||||
|
url: data.data.url,
|
||||||
|
mime: data.data.mime,
|
||||||
|
size: data.data.size || knownSize || 0,
|
||||||
|
name: data.data.name,
|
||||||
|
kind: data.data.kind || kind,
|
||||||
|
used_strategy: data.data.used_strategy || 'backend',
|
||||||
|
oss_sts_required: data.data.oss_sts_required || false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
|
||||||
|
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
|
||||||
|
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
||||||
|
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('解析响应失败: ' + e.message))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
const form = new FormData()
|
||||||
|
const file = await pathToFile(filePath, kind)
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('kind', kind)
|
||||||
|
if (addon === 'aiagent') form.append('agent_id', agentId)
|
||||||
|
else {
|
||||||
|
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
||||||
|
const uuid = aiService.getCurrentConfigUuid?.() || ''
|
||||||
|
form.append('config_uuid', uuid)
|
||||||
|
form.append('config_uid', uuid)
|
||||||
|
form.append('agent_id', agentId)
|
||||||
|
}
|
||||||
|
form.append('uniacid', aiService.getUniacid())
|
||||||
|
form.append('user_id', aiService.getUserId?.() || '')
|
||||||
|
const res = await fetch(Config.baseUrl + url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'token': store.state.token || '' },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0 || data.success) {
|
||||||
|
return {
|
||||||
|
url: data.data.url,
|
||||||
|
mime: data.data.mime,
|
||||||
|
size: data.data.size || knownSize || 0,
|
||||||
|
name: data.data.name,
|
||||||
|
kind: data.data.kind || kind,
|
||||||
|
used_strategy: data.data.used_strategy || 'backend',
|
||||||
|
oss_sts_required: data.data.oss_sts_required || false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
|
||||||
|
if (data.oss_sts_required === true) {
|
||||||
|
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
||||||
|
err.code = 'OSS_STS_REQUIRED'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
throw new Error(data.msg || data.message || '上传失败')
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STS 直传 (拿临时凭证 → 直传云存储)
|
||||||
|
*
|
||||||
|
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
|
||||||
|
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
||||||
|
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
|
||||||
|
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
|
||||||
|
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
|
||||||
|
* 标记, 不带 Authorization
|
||||||
|
*
|
||||||
|
* 后端 stsResp.data.data 可能字段:
|
||||||
|
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
|
||||||
|
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
|
||||||
|
* 1) filePath 最后一段正则匹配 .xxx
|
||||||
|
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
|
||||||
|
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
|
||||||
|
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
|
||||||
|
*
|
||||||
|
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
||||||
|
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
||||||
|
*/
|
||||||
|
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
||||||
|
let ext = 'bin'
|
||||||
|
if (typeof filePath === 'string') {
|
||||||
|
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
||||||
|
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
|
||||||
|
if (m) ext = m[1].toLowerCase()
|
||||||
|
}
|
||||||
|
if (ext === 'bin' && filePath && filePath.name) {
|
||||||
|
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
||||||
|
if (m2) ext = m2[1].toLowerCase()
|
||||||
|
}
|
||||||
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
|
||||||
|
if (ext === 'bin' && nameHint) {
|
||||||
|
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
|
||||||
|
if (m3) ext = m3[1].toLowerCase()
|
||||||
|
}
|
||||||
|
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
|
||||||
|
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
|
||||||
|
if (ext === 'bin' && mimeHint) {
|
||||||
|
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
|
||||||
|
if (m === mimeHint) { ext = e; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
|
||||||
|
*
|
||||||
|
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
|
||||||
|
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
|
||||||
|
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
|
||||||
|
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
|
||||||
|
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
|
||||||
|
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
|
||||||
|
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
|
||||||
|
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
|
||||||
|
*
|
||||||
|
* 端点: POST /<addon>/api/upload/ossPresign
|
||||||
|
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
|
||||||
|
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
|
||||||
|
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
|
||||||
|
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
|
||||||
|
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
|
||||||
|
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
|
||||||
|
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
|
||||||
|
*/
|
||||||
|
function uniRequest(opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request(Object.assign({}, opts, {
|
||||||
|
success: resolve,
|
||||||
|
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
|
||||||
|
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
|
||||||
|
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
|
||||||
|
let mimeHint = hints.mime || ''
|
||||||
|
let nameHint = hints.name || ''
|
||||||
|
let probeFile = null
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
probeFile = await pathToFile(filePath, kind)
|
||||||
|
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
|
||||||
|
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
|
||||||
|
} catch (e) {
|
||||||
|
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
|
||||||
|
console.warn('[media-upload] presign probe failed:', e && e.message)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
const ext = extractExt(filePath, mimeHint, nameHint)
|
||||||
|
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
|
||||||
|
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
|
||||||
|
const contentType = mimeHint || guessMime(finalExt)
|
||||||
|
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
|
||||||
|
? nameHint
|
||||||
|
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
|
||||||
|
|
||||||
|
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
|
||||||
|
const preResp = await uniRequest({
|
||||||
|
url: Config.baseUrl + preUrl,
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'token': store.state.token || ''
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
filename,
|
||||||
|
content_type: contentType,
|
||||||
|
uniacid: aiService.getUniacid(),
|
||||||
|
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
|
||||||
|
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (preResp.statusCode === 404) {
|
||||||
|
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
|
||||||
|
err.code = 'PRESIGN_UNSUPPORTED'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
|
||||||
|
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
|
||||||
|
}
|
||||||
|
const pre = preResp.data.data
|
||||||
|
const uploadUrl = pre.upload_url
|
||||||
|
const finalContentType = pre.content_type || contentType
|
||||||
|
|
||||||
|
let uploadedSize = knownSize || 0
|
||||||
|
// #ifdef H5
|
||||||
|
if (probeFile && probeFile.size) uploadedSize = probeFile.size
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
|
||||||
|
// #ifdef H5
|
||||||
|
const file = probeFile || await pathToFile(filePath, kind)
|
||||||
|
const ossRes = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': finalContentType },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
|
||||||
|
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
uni.getFileSystemManager().readFile({
|
||||||
|
filePath,
|
||||||
|
success: (res) => {
|
||||||
|
uni.request({
|
||||||
|
url: uploadUrl,
|
||||||
|
method: 'PUT',
|
||||||
|
header: { 'Content-Type': finalContentType },
|
||||||
|
data: res.data,
|
||||||
|
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
|
||||||
|
? resolve()
|
||||||
|
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: (pre.public_url || uploadUrl.split('?')[0]),
|
||||||
|
mime: finalContentType,
|
||||||
|
size: uploadedSize,
|
||||||
|
name: (pre.object_key || filename).split('/').pop(),
|
||||||
|
kind,
|
||||||
|
used_strategy: 'oss_sts',
|
||||||
|
provider: pre.provider || 'alioss',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
|
||||||
|
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
|
||||||
|
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
|
||||||
|
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
|
||||||
|
const providerHint = (config && config.oss_provider) || ''
|
||||||
|
if (!providerHint || providerHint === 'alioss') {
|
||||||
|
try {
|
||||||
|
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
|
||||||
|
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
|
||||||
|
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
|
||||||
|
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
|
||||||
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
|
||||||
|
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
|
||||||
|
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
|
||||||
|
|
||||||
|
// 1. 拿 STS 凭证 (addon 模式 URL)
|
||||||
|
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
||||||
|
const stsResp = await uniRequest({
|
||||||
|
url: Config.baseUrl + stsUrl,
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'token': store.state.token || ''
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
agent_id: agentId || undefined,
|
||||||
|
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||||
|
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||||
|
uniacid: aiService.getUniacid(),
|
||||||
|
user_id: aiService.getUserId?.() || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
|
||||||
|
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
|
||||||
|
}
|
||||||
|
const sts = stsResp.data.data
|
||||||
|
const provider = sts.provider || 'alioss' // 老后端默认 alioss
|
||||||
|
|
||||||
|
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
|
||||||
|
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0
|
||||||
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
|
||||||
|
.replace('{site_id}', sts.site_id || '0')
|
||||||
|
.replace('{agent_id}', agentId || '0')
|
||||||
|
.replace('{yyyy}', now.getFullYear().toString())
|
||||||
|
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
|
||||||
|
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
|
||||||
|
.replace('{rand}', uuid)
|
||||||
|
+ '.' + ext
|
||||||
|
|
||||||
|
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
|
||||||
|
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
|
||||||
|
const isSingleUrlMode = typeof sts.securityToken === 'string'
|
||||||
|
&& /-(SIGNED)$/.test(sts.securityToken)
|
||||||
|
|
||||||
|
let ossUrl, headers
|
||||||
|
if (isSingleUrlMode) {
|
||||||
|
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
|
||||||
|
ossUrl = sts.accessKeyId
|
||||||
|
headers = { 'Content-Type': guessMime(ext) }
|
||||||
|
} else if (provider === 'qiniu') {
|
||||||
|
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
|
||||||
|
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
|
||||||
|
headers = {
|
||||||
|
'Authorization': 'UpToken ' + sts.accessKeyId,
|
||||||
|
'Content-Type': guessMime(ext),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
||||||
|
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
|
||||||
|
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
|
||||||
|
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
|
||||||
|
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
|
||||||
|
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
|
||||||
|
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
|
||||||
|
const aliossTpl = sts.oss_url
|
||||||
|
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
|
||||||
|
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
|
||||||
|
const tpl = (provider === 'alioss') ? aliossTpl
|
||||||
|
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
|
||||||
|
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
|
||||||
|
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
|
||||||
|
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
|
||||||
|
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
|
||||||
|
ossUrl = `${tpl}/${key}`
|
||||||
|
headers = {
|
||||||
|
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
|
||||||
|
'x-oss-security-token': sts.securityToken,
|
||||||
|
'Content-Type': guessMime(ext),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
|
||||||
|
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
|
||||||
|
let uploadedSize = knownSize || 0
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
const probe = await pathToFile(filePath, kind)
|
||||||
|
if (probe && probe.size) uploadedSize = probe.size
|
||||||
|
} catch (e) {}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 3. 直传云存储
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: ossUrl,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: {},
|
||||||
|
header: headers,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
|
||||||
|
else reject(new Error(`${provider} PUT ${res.statusCode}`))
|
||||||
|
},
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
const file = await pathToFile(filePath, kind)
|
||||||
|
const ossRes = await fetch(ossUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 4. 返回公网 URL
|
||||||
|
return {
|
||||||
|
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
|
||||||
|
mime: guessMime(ext),
|
||||||
|
size: uploadedSize,
|
||||||
|
name: key.split('/').pop(),
|
||||||
|
kind,
|
||||||
|
used_strategy: 'oss_sts',
|
||||||
|
provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFileSize(filePath) {
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
|
||||||
|
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
|
||||||
|
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.getFileInfo({
|
||||||
|
filePath,
|
||||||
|
success: (res) => resolve(res.size || 0),
|
||||||
|
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
const res = await fetch(filePath)
|
||||||
|
const blob = await res.blob()
|
||||||
|
return blob.size
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('getFileSize fetch failed: ' + e.message)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathToFile(filePath, kind) {
|
||||||
|
// #ifdef H5
|
||||||
|
const res = await fetch(filePath)
|
||||||
|
const blob = await res.blob()
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
|
||||||
|
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
|
||||||
|
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
|
||||||
|
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
|
||||||
|
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
|
||||||
|
const rawName = filePath.split('/').pop() || `${kind}.bin`
|
||||||
|
const ext = extractExt(filePath, blob.type)
|
||||||
|
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
|
||||||
|
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
|
||||||
|
const mime = blob.type || guessMime(ext)
|
||||||
|
return new File([blob], finalName, { type: mime })
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
|
||||||
|
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
|
||||||
|
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
|
||||||
|
* 而不是路径 ②/③ (URL fetch / resource_link).
|
||||||
|
*
|
||||||
|
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
|
||||||
|
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
|
||||||
|
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
|
||||||
|
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
|
||||||
|
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
|
||||||
|
*
|
||||||
|
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
|
||||||
|
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
|
||||||
|
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
|
||||||
|
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
|
||||||
|
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
|
||||||
|
*
|
||||||
|
* 返回: { data: '<bare-base64>', size, mime } | null
|
||||||
|
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
|
||||||
|
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
|
||||||
|
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
|
||||||
|
*
|
||||||
|
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
|
||||||
|
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
|
||||||
|
*/
|
||||||
|
export async function fileToBase64(filePath, opts = {}) {
|
||||||
|
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
|
||||||
|
if (!filePath) return null
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
const res = await fetch(filePath)
|
||||||
|
if (!res.ok) throw new Error('fetch failed: ' + res.status)
|
||||||
|
const blob = await res.blob()
|
||||||
|
if (blob.size > maxBytes) {
|
||||||
|
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const dataUri = await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result)
|
||||||
|
reader.onerror = () => reject(new Error('FileReader failed'))
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
|
||||||
|
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
|
||||||
|
return { data: m[2], size: blob.size, mime: m[1] }
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[media-upload] fileToBase64 H5 失败:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN || APP-PLUS
|
||||||
|
try {
|
||||||
|
const fsm = uni.getFileSystemManager()
|
||||||
|
// 1. 探测大小, 超限直接返回 null
|
||||||
|
let size = 0
|
||||||
|
try {
|
||||||
|
const stat = fsm.statSync(filePath)
|
||||||
|
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
|
||||||
|
size = (stat && (stat.size || stat.fileSize)) || 0
|
||||||
|
} catch (e) {
|
||||||
|
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
|
||||||
|
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
|
||||||
|
}
|
||||||
|
if (size > maxBytes) {
|
||||||
|
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
|
||||||
|
const data = fsm.readFileSync(filePath, 'base64')
|
||||||
|
return { data, size: size || 0, mime: opts.mimeHint || '' }
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessMime(ext) {
|
||||||
|
return EXT_MIME_MAP[ext] || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { uploadImage, uploadMedia }
|
||||||
@@ -147,20 +147,13 @@ function getQuickAppPrivacySetting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 鸿蒙元服务: 查询平台级隐私设置
|
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
|
||||||
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
|
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
|
||||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
* @returns {Promise<{needAuthorization: boolean}>}
|
||||||
*/
|
*/
|
||||||
function getHarmonyPrivacySetting() {
|
function getHarmonyPrivacySetting() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
|
|
||||||
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
|
|
||||||
resolve({ needAuthorization: !getStorageAgreed() });
|
resolve({ needAuthorization: !getStorageAgreed() });
|
||||||
// #endif
|
|
||||||
// #ifndef MP-HARMONY
|
|
||||||
resolve({ needAuthorization: !getStorageAgreed() });
|
|
||||||
// #endif
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +181,7 @@ async function checkPrivacy() {
|
|||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
|
||||||
// H5 / 其他平台: 仅查 storage
|
// H5 / 其他平台: 仅查 storage
|
||||||
return checkPrivacySync();
|
return checkPrivacySync();
|
||||||
}
|
}
|
||||||
@@ -730,8 +723,9 @@ const PrivacyManager = {
|
|||||||
|
|
||||||
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
||||||
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
||||||
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
|
// 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
|
||||||
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
||||||
|
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
|
||||||
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
||||||
|
|
||||||
_needShow = result.needAuthorization;
|
_needShow = result.needAuthorization;
|
||||||
@@ -741,7 +735,18 @@ const PrivacyManager = {
|
|||||||
return;
|
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 的页面组件 mounted 时通过 consumePendingShow 补偿
|
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
|
||||||
_pendingShow = true;
|
_pendingShow = true;
|
||||||
@@ -757,18 +762,28 @@ const PrivacyManager = {
|
|||||||
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
||||||
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
||||||
*
|
*
|
||||||
* 鸿蒙元服务兼容:
|
* 历史兼容:
|
||||||
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
||||||
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
||||||
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
||||||
|
*
|
||||||
|
* 鸿蒙元服务(双轨制):
|
||||||
|
* 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
||||||
|
* 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
|
||||||
|
* 鸿蒙系统级弹窗叠加出现"多次弹出"。
|
||||||
* @returns {boolean} true=需要弹窗,false=不需要
|
* @returns {boolean} true=需要弹窗,false=不需要
|
||||||
*/
|
*/
|
||||||
consumePendingShow() {
|
consumePendingShow() {
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
// 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
|
||||||
|
return false;
|
||||||
|
// #endif
|
||||||
|
|
||||||
if (_pendingShow && _needShow) {
|
if (_pendingShow && _needShow) {
|
||||||
_pendingShow = false;
|
_pendingShow = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
// 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||||||
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
||||||
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
||||||
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
||||||
|
|||||||
@@ -33,7 +33,16 @@ function useNativeLoading() {
|
|||||||
// #endif
|
// #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 MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
|
||||||
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
|
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
|
||||||
|
|
||||||
@@ -71,6 +80,111 @@ class SplashState {
|
|||||||
// 避免多通道并发时, splash 组件 re-render 风暴
|
// 避免多通道并发时, splash 组件 re-render 风暴
|
||||||
this._pendingProgress = null; // 待 emit 的最新进度
|
this._pendingProgress = null; // 待 emit 的最新进度
|
||||||
this._flushTimer = null; // 节流定时器
|
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 = {}) {
|
show(opts = {}) {
|
||||||
@@ -111,7 +225,34 @@ class SplashState {
|
|||||||
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
|
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) {
|
if (this._timeoutId) {
|
||||||
clearTimeout(this._timeoutId);
|
clearTimeout(this._timeoutId);
|
||||||
this._timeoutId = null;
|
this._timeoutId = null;
|
||||||
@@ -125,6 +266,55 @@ class SplashState {
|
|||||||
}, timeout);
|
}, 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
|
* 隐藏 splash
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
@@ -136,15 +326,22 @@ class SplashState {
|
|||||||
* @param {string} [opts.mode] - 离开动画模式
|
* @param {string} [opts.mode] - 离开动画模式
|
||||||
* - 'default': 默认 480ms 动画
|
* - 'default': 默认 480ms 动画
|
||||||
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
||||||
|
* - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
|
||||||
|
* 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
|
||||||
|
* 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
|
||||||
|
* splash 必须真等到首页 diy 内容渲染好才能关
|
||||||
*/
|
*/
|
||||||
hide(opts = {}) {
|
hide(opts = {}) {
|
||||||
const reason = opts.reason || 'manual';
|
const reason = opts.reason || 'manual';
|
||||||
|
const useStrict = opts.mode === 'strict' || opts._strict === true;
|
||||||
// boot-complete 默认走软隐藏, 避免时序问题
|
// boot-complete 默认走软隐藏, 避免时序问题
|
||||||
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||||
const mode = opts.mode === 'fast' ? 'fast' : 'default';
|
// strict 模式复用 fast 动画
|
||||||
|
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
|
||||||
|
|
||||||
if (useSoft) {
|
if (useSoft) {
|
||||||
this._softHide({ reason, mode });
|
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
|
||||||
|
this._softHide(opts);
|
||||||
} else {
|
} else {
|
||||||
this._doHide({ reason, mode });
|
this._doHide({ reason, mode });
|
||||||
}
|
}
|
||||||
@@ -154,10 +351,17 @@ class SplashState {
|
|||||||
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
|
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
|
||||||
* 如果组件 1s 内没 mount, 兜底自动 hide
|
* 如果组件 1s 内没 mount, 兜底自动 hide
|
||||||
* 如果页面 2s 内没就绪, 兜底自动 hide
|
* 如果页面 2s 内没就绪, 兜底自动 hide
|
||||||
|
*
|
||||||
|
* strict 模式 (鸿蒙已接入平台托管):
|
||||||
|
* 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
|
||||||
|
* 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
|
||||||
|
* 保证 splash 一定等到首页 diy 内容真的渲染好
|
||||||
*/
|
*/
|
||||||
_softHide(opts) {
|
_softHide(opts) {
|
||||||
if (!this._visible) return; // 已经隐藏了
|
if (!this._visible) return; // 已经隐藏了
|
||||||
this._pendingHide = opts;
|
this._pendingHide = opts;
|
||||||
|
// strict 模式识别: mode='strict' 或 _strict=true
|
||||||
|
const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
|
||||||
|
|
||||||
// 如果组件已经 mount 且页面已就绪, 立即隐藏
|
// 如果组件已经 mount 且页面已就绪, 立即隐藏
|
||||||
if (this._hasMounted && this._pageReady) {
|
if (this._hasMounted && this._pageReady) {
|
||||||
@@ -165,6 +369,12 @@ class SplashState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
|
||||||
|
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
|
||||||
|
if (useStrict) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 兜底: 1s 内没 mount, 强制隐藏
|
// 兜底: 1s 内没 mount, 强制隐藏
|
||||||
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
||||||
this._mountGraceId = setTimeout(() => {
|
this._mountGraceId = setTimeout(() => {
|
||||||
@@ -177,15 +387,27 @@ class SplashState {
|
|||||||
/**
|
/**
|
||||||
* 真正触发软隐藏
|
* 真正触发软隐藏
|
||||||
* 如果页面还未就绪, 等待页面就绪后再 hide
|
* 如果页面还未就绪, 等待页面就绪后再 hide
|
||||||
|
* strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
|
||||||
*/
|
*/
|
||||||
_flushPendingHide() {
|
_flushPendingHide() {
|
||||||
if (this._mountGraceId) {
|
if (this._mountGraceId) {
|
||||||
clearTimeout(this._mountGraceId);
|
clearTimeout(this._mountGraceId);
|
||||||
this._mountGraceId = null;
|
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, 等页面就绪后再处理)
|
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
|
||||||
if (!this._pageReady) {
|
if (!this._pageReady) {
|
||||||
|
// strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
|
||||||
|
// 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
|
||||||
|
if (useStrict) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 兜底: 2s 内没就绪, 强制隐藏
|
// 兜底: 2s 内没就绪, 强制隐藏
|
||||||
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
|
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
|
||||||
this._pageReadyGraceId = setTimeout(() => {
|
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) {
|
_doHide(opts) {
|
||||||
if (!this._visible) return; // 防重入
|
if (!this._visible) return; // 防重入
|
||||||
@@ -225,6 +452,8 @@ class SplashState {
|
|||||||
if (useNativeLoading()) {
|
if (useNativeLoading()) {
|
||||||
try { uni.hideLoading(); } catch (e) {}
|
try { uni.hideLoading(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
// _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide
|
||||||
|
if (opts._silent) return;
|
||||||
this._emit('splash:hide', {
|
this._emit('splash:hide', {
|
||||||
reason: opts.reason || 'manual',
|
reason: opts.reason || 'manual',
|
||||||
mode: opts.mode || 'default',
|
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 状态管理)
|
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
|
||||||
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
|
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
|
||||||
|
|||||||
30
common/js/startup-ad.js
Normal file
30
common/js/startup-ad.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// 启动广告弹出信号
|
||||||
|
// 用于判定"当前是否需要弹出启动广告":
|
||||||
|
// - 小程序冷启动(首次进入): 由模块级 _coldLaunchedShown 标记判定, 整个 app 进程只记一次
|
||||||
|
// - 小程序重新进入(从后台切回 / 微信"重新进入小程序"菜单):
|
||||||
|
// 由 App.onShow 在"非首次展示"时调用 markNeedStartupAd() 置位
|
||||||
|
// 信号消费一次即清除, 不落地到持久化存储, 因此无需清缓存即可每次重新进入都弹出。
|
||||||
|
// 关键: 切换 Tab 回到首页只触发"页面级 onShow", 不会触发 App.onShow, 也不会重置
|
||||||
|
// 模块级冷启动标记, 因此不会在 Tab 切换时误弹。
|
||||||
|
|
||||||
|
let _needStartupAd = false;
|
||||||
|
let _coldLaunchedShown = false;
|
||||||
|
|
||||||
|
// App 层检测到"重新进入小程序"时调用, 标记本次需要弹出启动广告
|
||||||
|
export function markNeedStartupAd() {
|
||||||
|
_needStartupAd = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面层读取并消费该信号(消费后自动复位)
|
||||||
|
export function consumeNeedStartupAd() {
|
||||||
|
const v = _needStartupAd;
|
||||||
|
_needStartupAd = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否为本 app 进程的首次冷启动(只返回一次 true, 之后恒为 false)
|
||||||
|
export function isColdLaunchPending() {
|
||||||
|
if (_coldLaunchedShown) return false;
|
||||||
|
_coldLaunchedShown = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -62,7 +62,13 @@ export default {
|
|||||||
},
|
},
|
||||||
...{
|
...{
|
||||||
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
||||||
hover_nav_text_color: '#000' // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
hover_nav_text_color: '#000', // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
||||||
|
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
||||||
|
// ===== 逐按钮字号(与 hover_nav_lang/kefu/phone 的 bg/text/size 同约定, 基准 36rpx) =====
|
||||||
|
// 未单独配置时回退到上面的 hover_nav_font_size(全局旋钮)
|
||||||
|
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
||||||
|
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
||||||
|
hover_nav_phone_font_size: '36rpx' // 电话按钮内图标字号基准
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
||||||
@@ -71,6 +77,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -264,6 +274,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -456,6 +470,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -648,6 +666,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -840,6 +862,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -1032,6 +1058,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -1224,6 +1254,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -1416,6 +1450,10 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
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_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
|
|||||||
@@ -102,6 +102,36 @@ export function parsePageParams(raw, defaultValue = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
|
||||||
|
*
|
||||||
|
* 设计动机:URL query 中布尔值的合法写法不止一种——
|
||||||
|
* ?show_home=1 -> '1' -> true
|
||||||
|
* ?show_home=true -> 'true' -> true
|
||||||
|
* ?show_home=true -> true -> true
|
||||||
|
* ?show_home=0 -> '0' -> false
|
||||||
|
* ?show_home=false -> 'false' -> false
|
||||||
|
* ?show_home= -> '' -> false
|
||||||
|
* 未传 -> undefined -> false
|
||||||
|
*
|
||||||
|
* 与裸 `Boolean(x)` 的差异:
|
||||||
|
* Boolean('false') === true (任何非空字符串都是 true)
|
||||||
|
* Boolean('0') === true (同上)
|
||||||
|
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
|
||||||
|
*
|
||||||
|
* @param {*} val 任意值
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function coerceBool(val) {
|
||||||
|
if (val === true || val === 1) return true;
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
const s = val.trim().toLowerCase();
|
||||||
|
return s === '1' || s === 'true';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 关于页面路径,使用常量导出
|
// 关于页面路径,使用常量导出
|
||||||
export const INDEX_PAGE_URL = '/pages/index/index';
|
export const INDEX_PAGE_URL = '/pages/index/index';
|
||||||
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
||||||
@@ -109,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
|
|||||||
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
|
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
|
||||||
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
|
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
|
||||||
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
|
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
|
||||||
|
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
|
||||||
|
|
||||||
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
||||||
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
||||||
@@ -169,6 +200,16 @@ export const PAGE_KEY_MAP = {
|
|||||||
// === 电子名片 ===
|
// === 电子名片 ===
|
||||||
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
||||||
|
|
||||||
|
// === 名片云(card 插件) ===
|
||||||
|
PAGE_CARD_LIST: '/pages_tool/card/list',
|
||||||
|
PAGE_CARD_LEADS: '/pages_tool/card/leads',
|
||||||
|
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
|
||||||
|
|
||||||
|
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
|
||||||
|
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
||||||
|
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
|
||||||
|
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads',
|
||||||
|
|
||||||
// === VR ===
|
// === VR ===
|
||||||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||||||
|
|
||||||
@@ -457,6 +498,9 @@ export default {
|
|||||||
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
||||||
parsePageParams,
|
parsePageParams,
|
||||||
|
|
||||||
|
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
|
||||||
|
coerceBool,
|
||||||
|
|
||||||
/** HTML 实体解码 */
|
/** HTML 实体解码 */
|
||||||
decodeEntity,
|
decodeEntity,
|
||||||
|
|
||||||
@@ -511,6 +555,27 @@ export default {
|
|||||||
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
||||||
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
||||||
//
|
//
|
||||||
|
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
|
||||||
|
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
|
||||||
|
// 修复场景:
|
||||||
|
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
|
||||||
|
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
|
||||||
|
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
|
||||||
|
// onShow 死循环跳登录页.
|
||||||
|
// 排除场景 (重要, 避免影响):
|
||||||
|
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
|
||||||
|
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
|
||||||
|
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
|
||||||
|
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
|
||||||
|
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
|
||||||
|
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
|
||||||
|
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
|
||||||
|
if (cleanPath === LOGIN_PAGE_URL) {
|
||||||
|
mode = 'reLaunch';
|
||||||
|
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
||||||
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
||||||
|
|
||||||
@@ -615,6 +680,8 @@ export default {
|
|||||||
const diyTabBar = store.state.tabBarList;
|
const diyTabBar = store.state.tabBarList;
|
||||||
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
|
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
|
||||||
const cleanUrl = url.split('?')[0];
|
const cleanUrl = url.split('?')[0];
|
||||||
|
const isExcludedPage = NO_TAB_PAGES.has(cleanUrl);
|
||||||
|
if (!isExcludedPage) {
|
||||||
const isDiyTabRoot = diyTabBar.list.some(item => {
|
const isDiyTabRoot = diyTabBar.list.some(item => {
|
||||||
if (!item || !item.link || !item.link.wap_url) return false;
|
if (!item || !item.link || !item.link.wap_url) return false;
|
||||||
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
|
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
|
||||||
@@ -641,6 +708,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`====>mode=${mode}, url=${url}`);
|
console.log(`====>mode=${mode}, url=${url}`);
|
||||||
|
|
||||||
@@ -669,6 +737,41 @@ export default {
|
|||||||
break;
|
break;
|
||||||
case 'reLaunch':
|
case 'reLaunch':
|
||||||
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
||||||
|
// H5 平台修复 (2026-07-23):
|
||||||
|
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
|
||||||
|
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
|
||||||
|
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
|
||||||
|
// 注销/退出登录后用户能看到旧会员信息。
|
||||||
|
// location.replace 不产生新历史条目, 直接替换当前条目,
|
||||||
|
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
|
||||||
|
// #ifdef H5
|
||||||
|
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
|
||||||
|
&& typeof window.location.replace === 'function') {
|
||||||
|
window.location.replace(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
// 小程序平台修复 (2026-07-23):
|
||||||
|
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
|
||||||
|
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
|
||||||
|
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
|
||||||
|
// 解决方案:
|
||||||
|
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
|
||||||
|
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
|
||||||
|
// #ifdef MP
|
||||||
|
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
|
||||||
|
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
globalThis.__reLaunchLock = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({ url });
|
||||||
|
// 300ms 后释放锁
|
||||||
|
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
// #endif
|
||||||
|
// APP / 其他平台: 原生 reLaunch
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url
|
url
|
||||||
});
|
});
|
||||||
@@ -1111,6 +1214,23 @@ export default {
|
|||||||
* @param {Object} link
|
* @param {Object} link
|
||||||
*/
|
*/
|
||||||
diyRedirectTo(link) {
|
diyRedirectTo(link) {
|
||||||
|
|
||||||
|
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
|
||||||
|
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
|
||||||
|
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
|
||||||
|
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
|
||||||
|
const title = link.title || 'AI 智能体';
|
||||||
|
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
|
||||||
|
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
|
||||||
|
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
|
||||||
|
return this.redirectTo(AI_AGENT_PAGE_URL, {
|
||||||
|
agent_id: agentId,
|
||||||
|
title: encodeURIComponent(title),
|
||||||
|
enable_set_title: link.enable_set_title ? 1 : 0,
|
||||||
|
...(dyuEntry ? { __dyu: dyuEntry } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
|
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
|
||||||
//if (link == null || Object.keys(link).length == 1) return;
|
//if (link == null || Object.keys(link).length == 1) return;
|
||||||
|
|
||||||
@@ -1144,6 +1264,21 @@ export default {
|
|||||||
link.wap_url = resolvedUrl;
|
link.wap_url = resolvedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 仅微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页 =====
|
||||||
|
// 命中: page_key=PAGE_FILES_DETAIL / name=FILES_xx / wap_url 指向 files/detail
|
||||||
|
// 且能提取到 files_id。拉取或打开失败时自动降级回原详情页(openDiyFileDirect 内部处理)。
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
if (link && (link.page_key === 'PAGE_FILES_DETAIL'
|
||||||
|
|| (link.name && /^FILES_\d+$/i.test(String(link.name)))
|
||||||
|
|| String(link.wap_url || '').indexOf('files/detail') !== -1)) {
|
||||||
|
const filesId = this._extractFilesIdFromLink(link);
|
||||||
|
if (filesId) {
|
||||||
|
this.openDiyFileDirect(link, filesId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
||||||
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
||||||
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
||||||
@@ -1317,6 +1452,123 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 从装修"文件"链接中提取 files_id(仅微信小程序直开链路使用)。
|
||||||
|
* 兼容三种位置: 顶层字段(files_id/file_id/id) / page_params(JSON字符串或对象) / wap_url query。
|
||||||
|
* @returns {String} 拿不到返回 ''
|
||||||
|
*/
|
||||||
|
_extractFilesIdFromLink(link) {
|
||||||
|
if (!link) return '';
|
||||||
|
let id = link.files_id || link.file_id || link.id || '';
|
||||||
|
if (!id) {
|
||||||
|
const params = _parsePageParams(link.page_params);
|
||||||
|
id = params.files_id || params.file_id || params.id || '';
|
||||||
|
}
|
||||||
|
if (!id) {
|
||||||
|
const url = String(link.wap_url || '');
|
||||||
|
const qIdx = url.indexOf('?');
|
||||||
|
if (qIdx !== -1) {
|
||||||
|
url.slice(qIdx + 1).split('&').forEach(kv => {
|
||||||
|
if (id) return;
|
||||||
|
const eq = kv.indexOf('=');
|
||||||
|
const k = eq === -1 ? kv : kv.slice(0, eq);
|
||||||
|
if (k === 'files_id' || k === 'file_id' || k === 'id') {
|
||||||
|
id = eq === -1 ? '' : kv.slice(eq + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id === '' || id === undefined || id === null ? '' : String(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页。
|
||||||
|
* 链路: /api/files/getfile(files_url 已由后端 img() 补全域名)
|
||||||
|
* → downloadFile → saveFile 用文件名重命名 → openDocument(原生 PDF 阅读器)
|
||||||
|
* 任一环节失败 → 降级跳原详情页(files/detail), 保持旧体验兜底。
|
||||||
|
* @param {Object} link 装修链接对象(用于取标题)
|
||||||
|
* @param {String} filesId 文件 id
|
||||||
|
*/
|
||||||
|
openDiyFileDirect(link, filesId) {
|
||||||
|
const fallback = () => {
|
||||||
|
this.redirectTo(PAGE_KEY_MAP.PAGE_FILES_DETAIL, {
|
||||||
|
files_id: filesId,
|
||||||
|
title: link.title || '',
|
||||||
|
enable_set_title: link.enable_set_title ? 1 : 0,
|
||||||
|
page_params: Object.assign({}, _parsePageParams(link.page_params), { files_id: filesId })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const safeTitle = String(link.title || '').replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||||
|
Http.sendRequest({
|
||||||
|
url: '/api/files/getfile',
|
||||||
|
data: { files_id: filesId },
|
||||||
|
noRetry: true,
|
||||||
|
silentError: true,
|
||||||
|
success: res => {
|
||||||
|
if (!res || res.code !== 0 || !res.data || !res.data.files_url) {
|
||||||
|
fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = String(res.data.files_url);
|
||||||
|
// 相对路径补全域名($util.img 对含逗号路径会按多图截断, 规避)
|
||||||
|
if (!/^https?:\/\//i.test(url) && url.indexOf(',') === -1) {
|
||||||
|
url = this.img(url);
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawTitle = String(res.data.files_title || safeTitle || 'file');
|
||||||
|
const title = rawTitle.replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||||
|
const matched = url.split('?')[0].match(/\.([a-zA-Z0-9]+)$/);
|
||||||
|
const ext = matched ? matched[1].toLowerCase() : 'pdf';
|
||||||
|
|
||||||
|
uni.showLoading({ title: Lang.lang('files.list.loading') || 'Loading', mask: true });
|
||||||
|
uni.downloadFile({
|
||||||
|
url,
|
||||||
|
success: dres => {
|
||||||
|
if (dres.statusCode !== 200) {
|
||||||
|
uni.hideLoading();
|
||||||
|
fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fs = uni.getFileSystemManager();
|
||||||
|
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||||
|
fs.saveFile({
|
||||||
|
tempFilePath: dres.tempFilePath,
|
||||||
|
filePath: targetPath,
|
||||||
|
success: r => {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: r.savedFilePath,
|
||||||
|
fileType: ext,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => fallback(),
|
||||||
|
complete: () => {
|
||||||
|
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// saveFile 失败(配额等), 直接用临时文件打开兜底
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: dres.tempFilePath,
|
||||||
|
fileType: ext,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => fallback()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
fallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => fallback()
|
||||||
|
});
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* 获取默认图
|
* 获取默认图
|
||||||
*/
|
*/
|
||||||
@@ -1534,6 +1786,13 @@ export default {
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取小程序分享内容数据
|
* 获取小程序分享内容数据
|
||||||
|
*
|
||||||
|
* 重要:本函数保证永远 resolve(绝不 reject)。
|
||||||
|
* 原因:外部 8 处调用(category.vue / diy.js / promote_code.vue / contact.vue 等)
|
||||||
|
* 全部只写了 .then(res => {}) 没有 .catch(),一旦 reject 就会变成 Unhandled Promise Rejection,
|
||||||
|
* 微信小程序框架会把它包成 Error: MiniProgramError 并以红框形式打印到控制台,污染日志且易被
|
||||||
|
* 误判为业务崩溃。后台未配置某路径的分享(data:null)、接口返回 code<0、甚至 HTTP 网络失败,
|
||||||
|
* 全部退化为"从传入 path 构造最小分享对象",保证调用方始终能拿到可消费的结构。
|
||||||
*/
|
*/
|
||||||
getMpShare(path) {
|
getMpShare(path) {
|
||||||
// 如果没有特别指定 则获取当前页面的路由
|
// 如果没有特别指定 则获取当前页面的路由
|
||||||
@@ -1541,52 +1800,104 @@ export default {
|
|||||||
let route = this.getCurrentRoute();
|
let route = this.getCurrentRoute();
|
||||||
path = route.path;
|
path = route.path;
|
||||||
if (path == '/pages_tool/member/index') {
|
if (path == '/pages_tool/member/index') {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
resolve({})
|
resolve({})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
// 从传入 path 生成的兜底分享(后台未配置 / 接口异常时使用)
|
||||||
|
const buildFallback = (p) => {
|
||||||
|
const fallbackPath = p || '/pages/index/index';
|
||||||
|
const fallback = {
|
||||||
|
title: '小程序分享',
|
||||||
|
path: fallbackPath,
|
||||||
|
imageUrl: ''
|
||||||
|
};
|
||||||
|
const appMessage = {
|
||||||
|
title: fallback.title,
|
||||||
|
path: fallback.path,
|
||||||
|
imageUrl: fallback.imageUrl,
|
||||||
|
success: () => {},
|
||||||
|
fail: () => {}
|
||||||
|
};
|
||||||
|
const query = (fallback.path.indexOf('?') > 0) ? fallback.path.split('?')[1] : '';
|
||||||
|
const timeLine = {
|
||||||
|
title: fallback.title,
|
||||||
|
query: fallback.path,
|
||||||
|
imageUrl: fallback.imageUrl
|
||||||
|
};
|
||||||
|
return { appMessage, timeLine, _fallback: true, query };
|
||||||
|
};
|
||||||
|
// 通用构建:从后端返回的 {title,path,imageUrl} 拼成标准结构
|
||||||
|
const buildFromConfig = (shareConfig) => {
|
||||||
|
const appMessage = {
|
||||||
|
title: shareConfig.title,
|
||||||
|
path: shareConfig.path,
|
||||||
|
imageUrl: shareConfig.imageUrl || '',
|
||||||
|
success: () => {},
|
||||||
|
fail: () => {}
|
||||||
|
};
|
||||||
|
const query = (shareConfig.path && shareConfig.path.indexOf('?') > 0)
|
||||||
|
? shareConfig.path.split('?')[1]
|
||||||
|
: '';
|
||||||
|
const timeLine = {
|
||||||
|
title: shareConfig.title,
|
||||||
|
query: shareConfig.path,
|
||||||
|
imageUrl: shareConfig.imageUrl || ''
|
||||||
|
};
|
||||||
|
return { appMessage, timeLine, query };
|
||||||
|
};
|
||||||
|
const fallback = buildFallback(path);
|
||||||
|
return new Promise((resolve) => {
|
||||||
Http.sendRequest({
|
Http.sendRequest({
|
||||||
url: "/weapp/api/weapp/share",
|
url: "/weapp/api/weapp/share",
|
||||||
data: {
|
data: {
|
||||||
path: path
|
path: path
|
||||||
},
|
},
|
||||||
success: res => {
|
success: res => {
|
||||||
|
try {
|
||||||
|
if (!res || typeof res !== 'object') {
|
||||||
|
resolve(fallback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (res.code >= 0) {
|
if (res.code >= 0) {
|
||||||
let shareConfig = res.data.data;
|
// 响应可能是两种形态:
|
||||||
|
// A) { code, message, data: { title, path, imageUrl } }
|
||||||
|
// B) { code, message, permission:{...}, data: null }
|
||||||
|
// 统一安全解包:外层 payload 可能在 res.data 里,也可能直接在 res 里
|
||||||
|
const outer = res.data !== undefined ? res.data : res;
|
||||||
|
let shareConfig = null;
|
||||||
|
if (outer && typeof outer === 'object') {
|
||||||
|
// 后端在 outer 内嵌真正的配置对象(A 形态):outer.data = {title, path, ...}
|
||||||
|
if (outer.data && typeof outer.data === 'object' && (outer.data.title || outer.data.path)) {
|
||||||
|
shareConfig = outer.data;
|
||||||
|
} else if (outer.title || outer.path) {
|
||||||
|
// 极少数后端版本把配置平铺在 outer 上(扁平形态)
|
||||||
|
shareConfig = outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (shareConfig) {
|
if (shareConfig) {
|
||||||
//分享给好友
|
resolve(buildFromConfig(shareConfig));
|
||||||
let appMessageData = {
|
} else {
|
||||||
title: shareConfig.title,
|
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
|
||||||
path: shareConfig.path,
|
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
|
||||||
imageUrl: shareConfig.imageUrl,
|
resolve(fallback);
|
||||||
success: res => {
|
}
|
||||||
|
} else {
|
||||||
|
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
|
||||||
|
resolve(fallback);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
|
||||||
|
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
|
||||||
|
resolve(fallback);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
fail: res => {
|
fail: () => {
|
||||||
}
|
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
|
||||||
}
|
resolve(fallback);
|
||||||
//分享到朋友圈
|
},
|
||||||
let query = '';
|
complete: () => {}
|
||||||
if (shareConfig.path.indexOf('?') > 0) {
|
|
||||||
query = shareConfig.path.split('?')[1];
|
|
||||||
}
|
|
||||||
let timeLineData = {
|
|
||||||
title: shareConfig.title,
|
|
||||||
query: shareConfig.path,
|
|
||||||
imageUrl: shareConfig.imageUrl,
|
|
||||||
}
|
|
||||||
resolve({
|
|
||||||
appMessage: appMessageData,
|
|
||||||
timeLine: timeLineData,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
reject(res.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(res.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
819
common/js/voice-recorder.js
Normal file
819
common/js/voice-recorder.js
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
/**
|
||||||
|
* voice-recorder.js — 跨端录音抽象层
|
||||||
|
*
|
||||||
|
* PR-FRONTEND-VOICE-INPUT (2026-09-24)
|
||||||
|
* 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口,
|
||||||
|
* 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。
|
||||||
|
*
|
||||||
|
* PR-FRONTEND-VOICE-WAV (2026-09-25)
|
||||||
|
* H5 分支从 MediaRecorder (产出 webm/opus) 改为 AudioContext + ScriptProcessor
|
||||||
|
* 采原始 PCM, 停止时手动封装成标准 WAV (PCM 16-bit LE mono, 16kHz).
|
||||||
|
* 原因: Chrome MediaRecorder 只支持 webm/ogg 容器, 不支持 wav/mp3 录制;
|
||||||
|
* 大多数 STT / 后端 Agent 框架无法解析 webm/opus.
|
||||||
|
* 产 wav 后所有 STT 服务 (Whisper / 达摩院 / 腾讯 ASR / ...) 都吃.
|
||||||
|
*
|
||||||
|
* 平台分支:
|
||||||
|
* - H5 : navigator.mediaDevices.getUserMedia → AudioContext
|
||||||
|
* → ScriptProcessor 采 PCM Float32 → 停止时封装 WAV.
|
||||||
|
* 振幅采样走 AnalyserNode (与采 PCM 并行, 不冲突).
|
||||||
|
* - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射)
|
||||||
|
* 走 onFrameRecorded 推 progress 事件.
|
||||||
|
* uni-app format 参数可指定 wav/mp3/aac.
|
||||||
|
*
|
||||||
|
* 统一事件:
|
||||||
|
* progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画
|
||||||
|
* error → Error 录音失败
|
||||||
|
*
|
||||||
|
* 统一结果 (stop() 解析):
|
||||||
|
* {
|
||||||
|
* filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL
|
||||||
|
* blob?: Blob, // H5: WAV Blob (供 FileReader / uploadMedia 二次处理)
|
||||||
|
* fileName?: string, // H5: voice_<ts>.wav; MP 按 uni 约定
|
||||||
|
* duration: number, // ms
|
||||||
|
* mime: string, // 录音产物 MIME: H5='audio/wav', MP/APP/鸿蒙按 format
|
||||||
|
* size: number, // bytes
|
||||||
|
* cancelled: false
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_MAX_DURATION_MS = 60 * 1000
|
||||||
|
|
||||||
|
// PR-FRONTEND-VOICE-SINGLETON (2026-09-29): uni.getRecorderManager() / wx.getRecorderManager()
|
||||||
|
// 是全局单例, 且微信内部状态极易因上一次未完全停止而残留, 导致下一次 start() 报
|
||||||
|
// "operateRecorder:fail:audio is recording, don't start"。
|
||||||
|
// 这里用模块级 Promise 把所有 start/stop 串行化, 确保前一个实例 onStop/onError 完成后
|
||||||
|
// 才允许下一个实例 start()。
|
||||||
|
let _singletonIdlePromise = Promise.resolve()
|
||||||
|
let _singletonIdleResolver = null
|
||||||
|
let _singletonRec = null
|
||||||
|
let _singletonBusyInstance = null
|
||||||
|
|
||||||
|
function _getSingletonRecorder() {
|
||||||
|
if (!_singletonRec) {
|
||||||
|
_singletonRec = uni.getRecorderManager()
|
||||||
|
}
|
||||||
|
return _singletonRec
|
||||||
|
}
|
||||||
|
|
||||||
|
let _singletonIdleTimer = null
|
||||||
|
|
||||||
|
function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) {
|
||||||
|
if (_singletonIdleTimer) {
|
||||||
|
clearTimeout(_singletonIdleTimer)
|
||||||
|
_singletonIdleTimer = null
|
||||||
|
}
|
||||||
|
if (_singletonIdleResolver) {
|
||||||
|
// 已经有未释放的 busy promise, 理论上不应发生; 先强制释放避免死锁
|
||||||
|
_singletonIdleResolver()
|
||||||
|
_singletonIdleResolver = null
|
||||||
|
}
|
||||||
|
_singletonIdlePromise = new Promise((resolve) => {
|
||||||
|
_singletonIdleResolver = resolve
|
||||||
|
})
|
||||||
|
_singletonBusyInstance = instance
|
||||||
|
// 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放,
|
||||||
|
// 避免死锁导致后续所有 start() 永远挂起。
|
||||||
|
_singletonIdleTimer = setTimeout(() => {
|
||||||
|
console.warn('[voice-recorder:singleton] idle timeout, force release')
|
||||||
|
_setSingletonIdle(instance)
|
||||||
|
}, Math.min(maxDurationMs + 5000, 10000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setSingletonIdle(instance) {
|
||||||
|
if (_singletonIdleTimer) {
|
||||||
|
clearTimeout(_singletonIdleTimer)
|
||||||
|
_singletonIdleTimer = null
|
||||||
|
}
|
||||||
|
if (_singletonBusyInstance && _singletonBusyInstance !== instance) {
|
||||||
|
// 不是当前占用者释放, 忽略
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_singletonIdleResolver) {
|
||||||
|
_singletonIdleResolver()
|
||||||
|
_singletonIdleResolver = null
|
||||||
|
}
|
||||||
|
_singletonIdlePromise = Promise.resolve()
|
||||||
|
_singletonBusyInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// H5 录音参数: 16kHz mono PCM → 16-bit LE WAV
|
||||||
|
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
|
||||||
|
const H5_SAMPLE_RATE = 16000
|
||||||
|
const H5_NUM_CHANNELS = 1
|
||||||
|
const H5_SCRIPT_PROCESSOR_BUFFER_SIZE = 4096
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造带 code 字段的 Error
|
||||||
|
* - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译
|
||||||
|
* - message: 兜底英文 message, 用于开发/调试或未识别 code
|
||||||
|
* 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成
|
||||||
|
*/
|
||||||
|
function _err(code, message) {
|
||||||
|
const e = new Error(message || code)
|
||||||
|
e.code = code
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前运行环境 (避免编译器把所有分支都打进去)
|
||||||
|
* - true / false 都是运行时结果;条件编译靠注释里的 #ifdef
|
||||||
|
*/
|
||||||
|
function isH5() {
|
||||||
|
// #ifdef H5
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMpOrApp() {
|
||||||
|
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 mime 反推文件扩展名
|
||||||
|
*/
|
||||||
|
function mimeToExt(mime) {
|
||||||
|
if (!mime) return 'wav'
|
||||||
|
if (mime.indexOf('wav') !== -1) return 'wav'
|
||||||
|
if (mime.indexOf('webm') !== -1) return 'webm'
|
||||||
|
if (mime.indexOf('ogg') !== -1) return 'ogg'
|
||||||
|
if (mime.indexOf('mp4') !== -1) return 'm4a'
|
||||||
|
if (mime.indexOf('mpeg') !== -1) return 'mp3'
|
||||||
|
if (mime.indexOf('aac') !== -1) return 'aac'
|
||||||
|
return 'audio'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并多个 Float32Array (PCM 帧缓冲) 为单个 Float32Array
|
||||||
|
*/
|
||||||
|
function mergeFloat32Arrays(arrays) {
|
||||||
|
let totalLength = 0
|
||||||
|
for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].length
|
||||||
|
const out = new Float32Array(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < arrays.length; i++) {
|
||||||
|
out.set(arrays[i], offset)
|
||||||
|
offset += arrays[i].length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Float32 PCM (-1..1) 编码为标准 WAV (PCM 16-bit LE, RIFF/WAVE 头).
|
||||||
|
* 所有 STT 服务都吃这个格式; 浏览器 H5 录音不依赖 MediaRecorder 也能产出.
|
||||||
|
*
|
||||||
|
* 参考 RIFF/WAVE 规范:
|
||||||
|
* https://docs.fileformat.com/audio/wav/
|
||||||
|
* http://soundfile.sapp.org/doc/WaveFormat/
|
||||||
|
*/
|
||||||
|
function encodeWavPcm16LE(samples, sampleRate, numChannels) {
|
||||||
|
const bytesPerSample = 2 // 16-bit
|
||||||
|
const dataSize = samples.length * numChannels * bytesPerSample
|
||||||
|
const buffer = new ArrayBuffer(44 + dataSize)
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
|
||||||
|
// RIFF chunk descriptor (12 bytes)
|
||||||
|
writeAscii(view, 0, 'RIFF')
|
||||||
|
view.setUint32(4, 36 + dataSize, true) // ChunkSize = 文件总字节 - 8
|
||||||
|
writeAscii(view, 8, 'WAVE')
|
||||||
|
|
||||||
|
// fmt subchunk (24 bytes)
|
||||||
|
writeAscii(view, 12, 'fmt ')
|
||||||
|
view.setUint32(16, 16, true) // Subchunk1Size for PCM = 16
|
||||||
|
view.setUint16(20, 1, true) // AudioFormat: 1 = PCM
|
||||||
|
view.setUint16(22, numChannels, true)
|
||||||
|
view.setUint32(24, sampleRate, true)
|
||||||
|
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true) // ByteRate
|
||||||
|
view.setUint16(32, numChannels * bytesPerSample, true) // BlockAlign
|
||||||
|
view.setUint16(34, 16, true) // BitsPerSample
|
||||||
|
|
||||||
|
// data subchunk (8 + N bytes)
|
||||||
|
writeAscii(view, 36, 'data')
|
||||||
|
view.setUint32(40, dataSize, true)
|
||||||
|
|
||||||
|
// PCM 样本: Float32 (-1..1) → Int16 LE (clamp 到 [-1, 1] 防溢出)
|
||||||
|
let offset = 44
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
let s = samples[i]
|
||||||
|
if (s > 1) s = 1
|
||||||
|
else if (s < -1) s = -1
|
||||||
|
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7FFF)
|
||||||
|
view.setInt16(offset, int16, true)
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAscii(view, offset, str) {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建录音器实例。
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.maxDuration=60000] 自动停止时长 (ms)
|
||||||
|
* @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档)
|
||||||
|
* @param {number} [opts.sampleRate=16000]
|
||||||
|
* @param {number} [opts.numberOfChannels=1]
|
||||||
|
* @param {number} [opts.encodeBitRate=48000]
|
||||||
|
*
|
||||||
|
* @returns {VoiceRecorder}
|
||||||
|
*/
|
||||||
|
export function createVoiceRecorder(opts = {}) {
|
||||||
|
return new VoiceRecorder(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceRecorder {
|
||||||
|
constructor(opts) {
|
||||||
|
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
|
||||||
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3/aac 兼容性差,
|
||||||
|
// 表现为一启动就 onStop(duration~100ms)。wav 是无压缩格式, 兼容性最好, 优先使用。
|
||||||
|
this._format = opts.format || 'wav'
|
||||||
|
this._sampleRate = opts.sampleRate || 16000
|
||||||
|
this._channels = opts.numberOfChannels || 1
|
||||||
|
this._bitRate = opts.encodeBitRate || 48000
|
||||||
|
|
||||||
|
this._listeners = { progress: [], error: [] }
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false // start() 已调用, 但 onStart 尚未回调
|
||||||
|
this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve
|
||||||
|
this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停
|
||||||
|
this._startTs = 0
|
||||||
|
this._id = Math.random().toString(36).slice(2, 8)
|
||||||
|
this._autoStopTimer = null
|
||||||
|
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
|
||||||
|
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
|
||||||
|
|
||||||
|
// 平台相关句柄
|
||||||
|
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
||||||
|
this._uniRec = null // uni.getRecorderManager() 返回值
|
||||||
|
console.log(`[voice-recorder:${this._id}] created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, cb) {
|
||||||
|
if (!this._listeners[event]) this._listeners[event] = []
|
||||||
|
this._listeners[event].push(cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, cb) {
|
||||||
|
const arr = this._listeners[event]
|
||||||
|
if (!arr) return
|
||||||
|
const idx = arr.indexOf(cb)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(event, payload) {
|
||||||
|
const arr = this._listeners[event]
|
||||||
|
if (!arr) return
|
||||||
|
for (const cb of arr.slice()) {
|
||||||
|
try { cb(payload) } catch (e) { /* swallow */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRecording() { return this._isRecording }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始录音。
|
||||||
|
* resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
if (this._isRecording) {
|
||||||
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
|
||||||
|
}
|
||||||
|
if (this._isStarting) {
|
||||||
|
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isStarting = true
|
||||||
|
let p = null
|
||||||
|
if (isH5()) {
|
||||||
|
p = this._startH5()
|
||||||
|
} else if (isMpOrApp()) {
|
||||||
|
p = this._startUni()
|
||||||
|
} else {
|
||||||
|
this._isStarting = false
|
||||||
|
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
||||||
|
}
|
||||||
|
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
|
||||||
|
p.catch((e) => {
|
||||||
|
this._isStarting = false
|
||||||
|
return Promise.reject(e)
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止录音并返回产物。
|
||||||
|
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() called`, { isRecording: this._isRecording, isStarting: this._isStarting, hasUniRec: !!this._uniRec, hasStopResolve: !!this._stopResolve, earlyResult: !!this._earlyStopResult })
|
||||||
|
if (!this._isRecording) {
|
||||||
|
// PR-FRONTEND-VOICE-RACE (2026-09-29): 真机常见"按住即松手"——
|
||||||
|
// onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null
|
||||||
|
// 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停,
|
||||||
|
// 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。
|
||||||
|
if (this._isStarting) {
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._pendingStop = true
|
||||||
|
this._stopResolve = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// onStop 在 stop() 之前触发, 且有有效产物则返回缓存结果
|
||||||
|
if (this._earlyStopResult) {
|
||||||
|
const r = this._earlyStopResult
|
||||||
|
this._earlyStopResult = null
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() returning early onStop result`)
|
||||||
|
return Promise.resolve(r)
|
||||||
|
}
|
||||||
|
console.log(`[voice-recorder:${this._id}] stop() early return null (not recording)`)
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
if (isH5()) return this._stopH5()
|
||||||
|
return this._stopUni()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中断录音并丢弃产物。
|
||||||
|
*/
|
||||||
|
cancel() {
|
||||||
|
if (this._isStarting) {
|
||||||
|
// PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消,
|
||||||
|
// 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop,
|
||||||
|
// 等 onStart 触发后立即 stop(), 产物被丢弃。
|
||||||
|
console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._pendingStop = true
|
||||||
|
this._stopResolve = () => resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!this._isRecording) {
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (isH5()) return this._cancelH5()
|
||||||
|
return this._cancelUni()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== H5 实现 =====================
|
||||||
|
// PR-FRONTEND-VOICE-WAV (2026-09-25): 改为 AudioContext + ScriptProcessor 采 PCM,
|
||||||
|
// 停止时手动封装标准 WAV. 不再依赖 MediaRecorder, 解决 Chrome 只能产 webm/opus
|
||||||
|
// 导致后端 STT / Agent framework 无法解析的问题.
|
||||||
|
//
|
||||||
|
// 关键设计:
|
||||||
|
// 1. PCM 采样率固定 16kHz mono, 浏览器会自动 resample 麦克风输入
|
||||||
|
// (实际由 AudioContext sampleRate 参数保证)
|
||||||
|
// 2. ScriptProcessor 必须 connect 到 audioCtx.destination 才会触发
|
||||||
|
// onaudioprocess; 通过 GainNode(gain=0) 静音防止麦克风外放回声
|
||||||
|
// 3. 振幅采样走 AnalyserNode (与 PCM 采样并行, 不冲突)
|
||||||
|
// 4. onaudioprocess 每帧必须拷贝 channelData (new Float32Array(input)),
|
||||||
|
// 否则下一帧会覆盖导致 PCM 数据丢失
|
||||||
|
|
||||||
|
_startH5() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const Ctx = window.AudioContext || window.webkitAudioContext
|
||||||
|
if (!Ctx) {
|
||||||
|
reject(_err('H5_NO_AUDIO_CTX', 'AudioContext not available'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffers = []
|
||||||
|
let stream = null
|
||||||
|
let audioCtx = null
|
||||||
|
let source = null
|
||||||
|
let processor = null
|
||||||
|
let analyser = null
|
||||||
|
let silentGain = null
|
||||||
|
let rafId = null
|
||||||
|
|
||||||
|
// 清理 H5 资源 (失败 / 异常路径统一调用)
|
||||||
|
const cleanup = () => {
|
||||||
|
try { if (rafId) cancelAnimationFrame(rafId) } catch (e) { /* noop */ }
|
||||||
|
try { if (processor) processor.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (analyser) analyser.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (source) source.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (silentGain) silentGain.disconnect() } catch (e) { /* noop */ }
|
||||||
|
try { if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {}) } catch (e) { /* noop */ }
|
||||||
|
try { if (stream) stream.getTracks().forEach(t => t.stop()) } catch (e) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
channelCount: H5_NUM_CHANNELS,
|
||||||
|
// 注意: 部分浏览器会忽略 sampleRate 约束, 实际采样率以 AudioContext 为准
|
||||||
|
sampleRate: H5_SAMPLE_RATE,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((s) => {
|
||||||
|
stream = s
|
||||||
|
audioCtx = new Ctx({ sampleRate: H5_SAMPLE_RATE })
|
||||||
|
|
||||||
|
// 麦克风 → ScriptProcessor → 静音 GainNode → destination
|
||||||
|
// (ScriptProcessor 必须连到 destination 才会触发 onaudioprocess;
|
||||||
|
// 用 GainNode(gain=0) 静音, 防止麦克风声音外放产生回声)
|
||||||
|
source = audioCtx.createMediaStreamSource(stream)
|
||||||
|
const BufferSize = H5_SCRIPT_PROCESSOR_BUFFER_SIZE
|
||||||
|
processor = audioCtx.createScriptProcessor(BufferSize, H5_NUM_CHANNELS, H5_NUM_CHANNELS)
|
||||||
|
processor.onaudioprocess = (e) => {
|
||||||
|
const channelData = e.inputBuffer.getChannelData(0)
|
||||||
|
// 必须拷贝: inputBuffer 数据会被下一帧覆盖, 不拷贝会导致 PCM 丢失
|
||||||
|
buffers.push(new Float32Array(channelData))
|
||||||
|
}
|
||||||
|
silentGain = audioCtx.createGain()
|
||||||
|
silentGain.gain.value = 0
|
||||||
|
source.connect(processor)
|
||||||
|
processor.connect(silentGain)
|
||||||
|
silentGain.connect(audioCtx.destination)
|
||||||
|
|
||||||
|
// AnalyserNode 并行采振幅 (驱动波形动画)
|
||||||
|
analyser = audioCtx.createAnalyser()
|
||||||
|
analyser.fftSize = 256
|
||||||
|
source.connect(analyser)
|
||||||
|
const freqBuf = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
const tick = () => {
|
||||||
|
if (!this._isRecording) return
|
||||||
|
try {
|
||||||
|
analyser.getByteFrequencyData(freqBuf)
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < freqBuf.length; i++) sum += freqBuf[i]
|
||||||
|
const level = Math.min(1, sum / (freqBuf.length * 255))
|
||||||
|
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
|
||||||
|
this._h5 = { stream, audioCtx, source, processor, analyser, silentGain, rafId, buffers, sampleRate: audioCtx.sampleRate, cleanup }
|
||||||
|
this._isRecording = true
|
||||||
|
this._startTs = Date.now()
|
||||||
|
this._setAutoStopTimer()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// 麦克风权限拒绝 / 设备不可用
|
||||||
|
cleanup()
|
||||||
|
reject(_err('H5_MIC_PERMISSION', (err && err.message) || 'mic permission denied or device unavailable'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopH5() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const h5 = this._h5
|
||||||
|
if (!h5) { resolve(null); return }
|
||||||
|
|
||||||
|
// 1) 拆 audio graph + 停流 (避免 onprocess 继续累积 buffer)
|
||||||
|
try { h5.cleanup() } catch (e) { /* noop */ }
|
||||||
|
|
||||||
|
// 2) 合并 PCM 帧缓冲
|
||||||
|
const sampleRate = h5.sampleRate || H5_SAMPLE_RATE
|
||||||
|
const merged = mergeFloat32Arrays(h5.buffers || [])
|
||||||
|
|
||||||
|
// 3) 编码标准 WAV (PCM 16-bit LE mono)
|
||||||
|
const wavBuffer = encodeWavPcm16LE(merged, sampleRate, H5_NUM_CHANNELS)
|
||||||
|
const blob = new Blob([wavBuffer], { type: 'audio/wav' })
|
||||||
|
|
||||||
|
this._h5 = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
|
||||||
|
if (merged.length === 0) {
|
||||||
|
// 没有采到任何 PCM (例如用户没说话或立即 stop)
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `voice_${Date.now()}.wav`
|
||||||
|
const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL)
|
||||||
|
? URL.createObjectURL(blob)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
filePath: blobUrl,
|
||||||
|
blob,
|
||||||
|
fileName,
|
||||||
|
duration: Date.now() - this._startTs,
|
||||||
|
mime: 'audio/wav',
|
||||||
|
size: blob.size,
|
||||||
|
cancelled: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelH5() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const h5 = this._h5
|
||||||
|
if (!h5) {
|
||||||
|
this._isRecording = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { h5.cleanup() } catch (e) { /* noop */ }
|
||||||
|
this._h5 = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
|
||||||
|
|
||||||
|
_startUni() {
|
||||||
|
// PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问,
|
||||||
|
// 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。
|
||||||
|
return _singletonIdlePromise.then(() => {
|
||||||
|
// 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。
|
||||||
|
if (_singletonBusyInstance && _singletonBusyInstance !== this) {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`)
|
||||||
|
return this._startUni()
|
||||||
|
}
|
||||||
|
_setSingletonBusy(this, this._maxDuration)
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`)
|
||||||
|
return this._doStartUni()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_doStartUni() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let rec = null
|
||||||
|
try {
|
||||||
|
rec = _getSingletonRecorder()
|
||||||
|
} catch (e) {
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!rec) {
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._uniRec = rec
|
||||||
|
this._isStarting = true
|
||||||
|
console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`)
|
||||||
|
|
||||||
|
rec.onStart(() => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStart fired`)
|
||||||
|
this._isRecording = true
|
||||||
|
this._isStarting = false
|
||||||
|
this._startTs = Date.now()
|
||||||
|
this._setAutoStopTimer()
|
||||||
|
if (this._pendingStop) {
|
||||||
|
// 用户在录音真正开始前就松手: 立即结束, 产物经 onStop 返回
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStart: pendingStop detected, calling rec.stop()`)
|
||||||
|
this._pendingStop = false
|
||||||
|
try { rec.stop() } catch (e) { /* onStop 自会兜底 */ }
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
rec.onStop((res) => {
|
||||||
|
// res = { tempFilePath, duration, fileSize }
|
||||||
|
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式:
|
||||||
|
// Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名,
|
||||||
|
// 避免播放器/后端因后缀与容器不符而失败。
|
||||||
|
const tempPath = (res && res.tempFilePath) || ''
|
||||||
|
const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || ''
|
||||||
|
const ext = rawExt.toLowerCase()
|
||||||
|
const mime = ext === 'm4a' ? 'audio/mp4'
|
||||||
|
: ext === 'aac' ? 'audio/aac'
|
||||||
|
: ext === 'mp3' ? 'audio/mpeg'
|
||||||
|
: ext === 'wav' ? 'audio/wav'
|
||||||
|
: (this._format === 'wav' ? 'audio/wav'
|
||||||
|
: this._format === 'aac' ? 'audio/aac'
|
||||||
|
: 'audio/mpeg')
|
||||||
|
// PR-FRONTEND-VOICE-DURATION-FIX (2026-09-29): 微信 onStop 的 duration
|
||||||
|
// 偶尔为 0, 用 start→stop 真实时长兜底, 避免下游按 duration=0 丢弃。
|
||||||
|
const realDuration = this._startTs ? Date.now() - this._startTs : 0
|
||||||
|
const reportedDuration = (res && typeof res.duration === 'number') ? res.duration : 0
|
||||||
|
const duration = reportedDuration > 0 ? reportedDuration : realDuration
|
||||||
|
const result = {
|
||||||
|
filePath: tempPath,
|
||||||
|
blob: null,
|
||||||
|
fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '',
|
||||||
|
duration,
|
||||||
|
mime,
|
||||||
|
size: res.fileSize || 0,
|
||||||
|
cancelled: false
|
||||||
|
}
|
||||||
|
if (this._stopResolve) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
r(result)
|
||||||
|
} else if (result.filePath && result.duration > 0) {
|
||||||
|
// PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来
|
||||||
|
this._earlyStopResult = result
|
||||||
|
}
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
rec.onError((err) => {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
||||||
|
console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
||||||
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._emit('error', e)
|
||||||
|
if (this._stopResolve) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
r(null)
|
||||||
|
} else {
|
||||||
|
// 启动阶段的 reject
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
|
||||||
|
if (typeof rec.onFrameRecorded === 'function') {
|
||||||
|
rec.onFrameRecorded((res) => {
|
||||||
|
if (!res || !res.frameBuffer) return
|
||||||
|
// frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅
|
||||||
|
try {
|
||||||
|
const view = new DataView(res.frameBuffer)
|
||||||
|
let sum = 0
|
||||||
|
const len = res.frameBuffer.byteLength
|
||||||
|
const step = Math.max(2, Math.floor(len / 256))
|
||||||
|
for (let i = 0; i < len; i += step) {
|
||||||
|
const v = view.getInt16(i, true)
|
||||||
|
sum += Math.abs(v)
|
||||||
|
}
|
||||||
|
const avg = sum / Math.max(1, Math.floor(len / step))
|
||||||
|
const level = Math.min(1, avg / 16384)
|
||||||
|
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
rec.start({
|
||||||
|
// PR-FRONTEND-VOICE-DURATION-UNIT (2026-09-29): 微信 RecorderManager.start 的 duration
|
||||||
|
// 单位是毫秒(ms), 不是秒。之前 /1000 导致只录 60ms 就被自动停止。
|
||||||
|
duration: this._maxDuration,
|
||||||
|
sampleRate: this._sampleRate,
|
||||||
|
numberOfChannels: this._channels,
|
||||||
|
encodeBitRate: this._bitRate,
|
||||||
|
format: this._format
|
||||||
|
// PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感,
|
||||||
|
// 可能导致录音被提前中断。
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
// 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject
|
||||||
|
if (this._stopResolve && this._pendingStop) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
r(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopUni() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _stopUni called`)
|
||||||
|
const rec = this._uniRec
|
||||||
|
if (!rec) { resolve(null); return }
|
||||||
|
this._earlyStopResult = null
|
||||||
|
this._stopResolve = resolve
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起,
|
||||||
|
// UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。
|
||||||
|
this._stopFallbackTimer = setTimeout(() => {
|
||||||
|
console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`)
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve(null)
|
||||||
|
}, 3000)
|
||||||
|
try { rec.stop() } catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._stopResolve = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelUni() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(`[voice-recorder:${this._id}] _cancelUni called`)
|
||||||
|
this._earlyStopResult = null
|
||||||
|
const rec = this._uniRec
|
||||||
|
if (!rec) {
|
||||||
|
// 挂起的 stop 需要一并了结, 避免松手后卡在 pending
|
||||||
|
if (this._stopResolve && this._pendingStop) {
|
||||||
|
const r = this._stopResolve
|
||||||
|
this._stopResolve = null
|
||||||
|
this._pendingStop = false
|
||||||
|
r(null)
|
||||||
|
}
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._stopResolve = () => resolve()
|
||||||
|
this._pendingStop = false
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
// PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。
|
||||||
|
this._stopFallbackTimer = setTimeout(() => {
|
||||||
|
console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`)
|
||||||
|
this._stopResolve = null
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
}, 3000)
|
||||||
|
try { rec.stop() } catch (e) {
|
||||||
|
this._clearStopFallbackTimer()
|
||||||
|
this._isRecording = false
|
||||||
|
this._isStarting = false
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._uniRec = null
|
||||||
|
_setSingletonIdle(this)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 工具 =====================
|
||||||
|
|
||||||
|
_setAutoStopTimer() {
|
||||||
|
this._clearAutoStopTimer()
|
||||||
|
this._autoStopTimer = setTimeout(() => {
|
||||||
|
// 自动停止 (上限到达)
|
||||||
|
if (this._isRecording) {
|
||||||
|
this.stop().catch(() => { /* swallow */ })
|
||||||
|
}
|
||||||
|
}, this._maxDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAutoStopTimer() {
|
||||||
|
if (this._autoStopTimer) {
|
||||||
|
clearTimeout(this._autoStopTimer)
|
||||||
|
this._autoStopTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearStopFallbackTimer() {
|
||||||
|
if (this._stopFallbackTimer) {
|
||||||
|
clearTimeout(this._stopFallbackTimer)
|
||||||
|
this._stopFallbackTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createVoiceRecorder
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<view data-component-name="diy-category" :class="['category-page-wrap', 'category-template-' + value.template]"
|
<view data-component-name="diy-category" :class="['category-page-wrap', 'category-template-' + value.template]"
|
||||||
:style="{ height: 'calc(100vh - ' + tabBarHeight + ')' }">
|
:style="{ height: 'calc(100vh - var(--window-top, 0px) - ' + tabBarHeight + ')' }">
|
||||||
|
<!-- 高度说明: 100vh - H5顶部导航栏(--window-top, 小程序为0) - 底部导航高度(tabBarHeight),
|
||||||
|
保证底部购物车结算栏始终落在可视区内, 点击加购后无需向下翻即可看到 -->
|
||||||
<!-- 模板4: 顶部搜索框(图片样式) -->
|
<!-- 模板4: 顶部搜索框(图片样式) -->
|
||||||
<view class="search-box search-box-template-4" v-if="value.template == 4 && value.search"
|
<view class="search-box search-box-template-4" v-if="value.template == 4 && value.search"
|
||||||
@tap.stop="onClickSearch()">
|
@tap.stop="onClickSearch()">
|
||||||
@@ -93,20 +95,20 @@
|
|||||||
<view class="category-page-skeleton" v-if="categoryTreeLoading">
|
<view class="category-page-skeleton" v-if="categoryTreeLoading">
|
||||||
<view class="skeleton-content">
|
<view class="skeleton-content">
|
||||||
<view class="skeleton-tree">
|
<view class="skeleton-tree">
|
||||||
<view class="skeleton-tree-item" v-for="n in 8" :key="'sk-tree-' + n"></view>
|
<view class="skeleton-tree-item" v-for="n in 8" :key="n"></view>
|
||||||
</view>
|
</view>
|
||||||
<view class="skeleton-grid">
|
<view class="skeleton-grid">
|
||||||
<view class="skeleton-card" v-for="n in 6" :key="'sk-card-' + n"></view>
|
<view class="skeleton-card" v-for="n in 6" :key="n"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="content-box" v-else-if="categoryTree">
|
<view class="content-box" v-if="categoryTree">
|
||||||
<block v-if="categoryTree.length">
|
<block v-if="categoryTree.length">
|
||||||
<scroll-view scroll-y="true" class="tree-wrap">
|
<scroll-view scroll-y="true" class="tree-wrap">
|
||||||
<view class="category-item-wrap">
|
<view class="category-item-wrap">
|
||||||
<!-- 模板4: 左侧显示一级分类(顶部轮播中的分类) -->
|
<!-- 模板4: 左侧显示一级分类(顶部轮播中的分类) -->
|
||||||
<view v-if="value.template == 4">
|
<view v-if="value.template == 4">
|
||||||
<view class="category-item" v-for="(item, index) in templateFourData" :key="'one-' + index" :class="[
|
<view class="category-item" v-for="(item, index) in templateFourData" :key="index" :class="[
|
||||||
{ select: oneCategorySelect == index },
|
{ select: oneCategorySelect == index },
|
||||||
{ 'border-bottom': oneCategorySelect + 1 === index },
|
{ 'border-bottom': oneCategorySelect + 1 === index },
|
||||||
{ 'border-top': oneCategorySelect - 1 === index }
|
{ 'border-top': oneCategorySelect - 1 === index }
|
||||||
@@ -119,7 +121,8 @@
|
|||||||
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
|
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
|
||||||
{ select: select == index },
|
{ select: select == index },
|
||||||
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
{ 'border-bottom': value.template == 4 && select + 1 === index },
|
||||||
{ 'border-top': value.template == 4 && select - 1 === index }
|
{ 'border-top': value.template == 4 && select - 1 === index },
|
||||||
|
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1 || value.template == 5) }
|
||||||
]" @tap.stop="switchOneCategory(index)">
|
]" @tap.stop="switchOneCategory(index)">
|
||||||
<view class="">{{ item.category_name }}</view>
|
<view class="">{{ item.category_name }}</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -153,6 +156,39 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 模板5 + 二级分类(value.level==2): 单面板切换 - 右侧只显示"当前选中"的一级分类,
|
||||||
|
点左侧分类切换, 其它分类隐藏(display:none); 每个一级分类: 标题 + 商品网格 -->
|
||||||
|
<scroll-view scroll-y="true" class="content-wrap"
|
||||||
|
v-else-if="value.template == 5 && value.level == 2"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<!-- 模板5 + 三级分类(value.level!=2): 左侧分类树 + 右侧纵向平铺"全部一级分类",
|
||||||
|
每个一级分类: 标题 + 二级分类网格; 左侧点击分类用 scroll-into-view 定位,
|
||||||
|
滚动时由 listenScroll 反推并高亮左侧分类 -->
|
||||||
|
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 5"
|
||||||
|
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||||
|
:refresher-enabled="true" refresher-default-style="none"
|
||||||
|
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore"
|
||||||
|
@scroll="listenScroll" @touchstart="touchStart">
|
||||||
|
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||||
|
:id="'category-' + index">
|
||||||
|
<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"
|
||||||
|
@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>
|
||||||
|
|
||||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
|
<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"
|
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||||
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
|
||||||
@@ -163,6 +199,7 @@
|
|||||||
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
|
||||||
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
|
||||||
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
|
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
|
||||||
|
:currentOneCategory="categoryTree && categoryTree[select] ? categoryTree[select] : null"
|
||||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||||
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||||
</view>
|
</view>
|
||||||
@@ -189,9 +226,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 模板4: 底部购物车栏 -->
|
<!-- 风格2/模板4: 底部购物车栏
|
||||||
|
1) 不用 position:fixed: 小程序/H5/鸿蒙下 fixed 受页面避让与父级 transform 影响,
|
||||||
|
会"跟在内容后面"只有滚到底才看得见。
|
||||||
|
.category-page-wrap 是固定高度(calc(100vh - tabBarHeight))的纵向 flex 容器,
|
||||||
|
content-box 为 flex:1 height:0, 把结算栏作为普通子项排在末尾,
|
||||||
|
无论右侧商品列表怎么滚动, 结算栏始终固定显示在底部
|
||||||
|
2) cartNumber > 0: 只在购物车里有商品时才出现(点加购即出现, 减到 0 即消失),
|
||||||
|
商品加购后会立即 dispatch cartCalculate 同步 cartNumber, 不依赖接口回调 -->
|
||||||
<view class="cart-box cart-box-template-4"
|
<view class="cart-box cart-box-template-4"
|
||||||
v-if="value.template == 4 && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
v-if="(value.template == 2 || value.template == 4) && showCartSettlement && storeToken && cartNumber > 0 && categoryTree && categoryTree.length">
|
||||||
<view class="left-wrap">
|
<view class="left-wrap">
|
||||||
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation"
|
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation"
|
||||||
@tap.stop="$util.redirectTo('/pages_goods/cart')">
|
@tap.stop="$util.redirectTo('/pages_goods/cart')">
|
||||||
@@ -263,14 +307,15 @@ export default {
|
|||||||
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
|
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
|
||||||
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
|
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
|
||||||
// 显示骨架占位而非空态文案
|
// 显示骨架占位而非空态文案
|
||||||
categoryTreeLoading: true
|
categoryTreeLoading: true,
|
||||||
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.getCategoryTree();
|
this.getCategoryTree();
|
||||||
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
|
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
|
||||||
// 模板4: 默认单列列表布局(图片样式)
|
// 模板2/4: 默认单列列表布局(左图右文卡片), 点右上角图标切换为双列宫格
|
||||||
if (this.value.template == 4) {
|
if (this.value.template == 2 || this.value.template == 4) {
|
||||||
this.isList = true;
|
this.isList = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -296,17 +341,42 @@ export default {
|
|||||||
})
|
})
|
||||||
.exec();
|
.exec();
|
||||||
if (this.value.template == 1) this.getHeightArea(-1);
|
if (this.value.template == 1) this.getHeightArea(-1);
|
||||||
|
if (this.value.template == 5) this.getHeightArea(-1);
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
watch: {
|
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);
|
||||||
|
// 分类渲染完成后重新对齐到当前选中的一级分类顶部,
|
||||||
|
// 避免首屏第一个分类的标题被顶出可视区
|
||||||
|
if (this.categoryTree && this.categoryTree.length) this.alignToCategory(this.select);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
cartTotalMoney() {
|
cartTotalMoney() {
|
||||||
let money = parseFloat(this.cartMoney).toFixed(2);
|
let money = parseFloat(this.cartMoney).toFixed(2);
|
||||||
return money.split('.');
|
return money.split('.');
|
||||||
},
|
},
|
||||||
|
// 后台开关:快捷购买(显示购物车及结算按钮) = quickBuy; 加购按钮 = cartBtn
|
||||||
|
// 后台开启哪个就显示哪个, 关闭哪个就不显示哪个; 未下发 cartBtn 时兼容旧字段 quickBuy
|
||||||
|
showCartSettlement() {
|
||||||
|
const t = this.getDiyToggle(['showCartSettlement', 'show_cart_settlement']);
|
||||||
|
if (t !== undefined) return t;
|
||||||
|
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
|
||||||
|
return legacy !== undefined ? legacy : false;
|
||||||
|
},
|
||||||
|
showAddCart() {
|
||||||
|
const t = this.getDiyToggle(['showAddCart', 'show_add_cart', 'cartBtn', 'cart_btn']);
|
||||||
|
if (t !== undefined) return t;
|
||||||
|
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
|
||||||
|
return legacy !== undefined ? legacy : false;
|
||||||
|
},
|
||||||
// 转换字符数值为真正的数值
|
// 转换字符数值为真正的数值
|
||||||
navbarHeight() {
|
navbarHeight() {
|
||||||
// #ifdef APP-PLUS || H5
|
// #ifdef APP-PLUS || H5
|
||||||
@@ -374,6 +444,7 @@ export default {
|
|||||||
this.$store.dispatch('getCartNumber');
|
this.$store.dispatch('getCartNumber');
|
||||||
if (!this.heightArea.length) this.getHeightArea(-1);
|
if (!this.heightArea.length) this.getHeightArea(-1);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取高度区间
|
* 获取高度区间
|
||||||
*
|
*
|
||||||
@@ -459,16 +530,16 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 全局查询所有有商品的二级分类, 按名称去重, 汇总成虚拟分类供 template99 渲染按钮
|
* 汇总所有二级分类供 template99 渲染按钮(按名称去重)
|
||||||
* 只要任何商品属于该二级分类, 按钮就显示; 切换一级分类时按钮不变(一直显示)
|
* 注意: 保留"无商品"的二级分类也显示出来, 不再按是否有商品过滤,
|
||||||
* 新增的二级分类只要有商品也会自动包含
|
* 避免一级分类下 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题
|
||||||
*/
|
*/
|
||||||
loadGlobalSecondCategoryBtns() {
|
loadGlobalSecondCategoryBtns() {
|
||||||
if (!this.categoryTree || !this.categoryTree.length) {
|
if (!this.categoryTree || !this.categoryTree.length) {
|
||||||
this.template99Category = { category_name: '', child_list: [] };
|
this.template99Category = { category_name: '', child_list: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 收集所有二级分类(保留原始顺序, 不去重, 查询所有同名后再去重)
|
// 收集所有二级分类(保留原始顺序, 不去重, 汇总后去重)
|
||||||
const allSecond = [];
|
const allSecond = [];
|
||||||
this.categoryTree.forEach(one => {
|
this.categoryTree.forEach(one => {
|
||||||
if (one.child_list && one.child_list.length) {
|
if (one.child_list && one.child_list.length) {
|
||||||
@@ -481,16 +552,10 @@ export default {
|
|||||||
this.template99Category = { category_name: '', child_list: [] };
|
this.template99Category = { category_name: '', child_list: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let pending = allSecond.length;
|
// 按名称去重, 同名只保留第一个, 保持原始顺序
|
||||||
const results = [];
|
|
||||||
const finishOne = () => {
|
|
||||||
pending--;
|
|
||||||
if (pending <= 0) {
|
|
||||||
results.sort((a, b) => a._origIndex - b._origIndex);
|
|
||||||
// 按名称去重, 同名只保留第一个有商品的(只要任一同名有商品就显示)
|
|
||||||
const deduped = [];
|
const deduped = [];
|
||||||
const seenNames = {};
|
const seenNames = {};
|
||||||
results.forEach(item => {
|
allSecond.forEach(item => {
|
||||||
if (!seenNames[item.category_name]) {
|
if (!seenNames[item.category_name]) {
|
||||||
seenNames[item.category_name] = true;
|
seenNames[item.category_name] = true;
|
||||||
deduped.push(item);
|
deduped.push(item);
|
||||||
@@ -500,26 +565,6 @@ export default {
|
|||||||
category_name: '',
|
category_name: '',
|
||||||
child_list: deduped
|
child_list: deduped
|
||||||
};
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
allSecond.forEach(two => {
|
|
||||||
this.$api.sendRequest({
|
|
||||||
url: '/api/goodssku/pageByCategory',
|
|
||||||
data: {
|
|
||||||
category_id: two.category_id,
|
|
||||||
page: 1,
|
|
||||||
page_size: 1
|
|
||||||
},
|
|
||||||
success: res => {
|
|
||||||
if (res && res.code == 0 && res.data && res.data.list && res.data.list.length > 0) {
|
|
||||||
results.push(two);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
complete: () => {
|
|
||||||
finishOne();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 切换一级分类
|
* 切换一级分类
|
||||||
@@ -528,8 +573,30 @@ export default {
|
|||||||
switchOneCategory(index) {
|
switchOneCategory(index) {
|
||||||
if (index >= this.categoryTree.length) return;
|
if (index >= this.categoryTree.length) return;
|
||||||
this.select = index;
|
this.select = index;
|
||||||
this.categoryId = 'category-' + index;
|
|
||||||
this.scrollLock = true;
|
this.scrollLock = true;
|
||||||
|
this.alignToCategory(index);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 把右侧滚动区定位到指定一级分类的顶部
|
||||||
|
*
|
||||||
|
* uni-app 只在 scroll-into-view 的值"发生变化"时才重新滚动,
|
||||||
|
* 而 categoryId 的初值就是 'category-0', 且只有点击左侧分类才会被改写;
|
||||||
|
* 用户在右侧手动滚动后 categoryId 仍是 'category-0', 此时再点第一个分类(index 0)
|
||||||
|
* 值时没有变化, 右侧不会回滚到该分类, 该分类的标题就被顶出可视区
|
||||||
|
* (看起来像"标题丢了 / 卡片被切了一半"), 而点其它分类因为值变了所以都正常。
|
||||||
|
* 这里先置空、下一帧再设置目标 id, 保证每次点击都能重新定位到分类顶部。
|
||||||
|
* @param {Number} index 一级分类下标
|
||||||
|
*/
|
||||||
|
alignToCategory(index) {
|
||||||
|
const target = 'category-' + index;
|
||||||
|
if (this.categoryId === target) {
|
||||||
|
this.categoryId = '';
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.categoryId = target;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.categoryId = target;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
saveSecondCategoryName(name) {
|
saveSecondCategoryName(name) {
|
||||||
this.lastSecondCategoryName = name;
|
this.lastSecondCategoryName = name;
|
||||||
@@ -551,7 +618,10 @@ export default {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
|
// 模板5 三级分类为静态分类网格平铺, 不需要懒加载商品; 这里只保留 loadType=all 的懒加载,
|
||||||
|
// 并补上 heightArea[this.select] 越界保护
|
||||||
|
if (this.value.template != 1 && this.value.loadType == 'all' &&
|
||||||
|
this.heightArea[this.select] && this.heightArea[this.select][1] -
|
||||||
scrollTop - contentWrapHeight < 300) {
|
scrollTop - contentWrapHeight < 300) {
|
||||||
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
|
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
|
||||||
}
|
}
|
||||||
@@ -641,7 +711,8 @@ export default {
|
|||||||
* @param {Object} top
|
* @param {Object} top
|
||||||
*/
|
*/
|
||||||
addCartPoint(left, top) {
|
addCartPoint(left, top) {
|
||||||
if (this.value.template != 2 && !this.value.quickBuy) return;
|
if (this.value.template != 2 && this.value.template != 4) return;
|
||||||
|
if (!this.showCartSettlement) return;
|
||||||
|
|
||||||
let key = new Date().getTime();
|
let key = new Date().getTime();
|
||||||
this.$set(this.carIconList, key, {
|
this.$set(this.carIconList, key, {
|
||||||
@@ -732,6 +803,7 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 0;
|
height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
.tree-wrap {
|
.tree-wrap {
|
||||||
width: 170rpx;
|
width: 170rpx;
|
||||||
@@ -772,10 +844,10 @@ export default {
|
|||||||
padding: 26rpx 28rpx;
|
padding: 26rpx 28rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #f5f5f5;
|
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||||
|
|
||||||
view {
|
view {
|
||||||
color: #222222;
|
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
white-space: break-word;
|
white-space: break-word;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -792,10 +864,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.select {
|
&.select {
|
||||||
background: #fff;
|
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||||
|
|
||||||
view {
|
view {
|
||||||
color: #333;
|
color: var(--diy-category-tree-select-text-color, #333);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: var(--diy-category-tree-select-size, 28rpx);
|
font-size: var(--diy-category-tree-select-size, 28rpx);
|
||||||
}
|
}
|
||||||
@@ -1049,6 +1121,8 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes category-skeleton-shimmer {
|
@keyframes category-skeleton-shimmer {
|
||||||
0% {
|
0% {
|
||||||
background-position: 100% 50%;
|
background-position: 100% 50%;
|
||||||
@@ -1093,7 +1167,8 @@ export default {
|
|||||||
// 模板4 底部购物车栏(图片样式)
|
// 模板4 底部购物车栏(图片样式)
|
||||||
.cart-box-template-4 {
|
.cart-box-template-4 {
|
||||||
height: 110rpx;
|
height: 110rpx;
|
||||||
padding: 0 30rpx;
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1rpx solid #f0f0f0;
|
border-top: 1rpx solid #f0f0f0;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1197,11 +1272,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-box {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep .template-four {
|
::v-deep .template-four {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -1408,6 +1478,111 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 风格2 复用模板4 的底部购物车栏样式
|
||||||
|
.category-template-2 {
|
||||||
|
// 模板4 底部购物车栏(图片样式)
|
||||||
|
.cart-box-template-4 {
|
||||||
|
height: 110rpx;
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.content-wrap .categoty-goods-wrap .goods-list {
|
.content-wrap .categoty-goods-wrap .goods-list {
|
||||||
margin-top: 30rpx;
|
margin-top: 30rpx;
|
||||||
}
|
}
|
||||||
@@ -1445,7 +1620,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content-box {
|
.content-box {
|
||||||
background: #fff;
|
// 模板1 + 三级分类: 标题行 (离型纸 ----) 所在整片右半边需要去掉白色,
|
||||||
|
// 把 .content-box 也改为透明, 让右侧不再被强制白; 再让 .right-flex-wrap 显式
|
||||||
|
// 染 #f5f5f5, 与左侧 tree-wrap 默认色一致, 标题行只显示文字+短横线.
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
.tree-wrap {
|
.tree-wrap {
|
||||||
width: 170rpx;
|
width: 170rpx;
|
||||||
@@ -1453,10 +1631,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.right-flex-wrap {
|
.right-flex-wrap {
|
||||||
background: #fff;
|
background: #f5f5f5;
|
||||||
|
|
||||||
.content-wrap {
|
.content-wrap {
|
||||||
background: #fff;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1474,10 +1652,31 @@ export default {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 穿透到子组件 diy-category-item: 模板1标题行定高, 与左侧第一个一级分类等高对齐
|
||||||
::v-deep .category-title-with-lines {
|
::v-deep .category-title-with-lines {
|
||||||
|
height: 100rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 30rpx 0 20rpx;
|
padding: 30rpx 0 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模板1: 左侧第一个一级分类定高, 与右侧标题行(如"离型纸 ----")垂直对齐
|
||||||
|
.tree-wrap .category-item.first-category-item {
|
||||||
|
height: 100rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
view {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 穿透修复: 图片不需要灰色背景框,与目标图保持一致
|
// 穿透修复: 图片不需要灰色背景框,与目标图保持一致
|
||||||
::v-deep .img-box {
|
::v-deep .img-box {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -1498,11 +1697,11 @@ export default {
|
|||||||
// 左侧分类栏样式
|
// 左侧分类栏样式
|
||||||
.tree-wrap .category-item {
|
.tree-wrap .category-item {
|
||||||
padding: 28rpx 16rpx;
|
padding: 28rpx 16rpx;
|
||||||
background-color: #fff;
|
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|
||||||
view {
|
view {
|
||||||
color: #666;
|
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@@ -1513,10 +1712,138 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.select {
|
&.select {
|
||||||
background: #fff;
|
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||||
|
|
||||||
view {
|
view {
|
||||||
color: var(--base-color, #FF6B35);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 模板5 商品分类样式(新增: 基于模板1双栏布局) =====================
|
||||||
|
.category-template-5 {
|
||||||
|
.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 {
|
||||||
|
// 模板5 + 三级分类: 标题行所在整片右半边去掉白色, 与模板1一致
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.tree-wrap {
|
||||||
|
width: 170rpx;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-flex-wrap {
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板5 纵向平铺: 每个一级分类独占一行(与模板99"全部分类"结构一致)
|
||||||
|
.child-category {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
|
||||||
|
::v-deep .category-adv {
|
||||||
|
padding: 0 20rpx;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板5: .category-list 直接沿用子组件的 legacy 白底卡片样式(不再覆盖为透明)
|
||||||
|
|
||||||
|
// 穿透到子组件 diy-category-item: 模板5标题行定高, 与左侧第一个一级分类等高对齐
|
||||||
|
::v-deep .category-title-with-lines {
|
||||||
|
height: 100rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 30rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板5: 左侧第一个一级分类定高, 与右侧标题行垂直对齐
|
||||||
|
.tree-wrap .category-item.first-category-item {
|
||||||
|
height: 100rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
view {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板5: 分类网格的 .img-box / .name 直接沿用子组件 legacy 尺寸(80% + 名称),
|
||||||
|
// 不再做 70%/透明 覆盖, 以与 Branch_5cfee417 的商品分类保持一致
|
||||||
|
|
||||||
|
// 左侧分类栏样式
|
||||||
|
.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-weight: bold;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
@@ -1589,19 +1916,22 @@ export default {
|
|||||||
|
|
||||||
.tree-wrap .category-item {
|
.tree-wrap .category-item {
|
||||||
padding: 30rpx 16rpx;
|
padding: 30rpx 16rpx;
|
||||||
background-color: #f5f5f5;
|
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
view {
|
view {
|
||||||
color: #333;
|
color: var(--diy-category-tree-normal-text-color, #222222);
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
line-height: 1.3;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.select {
|
&.select {
|
||||||
background: #fff;
|
background: var(--diy-category-tree-select-bg-color, #fff);
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
view {
|
view {
|
||||||
color: #333;
|
color: var(--diy-category-tree-select-text-color, #333);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
@@ -1621,6 +1951,21 @@ 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 {
|
.content-wrap {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export default {
|
|||||||
// #ifndef MP-WEIXIN
|
// #ifndef MP-WEIXIN
|
||||||
if (item?.channelType === 'wechat') {
|
if (item?.channelType === 'wechat') {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '视频号仅支持在微信小程序环境中播放',
|
title: this.$lang('wechatChannel.wechatMpOnly'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<view :style="componentStyle" data-component-name="diy-digit">
|
<view :style="componentStyle" data-component-name="diy-digit">
|
||||||
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||||
:scroll-x="value.showStyle == 'singleSlide'">
|
:scroll-x="value.showStyle == 'singleSlide'">
|
||||||
<view class="uni-scroll-view-content">
|
<view class="uni-scroll-view-content">
|
||||||
<view v-for="(item, index) in value.list" :key="index"
|
<view v-for="(item, index) in value.list" :key="index"
|
||||||
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
|
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
|
||||||
:style="{ width: (100 / value.rowCount + '%') + ';' }">
|
:style="itemStyle">
|
||||||
<view style="display:flex;">
|
<view :style="innerWrapStyle">
|
||||||
<view :style="{
|
<view :style="innerRowStyle">
|
||||||
'line-height': '1.2;',
|
|
||||||
'font-size': (value.font.titlesize * 2 + 'rpx') + ';',
|
|
||||||
'font-weight': '600;',
|
|
||||||
'color': value.font.titlecolor + ';'
|
|
||||||
}">
|
|
||||||
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
|
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
|
||||||
:decimals="getvalue(item.title)" decimal="."></uv-count-to>
|
:decimals="getvalue(item.title)" decimal="."
|
||||||
<text :style="{
|
:color="value.font.titlecolor"
|
||||||
'margin-left': '4rpx;',
|
:fontSize="titleFontSize"
|
||||||
'font-size': (value.font.unitsize * 2 + 'rpx') + ';',
|
:bold="isBold(value.font.weight)"></uv-count-to>
|
||||||
'font-weight': value.font.weight + ';',
|
<text :style="unitStyle">{{ item.unit }}</text>
|
||||||
'color': value.font.unitcolor + ';'
|
|
||||||
}">{{ item.unit }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="graphic-text">
|
<view class="graphic-text">
|
||||||
<text :style="{
|
<text :style="descStyle">{{ item.desc }}</text>
|
||||||
'font-size': (value.font.descsize * 2 + 'rpx') + ';',
|
|
||||||
'font-weight': value.font.weight + ';',
|
|
||||||
'color': value.font.desccolor + ';'
|
|
||||||
}">{{ item.desc }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -54,10 +43,20 @@ export default {
|
|||||||
pageWidth: '',
|
pageWidth: '',
|
||||||
indicatorDots: false,
|
indicatorDots: false,
|
||||||
swiperCurrent: 0
|
swiperCurrent: 0
|
||||||
|
// #ifndef MP
|
||||||
|
,windowWidth: 375
|
||||||
|
// #endif
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
// 组件创建时的逻辑
|
// 组件创建时的逻辑
|
||||||
|
// #ifndef MP
|
||||||
|
try {
|
||||||
|
let info = uni.getSystemInfoSync();
|
||||||
|
this.windowWidth = info.windowWidth || info.screenWidth || 375;
|
||||||
|
if (this.windowWidth > 960) this.windowWidth = 375;
|
||||||
|
} catch (e) { }
|
||||||
|
// #endif
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
componentRefresh(newValue) {
|
componentRefresh(newValue) {
|
||||||
@@ -65,6 +64,42 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
// 全部返回字符串:WeChat 小程序的 style="{{obj}}" 对象绑定不会被运行时正确解析为内联 CSS
|
||||||
|
//(参考 diy-graphic-nav.vue:那里同样用字符串拼接 style="{{'width:...%'}}",能正常生效)。
|
||||||
|
// 字符串既能被 MP 直接当 cssText,也能被 H5 的 CSSOM 正确解析。
|
||||||
|
itemStyle() {
|
||||||
|
return 'width:' + (100 / this.value.rowCount) + '%;';
|
||||||
|
},
|
||||||
|
innerWrapStyle() {
|
||||||
|
return 'display:flex;justify-content:center;align-items:center;width:100%;';
|
||||||
|
},
|
||||||
|
innerRowStyle() {
|
||||||
|
return 'display:flex;align-items:baseline;justify-content:center;width:100%;line-height:1.2;';
|
||||||
|
},
|
||||||
|
unitStyle() {
|
||||||
|
// #ifdef MP
|
||||||
|
return 'margin-left:4rpx;font-size:' + (this.value.font.unitsize * 2) + 'rpx;font-weight:' + this.value.font.weight + ';color:' + this.value.font.unitcolor + ';';
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP
|
||||||
|
return 'margin-left:' + this.rpxToPx(4) + 'px;font-size:' + this.rpxToPx((Number(this.value.font.unitsize) || 12) * 2) + 'px;font-weight:' + this.value.font.weight + ';color:' + this.value.font.unitcolor + ';';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
descStyle() {
|
||||||
|
// #ifdef MP
|
||||||
|
return 'font-size:' + (this.value.font.descsize * 2) + 'rpx;font-weight:' + this.value.font.weight + ';color:' + this.value.font.desccolor + ';';
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP
|
||||||
|
return 'font-size:' + this.rpxToPx((Number(this.value.font.descsize) || 12) * 2) + 'px;font-weight:' + this.value.font.weight + ';color:' + this.value.font.desccolor + ';';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
titleFontSize() {
|
||||||
|
// #ifdef MP
|
||||||
|
return this.value.font.titlesize || 22;
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP
|
||||||
|
return this.rpxToPx((Number(this.value.font.titlesize) || 22) * 2);
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
componentStyle() {
|
componentStyle() {
|
||||||
let style = '';
|
let style = '';
|
||||||
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
|
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
|
||||||
@@ -83,11 +118,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// #ifndef MP
|
||||||
|
// rpx -> px:H5 行内样式里的 rpx 不生效,统一换算成 px
|
||||||
|
rpxToPx(rpx) {
|
||||||
|
let num = Number(rpx) || 0;
|
||||||
|
if (!num) return 0;
|
||||||
|
return Math.round((num * (this.windowWidth || 375) / 750) * 100) / 100;
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
|
||||||
// 获取小数位数
|
// 获取小数位数
|
||||||
getvalue(value) {
|
getvalue(value) {
|
||||||
return value % 1 !== 0 ? 2 : 0;
|
return value % 1 !== 0 ? 2 : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 是否加粗
|
||||||
|
isBold(weight) {
|
||||||
|
return weight === 'bold' || (weight && Number(weight) >= 600);
|
||||||
|
},
|
||||||
|
|
||||||
// 页面跳转
|
// 页面跳转
|
||||||
redirectTo(item) {
|
redirectTo(item) {
|
||||||
if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||||
@@ -257,4 +306,5 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -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 }"
|
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
|
||||||
:style="style">
|
:style="style">
|
||||||
<block v-for="(item, index) in value.list" :key="index">
|
<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' }">
|
: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"
|
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
|
||||||
show-menu-by-longpress />
|
show-menu-by-longpress />
|
||||||
@@ -44,7 +44,44 @@ export default {
|
|||||||
componentRefresh: function (nval) { }
|
componentRefresh: function (nval) { }
|
||||||
},
|
},
|
||||||
components: {},
|
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: {
|
computed: {
|
||||||
style() {
|
style() {
|
||||||
let style = {},
|
let style = {},
|
||||||
|
|||||||
@@ -6,32 +6,79 @@
|
|||||||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
||||||
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
||||||
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
||||||
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
<view v-if="value && 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" />
|
<!-- Banner 图片高度策略(与商家端预览 .preview-banner height:auto 对齐):
|
||||||
|
默认 mode="widthFix":宽度撑满、高度按图片原始比例自适应,图片完整显示不裁剪;
|
||||||
|
显式配置 bannerHeight/banner_height 或 bannerCropRatio → 切 aspectFill 居中裁剪;
|
||||||
|
显式配置 bannerCropBox → 切 widthFix + image 内联 transform translate/缩放(手动裁栏框);
|
||||||
|
裁剪位置(bannerCropPosition):uni-app image 不支持 object-position,
|
||||||
|
通过 .banner-img-crop 容器的 transform translateY 控制(top/居中/底部)。 -->
|
||||||
|
<view class="banner-img-wrap" :class="bannerImgWrapClass" :style="bannerImgWrapStyle">
|
||||||
|
<view class="banner-img-crop" :class="'crop-' + bannerCropPosition">
|
||||||
|
<image :src="$util.img(value.form_banner)" :mode="bannerImgMode" class="banner-img" :style="bannerImgStyle" />
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 卡片浮起模式:tip / 提交按钮位置可配(仅 card 风格生效)
|
||||||
|
- tip_position: outside(默认)= 独立白卡负 margin 压住 banner 底部;inside = 内容卡内
|
||||||
|
- submit_position: inside(默认)= 提交按钮在内容卡内;outside = 卡片下方独立区域 -->
|
||||||
|
<template v-if="hasBannerMode">
|
||||||
|
<!-- tip 独立卡(outside,默认):负 margin 压 banner + 白底 + 圆角 + 阴影,视觉参照商家端预览 .preview-tip -->
|
||||||
|
<view v-if="value && value.show_tip && value.form_tip && tipPosition === 'outside'" class="form-tip-card" :style="tipCardStyle">{{ value.form_tip }}</view>
|
||||||
|
<!-- 内容卡:tipPosition !== 'outside'(tip 关闭或 tip 在卡内)时由本卡接管负 margin 浮起 -->
|
||||||
|
<view class="form-card" :style="cardWrapStyle">
|
||||||
|
<!-- tip 卡内(inside):无独立卡视觉,用户配置的 bg/border/shadow 仍生效 -->
|
||||||
|
<view v-if="value && value.show_tip && value.form_tip && tipPosition !== 'outside'" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
|
||||||
|
<!-- 表单组件 -->
|
||||||
|
<ns-newform
|
||||||
|
:data="formComponents"
|
||||||
|
:customAttr="value"
|
||||||
|
:mode="mergedFormConfig.formLayout || 'vertical'"
|
||||||
|
:formConfig="mergedFormConfig"
|
||||||
|
ref="nsNewform"
|
||||||
|
></ns-newform>
|
||||||
|
<!-- 提交按钮(inside,默认):在内容卡内 -->
|
||||||
|
<view v-if="showSubmit && submitPosition !== 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
|
||||||
|
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 提交按钮独立区(outside):卡片下方,无卡背景 -->
|
||||||
|
<view v-if="showSubmit && submitPosition === 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
|
||||||
|
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<!-- 无 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>
|
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||||
<!-- 表单组件 -->
|
<!-- 表单组件 -->
|
||||||
<ns-newform
|
<ns-newform
|
||||||
:data="formComponents"
|
:data="formComponents"
|
||||||
:customAttr="value"
|
:customAttr="value"
|
||||||
:mode="mergedFormConfig.formLayout || 'horizontal'"
|
:mode="mergedFormConfig.formLayout || 'vertical'"
|
||||||
:formConfig="mergedFormConfig"
|
:formConfig="mergedFormConfig"
|
||||||
ref="nsNewform"
|
ref="nsNewform"
|
||||||
></ns-newform>
|
></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 v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||||
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 DiyMinx from './minx.js'
|
||||||
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
||||||
|
|
||||||
|
// 横幅裁剪比例白名单(与后端 form.js syncFormConfigToCSS 保持一致)
|
||||||
|
const BANNER_CROP_RATIOS = { '16:9': 16/9, '4:3': 4/3, '1:1': 1, '3:4': 3/4 }
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'diy-from',
|
name: 'diy-from',
|
||||||
components: { NsNewform },
|
components: { NsNewform },
|
||||||
@@ -52,6 +99,20 @@ export default {
|
|||||||
showSubmit: {
|
showSubmit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
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],
|
mixins: [DiyMinx],
|
||||||
@@ -97,24 +158,292 @@ export default {
|
|||||||
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
|
// Banner 图片高度模式(2026-09-03 对齐商家端预览):
|
||||||
|
// 商家端预览 .preview-banner 是 max-width:100%; height:auto —— 按图片原始比例完整显示。
|
||||||
|
// 此前 H5 固定 180rpx + aspectFill 居中裁剪,导致横幅高度不一/图片内容被裁缺失。
|
||||||
|
// 现在:无显式配置 → widthFix 自适应(与后端 height:auto 等价);
|
||||||
|
// 显式配置 bannerHeight / banner_height → 固定高度 + aspectFill 裁剪(用户主动选择);
|
||||||
|
// 显式配置 bannerCropRatio → 按比例锁高度,比例+高度都设时高度优先(保持数字 rpx 的语义);
|
||||||
|
// 显式配置 bannerCropBox → 手动裁栏框(widthFix + image transform,最高优先级,独享遮罩)。
|
||||||
|
hasBannerHeight() {
|
||||||
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const raw = cfg.bannerHeight !== undefined ? cfg.bannerHeight : cfg.banner_height;
|
||||||
|
const n = parseInt(raw);
|
||||||
|
return !isNaN(n) && n > 0;
|
||||||
|
},
|
||||||
|
bannerImgMode() {
|
||||||
|
// 手动裁栏框优先(widthFix + transform);
|
||||||
|
// 显式选择比例(16:9/4:3/...)时用 aspectFill 填满比例容器;
|
||||||
|
// 仅配置高度 + 原比例时与商家端预览一致:widthFix 自适应,容器限制最大高度,图片完整显示不裁剪。
|
||||||
|
if (this.hasManualCrop) return 'widthFix';
|
||||||
|
return this.bannerCropRatio ? 'aspectFill' : 'widthFix';
|
||||||
|
},
|
||||||
|
// Banner 图片包裹层高度:
|
||||||
|
// - 比例模式 / 手动框:输出固定 height,配合 aspectFill 裁剪;
|
||||||
|
// - 仅高度 + 原比例:输出 max-height,让图片按原始比例完整显示,高度不超过设定值。
|
||||||
|
bannerImgWrapStyle() {
|
||||||
|
if (!this.hasBannerHeight) return '';
|
||||||
|
let h = parseInt(this.mergedFormConfig.bannerHeight !== undefined ? this.mergedFormConfig.bannerHeight : this.mergedFormConfig.banner_height);
|
||||||
|
if (h < 40) h = 40;
|
||||||
|
if (h > 1200) h = 1200;
|
||||||
|
if (this.bannerCropRatio || this.hasManualCrop) {
|
||||||
|
return this.$util.objToStyle({ height: h + 'rpx' });
|
||||||
|
}
|
||||||
|
return this.$util.objToStyle({ 'max-height': h + 'rpx' });
|
||||||
|
},
|
||||||
|
// 裁剪比例:读双键(camel + snake);合法值 16:9/4:3/1:1/3:4;空 = 不启用
|
||||||
|
bannerCropRatio() {
|
||||||
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const raw = cfg.bannerCropRatio !== undefined ? cfg.bannerCropRatio : cfg.banner_crop_ratio;
|
||||||
|
return (raw && BANNER_CROP_RATIOS[raw]) ? raw : '';
|
||||||
|
},
|
||||||
|
// 裁剪位置:top/center/bottom(默认 center);通过 CSS object-position 控制(uni-app H5/MP 容器配合 transform 实现)
|
||||||
|
bannerCropPosition() {
|
||||||
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const raw = cfg.bannerCropPosition !== undefined ? cfg.bannerCropPosition : cfg.banner_crop_position;
|
||||||
|
return (raw === 'top' || raw === 'bottom') ? raw : 'center';
|
||||||
|
},
|
||||||
|
// 手动裁栏框:读双键;返回 {x,y,w,h} 0..1 比例或 null
|
||||||
|
// 三优先级:手动框 > 比例/位置 > 高度/位置(与后端 syncFormConfigToCSS 对齐)
|
||||||
|
bannerCropBox() {
|
||||||
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const raw = cfg.bannerCropBox !== undefined ? cfg.bannerCropBox : cfg.banner_crop_box;
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
if (obj && typeof obj.x === 'number' && typeof obj.y === 'number' && typeof obj.w === 'number' && typeof obj.h === 'number' && obj.w > 0 && obj.h > 0) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
} catch (e) { return null; }
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
// 手动裁栏框激活时:渲染模式切 widthFix(保留全图)+ 内层 img 用 transform 移动/缩放显示裁栏区域
|
||||||
|
hasManualCrop() { return !!this.bannerCropBox; },
|
||||||
|
// 手动裁栏框下 image 内联样式:放大到原图尺寸,按 box 坐标 translate 显示
|
||||||
|
// 用 rpx 转换(MP 原生 rpx / H5 换算 px)
|
||||||
|
bannerImgStyle() {
|
||||||
|
if (!this.hasManualCrop) return '';
|
||||||
|
const b = this.bannerCropBox;
|
||||||
|
const scaleX = 1 / b.w;
|
||||||
|
const scaleY = 1 / b.h;
|
||||||
|
const tx = -b.x * 100;
|
||||||
|
const ty = -b.y * 100;
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: (scaleX * 100) + '%',
|
||||||
|
height: (scaleY * 100) + '%',
|
||||||
|
maxWidth: 'none',
|
||||||
|
transform: 'translate(' + tx + '%, ' + ty + '%)',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 手动裁栏框下容器需要绝对定位 + 隐藏溢出
|
||||||
|
bannerImgWrapClass() {
|
||||||
|
const isAspectFill = this.bannerImgMode === 'aspectFill';
|
||||||
|
return {
|
||||||
|
'is-fixed-height': isAspectFill,
|
||||||
|
'has-crop': isAspectFill,
|
||||||
|
'has-manual-crop': this.hasManualCrop,
|
||||||
|
'has-height-limit': this.hasBannerHeight && !isAspectFill && !this.hasManualCrop
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// 表单风格:优先读显式 form_style(classic / card),未设置时回退到隐式判定(看 show_banner && form_banner)
|
||||||
|
// - classic:经典风格,平铺无 banner 卡片
|
||||||
|
// - card :卡片浮起风格,banner + 卡片浮起 + 容器内缩
|
||||||
|
// 注意:必须读 this.formCfg(原始 formConfig 对象)而不是 mergedFormConfig(白名单组装对象,不含 form_style 键)
|
||||||
|
// 双键兼容:formdata 链路 camel(formStyle,api splitFormConfig 转换);DIY 链路 snake(form_style,design.js 直存)
|
||||||
|
// 老表单无 form_style 字段时 → undefined → 走隐式判定,与原行为完全一致,零迁移风险
|
||||||
|
hasBannerMode() {
|
||||||
|
const v = this.value || {};
|
||||||
|
const fc = this.formCfg || {};
|
||||||
|
const style = fc.formStyle !== undefined ? fc.formStyle : fc.form_style;
|
||||||
|
if (style === 'card') return true;
|
||||||
|
if (style === 'classic') return false;
|
||||||
|
return !!(v.show_banner && v.form_banner);
|
||||||
|
},
|
||||||
|
// tip 提示语位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
|
||||||
|
// outside=独立白卡浮起压 banner(默认);inside=内容卡内(无独立卡视觉)
|
||||||
|
tipPosition() {
|
||||||
|
if (!this.hasBannerMode) return 'outside';
|
||||||
|
return this.mergedFormConfig.tipPosition === 'inside' ? 'inside' : 'outside';
|
||||||
|
},
|
||||||
|
// 提交按钮位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
|
||||||
|
// inside=内容卡内(默认);outside=卡片下方独立区域
|
||||||
|
submitPosition() {
|
||||||
|
if (!this.hasBannerMode) return 'outside';
|
||||||
|
return this.mergedFormConfig.submitPosition === 'outside' ? 'outside' : 'inside';
|
||||||
|
},
|
||||||
|
// 卡片容器内联样式:margin-top(负偏移+下移补偿)+ 左右外缩 + border-radius + box-shadow + background + border。
|
||||||
|
// clamp 与默认值对齐后端 form.js syncFormConfigToCSS:overlap 0-200(默认48) / radius 0-40(默认24) / sidePad 0-60(默认30);
|
||||||
|
// margin-top = -overlap + overlap_top(后端 --card-overlap 公式 (-overlap+overlapTop)/2 为 px,rpx 不除 2)
|
||||||
|
// tip 独立卡(outside + showTip)带队浮起时,本卡只留正常间距(后端预览 draggable margin 10px=20rpx)
|
||||||
|
// sidePad 语义(2026-09-03 与后端统一):卡片距屏幕左右距离(对齐后端 --card-side-pad,默认 30rpx=15px)。
|
||||||
|
// formdata 链路(containerInset>0)容器已有 padding,卡片负 margin 破壳后再缩 sidePad(与 banner bleed 同思路);
|
||||||
|
// DIY 链路(无 containerInset)直接 margin = sidePad。
|
||||||
|
// 卡内内容缩进由 ns-newform .form-wrap 的 formPaddingX 承担(与后端 .draggable-wrap padding: form-padding-x 同构)。
|
||||||
|
cardWrapStyle() {
|
||||||
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const parentInset = this._num(this.containerInset, 0);
|
||||||
|
let sidePad = this._num(cfg.cardSidePad, 30);
|
||||||
|
if (sidePad < 0) sidePad = 0;
|
||||||
|
if (sidePad > 60) sidePad = 60;
|
||||||
|
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
|
||||||
|
// overlap:负值向上压住 banner 底部;overlap_top 为下移补偿(可正可负,叠加生效)
|
||||||
|
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
|
||||||
|
if (overlap > 200) overlap = 200;
|
||||||
|
const overlapTop = this._num(cfg.cardOverlapTop, 0);
|
||||||
|
// banner 实际渲染时才用负 margin 压住它;banner 被隐藏(如 hideInnerBanner / 配置缺失)时
|
||||||
|
// 负 margin 会把卡片(含 tip)整体推出屏幕顶部,必须收回 0
|
||||||
|
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
|
||||||
|
// tip 独立卡存在时由 tip 卡带队压 banner,内容卡只留正常间距(20rpx ≈ 后端预览 10px)
|
||||||
|
const tipLeads = this.tipPosition === 'outside' && !!(this.value && this.value.show_tip && this.value.form_tip);
|
||||||
|
// 卡片圆角:缺省 24rpx(浮起卡片的圆角,比外层卡片的 16rpx 更明显,突出浮起感)
|
||||||
|
let radius = this._num(cfg.cardRadius, 24);
|
||||||
|
if (radius < 0) radius = 0;
|
||||||
|
if (radius > 40) radius = 40;
|
||||||
|
// 阴影:缺省中等阴影(与设计方案一致)—— 由本容器承担浮起感
|
||||||
|
const shadow = cfg.cardShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)';
|
||||||
|
// 卡片底色:纯白,与设计方案一致;可由后台 formConfig.cardBgColor 覆盖
|
||||||
|
const bgColor = cfg.cardBgColor || '#ffffff';
|
||||||
|
// 边框:宽度 rpx(0-20),留空/0 = 无边框
|
||||||
|
let borderWidth = this._num(cfg.cardBorderWidth, 0);
|
||||||
|
if (borderWidth < 0) borderWidth = 0;
|
||||||
|
if (borderWidth > 20) borderWidth = 20;
|
||||||
|
const style = {
|
||||||
|
marginTop: (bannerVisible ? (tipLeads ? 20 : (-overlap + overlapTop)) : 0) + 'rpx',
|
||||||
|
marginLeft: sideMargin + 'rpx',
|
||||||
|
marginRight: sideMargin + 'rpx',
|
||||||
|
borderRadius: radius + 'rpx',
|
||||||
|
boxShadow: shadow,
|
||||||
|
backgroundColor: bgColor
|
||||||
|
};
|
||||||
|
if (borderWidth > 0) {
|
||||||
|
style['border'] = borderWidth + 'rpx ' + (cfg.cardBorderStyle || 'solid') + ' ' + (cfg.cardBorderColor || 'transparent');
|
||||||
|
}
|
||||||
|
return this.$util.objToStyle(style);
|
||||||
|
},
|
||||||
|
// tip 独立卡样式(card + outside):负 margin 压 banner + 左右外缩 + 白底 + 圆角 + 阴影 + 细边框。
|
||||||
|
// 视觉参照后端预览 .form-style-card .preview-tip(margin: var(--card-overlap) var(--card-side-pad) 0;白底 12px 圆角 柔和阴影 左对齐)
|
||||||
|
// 用户配置的 tip 四态属性(bg/border/radius/shadow/padding/align)在此全部生效,仅默认值不同(独立卡默认白底卡视觉)
|
||||||
|
// sidePad 语义与 cardWrapStyle 一致:卡片距屏幕左右距离(默认 30rpx;formdata 链路破壳 sidePad-parentInset)
|
||||||
|
tipCardStyle() {
|
||||||
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
|
||||||
|
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
|
||||||
|
if (overlap > 200) overlap = 200;
|
||||||
|
const overlapTop = this._num(cfg.cardOverlapTop, 0);
|
||||||
|
const parentInset = this._num(this.containerInset, 0);
|
||||||
|
let sidePad = this._num(cfg.cardSidePad, 30);
|
||||||
|
if (sidePad < 0) sidePad = 0;
|
||||||
|
if (sidePad > 60) sidePad = 60;
|
||||||
|
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
|
||||||
|
const padT = this._num(cfg.tipPaddingTop, 32);
|
||||||
|
const padR = this._num(cfg.tipPaddingRight, 32);
|
||||||
|
const padB = this._num(cfg.tipPaddingBottom, 32);
|
||||||
|
const padL = this._num(cfg.tipPaddingLeft, 32);
|
||||||
|
const style = {
|
||||||
|
marginTop: (bannerVisible ? (-overlap + overlapTop) : 0) + 'rpx',
|
||||||
|
marginLeft: sideMargin + 'rpx',
|
||||||
|
marginRight: sideMargin + 'rpx',
|
||||||
|
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
|
||||||
|
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
||||||
|
'color': cfg.tipColor || '#1f2937',
|
||||||
|
'background-color': cfg.tipBgColor || '#ffffff',
|
||||||
|
'border-radius': `${this._num(cfg.tipRadius, 24)}rpx`,
|
||||||
|
'box-shadow': cfg.tipShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)',
|
||||||
|
'text-align': cfg.tipAlign || 'left'
|
||||||
|
};
|
||||||
|
// 边框:独立卡默认细边框(2rpx ≈ 后端预览 1px);配置 0 = 无边框
|
||||||
|
const bw = this._num(cfg.tipBorderWidth, 2);
|
||||||
|
if (bw > 0) {
|
||||||
|
style['border'] = `${bw}rpx ${cfg.tipBorderStyle || 'solid'} ${cfg.tipBorderColor || 'rgba(0,0,0,0.06)'}`;
|
||||||
|
}
|
||||||
|
return this.$util.objToStyle(style);
|
||||||
|
},
|
||||||
|
// 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)'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 提示语样式(classic 平铺 / card+inside 卡内共用):与后端 edit.html 内联 .preview-tip 对齐
|
||||||
|
// - 四边内边距:未配置时经典态 上/下=rowPaddingY(4) 左右=tipPaddingX(14);卡片态统一 32rpx
|
||||||
|
// - 边框:classic 用 border-bottom(宽度默认 0;旧数据仅配 color → 2rpx 底边);
|
||||||
|
// card 用全边框(配置宽度才输出)
|
||||||
|
// - 圆角 / 阴影(rpx 字符串) / 对齐(card 态默认左对齐,经典态居中)
|
||||||
|
// - 背景色:''=未配置 → 透明(独立卡白底兜底已挪到 tipCardStyle,卡内 tip 无独立卡视觉)
|
||||||
tipStyle() {
|
tipStyle() {
|
||||||
const cfg = this.mergedFormConfig || {};
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
const card = this.hasBannerMode;
|
||||||
|
const padT = this._num(cfg.tipPaddingTop, card ? 32 : this._num(cfg.rowPaddingY, 4));
|
||||||
|
const padR = this._num(cfg.tipPaddingRight, card ? 32 : this._num(cfg.tipPaddingX, 14));
|
||||||
|
const padB = this._num(cfg.tipPaddingBottom, card ? 32 : this._num(cfg.rowPaddingY, 4));
|
||||||
|
const padL = this._num(cfg.tipPaddingLeft, card ? 32 : this._num(cfg.tipPaddingX, 14));
|
||||||
const style = {
|
const style = {
|
||||||
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.tipPaddingX, 14)}rpx`,
|
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
|
||||||
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
||||||
'color': cfg.tipColor || '#000000',
|
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
|
||||||
'background-color': cfg.tipBgColor || 'transparent'
|
'background-color': cfg.tipBgColor || 'transparent',
|
||||||
|
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
|
||||||
};
|
};
|
||||||
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
|
// 边框:配置了宽度(>0)输出;未配宽度但配了颜色 → 旧数据兼容,2rpx 底边(仅经典态)
|
||||||
if (cfg.tipBorderColor) {
|
const bw = this._num(cfg.tipBorderWidth, 0);
|
||||||
|
if (bw > 0) {
|
||||||
|
const bs = cfg.tipBorderStyle || 'solid';
|
||||||
|
const bc = cfg.tipBorderColor || 'rgba(0,0,0,0.06)';
|
||||||
|
if (card) {
|
||||||
|
style['border'] = `${bw}rpx ${bs} ${bc}`;
|
||||||
|
} else {
|
||||||
|
style['border-bottom'] = `${bw}rpx ${bs} ${bc}`;
|
||||||
|
}
|
||||||
|
} else if (!card && cfg.tipBorderColor) {
|
||||||
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
|
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
|
||||||
}
|
}
|
||||||
|
// 圆角(rpx)
|
||||||
|
if (cfg.tipRadius !== undefined && cfg.tipRadius !== null && cfg.tipRadius !== '') {
|
||||||
|
style['border-radius'] = `${this._num(cfg.tipRadius, 0)}rpx`;
|
||||||
|
}
|
||||||
|
// 阴影:后端存 rpx 单位字符串(如 '0 4rpx 12rpx rgba(...)'),uniapp H5/MP 运行时支持 rpx
|
||||||
|
if (cfg.tipShadow) {
|
||||||
|
style['box-shadow'] = cfg.tipShadow;
|
||||||
|
}
|
||||||
return this.$util.objToStyle(style);
|
return this.$util.objToStyle(style);
|
||||||
},
|
},
|
||||||
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
|
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
|
||||||
|
// card + inside(默认)时按钮已在内容卡内:padding 收窄为卡内边距(≈后端预览 14px 12px 20px),不设独立背景(继承卡白底)
|
||||||
submitWrapStyle() {
|
submitWrapStyle() {
|
||||||
const cfg = this.mergedFormConfig || {};
|
const cfg = this.mergedFormConfig || {};
|
||||||
|
if (this.hasBannerMode && this.submitPosition !== 'outside') {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
||||||
|
padding: `24rpx 24rpx 40rpx`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.hasBannerMode) {
|
||||||
|
// card + outside:卡片下方独立区域 —— 与后端 .preview-submit-wrap(card 模式)同构:
|
||||||
|
// 左右破壳对齐卡片缩进(sidePad - parentInset),水平内缩 formPaddingX,
|
||||||
|
// 上下 padding 36rpx/48rpx(后端 18px/24px),背景白底(后端 .preview-submit-wrap 亦为白底)
|
||||||
|
const parentInset = this._num(this.containerInset, 0);
|
||||||
|
let sidePad = this._num(cfg.cardSidePad, 30);
|
||||||
|
if (sidePad < 0) sidePad = 0;
|
||||||
|
if (sidePad > 60) sidePad = 60;
|
||||||
|
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : 0;
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
||||||
|
marginLeft: sideMargin + 'rpx',
|
||||||
|
marginRight: sideMargin + 'rpx',
|
||||||
|
padding: `36rpx ${this._num(cfg.formPaddingX, 14)}rpx 48rpx`,
|
||||||
|
'background-color': cfg.formBgColor || '#ffffff'
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.$util.objToStyle({
|
return this.$util.objToStyle({
|
||||||
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
||||||
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
|
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
|
||||||
@@ -138,7 +467,7 @@ export default {
|
|||||||
width: widthPct + '%',
|
width: widthPct + '%',
|
||||||
padding: `${submitPaddingY}rpx 0`,
|
padding: `${submitPaddingY}rpx 0`,
|
||||||
color: cfg.submitColor || '#ffffff',
|
color: cfg.submitColor || '#ffffff',
|
||||||
'background-color': cfg.submitBgColor || '#ff4544',
|
'background-color': cfg.submitBgColor || '#3b82f6',
|
||||||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||||||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||||||
});
|
});
|
||||||
@@ -148,6 +477,10 @@ export default {
|
|||||||
submitText() {
|
submitText() {
|
||||||
return this.mergedFormConfig.submitText || '提交';
|
return this.mergedFormConfig.submitText || '提交';
|
||||||
},
|
},
|
||||||
|
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
|
||||||
|
hasCustomSubmit() {
|
||||||
|
return typeof this.onSubmit === 'function';
|
||||||
|
},
|
||||||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||||||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||||||
formCfg() {
|
formCfg() {
|
||||||
@@ -169,12 +502,33 @@ export default {
|
|||||||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||||||
return fc[snake];
|
return fc[snake];
|
||||||
};
|
};
|
||||||
|
// 多别名取值:后台不同版本 / 不同序列化路径对同一配置项可能用不同键名,
|
||||||
|
// 这里按优先级取第一个有效值,避免白名单裁剪时把后台配置丢掉(丢了下层永远读不到)
|
||||||
|
const pickAny = function(keys) {
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
var kv = fc[keys[i]];
|
||||||
|
if (kv !== undefined && kv !== null && kv !== '') return kv;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
|
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
|
||||||
const base = {
|
const base = {
|
||||||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||||||
|
// Banner 高度(rpx)。后台可配,默认 180rpx ≈ 页面 1/8 高度(适配主流手机屏高)
|
||||||
|
// 配合 mode="aspectFill" 居中裁剪,保证横幅只占页面八分之一左右
|
||||||
|
bannerHeight: pick('bannerHeight', 'banner_height'),
|
||||||
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
|
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
|
||||||
|
// 横幅裁剪位置(top/center/bottom)+ 比例(16:9/4:3/1:1/3:4)+ 手动裁栏框 JSON(2026-09-04 方案 C 抽屉配套)
|
||||||
|
bannerCropPosition: pick('bannerCropPosition', 'banner_crop_position'),
|
||||||
|
bannerCropRatio: pick('bannerCropRatio', 'banner_crop_ratio'),
|
||||||
|
bannerCropBox: pick('bannerCropBox', 'banner_crop_box'),
|
||||||
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
|
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
|
||||||
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
|
// 行内上下边距:兼容 row_padding_y / rowPaddingY / row_padding / rowPadding 等键名
|
||||||
|
rowPaddingY: (function() {
|
||||||
|
var v = pick('rowPaddingY', 'row_padding_y');
|
||||||
|
if (v !== undefined && v !== null && v !== '') return v;
|
||||||
|
return pickAny(['rowPadding', 'row_padding', 'rowPaddingVertical', 'row_padding_vertical', 'row_padding_top', 'rowPaddingTop']);
|
||||||
|
})(),
|
||||||
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
||||||
labelGap: pick('labelGap', 'label_gap'),
|
labelGap: pick('labelGap', 'label_gap'),
|
||||||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||||||
@@ -187,11 +541,24 @@ export default {
|
|||||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||||
labelColor: pick('labelColor', 'label_color'),
|
labelColor: pick('labelColor', 'label_color'),
|
||||||
|
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
|
||||||
|
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
|
||||||
requiredColor: pick('requiredColor', 'required_color'),
|
requiredColor: pick('requiredColor', 'required_color'),
|
||||||
|
// 必填星号位置:before=标签前(默认)/ after=标签后(终端无关,存 base 层)
|
||||||
|
requiredPosition: pick('requiredPosition', 'required_position'),
|
||||||
checkedColor: pick('checkedColor', 'checked_color'),
|
checkedColor: pick('checkedColor', 'checked_color'),
|
||||||
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
||||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
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'),
|
submitText: pick('submitText', 'submit_text'),
|
||||||
submitColor: pick('submitColor', 'submit_color'),
|
submitColor: pick('submitColor', 'submit_color'),
|
||||||
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
||||||
@@ -199,11 +566,37 @@ export default {
|
|||||||
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
||||||
submitSize: pick('submitSize', 'submit_size'),
|
submitSize: pick('submitSize', 'submit_size'),
|
||||||
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
||||||
// 提示语
|
// 提示语(与后端 form.js syncFormConfigToCSS 的 --tip-* 变量一一对应)
|
||||||
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
||||||
tipColor: pick('tipColor', 'tip_color'),
|
tipColor: pick('tipColor', 'tip_color'),
|
||||||
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
||||||
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
|
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
|
||||||
|
// 边框:宽度(rpx)/线型;未配置宽度时保留旧逻辑(仅 color 配置 → 底边 2rpx)
|
||||||
|
tipBorderWidth: pick('tipBorderWidth', 'tip_border_width'),
|
||||||
|
tipBorderStyle: pick('tipBorderStyle', 'tip_border_style'),
|
||||||
|
// 独立样式:圆角 / 四边内边距 / 阴影 / 对齐('' 与 null 均走 delete → 用各自态的兜底默认)
|
||||||
|
tipRadius: pick('tipRadius', 'tip_radius'),
|
||||||
|
tipPaddingTop: pick('tipPaddingTop', 'tip_padding_top'),
|
||||||
|
tipPaddingRight: pick('tipPaddingRight', 'tip_padding_right'),
|
||||||
|
tipPaddingBottom: pick('tipPaddingBottom', 'tip_padding_bottom'),
|
||||||
|
tipPaddingLeft: pick('tipPaddingLeft', 'tip_padding_left'),
|
||||||
|
tipShadow: pick('tipShadow', 'tip_shadow'),
|
||||||
|
tipAlign: pick('tipAlign', 'tip_align'),
|
||||||
|
// 卡片浮起(仅 show_banner && form_banner 启用)
|
||||||
|
cardOverlap: pick('cardOverlap', 'card_overlap'),
|
||||||
|
// 额外下移补偿(rpx,可正可负,与 overlap 叠加:margin-top = -overlap + overlap_top)
|
||||||
|
cardOverlapTop: pick('cardOverlapTop', 'card_overlap_top'),
|
||||||
|
cardRadius: pick('cardRadius', 'card_radius'),
|
||||||
|
cardShadow: pick('cardShadow', 'card_shadow'),
|
||||||
|
cardSidePad: pick('cardSidePad', 'card_side_pad'),
|
||||||
|
cardBgColor: pick('cardBgColor', 'card_bg_color'),
|
||||||
|
// 卡片边框(宽度 rpx / CSS 线型 / 颜色;留空 = 无边框)
|
||||||
|
cardBorderWidth: pick('cardBorderWidth', 'card_border_width'),
|
||||||
|
cardBorderStyle: pick('cardBorderStyle', 'card_border_style'),
|
||||||
|
cardBorderColor: pick('cardBorderColor', 'card_border_color'),
|
||||||
|
// 位置控制(仅 card 风格生效;outside/inside 见 tipPosition/submitPosition computed)
|
||||||
|
tipPosition: pick('tipPosition', 'tip_position'),
|
||||||
|
submitPosition: pick('submitPosition', 'submit_position')
|
||||||
};
|
};
|
||||||
// 移除 undefined / 空字符串(保留 0/false)
|
// 移除 undefined / 空字符串(保留 0/false)
|
||||||
Object.keys(base).forEach(function(k) {
|
Object.keys(base).forEach(function(k) {
|
||||||
@@ -211,9 +604,33 @@ export default {
|
|||||||
delete base[k];
|
delete base[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 前端视觉默认:仅作兜底(后端未配置的字段才生效)。
|
||||||
|
// 注意合并顺序:base 在后 —— 后台 formConfig 显式配置的值(如 submit_bg_color / input_radius)
|
||||||
|
// 必须覆盖前端默认,否则商家端样式配置完全不生效(曾因此导致提交按钮恒为蓝色)。
|
||||||
|
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 子对象内)
|
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
||||||
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
||||||
const result = Object.assign({}, base);
|
const result = Object.assign({}, styleDefaults, base);
|
||||||
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
||||||
if (termBlock && typeof termBlock === 'object') {
|
if (termBlock && typeof termBlock === 'object') {
|
||||||
Object.keys(termBlock).forEach(function(platform) {
|
Object.keys(termBlock).forEach(function(platform) {
|
||||||
@@ -279,24 +696,26 @@ export default {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 提交表单
|
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
|
||||||
submitForm() {
|
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
|
||||||
var self = this;
|
handleSubmit() {
|
||||||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||||||
if (!this.verify()) {
|
if (!this.verify()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const formData = this.getFormData();
|
||||||
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
|
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
|
||||||
if (this.$listeners && this.$listeners.submit) {
|
if (this.hasCustomSubmit) {
|
||||||
this.$emit('submit', this.getFormData());
|
return this.onSubmit(formData);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// 兜底:内置 sendRequest 流程
|
||||||
|
this.defaultSubmit(formData);
|
||||||
|
},
|
||||||
|
|
||||||
// 获得保单数据
|
// 公共兜底实现:默认的 /form/api/form/create 流程;
|
||||||
const submitFormData = this.getFormData();
|
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
|
||||||
|
defaultSubmit(submitFormData) {
|
||||||
// 提交到后端
|
var self = this;
|
||||||
this.$api.sendRequest({
|
this.$api.sendRequest({
|
||||||
url: '/form/api/form/create',
|
url: '/form/api/form/create',
|
||||||
data: {
|
data: {
|
||||||
@@ -308,7 +727,7 @@ export default {
|
|||||||
success: function(res) {
|
success: function(res) {
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '提交成功',
|
title: self.$lang('submitSuccess'),
|
||||||
icon: 'success'
|
icon: 'success'
|
||||||
});
|
});
|
||||||
// 重置表单
|
// 重置表单
|
||||||
@@ -317,7 +736,7 @@ export default {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: res.message || '提交失败',
|
title: res.message || self.$lang('submitFail'),
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -340,12 +759,139 @@ export default {
|
|||||||
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
|
||||||
|
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
|
||||||
|
&.is-card-overlap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 横幅图片容器:默认高度由 image widthFix 按图片比例自适应(与商家端预览 height:auto 对齐);
|
||||||
|
// .is-fixed-height(显式配置 bannerHeight)时高度由 bannerImgWrapStyle 内联写入,
|
||||||
|
// 配合 mode="aspectFill" 居中裁剪填充。
|
||||||
|
.banner-img-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// 默认作为 flex 容器,支持 widthFix + max-height 模式下的顶部/居中/底部对齐
|
||||||
|
.banner-img-crop {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.crop-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.crop-bottom {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-fixed-height {
|
||||||
|
.banner-img {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用 aspectFill 裁剪时,包装一层 .banner-img-crop 承接裁剪位置。
|
||||||
|
// 重要:uni-app image 在 H5 端固定转译为 <div style="background-image:url; background-size:cover; background-position:center center;"></div>,
|
||||||
|
// 内层 div 的 background-position 是运行时生成的 inline 样式,普通 CSS 覆盖不了,必须 !important;
|
||||||
|
// (不能用 .banner-img 的 translateY 实现位置:image 高度被钉死为容器 100% 后 transform 一去一回相互抵消,是零位移)
|
||||||
|
// - crop-top → background-position: center top(显示图片顶部区域)
|
||||||
|
// - crop-center → 默认 center center(不覆盖)
|
||||||
|
// - crop-bottom → background-position: center bottom(显示图片底部区域)
|
||||||
|
&.has-crop {
|
||||||
|
.banner-img-crop {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.banner-img {
|
||||||
|
// 撑满 wrap,让内层 background-image div 拿到确定高度(uni-image H5 端转译依赖)
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
// uni-app H5 编译器会给样式追加 [data-v-xxx](即使未写 scoped),
|
||||||
|
// 而 uni-image 运行时 JS 生成的内层 div 没有该属性 → 必须 ::v-deep 把属性选择器挪到 .banner-img 上
|
||||||
|
.banner-img-crop.crop-top .banner-img ::v-deep div {
|
||||||
|
background-position: center top !important;
|
||||||
|
}
|
||||||
|
.banner-img-crop.crop-bottom .banner-img ::v-deep div {
|
||||||
|
background-position: center bottom !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用手动裁栏框(bannerCropBox 非空)时:image 用 transform 显示裁栏部分,原图按 box 缩放 + 平移
|
||||||
|
&.has-manual-crop {
|
||||||
|
.banner-img-crop {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.banner-img {
|
||||||
|
// 手动框下由 bannerImgStyle 内联 width/height/transform 驱动,不固定 100%
|
||||||
|
height: auto;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.banner-img {
|
.banner-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
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);
|
||||||
|
// 关键:裁剪卡内子元素(ns-newform 白底 / submit 区)到圆角内。
|
||||||
|
// tip 关闭时本卡带队负 margin 压 banner,若无 overflow:hidden,
|
||||||
|
// 卡内第一个子元素的方形背景会盖住卡片上圆角(banner 图片在圆角外的部分被遮)。
|
||||||
|
// 商家端预览 .draggable-wrap 同样有 overflow:hidden(form.css 两处同步)。
|
||||||
|
overflow: hidden;
|
||||||
|
// 兜底 padding-bottom:让卡片与后面内容(如推荐码、其它 diy 组件)有间距
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tip 独立卡(card + tip_position=outside):浮在 banner 上方的独立白卡。
|
||||||
|
// margin/padding/白底/圆角/阴影/边框 全部由 tipCardStyle 内联写入;
|
||||||
|
// 必须自带 z-index 高于 banner(z-index:1)才能真正"盖住"banner 底部形成浮起。
|
||||||
|
.form-tip-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.form-tip {
|
.form-tip {
|
||||||
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -64,6 +64,13 @@
|
|||||||
<diy-rich-text :value="item"></diy-rich-text>
|
<diy-rich-text :value="item"></diy-rich-text>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- #ifdef H5 -->
|
||||||
|
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
|
||||||
|
<!-- 网页容器 -->
|
||||||
|
<diy-webview :value="item"></diy-webview>
|
||||||
|
</template>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
<template v-if="item.componentName == 'HorzLine'">
|
<template v-if="item.componentName == 'HorzLine'">
|
||||||
<!-- 辅助线 -->
|
<!-- 辅助线 -->
|
||||||
<diy-horz-line :value="item"></diy-horz-line>
|
<diy-horz-line :value="item"></diy-horz-line>
|
||||||
@@ -159,7 +166,10 @@
|
|||||||
<!-- 商品推荐 -->
|
<!-- 商品推荐 -->
|
||||||
<diy-goods-recommend :value="item"></diy-goods-recommend>
|
<diy-goods-recommend :value="item"></diy-goods-recommend>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="item.componentName == 'DiyRecommendCode'">
|
||||||
|
<!-- 自定义推荐码组件 -->
|
||||||
|
<diy-recommend-code :value="item"></diy-recommend-code>
|
||||||
|
</template>
|
||||||
<template v-if="item.componentName == 'GoodsBrand'">
|
<template v-if="item.componentName == 'GoodsBrand'">
|
||||||
<!-- 商品品牌 -->
|
<!-- 商品品牌 -->
|
||||||
<diy-goods-brand :value="item"></diy-goods-brand>
|
<diy-goods-brand :value="item"></diy-goods-brand>
|
||||||
@@ -306,9 +316,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
console.log('[DIY-GROUP][created] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
|
||||||
this.processData();
|
this.processData();
|
||||||
},
|
},
|
||||||
mounted() {
|
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) {
|
if (this.diyData && this.diyData.value) {
|
||||||
this._notifyLoaded();
|
this._notifyLoaded();
|
||||||
}
|
}
|
||||||
@@ -349,8 +361,10 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
_notifyLoaded() {
|
_notifyLoaded() {
|
||||||
|
console.log('[DIY-GROUP][_notifyLoaded] enter, will emit loaded in 300ms');
|
||||||
if (this._loadedTimer) clearTimeout(this._loadedTimer);
|
if (this._loadedTimer) clearTimeout(this._loadedTimer);
|
||||||
this._loadedTimer = setTimeout(() => {
|
this._loadedTimer = setTimeout(() => {
|
||||||
|
console.log('[DIY-GROUP][_notifyLoaded] emit loaded');
|
||||||
this.$emit('loaded');
|
this.$emit('loaded');
|
||||||
}, 300);
|
}, 300);
|
||||||
},
|
},
|
||||||
@@ -370,14 +384,41 @@ export default {
|
|||||||
: index + 1;
|
: index + 1;
|
||||||
|
|
||||||
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
|
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
|
||||||
newItem.pageStyle = `background-color:${newItem.pageBgColor};`;
|
// PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色
|
||||||
|
// 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体;
|
||||||
|
// 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。
|
||||||
|
const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor)
|
||||||
|
? newItem.componentBgColor
|
||||||
|
: newItem.pageBgColor;
|
||||||
|
newItem.pageStyle = `background-color:${wrapBgColor};`;
|
||||||
|
|
||||||
if (newItem.margin) {
|
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
|
||||||
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
|
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
|
||||||
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
|
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
|
||||||
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
|
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
|
||||||
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
|
// both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致)
|
||||||
|
const rawMargin = newItem.margin;
|
||||||
|
const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null;
|
||||||
|
const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
|
||||||
|
const pickMargin = (objKey, flatKey) => {
|
||||||
|
if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') {
|
||||||
|
return toMarginNum(objMargin[objKey]);
|
||||||
}
|
}
|
||||||
|
return toMarginNum(newItem[flatKey]);
|
||||||
|
};
|
||||||
|
const marginTopNum = pickMargin('top', 'marginTop');
|
||||||
|
const marginBottomNum = pickMargin('bottom', 'marginBottom');
|
||||||
|
const marginBothNum = pickMargin('both', 'marginBoth');
|
||||||
|
// 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致)
|
||||||
|
const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left)
|
||||||
|
: (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum);
|
||||||
|
const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right)
|
||||||
|
: (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum);
|
||||||
|
|
||||||
|
newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`;
|
||||||
|
newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`;
|
||||||
|
newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`;
|
||||||
|
newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
|
||||||
:scroll-x="value.showStyle == 'singleSlide'">
|
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
|
||||||
|
注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效),
|
||||||
|
无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar),
|
||||||
|
H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。
|
||||||
|
fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 -->
|
||||||
|
<view class="image-nav-wrap">
|
||||||
|
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
|
||||||
|
:style="imageSpacingStyle"
|
||||||
|
:scroll-x="value.showStyle == 'singleSlide'"
|
||||||
|
:show-scrollbar="showScrollbarAttr"
|
||||||
|
:enhanced="enhancedScrollbar"
|
||||||
|
@scroll="onSlideScroll">
|
||||||
<view class="uni-scroll-view-content">
|
<view class="uni-scroll-view-content">
|
||||||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||||
style="margin-right: 28rpx;">
|
:style="imageItemStyle">
|
||||||
<!-- 图片部分 -->
|
<!-- 图片部分 -->
|
||||||
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
<!-- PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||||||
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
图片按后台上传/配置的尺寸渲染,避免 uni <image> 取不到尺寸时
|
||||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
回退到浏览器默认 320x240 而被"撑到最大";mode=aspectFit 保证不拉伸变形 -->
|
||||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
<view v-if="value.mode != 'text'" class="image-img" :style="imageBoxStyle(item)">
|
||||||
}">
|
<image v-if="item.link.wap_url" class="image-content" :style="imageInnerStyle(item)"
|
||||||
<image v-if="item.link.wap_url" :style="{
|
mode="aspectFit"
|
||||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
|
||||||
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
|
||||||
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||||||
<image v-else :style="{
|
<image v-else class="image-content" :style="imageInnerStyle(item)" mode="aspectFit"
|
||||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
|
||||||
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
|
||||||
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -33,6 +40,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
<!-- #ifdef MP -->
|
||||||
|
<!-- 小程序端自定义水平滚动条: 原生 show-scrollbar 被忽略, 这里自绘以对齐后台/ H5 -->
|
||||||
|
<view v-if="showCustomScrollbar" class="img-nav-scrollbar">
|
||||||
|
<view class="img-nav-scrollbar-thumb" :style="scrollbarThumbStyle"></view>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
<ns-login ref="login"></ns-login>
|
<ns-login ref="login"></ns-login>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,7 +74,12 @@ export default {
|
|||||||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||||||
slideStartX: 0,
|
slideStartX: 0,
|
||||||
slideStartScrollLeft: 0,
|
slideStartScrollLeft: 0,
|
||||||
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
|
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||||
|
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
|
||||||
|
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
|
||||||
|
slideScrolling: false,
|
||||||
|
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
|
||||||
|
mpScroll: { viewW: 0, contentW: 0, left: 0 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -75,23 +94,94 @@ export default {
|
|||||||
var self = this;
|
var self = this;
|
||||||
this.$nextTick(function() {
|
this.$nextTick(function() {
|
||||||
self.bindSlideMouseEvents();
|
self.bindSlideMouseEvents();
|
||||||
|
self.bindSlideScrollbar();
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
// 小程序端:测量滚动条所需的视口/内容宽度(图片尺寸由内联样式决定, 渲染后稳定)
|
||||||
|
// 多次(0/300/1000ms)重测: 首次渲染时节点可能尚未布局完成, 单次测量容易拿不到宽度
|
||||||
|
// → 滚动条不显示; 之后的测量只覆盖有效值, 不会把已测得的宽度清零。
|
||||||
|
// #ifdef MP
|
||||||
|
var selfMp = this;
|
||||||
|
this.$nextTick(function() {
|
||||||
|
selfMp.measureScrollbar();
|
||||||
|
});
|
||||||
|
this._mpMeasureTimers = [300, 1000].map(function(delay) {
|
||||||
|
return setTimeout(function() {
|
||||||
|
selfMp.measureScrollbar();
|
||||||
|
}, delay);
|
||||||
});
|
});
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
this.unbindSlideMouseEvents();
|
this.unbindSlideMouseEvents();
|
||||||
|
this.unbindSlideScrollbar();
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP
|
||||||
|
if (this._mpMeasureTimers) {
|
||||||
|
this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); });
|
||||||
|
this._mpMeasureTimers = null;
|
||||||
|
}
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
componentRefresh(newValue) {
|
componentRefresh(newValue) {
|
||||||
// 监听组件刷新
|
// 监听组件刷新
|
||||||
|
},
|
||||||
|
// 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条
|
||||||
|
'value.list'(newVal, oldVal) {
|
||||||
|
// #ifdef MP
|
||||||
|
if (newVal !== oldVal) {
|
||||||
|
var self = this;
|
||||||
|
this.$nextTick(function() {
|
||||||
|
self.measureScrollbar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
|
||||||
|
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
|
||||||
|
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
|
||||||
|
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
|
||||||
|
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
|
||||||
|
componentBgColor() {
|
||||||
|
const v = this.value || {};
|
||||||
|
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||||
|
const asColor = val => {
|
||||||
|
if (Array.isArray(val)) val = val[0];
|
||||||
|
if (typeof val !== 'string') return '';
|
||||||
|
const s = val.trim();
|
||||||
|
if (!s) return '';
|
||||||
|
// 排除图片/URL 等非颜色值
|
||||||
|
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const sources = [v];
|
||||||
|
['style', 'config', 'setting', 'data'].forEach(k => {
|
||||||
|
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||||||
|
});
|
||||||
|
const candidates = [
|
||||||
|
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
|
||||||
|
'bgcolor', 'backgroundcolor', 'background',
|
||||||
|
'pagebgcolor'
|
||||||
|
];
|
||||||
|
for (let c = 0; c < candidates.length; c++) {
|
||||||
|
for (let s = 0; s < sources.length; s++) {
|
||||||
|
for (const key in sources[s]) {
|
||||||
|
if (norm(key) === candidates[c]) {
|
||||||
|
const color = asColor(sources[s][key]);
|
||||||
|
if (color) return color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
componentStyle() {
|
componentStyle() {
|
||||||
let style = '';
|
let style = '';
|
||||||
style += 'background-color:' + this.value.componentBgColor + ';';
|
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
|
||||||
|
|
||||||
if (this.value.componentAngle == 'round') {
|
if (this.value.componentAngle == 'round') {
|
||||||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||||
@@ -123,9 +213,446 @@ export default {
|
|||||||
isIndicatorDots() {
|
isIndicatorDots() {
|
||||||
return this.value.carousel.type != 'hide' &&
|
return this.value.carousel.type != 'hide' &&
|
||||||
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||||||
|
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
|
||||||
|
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
|
||||||
|
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
|
||||||
|
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
|
||||||
|
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
|
||||||
|
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
|
||||||
|
scrollBarMode() {
|
||||||
|
const v = this.value || {};
|
||||||
|
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||||
|
// 从配置对象中取"滚动条"字段的原始值
|
||||||
|
const pickRaw = obj => {
|
||||||
|
if (!obj || typeof obj != 'object') return undefined;
|
||||||
|
for (const key in obj) {
|
||||||
|
const k = norm(key);
|
||||||
|
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
|
||||||
|
const val = obj[key];
|
||||||
|
if (val && typeof val === 'object') {
|
||||||
|
// 子配置对象形态: { scrollBar: { type: 'always' } }
|
||||||
|
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
|
||||||
|
}
|
||||||
|
if (val !== undefined && val !== null && val !== '') return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw = pickRaw(v);
|
||||||
|
// 嵌套容器兜底
|
||||||
|
if (raw === undefined) {
|
||||||
|
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
|
||||||
|
for (let i = 0; i < containers.length; i++) {
|
||||||
|
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
|
||||||
|
raw = pickRaw(v[containers[i]]);
|
||||||
|
if (raw !== undefined) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === undefined || raw === null || raw === '') return 'slide';
|
||||||
|
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
|
||||||
|
if (raw === 0 || raw === '0') return 'slide';
|
||||||
|
if (raw === 1 || raw === '1') return 'always';
|
||||||
|
if (raw === 2 || raw === '2') return 'never';
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
|
||||||
|
always: 'always', alwaysdisplay: 'always', show: 'always',
|
||||||
|
never: 'never', none: 'never', hidden: 'never', hide: 'never',
|
||||||
|
// 后台直出中文枚举时兜底
|
||||||
|
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
|
||||||
|
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
|
||||||
|
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
|
||||||
|
};
|
||||||
|
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
|
||||||
|
return hit || 'slide';
|
||||||
|
},
|
||||||
|
|
||||||
|
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
|
||||||
|
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
|
||||||
|
scrollBarClass() {
|
||||||
|
const cls = ['scrollbar-' + this.scrollBarMode];
|
||||||
|
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
|
||||||
|
return cls;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 原生 show-scrollbar / enhanced 仅在 App 端生效, 小程序端被忽略且会干扰原生行为,
|
||||||
|
// 故小程序端一律关闭, 改用自定义滚动条; H5 端这两个属性为 no-op, 不影响。
|
||||||
|
enhancedScrollbar() {
|
||||||
|
// #ifndef MP
|
||||||
|
return this.scrollBarMode != 'never';
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP
|
||||||
|
return false;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
showScrollbarAttr() {
|
||||||
|
// #ifndef MP
|
||||||
|
return this.scrollBarMode != 'never';
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP
|
||||||
|
return false;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// 小程序端自定义滚动条的"是否显示"
|
||||||
|
showCustomScrollbar() {
|
||||||
|
// #ifdef MP
|
||||||
|
if (this.scrollBarMode === 'never') return false;
|
||||||
|
// 只有 singleSlide(横向可滚动)才有滚动条, 与 H5 的 .singleSlide 样式作用域保持一致
|
||||||
|
if (this.value.showStyle != 'singleSlide') return false;
|
||||||
|
// 内容没有溢出(无需横向滚动)则不显示
|
||||||
|
if (!(this.mpScroll.contentW > this.mpScroll.viewW)) return false;
|
||||||
|
// 滑动时显示: 仅在滚动中显示
|
||||||
|
if (this.scrollBarMode === 'slide') return this.slideScrolling;
|
||||||
|
// 始终显示: 一直显示
|
||||||
|
return true;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// 小程序端自定义滚动条 thumb 的内联样式(width + 位移)
|
||||||
|
scrollbarThumbStyle() {
|
||||||
|
// #ifdef MP
|
||||||
|
const viewW = this.mpScroll.viewW;
|
||||||
|
const contentW = this.mpScroll.contentW;
|
||||||
|
const left = this.mpScroll.left;
|
||||||
|
if (!viewW || !contentW || contentW <= viewW) return 'display:none;';
|
||||||
|
const thumbW = Math.max(20, Math.floor(viewW * viewW / contentW));
|
||||||
|
const maxLeft = contentW - viewW;
|
||||||
|
const ratio = maxLeft > 0 ? Math.min(1, Math.max(0, left / maxLeft)) : 0;
|
||||||
|
const thumbLeft = ratio * (viewW - thumbW);
|
||||||
|
return 'width:' + thumbW + 'px;transform:translateX(' + thumbLeft + 'px);';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
|
||||||
|
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
|
||||||
|
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
|
||||||
|
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
|
||||||
|
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
|
||||||
|
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
|
||||||
|
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
|
||||||
|
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
|
||||||
|
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
|
||||||
|
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
|
||||||
|
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
|
||||||
|
imageSpacingConfig() {
|
||||||
|
const v = this.value || {};
|
||||||
|
const RATIO = 2; // 后台数值 → rpx 换算比例
|
||||||
|
|
||||||
|
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||||
|
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
|
||||||
|
const toRpx = val => {
|
||||||
|
if (val === undefined || val === null || val === '') return null;
|
||||||
|
const n = Number(val);
|
||||||
|
if (isNaN(n)) return null;
|
||||||
|
return Math.max(0, n) * RATIO;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
|
||||||
|
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
|
||||||
|
const sources = [v];
|
||||||
|
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
|
||||||
|
.forEach(k => {
|
||||||
|
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
|
||||||
|
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
|
||||||
|
const pick = (exact, fuzzy, nested) => {
|
||||||
|
for (let s = 0; s < sources.length; s++) {
|
||||||
|
const src = sources[s];
|
||||||
|
for (const key in src) {
|
||||||
|
if (exact.indexOf(norm(key)) != -1) {
|
||||||
|
const rpx = toRpx(src[key]);
|
||||||
|
if (rpx !== null) return rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let s = 0; s < sources.length; s++) {
|
||||||
|
const src = sources[s];
|
||||||
|
for (const key in src) {
|
||||||
|
const k = norm(key);
|
||||||
|
if (fuzzy.some(f => k.indexOf(f) != -1)) {
|
||||||
|
const rpx = toRpx(src[key]);
|
||||||
|
if (rpx !== null) return rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
|
||||||
|
for (let s = 1; s < sources.length; s++) {
|
||||||
|
const src = sources[s];
|
||||||
|
for (const key in src) {
|
||||||
|
if (nested.indexOf(norm(key)) != -1) {
|
||||||
|
const rpx = toRpx(src[key]);
|
||||||
|
if (rpx !== null) return rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
|
||||||
|
horizontal: pick(
|
||||||
|
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
|
||||||
|
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
|
||||||
|
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
|
||||||
|
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
|
||||||
|
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
|
||||||
|
),
|
||||||
|
// 图片上间距 —— 后台现用命名 imageSpacingTop
|
||||||
|
paddingTop: pick(
|
||||||
|
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
|
||||||
|
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
|
||||||
|
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
|
||||||
|
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
|
||||||
|
),
|
||||||
|
// 图片下间距 —— 后台现用命名 imageSpacingBottom
|
||||||
|
paddingBottom: pick(
|
||||||
|
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
|
||||||
|
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
|
||||||
|
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
|
||||||
|
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
|
||||||
|
)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
|
||||||
|
imageSpacingStyle() {
|
||||||
|
const cfg = this.imageSpacingConfig;
|
||||||
|
let style = '';
|
||||||
|
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
|
||||||
|
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
|
||||||
|
imageItemStyle() {
|
||||||
|
const horizontal = this.imageSpacingConfig.horizontal;
|
||||||
|
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ==========
|
||||||
|
// 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。
|
||||||
|
// 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸,
|
||||||
|
// 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略,
|
||||||
|
// <image> 于是回退到 uni-app 默认尺寸 320px x 240px, 视觉上就是"被撑到最大"。
|
||||||
|
// 取值优先级(后台数值单位按 rpx 约定, 经 $util.rpx() 转成当前端可用单位):
|
||||||
|
// ① 每一项下发的 imgWidth / imgHeight —— 沿用历史约定: 数值 ÷ 2
|
||||||
|
// ② 组件级 imgWidth / imgHeight —— 兜底, 同样 ÷ 2
|
||||||
|
// ③ 组件级"图片尺寸"imageSize —— 与 diy-graphic-nav / diy-merch-list 一致, × 2
|
||||||
|
// ④ 都没有 → 120 正方形, 只做最小安全兜底(远小于 320x240 默认值)
|
||||||
|
//
|
||||||
|
// 落地结构: 外层 .image-img(view) 与 <image> 都按解析出的尺寸渲染, 两端一致;
|
||||||
|
// 样式统一由下面的方法输出"字符串"(见 imageBoxStyle 的说明, 对象在小程序端会失效)。
|
||||||
|
rpxVal(val) {
|
||||||
|
// 项目约定: 小程序/App 输出 'Nrpx', H5 输出 'Npx'(uni.upx2px)
|
||||||
|
if (this.$util && typeof this.$util.rpx === 'function') return this.$util.rpx(val);
|
||||||
|
return val + 'rpx';
|
||||||
|
},
|
||||||
|
resolveItemImgSize(item) {
|
||||||
|
const valid = v => {
|
||||||
|
if (v === undefined || v === null || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return (!isNaN(n) && n > 0) ? n : null;
|
||||||
|
};
|
||||||
|
// ① 每一项图片自身尺寸
|
||||||
|
const iw = valid(item && item.imgWidth);
|
||||||
|
const ih = valid(item && item.imgHeight);
|
||||||
|
if (iw !== null && ih !== null) return { width: iw / 2, height: ih / 2 };
|
||||||
|
if (iw !== null) return { width: iw / 2, height: iw / 2 };
|
||||||
|
if (ih !== null) return { width: ih / 2, height: ih / 2 };
|
||||||
|
// ② 组件级 imgWidth / imgHeight
|
||||||
|
const v = this.value || {};
|
||||||
|
const ciw = valid(v.imgWidth);
|
||||||
|
const cih = valid(v.imgHeight);
|
||||||
|
if (ciw !== null && cih !== null) return { width: ciw / 2, height: cih / 2 };
|
||||||
|
if (ciw !== null) return { width: ciw / 2, height: ciw / 2 };
|
||||||
|
if (cih !== null) return { width: cih / 2, height: cih / 2 };
|
||||||
|
// ③ 组件级"图片尺寸"
|
||||||
|
const is = valid(v.imageSize);
|
||||||
|
if (is !== null) return { width: is * 2, height: is * 2 };
|
||||||
|
// ④ 安全兜底
|
||||||
|
return { width: 120, height: 120 };
|
||||||
|
},
|
||||||
|
// 图片外层占位盒样式(决定图片最终尺寸)
|
||||||
|
//
|
||||||
|
// ⚠️ 必须返回"样式字符串", 不能返回对象!
|
||||||
|
// 返回对象时 mp-weixin 的编译产物是 `style="{{(item.m0)}}"`(m0 是对象),
|
||||||
|
// 小程序 WXML 不能把对象转成 style 字符串 → 整条样式直接失效:
|
||||||
|
// · 只有 <image> 时 → 图片回退到默认 320x240(看起来"被撑到最大")
|
||||||
|
// · 外层盒没尺寸 + 图片 width:100% 时 → 高度塌成 0(图片完全不显示)
|
||||||
|
// diy-graphic-nav.vue 能在小程序正常显示, 正是因为它的 style 编译出来是字符串。
|
||||||
|
imageBoxStyle(item) {
|
||||||
|
const size = this.resolveItemImgSize(item);
|
||||||
|
const imageSize = Number(this.value && this.value.imageSize);
|
||||||
|
const font = (!isNaN(imageSize) && imageSize > 0) ? imageSize * 2 : size.height;
|
||||||
|
return 'font-size:' + this.rpxVal(font) + ';'
|
||||||
|
+ 'width:' + this.rpxVal(size.width) + ';'
|
||||||
|
+ 'height:' + this.rpxVal(size.height) + ';';
|
||||||
|
},
|
||||||
|
// 图片自身样式(与占位盒同尺寸, 配合 mode="aspectFit" 等比完整显示)
|
||||||
|
// max-width / max-height 与 diy-graphic-nav 的写法保持一致, 用于兜底夹住尺寸,
|
||||||
|
// 保证图片不会超出后台配置的大小。
|
||||||
|
imageInnerStyle(item) {
|
||||||
|
const size = this.resolveItemImgSize(item);
|
||||||
|
const w = this.rpxVal(size.width);
|
||||||
|
const h = this.rpxVal(size.height);
|
||||||
|
return 'width:' + w + ';height:' + h + ';max-width:' + w + ';max-height:' + h + ';';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
|
||||||
|
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
|
||||||
|
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
|
||||||
|
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
|
||||||
|
bindSlideScrollbar() {
|
||||||
|
// #ifdef H5
|
||||||
|
if (this._slideScrollBound) return;
|
||||||
|
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
|
||||||
|
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
|
||||||
|
var el = this.getSlideContainer();
|
||||||
|
if (!el) return;
|
||||||
|
this._slideScrollEl = el;
|
||||||
|
this._slideScrollBound = true;
|
||||||
|
var self = this;
|
||||||
|
this._onSlideScroll = function() {
|
||||||
|
if (self.scrollBarMode != 'slide') return;
|
||||||
|
if (!self.slideScrolling) self.slideScrolling = true;
|
||||||
|
clearTimeout(self._slideScrollTimer);
|
||||||
|
self._slideScrollTimer = setTimeout(function() {
|
||||||
|
self.slideScrolling = false;
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
unbindSlideScrollbar() {
|
||||||
|
// #ifdef H5
|
||||||
|
if (this._slideScrollEl && this._onSlideScroll) {
|
||||||
|
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
|
||||||
|
}
|
||||||
|
clearTimeout(this._slideScrollTimer);
|
||||||
|
this._slideScrollEl = null;
|
||||||
|
this._onSlideScroll = null;
|
||||||
|
this._slideScrollBound = false;
|
||||||
|
this.slideScrolling = false;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// 统一的 scroll 事件处理(模板 @scroll 绑定, 小程序/H5 共用)
|
||||||
|
// · 两端:"滑动时显示"模式 → 维护 slideScrolling(滚动中标记, H5 用于 is-scrolling 类)
|
||||||
|
// · 小程序端: 驱动自定义滚动条 thumb 位置(原生 show-scrollbar 被忽略, 改用自绘)
|
||||||
|
onSlideScroll(e) {
|
||||||
|
if (this.scrollBarMode === 'slide') {
|
||||||
|
if (!this.slideScrolling) this.slideScrolling = true;
|
||||||
|
clearTimeout(this._slideScrollTimer);
|
||||||
|
this._slideScrollTimer = setTimeout(() => {
|
||||||
|
this.slideScrolling = false;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
// #ifdef MP
|
||||||
|
// 首次滚动时若还没拿到尺寸, 先测量一次(图片尺寸由内联样式决定, 渲染后即稳定)
|
||||||
|
if (!this.mpScroll.viewW || !this.mpScroll.contentW) this.measureScrollbar();
|
||||||
|
// scroll-view 的 scroll 事件 detail 自带真实 scrollWidth(小程序原生返回),
|
||||||
|
// 只要它比当前记录的更大(内容更长), 就更新 —— 保证滚动条长度比例准确
|
||||||
|
if (e && e.detail && typeof e.detail.scrollWidth === 'number'
|
||||||
|
&& e.detail.scrollWidth > this.mpScroll.contentW) {
|
||||||
|
this.mpScroll.contentW = e.detail.scrollWidth;
|
||||||
|
}
|
||||||
|
let left = 0;
|
||||||
|
if (e && e.detail && typeof e.detail.scrollLeft === 'number') left = e.detail.scrollLeft;
|
||||||
|
this.mpScroll.left = left;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// 小程序端: 测量 scroll-view 视口宽度与内容宽度(供自定义滚动条计算 thumb)
|
||||||
|
//
|
||||||
|
// ⚠️ fix (2026-09-22) 「始终显示水平滚动条」在小程序端不生效的根因:
|
||||||
|
// 原实现只取 .uni-scroll-view-content 的 width 当"内容宽度"。但在 mp-weixin 中,
|
||||||
|
// 该 view 是块级/flex 容器, 宽度恒等于 scroll-view 宽度(子项只是溢出它而不是撑开它),
|
||||||
|
// 于是 contentW 永远 == viewW → showCustomScrollbar / scrollbarThumbStyle 判定
|
||||||
|
// "内容没有溢出" → 滚动条永不渲染(后台选"始终显示"也无效)。
|
||||||
|
// 现改为三级取样, 取到即可用, 且已被测得的有效值不会被后续空测量覆盖:
|
||||||
|
// ① scroll-view 的 scrollOffset.scrollWidth —— WeChat 原生返回的真实滚动区宽度(首选, 含内边距)
|
||||||
|
// ② 全部 .image-nav-item 的 rect 跨度 max(right) - min(left) + 左右内边距 —— 最可靠的 DOM 兜底
|
||||||
|
// ③ 按后台下发的图片/文本尺寸累加推算(rpx → px) —— 极端情况(查询全部失败)下兜底
|
||||||
|
measureScrollbar() {
|
||||||
|
// #ifdef MP
|
||||||
|
if (!uni || !uni.createSelectorQuery) return;
|
||||||
|
var self = this;
|
||||||
|
var query = uni.createSelectorQuery().in(this);
|
||||||
|
query.select('.image-nav').fields({ size: true, scrollOffset: true });
|
||||||
|
query.selectAll('.image-nav-item').boundingClientRect();
|
||||||
|
query.exec(function(res) {
|
||||||
|
var view = res && res[0];
|
||||||
|
if (!view) return;
|
||||||
|
var viewW = view.width || 0;
|
||||||
|
var items = res && res[1];
|
||||||
|
|
||||||
|
// ① 原生 scrollWidth
|
||||||
|
var contentW = view.scrollWidth || 0;
|
||||||
|
|
||||||
|
// ② 首尾 item 的 rect 跨度(与当前滚动位置无关: 两端同步平移, 差值稳定)
|
||||||
|
if (contentW <= viewW && items && items.length) {
|
||||||
|
var minLeft = Infinity;
|
||||||
|
var maxRight = -Infinity;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var r = items[i];
|
||||||
|
if (!r) continue;
|
||||||
|
if (r.left < minLeft) minLeft = r.left;
|
||||||
|
if (r.right > maxRight) maxRight = r.right;
|
||||||
|
}
|
||||||
|
if (maxRight > minLeft) {
|
||||||
|
var padPx = (uni.upx2px ? uni.upx2px(16) : 8) * 2; // .image-nav 左右各 16rpx
|
||||||
|
var spanW = (maxRight - minLeft) + padPx;
|
||||||
|
if (spanW > contentW) contentW = spanW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 数据推算兜底
|
||||||
|
if (!contentW) contentW = self.estimateContentWidthPx();
|
||||||
|
|
||||||
|
self.mpScroll.viewW = viewW;
|
||||||
|
// 有效值只增不减: 避免后一次空测量把已测得的宽度清零(会导致滚动条突然消失)
|
||||||
|
if (contentW > 0) self.mpScroll.contentW = contentW;
|
||||||
|
else if (!self.mpScroll.contentW) self.mpScroll.contentW = 0;
|
||||||
|
self.mpScroll.left = view.scrollLeft || 0;
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// 小程序端: 按后台下发的尺寸推算内容总宽度(rpx → px), 仅在 DOM 测量失败时兜底
|
||||||
|
estimateContentWidthPx() {
|
||||||
|
// #ifdef MP
|
||||||
|
const list = (this.value && this.value.list) || [];
|
||||||
|
if (!list.length) return 0;
|
||||||
|
const cfg = this.imageSpacingConfig;
|
||||||
|
const gap = cfg.horizontal === null ? 28 : cfg.horizontal;
|
||||||
|
let totalRpx = 32; // 容器左右 padding(16rpx × 2)
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
if (this.value.mode == 'text') {
|
||||||
|
const w = Number(list[i] && list[i].imgWidth);
|
||||||
|
totalRpx += (!isNaN(w) && w > 0) ? w / 2 : 0;
|
||||||
|
} else {
|
||||||
|
totalRpx += this.resolveItemImgSize(list[i]).width;
|
||||||
|
}
|
||||||
|
totalRpx += gap;
|
||||||
|
}
|
||||||
|
let winW = 375;
|
||||||
|
try {
|
||||||
|
if (uni && uni.getSystemInfoSync) winW = uni.getSystemInfoSync().windowWidth || 375;
|
||||||
|
} catch (e) {}
|
||||||
|
return totalRpx * winW / 750;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
// 页面跳转
|
// 页面跳转
|
||||||
redirectTo(link) {
|
redirectTo(link) {
|
||||||
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||||
@@ -153,14 +680,10 @@ export default {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// 防止重复绑定
|
// 防止重复绑定
|
||||||
if (this._slideEventsBound) return;
|
if (this._slideEventsBound) return;
|
||||||
var outerEl = this.getSlideOuterEl();
|
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
|
||||||
var innerEl = this.getSlideContainer();
|
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
|
||||||
if (!outerEl || !innerEl) return;
|
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
|
||||||
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
|
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
|
||||||
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
|
|
||||||
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
|
|
||||||
this._slideOuterEl = outerEl;
|
|
||||||
this._slideEl = innerEl;
|
|
||||||
this._slideEventsBound = true;
|
this._slideEventsBound = true;
|
||||||
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
||||||
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
||||||
@@ -183,50 +706,79 @@ export default {
|
|||||||
this._slideEventsBound = false;
|
this._slideEventsBound = false;
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
getSlideContainer() {
|
// 本组件实例的根元素(<view data-component-name="diy-image-nav">)
|
||||||
|
//
|
||||||
|
// ⚠️ 必须用 this.$el 限定在"本实例"内查找元素!
|
||||||
|
// 历史实现用 document.querySelector('[data-component-name="diy-image-nav"]'),
|
||||||
|
// 而同一个页面可以放多个滑动图组件(如 首页的 合作伙伴 + 证书资质 + 服务资质),
|
||||||
|
// document.querySelector 永远只返回页面上的**第一个**实例 ——
|
||||||
|
// 于是拖拽/滚轮第 2、3 个组件时,"指针是否在容器内"用的是第 1 个组件的位置,
|
||||||
|
// 判断不通过直接 return → 表现就是"鼠标拖不动、滚轮也没反应"。
|
||||||
|
getSlideRootEl() {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
|
var el = this.$el;
|
||||||
// 嵌套结构:
|
if (el && el.nodeType === 1) return el;
|
||||||
// <uni-scroll-view> ← 自定义元素
|
return null;
|
||||||
// <div class="uni-scroll-view"> ← 第1层,visible
|
|
||||||
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
|
|
||||||
// 真正能响应 scrollLeft 的是第 2 层 div。
|
|
||||||
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
|
|
||||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
|
||||||
if (!componentRoot) return null;
|
|
||||||
// 找 overflowX:auto 的 div.uni-scroll-view
|
|
||||||
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
|
|
||||||
for (var i = 0; i < candidates.length; i++) {
|
|
||||||
var s = getComputedStyle(candidates[i]).overflowX;
|
|
||||||
if (s === 'auto' || s === 'scroll') {
|
|
||||||
return candidates[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
|
|
||||||
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
|
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
|
||||||
getSlideOuterEl() {
|
getSlideOuterEl() {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
|
return this.getSlideRootEl();
|
||||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
// #endif
|
||||||
if (!componentRoot) return null;
|
},
|
||||||
var sc = componentRoot.querySelector('uni-scroll-view');
|
getSlideContainer() {
|
||||||
return sc || null;
|
// #ifdef H5
|
||||||
|
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> 自定义元素,
|
||||||
|
// 真正的滚动 div 是内部的 .uni-scroll-view。这里在**本实例范围内**查找:
|
||||||
|
// ① 优先"内容宽度 > 可视宽度"判定真滚动容器(与 overflow 写法无关, 最可靠)
|
||||||
|
// ② 其次 overflow-x: auto/scroll
|
||||||
|
// ③ 再退化为最后一个 .uni-scroll-view
|
||||||
|
// ④ 最后退化到 .uni-scroll-view-content 的父元素
|
||||||
|
var root = this.getSlideRootEl();
|
||||||
|
if (!root) return null;
|
||||||
|
var candidates = root.querySelectorAll('.uni-scroll-view');
|
||||||
|
var i;
|
||||||
|
for (i = 0; i < candidates.length; i++) {
|
||||||
|
if (candidates[i].scrollWidth > candidates[i].clientWidth + 1) return candidates[i];
|
||||||
|
}
|
||||||
|
for (i = 0; i < candidates.length; i++) {
|
||||||
|
var s = getComputedStyle(candidates[i]).overflowX;
|
||||||
|
if (s === 'auto' || s === 'scroll') return candidates[i];
|
||||||
|
}
|
||||||
|
if (candidates.length > 0) return candidates[candidates.length - 1];
|
||||||
|
var content = root.querySelector('.uni-scroll-view-content');
|
||||||
|
if (content && content.parentElement) return content.parentElement;
|
||||||
|
return root.querySelector('uni-scroll-view');
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
// 按需解析本实例的"根元素 + 真滚动容器"(挂载时机不确定, 统一走这里)
|
||||||
|
ensureSlideEls() {
|
||||||
|
// #ifdef H5
|
||||||
|
var root = this.getSlideRootEl();
|
||||||
|
if (!root) return false;
|
||||||
|
this._slideOuterEl = root;
|
||||||
|
var el = this.getSlideContainer();
|
||||||
|
if (!el) return false;
|
||||||
|
this._slideEl = el;
|
||||||
|
// 滚动条显隐依赖真滚动容器的 scroll 事件:mounted 时若还没拿到容器,
|
||||||
|
// 这里补绑一次(否则"滑动时显示滚动条"也会失效)。
|
||||||
|
if (!this._slideScrollBound) this.bindSlideScrollbar();
|
||||||
|
return true;
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
onSlideMouseDown(e) {
|
onSlideMouseDown(e) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (this.value.showStyle != 'singleSlide') return;
|
if (this.value.showStyle != 'singleSlide') return;
|
||||||
// document 级别监听,只处理落在容器内的事件
|
// document 级别监听,只处理落在"本实例"容器内的事件
|
||||||
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
|
// 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致),
|
||||||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||||||
if (!this._slideOuterEl) return;
|
if (!this.ensureSlideEls()) return;
|
||||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
var x = e.clientX, y = e.clientY;
|
var x = e.clientX, y = e.clientY;
|
||||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||||
var el = this.getSlideContainer();
|
var el = this._slideEl;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
this.slideIsDragging = true;
|
this.slideIsDragging = true;
|
||||||
this.slideStartX = x;
|
this.slideStartX = x;
|
||||||
@@ -240,7 +792,7 @@ export default {
|
|||||||
onSlideMouseMove(e) {
|
onSlideMouseMove(e) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (!this.slideIsDragging) return;
|
if (!this.slideIsDragging) return;
|
||||||
var el = this.getSlideContainer();
|
var el = this._slideEl;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
var x = (e.clientX != null) ? e.clientX : 0;
|
var x = (e.clientX != null) ? e.clientX : 0;
|
||||||
var deltaX = x - this.slideStartX;
|
var deltaX = x - this.slideStartX;
|
||||||
@@ -267,7 +819,7 @@ export default {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (!this._suppressNextClick) return;
|
if (!this._suppressNextClick) return;
|
||||||
if (!e.target) return;
|
if (!e.target) return;
|
||||||
var outer = this._slideOuterEl;
|
var outer = this._slideOuterEl || this.getSlideOuterEl();
|
||||||
if (!outer || !outer.contains(e.target)) return;
|
if (!outer || !outer.contains(e.target)) return;
|
||||||
this._suppressNextClick = false;
|
this._suppressNextClick = false;
|
||||||
if (e.preventDefault) e.preventDefault();
|
if (e.preventDefault) e.preventDefault();
|
||||||
@@ -278,13 +830,13 @@ export default {
|
|||||||
onSlideWheel(e) {
|
onSlideWheel(e) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (this.value.showStyle != 'singleSlide') return;
|
if (this.value.showStyle != 'singleSlide') return;
|
||||||
if (!this._slideOuterEl) return;
|
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在"本实例"容器内
|
||||||
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
|
if (!this.ensureSlideEls()) return;
|
||||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
var x = e.clientX, y = e.clientY;
|
var x = e.clientX, y = e.clientY;
|
||||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||||
var el = this.getSlideContainer();
|
var el = this._slideEl;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
||||||
var dx = e.deltaX || 0;
|
var dx = e.deltaX || 0;
|
||||||
@@ -301,6 +853,36 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
// 包裹 scroll-view 的容器(相对定位, 自定义滚动条绝对定位于其底部)
|
||||||
|
.image-nav-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序端自定义水平滚动条(MP 原生 show-scrollbar 被忽略, 改用自绘,
|
||||||
|
// 与 H5 的 CSS 伪元素滚动条视觉一致; 元素仅在 #ifdef MP 下渲染)
|
||||||
|
.img-nav-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 2rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
background-color: rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 2rpx;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.img-nav-scrollbar-thumb {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 4rpx;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 2rpx;
|
||||||
|
transition: transform 0.08s linear;
|
||||||
|
}
|
||||||
|
|
||||||
.image-nav {
|
.image-nav {
|
||||||
padding: 16rpx;
|
padding: 16rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -327,17 +909,72 @@ export default {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
|
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
|
||||||
scrollbar-width: none; // Firefox
|
cursor: grab;
|
||||||
-ms-overflow-style: none; // IE/Edge legacy
|
&:active {
|
||||||
&::-webkit-scrollbar {
|
cursor: grabbing;
|
||||||
display: none;
|
}
|
||||||
|
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
|
||||||
|
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
|
||||||
|
::v-deep .uni-scroll-view {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||||||
|
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
|
||||||
|
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
|
||||||
|
scrollbar-width: none; // Firefox
|
||||||
|
-ms-overflow-style: none; // IE/Edge legacy
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
|
||||||
|
::v-deep .uni-scroll-view {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
|
||||||
|
&.scrollbar-always,
|
||||||
|
&.scrollbar-slide.is-scrolling {
|
||||||
|
scrollbar-width: thin; // Firefox
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
::v-deep .uni-scroll-view {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||||
|
}
|
||||||
|
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +1039,15 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 90rpx;
|
font-size: 90rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||||||
|
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
|
||||||
|
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
|
||||||
|
.image-content {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -6,13 +6,18 @@
|
|||||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="fui-list-inner">
|
<view class="fui-list-inner">
|
||||||
<view class="title" style="height: 75rpx;">
|
<view class="title">
|
||||||
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
|
||||||
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
|
||||||
|
<!-- #ifndef H5 -->
|
||||||
|
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
|
||||||
|
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
|
||||||
|
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
|
||||||
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
|
||||||
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||||
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
||||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||||
</view>
|
</view>
|
||||||
@@ -35,15 +40,31 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
|
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
|
||||||
|
mpTitleSize: 0,
|
||||||
|
mpDescSize: 0
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
// this.getDataList();
|
// this.getDataList();
|
||||||
},
|
},
|
||||||
|
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
|
||||||
|
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
|
||||||
|
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(this.fitOneLineText)
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
// 组件刷新监听
|
// 组件刷新监听
|
||||||
componentRefresh: function (nval) {
|
componentRefresh: function (nval) {
|
||||||
// this.getDataList();
|
// this.getDataList();
|
||||||
|
this.$nextTick(this.fitOneLineText)
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.$nextTick(this.fitOneLineText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -66,9 +87,130 @@ export default {
|
|||||||
current: img,
|
current: img,
|
||||||
urls: [img]
|
urls: [img]
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
|
||||||
|
// #ifdef H5
|
||||||
|
fitOneLineText() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
|
||||||
|
if (!root) return
|
||||||
|
const nodes = root.querySelectorAll('.title-text, .subtitle')
|
||||||
|
nodes.forEach((el) => {
|
||||||
|
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
|
||||||
|
el.style.fontSize = ''
|
||||||
|
let size = parseFloat(window.getComputedStyle(el).fontSize)
|
||||||
|
const MIN_PX = 10 // 下限 10px, 再小可读性太差
|
||||||
|
const target = el.clientWidth - 3
|
||||||
|
let guard = 0
|
||||||
|
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
|
||||||
|
size -= 0.5
|
||||||
|
el.style.fontSize = size + 'px'
|
||||||
|
guard++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
|
||||||
|
fitOneLineText() {
|
||||||
|
uni.createSelectorQuery().in(this)
|
||||||
|
.select('.fui-list-inner').boundingClientRect()
|
||||||
|
.select('.measure-title').boundingClientRect()
|
||||||
|
.select('.measure-desc').boundingClientRect()
|
||||||
|
.exec((rects) => {
|
||||||
|
if (!rects || !rects[0]) return
|
||||||
|
const sys = uni.getSystemInfoSync()
|
||||||
|
const pxPerRpx = (sys.windowWidth || 375) / 750
|
||||||
|
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
|
||||||
|
const avail = rects[0].width - 16 * pxPerRpx
|
||||||
|
if (avail <= 0) return
|
||||||
|
// 标题: 默认 28rpx, 最小 16rpx
|
||||||
|
if (rects[1] && rects[1].width > avail) {
|
||||||
|
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
|
||||||
|
} else {
|
||||||
|
this.mpTitleSize = 0
|
||||||
|
}
|
||||||
|
// 描述: 默认 24rpx, 最小 16rpx
|
||||||
|
if (rects[2] && rects[2].width > avail) {
|
||||||
|
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
|
||||||
|
} else {
|
||||||
|
this.mpDescSize = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
titleStyle() {
|
||||||
|
// #ifndef H5
|
||||||
|
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
return ''
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
descStyle() {
|
||||||
|
// #ifndef H5
|
||||||
|
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
return ''
|
||||||
|
// #endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss"></style>
|
<style lang="scss">
|
||||||
|
// 客服展示:标题/描述需要完整展示(中英文均不截断)
|
||||||
|
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
|
||||||
|
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
|
||||||
|
.diy-kefu {
|
||||||
|
.fui-list-group {
|
||||||
|
.fui-list {
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.fui-list-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
|
||||||
|
padding-right: 8rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
.title-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
|
||||||
|
.measure-text {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999rpx;
|
||||||
|
top: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
<!-- #ifdef MP -->
|
<!-- #ifdef MP -->
|
||||||
<block
|
<block
|
||||||
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
||||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||||
</block>
|
</block>
|
||||||
<view class="nickname" v-else>
|
<view class="nickname" v-else>
|
||||||
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||||
<view class="member-level"
|
<view class="member-level"
|
||||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||||
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
||||||
</block>
|
</block>
|
||||||
<view class="nickname" v-else>
|
<view class="nickname" v-else>
|
||||||
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||||
}}</text>
|
}}</text>
|
||||||
<view class="member-level"
|
<view class="member-level"
|
||||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="info">
|
<view class="info">
|
||||||
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
|
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
|
||||||
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -367,6 +367,26 @@ export default {
|
|||||||
isDisabled() {
|
isDisabled() {
|
||||||
if (this.nickName.length > 0) return false;
|
if (this.nickName.length > 0) return false;
|
||||||
return true;
|
return true;
|
||||||
|
},
|
||||||
|
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
|
||||||
|
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
|
||||||
|
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
|
||||||
|
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
|
||||||
|
nameStyle() {
|
||||||
|
let name = '';
|
||||||
|
if (this.memberInfo && this.memberInfo.nickname) {
|
||||||
|
name = this.memberInfo.nickname;
|
||||||
|
} else {
|
||||||
|
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
|
||||||
|
}
|
||||||
|
if (!name) return '';
|
||||||
|
let units = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
|
||||||
|
}
|
||||||
|
if (units <= 10) return ''; // 短标题走默认 38rpx
|
||||||
|
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
|
||||||
|
return 'font-size:' + size + 'rpx;';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
@error="imgError(index)" show-menu-by-longpress />
|
@error="imgError(index)" show-menu-by-longpress />
|
||||||
</view>
|
</view>
|
||||||
<view class="info-wrap">
|
<view class="info-wrap">
|
||||||
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||||
<text class="desc">{{ item.desc }}</text>
|
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
|
||||||
<view class="read-wrap"></view>
|
<view class="read-wrap"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -179,6 +179,25 @@ export default {
|
|||||||
this.toDetail(item);
|
this.toDetail(item);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
|
||||||
|
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
|
||||||
|
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
|
||||||
|
oneLineFontStyle(text, defaultSize, minSize) {
|
||||||
|
if (!text) return '';
|
||||||
|
let units = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
|
||||||
|
}
|
||||||
|
if (units <= 12) return '';
|
||||||
|
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
|
||||||
|
return 'font-size:' + size + 'rpx;';
|
||||||
|
},
|
||||||
|
titleStyle(item) {
|
||||||
|
return this.oneLineFontStyle(item.merch_name, 30, 20);
|
||||||
|
},
|
||||||
|
descStyle(item) {
|
||||||
|
return this.oneLineFontStyle(item.desc, 24, 20);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -296,13 +315,19 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-wrap {
|
.info-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
|
||||||
|
|
||||||
// flex: 1;
|
|
||||||
// display: flex;
|
// display: flex;
|
||||||
// flex-direction: column;
|
// flex-direction: column;
|
||||||
// justify-content: space-between;
|
// justify-content: space-between;
|
||||||
.desc {
|
.desc {
|
||||||
color: #888
|
color: #888;
|
||||||
|
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -311,8 +336,9 @@ export default {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
white-space: nowrap;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<view data-component-name="diy-search" class="diy-search">
|
<view data-component-name="diy-search" class="diy-search">
|
||||||
<view class="diy-search-wrap" :class="value.positionWay" :style="fixedCss">
|
<view class="diy-search-wrap" :class="value.positionWay" :style="fixedCss">
|
||||||
|
<!-- 卡片底板: 搜索框背后一圈底板, 与整体区域背景相互独立; 后台清空则透明(无底板) -->
|
||||||
|
<view class="search-board" :style="boardLayerStyle">
|
||||||
<view :class="['search-box', 'search-box-' + value.searchStyle]" :style="searchWrapCss"
|
<view :class="['search-box', 'search-box-' + value.searchStyle]" :style="searchWrapCss"
|
||||||
@tap.stop="handlerSearchClick">
|
@tap.stop="handlerSearchClick">
|
||||||
<block v-if="[1, 2].includes(value.searchStyle)">
|
<block v-if="[1, 2].includes(value.searchStyle)">
|
||||||
@@ -13,20 +15,20 @@
|
|||||||
<view class="search-content" :style="inputStyle">
|
<view class="search-content" :style="inputStyle">
|
||||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||||
:placeholderStyle="placeholderStyle" />
|
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
||||||
<text class="iconfont icon-sousuo3" @tap.stop="handlerSearchClick"
|
<text class="iconfont icon-sousuo3" @tap.stop="handlerSearchClick"
|
||||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
|
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<block v-if="value.searchStyle == 3">
|
<block v-if="value.searchStyle == 3">
|
||||||
<view class="search-content" :style="inputStyle"
|
<view class="search-content" :style="inputStyle"
|
||||||
@tap.stop="handlerSearchClick">
|
@tap.stop="handlerSearchClick">
|
||||||
<text class="iconfont icon-sousuo3"
|
<text class="iconfont icon-sousuo3"
|
||||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
|
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
|
||||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||||
@tap.stop="handlerSearchClick"
|
@tap.stop="handlerSearchClick"
|
||||||
:placeholderStyle="placeholderStyle" />
|
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
||||||
<text class="search-content-btn" @tap.stop="handlerSearchClick"
|
<text class="search-content-btn" @tap.stop="handlerSearchClick"
|
||||||
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
|
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
|
||||||
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
|
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<!-- 解决fixed定位后导航栏塌陷的问题 -->
|
<!-- 解决fixed定位后导航栏塌陷的问题 -->
|
||||||
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
|
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
|
||||||
:style="{ width: '100%', paddingTop: moduleHeight }"></view>
|
:style="{ width: '100%', paddingTop: moduleHeight }"></view>
|
||||||
@@ -114,20 +117,90 @@ export default {
|
|||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
// 搜索行框(紧包搜索输入框的一行, 含左侧 logo/图标): 背景色优先取后台"行框背景色",
|
||||||
|
// 未配置时回退到组件背景色(componentBgColor), 保持旧装修视觉; 兼容嵌套字段命名
|
||||||
searchWrapCss() {
|
searchWrapCss() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
var rowBg = this._probe([
|
||||||
|
'formBgColor', 'rowBgColor', 'lineBgColor', 'searchBoxBgColor', 'searchRowBgColor',
|
||||||
|
'boxBgColor', 'rowBackground', 'lineBackground', 'searchLineBgColor'
|
||||||
|
]) || this.value.componentBgColor || '';
|
||||||
|
if (rowBg) obj += 'background-color:' + rowBg + ';';
|
||||||
obj += 'text-align:' + this.value.textAlign + ';';
|
obj += 'text-align:' + this.value.textAlign + ';';
|
||||||
|
obj += this._radiusStyle(['formBorderRadius', 'rowRadius', 'rowAngle', 'lineRadius', 'searchBoxRadius', 'boxRadius', 'rowRound']);
|
||||||
|
obj += this._boxSpacingStyle('rowPadding', 'rowPaddingTop', 'rowPaddingBottom', 'rowPaddingLeft', 'rowPaddingRight');
|
||||||
|
obj += this._boxSpacingStyle('rowMargin', 'rowMarginTop', 'rowMarginBottom', 'rowMarginLeft', 'rowMarginRight', 'margin');
|
||||||
|
// 后台"搜索行框-内边距/外边距"真实字段 formPadding/formMargin 为带单位 shorthand(如 "0px")
|
||||||
|
obj += this._cssShorthand(['formPadding', 'rowPaddingStr'], 'padding');
|
||||||
|
obj += this._cssShorthand(['formMargin', 'rowMarginStr'], 'margin');
|
||||||
|
// 后台"搜索行框-四边样式"真实字段 formBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
||||||
|
obj += this._borderStyle('formBorder');
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
// 搜索输入框: 背景色优先取后台"输入框背景色"(含旧字段 elementBgColor), 文本色同步 textColor;
|
||||||
|
// 高度/内边距/圆角(框体样式)取后台字段, 兼容嵌套命名与缺省
|
||||||
inputStyle() {
|
inputStyle() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
var inputBg = this._probe([
|
||||||
if (this.value.borderType == 2) {
|
'elementBgColor', 'inputBgColor', 'searchInputBgColor', 'inputBackground'
|
||||||
obj += 'border-radius:' + '40rpx;';
|
]) || this.value.elementBgColor || '';
|
||||||
|
if (inputBg) obj += 'background-color:' + inputBg + ';';
|
||||||
|
if (this.value.textColor) obj += 'color:' + this.value.textColor + ';';
|
||||||
|
obj += this._boxSpacingStyle('inputPadding', 'inputPaddingTop', 'inputPaddingBottom', 'inputPaddingLeft', 'inputPaddingRight');
|
||||||
|
// 后台"输入框-内边距"真实字段 searchPadding 为带单位 shorthand(如 "0px")
|
||||||
|
obj += this._cssShorthand(['searchPadding', 'inputPaddingStr'], 'padding');
|
||||||
|
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
|
||||||
|
if (h !== null) obj += 'height:' + (h * 2) + 'rpx;';
|
||||||
|
// 框体样式(圆形): 新字段 round/circle/圆形 → 全圆角; 旧字段 borderType==2 → 40rpx
|
||||||
|
var shape = this._probe(['boxStyle', 'inputShape', 'frameStyle', 'borderShape', 'searchShape']);
|
||||||
|
if (shape && ['round', 'circle', '圆形', '1'].indexOf(String(shape).toLowerCase()) !== -1) {
|
||||||
|
obj += 'border-radius:999rpx;';
|
||||||
|
} else if (this.value.borderType == 2) {
|
||||||
|
obj += 'border-radius:40rpx;';
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
// 卡片底板: 搜索框背后一圈底板; 后台"底板背景色/卡片底版"真实字段名为 containerBgColor(历史命名亦兼容)
|
||||||
|
boardLayerStyle() {
|
||||||
|
var obj = '';
|
||||||
|
// 探测候选名尽量覆盖后台各种命名(驼峰/snake_case/下划线都会被 _norm 归一化匹配)
|
||||||
|
var bg = this._probe([
|
||||||
|
'containerBgColor', 'containerBackground', 'containerColor',
|
||||||
|
'cardBgColor', 'boardBgColor', 'cardBackground', 'boardBackground',
|
||||||
|
'baseBgColor', 'panelBgColor', 'plateBgColor', 'bottomBoardBgColor', 'boardColor',
|
||||||
|
'backgroundColor', 'bgColor', 'background_color', 'bg_color', 'plateColor',
|
||||||
|
'bottomBgColor', 'basePlateBgColor', 'basePlateColor', 'cardPlateBgColor', 'cardBottomBgColor'
|
||||||
|
]);
|
||||||
|
if (bg) {
|
||||||
|
obj += 'background-color:' + bg + ';';
|
||||||
|
// 设了底板背景色却没单独配置内边距时, 搜索框会完全盖住底板 → 改了"没反应";
|
||||||
|
// 因此未配置 boardPadding 时给默认内边距, 让底板颜色作为卡片显出来
|
||||||
|
var hasBoardPad = this._probeNum(['boardPadding']) !== null
|
||||||
|
|| this._probeNum(['boardPaddingTop']) !== null
|
||||||
|
|| this._probeNum(['boardPaddingBottom']) !== null
|
||||||
|
|| this._probeNum(['boardPaddingLeft']) !== null
|
||||||
|
|| this._probeNum(['boardPaddingRight']) !== null
|
||||||
|
|| this._probe(['containerPadding']) !== '';
|
||||||
|
if (!hasBoardPad) obj += 'padding:16rpx 12rpx;';
|
||||||
|
}
|
||||||
|
obj += this._radiusStyle(['containerBorderRadius', 'boardRadius', 'boardAngle', 'cardRadius', 'baseRadius', 'boardRound']);
|
||||||
|
obj += this._boxSpacingStyle('boardPadding', 'boardPaddingTop', 'boardPaddingBottom', 'boardPaddingLeft', 'boardPaddingRight');
|
||||||
|
// 后台"底板-内边距"真实字段 containerPadding 为带单位 shorthand(如 "27px 10px 31px")
|
||||||
|
obj += this._cssShorthand(['containerPadding', 'boardPaddingStr'], 'padding');
|
||||||
|
obj += this._boxSpacingStyle('boardMargin', 'boardMarginTop', 'boardMarginBottom', 'boardMarginLeft', 'boardMarginRight', 'margin');
|
||||||
|
// 后台"底板-外边距"真实字段 containerMargin 为带单位 shorthand(如 "0px")
|
||||||
|
obj += this._cssShorthand(['containerMargin', 'boardMarginStr'], 'margin');
|
||||||
|
// 后台"底板-四边样式"真实字段 containerBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
||||||
|
obj += this._borderStyle('containerBorder');
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
magnifierBg() {
|
||||||
|
return this._probe(['magnifierBgColor', 'searchIconBgColor', 'iconBgColor', 'magnifierBackground']) || '';
|
||||||
|
},
|
||||||
|
inputElStyle() {
|
||||||
|
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
|
||||||
|
return h !== null ? { height: (h * 2) + 'rpx' } : {};
|
||||||
|
},
|
||||||
placeholderStyle() {
|
placeholderStyle() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
if (this.value.textColor) {
|
if (this.value.textColor) {
|
||||||
@@ -192,6 +265,93 @@ export default {
|
|||||||
this.setModuleLocationFn();
|
this.setModuleLocationFn();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// —— 后台字段名探测(兼容多种命名 / 嵌套容器) ——
|
||||||
|
_norm(k) {
|
||||||
|
return String(k == null ? '' : k).toLowerCase().replace(/[_\-]/g, '');
|
||||||
|
},
|
||||||
|
// 在 value 自身及常见嵌套容器(style/config/setting/board/card/row/input/...)中按候选字段名探测,
|
||||||
|
// 命中即返回原值(含 transparent); 未命中返回 ''
|
||||||
|
_probe(candidates) {
|
||||||
|
var self = this;
|
||||||
|
var cands = candidates.map(function (c) { return self._norm(c); });
|
||||||
|
var v = this.value || {};
|
||||||
|
var sources = [v];
|
||||||
|
['style', 'config', 'setting', 'styleSetting', 'board', 'card', 'row', 'line', 'input', 'element', 'search', 'box', 'margin', 'padding']
|
||||||
|
.forEach(function (k) {
|
||||||
|
var o = v[k];
|
||||||
|
if (o && typeof o === 'object' && !Array.isArray(o)) sources.push(o);
|
||||||
|
});
|
||||||
|
for (var i = 0; i < sources.length; i++) {
|
||||||
|
var src = sources[i];
|
||||||
|
if (!src || typeof src !== 'object') continue;
|
||||||
|
var keys = Object.keys(src);
|
||||||
|
for (var j = 0; j < keys.length; j++) {
|
||||||
|
if (cands.indexOf(self._norm(keys[j])) !== -1) {
|
||||||
|
var val = src[keys[j]];
|
||||||
|
if (val !== undefined && val !== null && val !== '') return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
_probeNum(candidates) {
|
||||||
|
var s = this._probe(candidates);
|
||||||
|
var n = parseFloat(s);
|
||||||
|
return isNaN(n) ? null : n;
|
||||||
|
},
|
||||||
|
// 后台带单位 shorthand 字段(如 "27px 10px 31px" / "0px"): 换算为 rpx(数值×2, 与组件内数值字段口径一致)后输出 prop:value;
|
||||||
|
// 未命中候选名返回 ''。无 px 单位的纯数字/其他单位原样透传
|
||||||
|
_cssShorthand(candidates, prop) {
|
||||||
|
var raw = this._probe(candidates);
|
||||||
|
if (raw === '') return '';
|
||||||
|
var s = String(raw).replace(/(-?\d+(?:\.\d+)?)px/g, function (m, n) {
|
||||||
|
return (parseFloat(n) * 2) + 'rpx';
|
||||||
|
});
|
||||||
|
return prop + ':' + s + ';';
|
||||||
|
},
|
||||||
|
// 后台四边独立边框字段: {prefix}{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
||||||
|
// Show 为 true/1 才输出该边; 输出格式 border-xxx: 宽(rpx×2) 线型 颜色
|
||||||
|
_borderStyle(prefix) {
|
||||||
|
var sides = ['top', 'right', 'bottom', 'left'];
|
||||||
|
var obj = '';
|
||||||
|
for (var i = 0; i < sides.length; i++) {
|
||||||
|
var s = sides[i];
|
||||||
|
var show = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Show']);
|
||||||
|
if (show === '' || show === 'false' || show === '0') continue;
|
||||||
|
var color = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Color']);
|
||||||
|
var width = this._probeNum([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Width']);
|
||||||
|
var style = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Style']);
|
||||||
|
if (width === null && !color) continue;
|
||||||
|
obj += 'border-' + s + ':' + ((width === null ? 0 : width) * 2) + 'rpx ' + (style || 'solid') + ' ' + (color || 'transparent') + ';';
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
// 圆角: round/circle/圆形 → 全圆角; 数值 → 半径(rpx×2)
|
||||||
|
_radiusStyle(cands) {
|
||||||
|
var raw = this._probe(cands);
|
||||||
|
if (raw === '') return '';
|
||||||
|
var lower = String(raw).toLowerCase();
|
||||||
|
if (['round', 'circle', '圆形'].indexOf(lower) !== -1) return 'border-radius:999rpx;';
|
||||||
|
var n = parseFloat(raw);
|
||||||
|
if (!isNaN(n)) return 'border-radius:' + (n * 2) + 'rpx;';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
// 内/外边距: 单值字段 → 四边; 否则上下左右独立字段。kind 默认 padding
|
||||||
|
_boxSpacingStyle(single, top, bottom, left, right, kind) {
|
||||||
|
kind = kind || 'padding';
|
||||||
|
var singleV = this._probeNum([single]);
|
||||||
|
if (singleV !== null) return kind + ':' + (singleV * 2) + 'rpx;';
|
||||||
|
var s = '';
|
||||||
|
var t = this._probeNum([top]);
|
||||||
|
var b = this._probeNum([bottom]);
|
||||||
|
var l = this._probeNum([left]);
|
||||||
|
var r = this._probeNum([right]);
|
||||||
|
if (t !== null) s += kind + '-top:' + (t * 2) + 'rpx;';
|
||||||
|
if (b !== null) s += kind + '-bottom:' + (b * 2) + 'rpx;';
|
||||||
|
if (l !== null) s += kind + '-left:' + (l * 2) + 'rpx;';
|
||||||
|
if (r !== null) s += kind + '-right:' + (r * 2) + 'rpx;';
|
||||||
|
return s;
|
||||||
|
},
|
||||||
search() {
|
search() {
|
||||||
this.$util.redirectTo('/pages_tool/goods/search');
|
this.$util.redirectTo('/pages_tool/goods/search');
|
||||||
},
|
},
|
||||||
@@ -264,6 +424,11 @@ export default {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-board {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.fixed {
|
.fixed {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -5,15 +5,18 @@
|
|||||||
<!-- 顶部标签位置:标签导航 + 指示器 + 内容区域 -->
|
<!-- 顶部标签位置:标签导航 + 指示器 + 内容区域 -->
|
||||||
<template v-if="mergedValue.tabPosition === 'top'">
|
<template v-if="mergedValue.tabPosition === 'top'">
|
||||||
<!-- 标签导航 -->
|
<!-- 标签导航 -->
|
||||||
<view class="tab-nav" :style="[tabNavStyle]">
|
<scroll-view class="tab-nav" :style="[tabNavStyle]" scroll-x
|
||||||
|
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
||||||
|
<view class="tab-nav-inner" :style="[tabNavInnerStyle]">
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
||||||
:class="{ active: activeTab === idx }"
|
:class="{ active: activeTab === idx }" :id="getNavItemId(idx)"
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
|
||||||
{{ getTabTitle(tab.title) }}
|
{{ getTabTitle(tab.title) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 指示器 -->
|
<!-- 指示器 -->
|
||||||
<view v-if="mergedValue.showIndicator" class="tab-indicator" :style="[
|
<view v-if="mergedValue.showIndicator" class="tab-indicator" :style="[
|
||||||
@@ -74,30 +77,36 @@
|
|||||||
]"></view>
|
]"></view>
|
||||||
|
|
||||||
<!-- 标签导航 -->
|
<!-- 标签导航 -->
|
||||||
<view class="tab-nav" :style="[tabNavStyle]">
|
<scroll-view class="tab-nav" :style="[tabNavStyle]" scroll-x
|
||||||
|
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
||||||
|
<view class="tab-nav-inner" :style="[tabNavInnerStyle]">
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
||||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||||
|
:id="getNavItemId(idx)"
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||||
{{ getTabTitle(tab.title) }}
|
{{ getTabTitle(tab.title) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</scroll-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
|
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
|
||||||
<template v-else-if="mergedValue.tabPosition === 'left'">
|
<template v-else-if="mergedValue.tabPosition === 'left'">
|
||||||
<view class="tab-layout-horizontal">
|
<view class="tab-layout-horizontal">
|
||||||
<!-- 左侧标签导航 -->
|
<!-- 左侧标签导航 -->
|
||||||
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
|
<scroll-view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]" scroll-y
|
||||||
|
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
||||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||||
|
:id="getNavItemId(idx)"
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||||
{{ getTabTitle(tab.title) }}
|
{{ getTabTitle(tab.title) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 左侧指示器 -->
|
<!-- 左侧指示器 -->
|
||||||
<view v-if="mergedValue.showIndicator" class="tab-indicator tab-indicator-vertical" :style="[
|
<view v-if="mergedValue.showIndicator" class="tab-indicator tab-indicator-vertical" :style="[
|
||||||
@@ -161,15 +170,17 @@
|
|||||||
]"></view>
|
]"></view>
|
||||||
|
|
||||||
<!-- 右侧标签导航 -->
|
<!-- 右侧标签导航 -->
|
||||||
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
|
<scroll-view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]" scroll-y
|
||||||
|
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
||||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||||
|
:id="getNavItemId(idx)"
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||||
{{ getTabTitle(tab.title) }}
|
{{ getTabTitle(tab.title) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
@@ -208,6 +219,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
activeTab: this.value?.activeTabIndex ?? 0, // 设置当前激活的标签索引
|
activeTab: this.value?.activeTabIndex ?? 0, // 设置当前激活的标签索引
|
||||||
|
scrollIntoId: '', // scroll-view 滚动定位的导航项 id
|
||||||
indicatorVars: { // 指示器CSS变量
|
indicatorVars: { // 指示器CSS变量
|
||||||
top: '',
|
top: '',
|
||||||
left: '',
|
left: '',
|
||||||
@@ -229,6 +241,8 @@ export default {
|
|||||||
// 初始化指示器位置
|
// 初始化指示器位置
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateIndicatorPosition();
|
this.updateIndicatorPosition();
|
||||||
|
// 初始化时将激活标签滚动到可视区域
|
||||||
|
this.syncScrollIntoId();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -417,14 +431,6 @@ export default {
|
|||||||
// 对于水平布局(顶部和底部),设置固定高度
|
// 对于水平布局(顶部和底部),设置固定高度
|
||||||
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
||||||
style.height = this.formatStyleValue(this.mergedValue.tabHeight, 24);
|
style.height = this.formatStyleValue(this.mergedValue.tabHeight, 24);
|
||||||
// 应用对齐方式
|
|
||||||
if (this.mergedValue.navAlign === 'start') {
|
|
||||||
style.justifyContent = 'flex-start';
|
|
||||||
} else if (this.mergedValue.navAlign === 'center') {
|
|
||||||
style.justifyContent = 'center';
|
|
||||||
} else if (this.mergedValue.navAlign === 'end') {
|
|
||||||
style.justifyContent = 'flex-end';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 对于垂直布局(左侧和右侧),不设置固定高度,让它自适应内容
|
// 对于垂直布局(左侧和右侧),不设置固定高度,让它自适应内容
|
||||||
else {
|
else {
|
||||||
@@ -451,6 +457,24 @@ export default {
|
|||||||
return style;
|
return style;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 导航栏内层容器样式(与后端版本一致)
|
||||||
|
tabNavInnerStyle() {
|
||||||
|
let style = {};
|
||||||
|
|
||||||
|
// 仅水平布局(顶部和底部)应用对齐方式
|
||||||
|
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
||||||
|
if (this.mergedValue.navAlign === 'start') {
|
||||||
|
style.justifyContent = 'flex-start';
|
||||||
|
} else if (this.mergedValue.navAlign === 'center') {
|
||||||
|
style.justifyContent = 'center';
|
||||||
|
} else if (this.mergedValue.navAlign === 'end') {
|
||||||
|
style.justifyContent = 'flex-end';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
|
||||||
// 标签项样式(与后端版本一致)
|
// 标签项样式(与后端版本一致)
|
||||||
tabItemStyle() {
|
tabItemStyle() {
|
||||||
let style = {
|
let style = {
|
||||||
@@ -601,9 +625,10 @@ export default {
|
|||||||
|
|
||||||
// 监听器
|
// 监听器
|
||||||
watch: {
|
watch: {
|
||||||
// 监听激活标签变化,更新指示器
|
// 监听激活标签变化,更新指示器并滚动导航
|
||||||
activeTab: {
|
activeTab: {
|
||||||
handler() {
|
handler() {
|
||||||
|
this.syncScrollIntoId();
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateIndicatorPosition();
|
this.updateIndicatorPosition();
|
||||||
});
|
});
|
||||||
@@ -724,6 +749,16 @@ export default {
|
|||||||
getTabKey(tab, idx) {
|
getTabKey(tab, idx) {
|
||||||
return `tab-${tab?.id ?? idx}`;
|
return `tab-${tab?.id ?? idx}`;
|
||||||
},
|
},
|
||||||
|
// 获取导航项的 DOM id(用于 scroll-view 的 scroll-into-view 定位)
|
||||||
|
getNavItemId(idx) {
|
||||||
|
return `tab-nav-item-${idx}`;
|
||||||
|
},
|
||||||
|
// 将当前激活标签滚动到导航栏可视区域
|
||||||
|
syncScrollIntoId() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollIntoId = this.getNavItemId(this.activeTab);
|
||||||
|
});
|
||||||
|
},
|
||||||
// 更新指示器位置(与后端版本一致)
|
// 更新指示器位置(与后端版本一致)
|
||||||
updateIndicatorPosition() {
|
updateIndicatorPosition() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -1034,8 +1069,6 @@ export default {
|
|||||||
|
|
||||||
// ===== 标签导航栏样式 =====
|
// ===== 标签导航栏样式 =====
|
||||||
.tab-nav {
|
.tab-nav {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -1049,6 +1082,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导航栏内层容器:使用 inline-flex 让内容自然撑开宽度,保证小程序 scroll-view 可横向滚动
|
||||||
|
.tab-nav-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
// 垂直导航栏
|
// 垂直导航栏
|
||||||
.tab-nav-vertical {
|
.tab-nav-vertical {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1068,6 +1111,7 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
flex-shrink: 0; // 防止标签在 scroll-view 内被压缩,保证可完整展示并横向滑动
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--active-color, #339af0);
|
color: var(--active-color, #339af0);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
||||||
<view class="video-container" :style="popupVideoStyle">
|
<view class="video-container" :style="popupVideoStyle">
|
||||||
<video
|
<video
|
||||||
|
:key="video_url"
|
||||||
ref="popupVideoRef"
|
ref="popupVideoRef"
|
||||||
class="adaptive-video"
|
class="adaptive-video"
|
||||||
:autoPauseIfNavigate="true"
|
:autoPauseIfNavigate="true"
|
||||||
@@ -65,6 +66,9 @@
|
|||||||
:src="video_url"
|
:src="video_url"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
object-fit="contain"
|
object-fit="contain"
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
x5-playsinline
|
||||||
@loadedmetadata="onPopupVideoLoaded"
|
@loadedmetadata="onPopupVideoLoaded"
|
||||||
@error="onPopupVideoError"
|
@error="onPopupVideoError"
|
||||||
></video>
|
></video>
|
||||||
@@ -144,41 +148,21 @@ export default {
|
|||||||
|
|
||||||
// 弹窗视频容器样式
|
// 弹窗视频容器样式
|
||||||
popupVideoStyle() {
|
popupVideoStyle() {
|
||||||
let style = 'position:fixed;width:100%;left:0;';
|
// 容器横向铺满,黑色背景填满左右边界
|
||||||
const defaultRatio = 9 / 16;
|
// 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
|
||||||
|
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
|
||||||
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
|
|
||||||
|
|
||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 显示视频播放弹窗
|
// 显示视频播放弹窗
|
||||||
showVideo(item) {
|
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.popupVideoWidth = 0;
|
||||||
this.popupVideoHeight = 0;
|
this.popupVideoHeight = 0;
|
||||||
this.$refs.videoPopup.open();
|
this.$refs.videoPopup.open();
|
||||||
@@ -625,22 +609,6 @@ export default {
|
|||||||
left: 0;
|
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 {
|
.video-nav {
|
||||||
padding: 16rpx;
|
padding: 16rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
:style="videoWarpCss"
|
:style="videoWarpCss"
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
x5-playsinline
|
||||||
@loadedmetadata="onVideoLoaded"
|
@loadedmetadata="onVideoLoaded"
|
||||||
@click="handlerClick(videoUrl)"
|
@click="handlerClick(videoUrl)"
|
||||||
></video>
|
></video>
|
||||||
@@ -106,11 +109,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
|
|||||||
499
components-diy/diy-webview.vue
Normal file
499
components-diy/diy-webview.vue
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
<template>
|
||||||
|
<view data-component-name="diy-webview" class="diy-webview" :style="containerStyle" @tap="handleTap">
|
||||||
|
<template v-if="src">
|
||||||
|
<!-- #ifdef H5 -->
|
||||||
|
<iframe
|
||||||
|
v-if="!lazyPending"
|
||||||
|
class="diy-webview-iframe"
|
||||||
|
:src="src"
|
||||||
|
:scrolling="scrollMode"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
frameborder="0"
|
||||||
|
allow="fullscreen"
|
||||||
|
loading="lazy"
|
||||||
|
@load="handleIframeLoad"
|
||||||
|
@error="handleIframeError"
|
||||||
|
></iframe>
|
||||||
|
<!-- 后台配置的占位图 / 懒加载未进入视口时的展示 -->
|
||||||
|
<image
|
||||||
|
v-if="placeholderUrl && (lazyPending || !loaded)"
|
||||||
|
class="diy-webview-placeholder"
|
||||||
|
:src="placeholderUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
></image>
|
||||||
|
<!-- 加载中遮罩:iframe 触发 load 前展示,避免白屏观感 -->
|
||||||
|
<view v-if="!loaded && !loadError && !lazyPending" class="webview-loading" :style="errorStyle">
|
||||||
|
<view class="webview-loading-spinner"></view>
|
||||||
|
<text class="webview-loading-text">网页加载中...</text>
|
||||||
|
</view>
|
||||||
|
<!-- H5 下 iframe 内部点击不会冒泡到父容器,提供角标按钮保证"点击跳转"可用 -->
|
||||||
|
<view class="webview-open-chip" @tap.stop="mpGo">
|
||||||
|
<text class="iconfont icon-fuzhilianjie"></text>
|
||||||
|
<text class="webview-open-chip-text">{{ mpGoText }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- #ifndef H5 -->
|
||||||
|
<!-- 小程序/APP:内嵌 web-view 会整页覆盖原生层,改为卡片展示 + 点击跳转 -->
|
||||||
|
<view class='webview-mp-go' :style='mpGoStyle' @tap.stop='mpGo'>
|
||||||
|
<text class='iconfont icon-fuzhilianjie'></text>
|
||||||
|
<text class='webview-mp-go-text'>{{ mpGoText }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- 加载失败兜底(如目标站禁止被嵌入/内核过旧加载失败):可点击直接打开 -->
|
||||||
|
<view v-if="loadError" class="webview-error" :style="errorStyle" @tap.stop="mpGo">
|
||||||
|
<text class="iconfont icon-fuzhilianjie"></text>
|
||||||
|
<text class="error-text">{{ errorText }}</text>
|
||||||
|
<text class="error-open">点击此处打开网页</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view v-else class="webview-empty" :style="emptyStyle">
|
||||||
|
<text class="iconfont icon-fuzhilianjie"></text>
|
||||||
|
<text class="empty-text">请配置网页链接</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 网页/WebView 组件:后台配置 URL 后嵌入展示,H5 使用 iframe,小程序/APP 使用原生 web-view
|
||||||
|
import DiyMinx from './minx.js'
|
||||||
|
export default {
|
||||||
|
name: 'diy-webview',
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
src: '',
|
||||||
|
loaded: false,
|
||||||
|
loadError: false,
|
||||||
|
// 后台开启"懒加载"时,iframe 需等容器滚动进入视口后才真正渲染
|
||||||
|
lazyPending: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mixins: [DiyMinx],
|
||||||
|
created() {
|
||||||
|
this.initWebview();
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this._mountedFlag = true;
|
||||||
|
// #ifdef H5
|
||||||
|
if (this.lazyPending) this._setupLazyObserver();
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this._observer) {
|
||||||
|
try { this._observer.disconnect(); } catch (e) {}
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
this._clearLoadTimer();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.initWebview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
containerStyle() {
|
||||||
|
let style = '';
|
||||||
|
// 后台高度为 px 值,与项目其他组件一致 ×2 转 rpx(diy-horz-blank 同款换算)
|
||||||
|
const height = Number(this.value?.height) || 300;
|
||||||
|
style += `height:${height * 2}rpx;`;
|
||||||
|
// 组件角度 round 时按后台圆角值渲染(×2 rpx)
|
||||||
|
if (this.value?.componentAngle === 'round') {
|
||||||
|
const topR = Number(this.value?.topAroundRadius) || 0;
|
||||||
|
const bottomR = Number(this.value?.bottomAroundRadius) || 0;
|
||||||
|
style += `border-radius:${topR * 2}rpx ${topR * 2}rpx ${bottomR * 2}rpx ${bottomR * 2}rpx;`;
|
||||||
|
}
|
||||||
|
if (this.value?.bgColor) {
|
||||||
|
style += `background:${this.value.bgColor};`;
|
||||||
|
}
|
||||||
|
if (this.value?.marginTop) {
|
||||||
|
style += `margin-top:${this.value.marginTop}rpx;`;
|
||||||
|
}
|
||||||
|
if (this.value?.marginBottom) {
|
||||||
|
style += `margin-bottom:${this.value.marginBottom}rpx;`;
|
||||||
|
}
|
||||||
|
if (this.value?.padding) {
|
||||||
|
style += `padding:${this.value.padding}rpx;`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
emptyStyle() {
|
||||||
|
let style = '';
|
||||||
|
if (this.value?.bgColor) {
|
||||||
|
style += `background:${this.value.bgColor};`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
errorStyle() {
|
||||||
|
let style = '';
|
||||||
|
if (this.value?.bgColor) {
|
||||||
|
style += `background:${this.value.bgColor};`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
mpGoText() {
|
||||||
|
// 注意:this.$lang(key) 在 key 不存在时返回 key 字符串本身,不能仅靠 this.$lang 是否存在判断。
|
||||||
|
// 取到 key 与传入 key 不同才视为命中语言包,否则用 fallback。
|
||||||
|
const t = this.$lang && this.$lang('components-diy.diy-webview.mpGo');
|
||||||
|
return (t && t !== 'components-diy.diy-webview.mpGo') ? t : '点击查看网页内容';
|
||||||
|
},
|
||||||
|
mpGoStyle() {
|
||||||
|
let style = '';
|
||||||
|
if (this.value && this.value.bgColor) {
|
||||||
|
style += 'background:' + this.value.bgColor + ';';
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
errorText() {
|
||||||
|
const t = this.$lang && this.$lang('components-diy.diy-webview.loadError');
|
||||||
|
return (t && t !== 'components-diy.diy-webview.loadError') ? t : '网页加载失败,请检查链接';
|
||||||
|
},
|
||||||
|
// iframe 滚动条开关(后台"滚动"配置:true 显示 / false 隐藏)
|
||||||
|
scrollMode() {
|
||||||
|
const s = this.value?.scrolling;
|
||||||
|
return (s === false || s === 'false' || s === 0) ? 'no' : 'yes';
|
||||||
|
},
|
||||||
|
// 占位图(后台 imageUrl,iframe 加载完成前展示)
|
||||||
|
placeholderUrl() {
|
||||||
|
const url = this.value?.imageUrl;
|
||||||
|
return url ? (this.$util.img ? this.$util.img(url) : url) : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initWebview() {
|
||||||
|
const config = this.value || {};
|
||||||
|
this.loadError = false;
|
||||||
|
this.loaded = false;
|
||||||
|
// 调试用:把后台下发的完整 value 对象打印到控制台,方便定位 URL 字段名
|
||||||
|
console.log('[diy-webview] value:', JSON.stringify(config));
|
||||||
|
// 兼容后台可能使用的多种链接字段名/嵌套结构
|
||||||
|
const url = this.extractUrl(config);
|
||||||
|
// 懒加载:开启时等容器进入视口再渲染 iframe
|
||||||
|
this.lazyPending = !!(config.lazyRender || config.lazyLoad);
|
||||||
|
// #ifdef H5
|
||||||
|
// 数据热更新时(后台重新保存)若尚未进入过视口,需重建观察器(仅在挂载后才可建立)
|
||||||
|
if (this.lazyPending && this._mountedFlag) this._setupLazyObserver();
|
||||||
|
// #endif
|
||||||
|
// 优先使用后台直接下发的 url;若配置的是接口来源(source=api),则通过接口拉取
|
||||||
|
if (config.source === 'api' && config.apiUrl) {
|
||||||
|
this.loadUrlFromApi(config.apiUrl);
|
||||||
|
} else if (url) {
|
||||||
|
this.src = this.buildSrc(url);
|
||||||
|
console.log('[diy-webview] src:', this.src);
|
||||||
|
this._startLoadTimer();
|
||||||
|
} else {
|
||||||
|
this.src = '';
|
||||||
|
console.warn('[diy-webview] 未找到有效链接字段');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 在格式化后的 URL 基础上,拼接后台配置的"附加参数"与"携带登录态"(token / member_id)
|
||||||
|
buildSrc(url) {
|
||||||
|
let src = this.formatUrl(url);
|
||||||
|
const config = this.value || {};
|
||||||
|
const extra = [];
|
||||||
|
// 附加参数(后台 urlParams 字段,格式 "a=1&b=2",多组参数用 & 连接)
|
||||||
|
const rawExtra = config.urlParams || config.extraParams || config.appendParams || config.additionalParams || config.params;
|
||||||
|
if (rawExtra && typeof rawExtra === 'string') {
|
||||||
|
extra.push(rawExtra.replace(/^[?&]+/, ''));
|
||||||
|
}
|
||||||
|
// 携带登录态(后台 carryToken 字段:none 不携带 / token / member_id)
|
||||||
|
const loginState = config.carryToken || config.loginState || config.login_state;
|
||||||
|
if (loginState === 'token') {
|
||||||
|
const token = (this.$store && this.$store.state && this.$store.state.token) || '';
|
||||||
|
if (token) extra.push('token=' + encodeURIComponent(token));
|
||||||
|
} else if (loginState === 'member_id') {
|
||||||
|
const memberInfo = (this.$store && this.$store.state && this.$store.state.memberInfo) || {};
|
||||||
|
const memberId = memberInfo.member_id || memberInfo.memberId || '';
|
||||||
|
if (memberId) extra.push('member_id=' + memberId);
|
||||||
|
}
|
||||||
|
if (extra.length) {
|
||||||
|
src += (src.indexOf('?') !== -1 ? '&' : '?') + extra.filter(Boolean).join('&');
|
||||||
|
}
|
||||||
|
return src;
|
||||||
|
},
|
||||||
|
// #ifdef H5
|
||||||
|
// 懒加载:容器滚动进入视口(含预加载缓冲 200px)后才渲染 iframe
|
||||||
|
_setupLazyObserver() {
|
||||||
|
try {
|
||||||
|
if (this._observer) {
|
||||||
|
this._observer.disconnect();
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
this._observer = uni.createIntersectionObserver(this, { thresholds: [0] });
|
||||||
|
this._observer.relativeToViewport({ bottom: 200 }).observe('.diy-webview', () => {
|
||||||
|
this.lazyPending = false;
|
||||||
|
if (this._observer) {
|
||||||
|
this._observer.disconnect();
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 观察器异常时直接渲染,避免内容永远空白
|
||||||
|
this.lazyPending = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
extractUrl(config) {
|
||||||
|
if (!config || typeof config !== 'object') return '';
|
||||||
|
// 优先读取常见字段名(兼容后台不同命名习惯)
|
||||||
|
let direct = config.url || config.link || config.linkUrl || config.pageUrl || config.webUrl || config.web_url || config.externalUrl || config.targetUrl || config.src || config.href || config.path || config.address;
|
||||||
|
// 兼容装修链接选择器下发的对象形态:link = { wap_url / url / h5_url ... }
|
||||||
|
if (direct && typeof direct === 'object') {
|
||||||
|
direct = direct.wap_url || direct.h5_url || direct.url || direct.app_url || direct.link || '';
|
||||||
|
}
|
||||||
|
if (direct && typeof direct === 'string') return direct.trim();
|
||||||
|
// 兼容嵌套结构:如 value.value.url / value.config.url / value.data.url
|
||||||
|
for (const key of ['value', 'config', 'data', 'content', 'params']) {
|
||||||
|
if (config[key] && typeof config[key] === 'object') {
|
||||||
|
const nested = this.extractUrl(config[key]);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
formatUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return '';
|
||||||
|
let formatted = url.trim();
|
||||||
|
// 如果后台把 URL 编码存储,做一次解码
|
||||||
|
if (formatted.indexOf('%') !== -1) {
|
||||||
|
try { formatted = decodeURIComponent(formatted); } catch (e) {}
|
||||||
|
}
|
||||||
|
// 支持以 http:// 或 https:// 开头的完整地址
|
||||||
|
if (/^https?:\/\//i.test(formatted)) return formatted;
|
||||||
|
// 支持相对路径或站点内链接,自动补全
|
||||||
|
return this.$util.img ? this.$util.img(formatted) : formatted;
|
||||||
|
},
|
||||||
|
loadUrlFromApi(apiUrl) {
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: apiUrl,
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
const data = typeof res.data === 'string' ? { url: res.data } : res.data;
|
||||||
|
this.src = this.formatUrl(data.url || data.link || '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.src = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateNavbarTitle(e) {
|
||||||
|
// 参照 pages-tool/webview.vue:当 web-view 页面标题更新时,可同步设置导航栏标题
|
||||||
|
const title = e?.detail?.title || e?.title || '';
|
||||||
|
if (title) {
|
||||||
|
uni.setNavigationBarTitle({ title });
|
||||||
|
}
|
||||||
|
this.$emit('titleUpdate', e);
|
||||||
|
},
|
||||||
|
// 点击网页容器整体 → 跳转到配置的链接(走统一 diyRedirectTo,外链自动进 web-view / 新窗口)
|
||||||
|
handleTap() {
|
||||||
|
if (this.src) this.mpGo();
|
||||||
|
},
|
||||||
|
mpGo() {
|
||||||
|
if (!this.src) return;
|
||||||
|
if (this.$util && this.$util.diyRedirectTo) {
|
||||||
|
this.$util.diyRedirectTo({
|
||||||
|
wap_url: this.src,
|
||||||
|
title: (this.value && this.value.title) || (this.value && this.value.name) || '网页',
|
||||||
|
name: (this.value && this.value.name) ? this.value.name : '网页'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(this.src) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 注意:方法名不能叫 onLoad/onError —— uni-app 会把组件里的 onLoad 当页面
|
||||||
|
// 生命周期提升出 methods,导致 @load="onLoad" 渲染时报 handler undefined。
|
||||||
|
handleIframeLoad(e) {
|
||||||
|
this.loaded = true;
|
||||||
|
this.loadError = false;
|
||||||
|
this._clearLoadTimer();
|
||||||
|
this.$emit('load', e);
|
||||||
|
},
|
||||||
|
handleIframeError(e) {
|
||||||
|
this.loadError = true;
|
||||||
|
this.loaded = true;
|
||||||
|
this._clearLoadTimer();
|
||||||
|
console.error('[diy-webview] 加载失败:', this.src, e);
|
||||||
|
this.$emit('error', e);
|
||||||
|
},
|
||||||
|
// 加载超时兜底:部分浏览器内核(如 HBuilderX 内置浏览器的老 CEF 内核)对跨域 iframe
|
||||||
|
// 请求失败时不会触发 error 事件,用定时器兜住,避免容器一直白屏
|
||||||
|
_startLoadTimer() {
|
||||||
|
this._clearLoadTimer();
|
||||||
|
// #ifdef H5
|
||||||
|
this._loadTimer = setTimeout(() => {
|
||||||
|
if (!this.loaded) {
|
||||||
|
this.loadError = true;
|
||||||
|
console.warn('[diy-webview] 加载超时(10s),显示兜底内容:', this.src);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
_clearLoadTimer() {
|
||||||
|
if (this._loadTimer) {
|
||||||
|
clearTimeout(this._loadTimer);
|
||||||
|
this._loadTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMessage(e) {
|
||||||
|
// 接收 web-view / iframe 发来的消息,可用于与后台数据交互
|
||||||
|
this.$emit('message', e);
|
||||||
|
// 同时通过事件总线广播,方便页面级监听
|
||||||
|
this.__$emitEvent({
|
||||||
|
eventName: 'diy-webview-message',
|
||||||
|
data: {
|
||||||
|
componentName: this.value?.componentName,
|
||||||
|
moduleIndex: this.value?.moduleIndex,
|
||||||
|
message: e?.detail?.data || e?.data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.diy-webview {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.diy-webview-iframe,
|
||||||
|
.diy-webview-native {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 占位图 / 懒加载未进入视口时的展示 */
|
||||||
|
.diy-webview-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 角标跳转按钮:iframe 内部点击不冒泡,用它兜底"点击跳转" */
|
||||||
|
.webview-open-chip {
|
||||||
|
position: absolute;
|
||||||
|
right: 16rpx;
|
||||||
|
bottom: 16rpx;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-right: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中遮罩 */
|
||||||
|
.webview-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-loading-spinner {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border: 4rpx solid #e5e6eb;
|
||||||
|
border-top-color: #576b95;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
animation: webview-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes webview-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-empty,
|
||||||
|
.webview-mp-go {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #576B95;
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 48rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-error {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 48rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-error {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: #ff6b35;
|
||||||
|
|
||||||
|
.error-open {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #576b95;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,56 @@
|
|||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* 读取后台装修下发的布尔开关
|
||||||
|
* 依次取 names 中的字段, 兼容 1/0、"1"/"0"、true/false、"true"/"false" 等值类型
|
||||||
|
* 命中返回 true/false; 所有字段都未下发时返回 undefined (由调用方决定兜底)
|
||||||
|
*
|
||||||
|
* 匹配顺序:
|
||||||
|
* 1. 精确命中 (showAddCart / show_add_cart ...)
|
||||||
|
* 2. 忽略大小写、下划线、中划线、空格的差异 (showaddcart === showAddCart === show_add_cart)
|
||||||
|
* —— 后台实际字段名与前端约定写法不一致时, 这一步兜底
|
||||||
|
* 3. 常见嵌套容器 (value.style / value.config / value.setting / value.data)
|
||||||
|
*/
|
||||||
|
getDiyToggle(names) {
|
||||||
|
const v = this.value || {};
|
||||||
|
const parse = val => {
|
||||||
|
if (val === undefined || val === null || val === '') return undefined;
|
||||||
|
if (val === true || val === 1 || val === '1' || val === 'true') return true;
|
||||||
|
if (val === false || val === 0 || val === '0' || val === 'false') return false;
|
||||||
|
return !!val;
|
||||||
|
};
|
||||||
|
const pick = obj => {
|
||||||
|
if (!obj || typeof obj != 'object') return undefined;
|
||||||
|
// 1. 精确命中
|
||||||
|
let i, res;
|
||||||
|
for (i = 0; i < names.length; i++) {
|
||||||
|
res = parse(obj[names[i]]);
|
||||||
|
if (res !== undefined) return res;
|
||||||
|
}
|
||||||
|
// 2. 忽略大小写/下划线等差异
|
||||||
|
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||||
|
const wanted = names.map(norm);
|
||||||
|
for (const key in obj) {
|
||||||
|
if (wanted.indexOf(norm(key)) != -1) {
|
||||||
|
res = parse(obj[key]);
|
||||||
|
if (res !== undefined) return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = pick(v);
|
||||||
|
if (root !== undefined) return root;
|
||||||
|
|
||||||
|
// 3. 嵌套容器兜底
|
||||||
|
const containers = ['style', 'config', 'setting', 'data'];
|
||||||
|
for (let i = 0; i < containers.length; i++) {
|
||||||
|
const res = pick(v[containers[i]]);
|
||||||
|
if (res !== undefined) return res;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
|
||||||
// 异步触发事件
|
// 异步触发事件
|
||||||
async __$emitEvent(payload = {eventName: '__unnamedEvent', data: {}, promiseCallback: null}) {
|
async __$emitEvent(payload = {eventName: '__unnamedEvent', data: {}, promiseCallback: null}) {
|
||||||
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)
|
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||||
<view>
|
<view>
|
||||||
<view v-if="pageCount === 1 || need" class="fixed-box"
|
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
||||||
:style="[customContainerStyle, {
|
:style="[customContainerStyle, {
|
||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
|
width: containerWidth,
|
||||||
bottom: containerBottom,
|
bottom: containerBottom,
|
||||||
right: containerRight,
|
right: containerRight,
|
||||||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||||||
@@ -11,18 +12,18 @@
|
|||||||
}]">
|
}]">
|
||||||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||||||
<view
|
<view
|
||||||
v-if="isLanguageSwitchEnabled && fixBtnShow"
|
v-if="isLanguageSwitchEnabled && hasLangButton"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-lang"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
|
||||||
@click="handleLangButtonClick"
|
@click="handleLangButtonClick"
|
||||||
>
|
>
|
||||||
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
|
<view class="lang-btn-text">{{ langButtonText }}</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 微信官方客服按钮-->
|
<!-- 微信官方客服按钮-->
|
||||||
<button
|
<button
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
>
|
>
|
||||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||||
@@ -30,9 +31,9 @@
|
|||||||
|
|
||||||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||||||
<view
|
<view
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
@click="handleUnifiedKefuClick"
|
@click="handleUnifiedKefuClick"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
@@ -48,9 +49,9 @@
|
|||||||
|
|
||||||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||||||
<view
|
<view
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
@click="handleUnifiedKefuClick"
|
@click="handleUnifiedKefuClick"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
@@ -66,9 +67,9 @@
|
|||||||
|
|
||||||
<!-- 小程序系统客服按钮 -->
|
<!-- 小程序系统客服按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
|
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-kefu"
|
||||||
:style="btnItemStyle"
|
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
>
|
>
|
||||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||||
@@ -76,9 +77,9 @@
|
|||||||
|
|
||||||
<!-- 电话按钮 -->
|
<!-- 电话按钮 -->
|
||||||
<view
|
<view
|
||||||
v-if="fixBtnShow"
|
v-if="hasPhoneButton"
|
||||||
class="btn-item common-bg"
|
class="btn-item common-bg btn-phone"
|
||||||
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||||
@click="call"
|
@click="call"
|
||||||
>
|
>
|
||||||
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
||||||
@@ -109,6 +110,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createCustomerService } from '@/common/js/customer-service.js';
|
import { createCustomerService } from '@/common/js/customer-service.js';
|
||||||
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
||||||
|
import {
|
||||||
|
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
|
||||||
|
resolveLangButtonText, isLangButtonVisible,
|
||||||
|
} from '@/common/js/floating-button.js';
|
||||||
|
|
||||||
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
||||||
// key: 语言 value,value: 显示短码(按钮上展示)
|
// key: 语言 value,value: 显示短码(按钮上展示)
|
||||||
@@ -161,6 +166,20 @@ function getLangShortCode(langValue, customMap) {
|
|||||||
return prefix.toUpperCase();
|
return prefix.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跨页面/重挂载复用的悬浮按钮避让值(设计稿 rpx)。
|
||||||
|
// H5 切换页面时 hover-nav 会重新挂载并重新测量公告栏: 无公告栏的页面(如分类页)
|
||||||
|
// noticeRect 为 null, 若直接回落基础位置, 会与首页"公告下方"位置不一致,
|
||||||
|
// 切回首页时按钮突然上/下跳。缓存上次算出的避让值, 让所有页面位置恒定。
|
||||||
|
let cachedAvoidBottom = null;
|
||||||
|
|
||||||
|
// 需要隐藏悬浮按钮的页面路由(全平台生效: H5 + 小程序)
|
||||||
|
// /pages_goods/category 商品分类
|
||||||
|
// /pages_tool/contact/contact 电子名片(名片)
|
||||||
|
const H5_HIDDEN_ROUTES = [
|
||||||
|
'/pages_goods/category',
|
||||||
|
'/pages_tool/contact/contact',
|
||||||
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'hover-nav',
|
name: 'hover-nav',
|
||||||
mixins: [i18nLazyMixin],
|
mixins: [i18nLazyMixin],
|
||||||
@@ -170,7 +189,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
fixBtnShow: true,
|
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
|
||||||
shopInfo: null,
|
shopInfo: null,
|
||||||
currentLangIndex: 0,
|
currentLangIndex: 0,
|
||||||
langIndexMap: {},
|
langIndexMap: {},
|
||||||
@@ -190,6 +209,12 @@ export default {
|
|||||||
layoutTopBottom: 272, // --top-btn-bottom
|
layoutTopBottom: 272, // --top-btn-bottom
|
||||||
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
||||||
layoutFloatingWidth: 100, // --floating-btn-width
|
layoutFloatingWidth: 100, // --floating-btn-width
|
||||||
|
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
|
||||||
|
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
|
||||||
|
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
|
||||||
|
noticeRect: null, // {top,bottom,height} 视口坐标(px)
|
||||||
|
winW: 0, // 视口宽(px)
|
||||||
|
winH: 0, // 视口高(px)
|
||||||
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
||||||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||||||
i18n: {
|
i18n: {
|
||||||
@@ -237,32 +262,75 @@ export default {
|
|||||||
customButtonStyle() {
|
customButtonStyle() {
|
||||||
return this.shopInfo?.floatingButton?.button || {};
|
return this.shopInfo?.floatingButton?.button || {};
|
||||||
},
|
},
|
||||||
|
// ============================================================
|
||||||
|
// 浮动侧边栏配置消费 (来源: /api/config/init → data.floating_button,
|
||||||
|
// store/index.js 的 floatingButtonConfig, 初始 state 已声明 → 响应式)
|
||||||
|
// fbConfig 为 null (老缓存响应缺字段) 时, 各判定全部走老行为兜底。
|
||||||
|
// ============================================================
|
||||||
|
// 接口下发的浮动侧边栏配置 (老缓存响应 → null)
|
||||||
|
fbConfig() {
|
||||||
|
return (this.$store && this.$store.state && this.$store.state.floatingButtonConfig) || null;
|
||||||
|
},
|
||||||
|
// 指定页面隐藏悬浮按钮(商品分类 / 电子名片), 全平台生效
|
||||||
|
hiddenOnRoute() {
|
||||||
|
const r = this.currentRoute;
|
||||||
|
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
|
||||||
|
},
|
||||||
|
// 总开关 + 强制隐藏 + 页面范围 判定结果
|
||||||
|
fbVisible() {
|
||||||
|
if (this.hiddenOnRoute) return false; // 指定页面直接隐藏
|
||||||
|
return shouldShowOnPage(this.fbConfig, this.currentRoute, true);
|
||||||
|
},
|
||||||
|
// 最终要渲染的按钮 (已按 button_order 排序 + 显隐过滤); null = 用老行为
|
||||||
|
visibleButtons() {
|
||||||
|
return resolveVisibleButtons(this.fbConfig);
|
||||||
|
},
|
||||||
|
hasAnyButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.length > 0 : true; // 老行为: 电话按钮恒显
|
||||||
|
},
|
||||||
|
hasLangButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.indexOf('lang') !== -1
|
||||||
|
: isLangButtonVisible(this.fbConfig, this.shopInfo, this.langList.length);
|
||||||
|
},
|
||||||
|
hasKefuButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.indexOf('kefu') !== -1 : true;
|
||||||
|
},
|
||||||
|
hasPhoneButton() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
return b ? b.indexOf('phone') !== -1 : true;
|
||||||
|
},
|
||||||
|
// 语言按钮文案: 商家可配「目标语言 / 当前语言 / 固定文案」。
|
||||||
|
// 配置缺失时回退老行为 currentLangDisplayName(展示当前语言)。
|
||||||
|
langButtonText() {
|
||||||
|
if (!this.fbConfig) return this.currentLangDisplayName;
|
||||||
|
return resolveLangButtonText(this.fbConfig, this.langList, this.currentLangIndex);
|
||||||
|
},
|
||||||
|
// 按钮渲染顺序 (button_order 自上而下); .fixed-box 是 flex-direction: column,
|
||||||
|
// 用 CSS order 控制三段(语言/客服/电话)的先后, 配置缺失时退回老顺序。
|
||||||
|
orderIndex() {
|
||||||
|
const order = (this.fbConfig && Array.isArray(this.fbConfig.button_order))
|
||||||
|
? this.fbConfig.button_order
|
||||||
|
: ['lang', 'kefu', 'phone'];
|
||||||
|
const map = {};
|
||||||
|
for (let i = 0; i < order.length; i++) map[order[i]] = i + 1;
|
||||||
|
map.lang = map.lang || 1; map.kefu = map.kefu || 2; map.phone = map.phone || 3;
|
||||||
|
return map;
|
||||||
|
},
|
||||||
// 所有 .btn-item 的 inline-style 兜底:
|
// 所有 .btn-item 的 inline-style 兜底:
|
||||||
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
||||||
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
||||||
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
||||||
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
|
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
|
||||||
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
|
||||||
btnItemStyle() {
|
// hover_nav_lang_btn_size / hover_nav_lang_size
|
||||||
return {
|
// hover_nav_kefu_btn_size / hover_nav_kefu_size
|
||||||
width: '72rpx',
|
// hover_nav_phone_btn_size / hover_nav_phone_size
|
||||||
height: '72rpx',
|
// 未配置时回退 72rpx(与原行为一致)。
|
||||||
'min-width': '72rpx',
|
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||||
'min-height': '72rpx',
|
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
|
||||||
'max-width': '72rpx',
|
|
||||||
'max-height': '72rpx',
|
|
||||||
'aspect-ratio': '1',
|
|
||||||
// border-radius 保留作 clip-path 不支持时的 fallback
|
|
||||||
'border-radius': '50%',
|
|
||||||
'flex-shrink': '0',
|
|
||||||
'flex-grow': '0',
|
|
||||||
'align-self': 'center',
|
|
||||||
'box-sizing': 'border-box',
|
|
||||||
padding: '0',
|
|
||||||
margin: '14rpx 0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
unreadCount() {
|
unreadCount() {
|
||||||
const customerService = createCustomerService(this);
|
const customerService = createCustomerService(this);
|
||||||
return customerService.getUnreadCount();
|
return customerService.getUnreadCount();
|
||||||
@@ -296,53 +364,101 @@ export default {
|
|||||||
return customerService.hasValidKefuConfig();
|
return customerService.hasValidKefuConfig();
|
||||||
},
|
},
|
||||||
|
|
||||||
//根据显示的按钮数量动态计算容器高度
|
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
|
||||||
// 计算明细(buttonCount 按钮场景):
|
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
|
||||||
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
|
renderedBtnSizes() {
|
||||||
|
const b = this.visibleButtons;
|
||||||
|
if (b && b.length === 0) return [];
|
||||||
|
|
||||||
|
const sizes = [];
|
||||||
|
if (this.isLanguageSwitchEnabled && this.hasLangButton) {
|
||||||
|
sizes.push(this.btnSizeRpx('lang'));
|
||||||
|
}
|
||||||
|
if (this.hasKefuButton && this.hasValidKefuConfig) {
|
||||||
|
sizes.push(this.btnSizeRpx('kefu'));
|
||||||
|
// 小程序系统客服与客服主按钮并存时, 模板会多渲染一个按钮
|
||||||
|
if (this.showWeappSystemKefu && !this.isWeappOfficialKefu) {
|
||||||
|
sizes.push(this.btnSizeRpx('kefu'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.hasPhoneButton) {
|
||||||
|
sizes.push(this.btnSizeRpx('phone'));
|
||||||
|
}
|
||||||
|
return sizes;
|
||||||
|
},
|
||||||
|
// 根据显示的按钮数量与各自尺寸动态计算容器高度
|
||||||
|
// 每个按钮槽位 = 按钮尺寸 + 14rpx(上 margin) + 14rpx(下 margin)
|
||||||
// 容器 padding: 20rpx 上 + 20rpx 下 = 40rpx
|
// 容器 padding: 20rpx 上 + 20rpx 下 = 40rpx
|
||||||
// 总高度 (rpx):buttonCount × 100 + 40
|
// 旧公式 buttonCount*100+40 是"所有按钮 72rpx"的特例, 现按逐按钮实际尺寸累加
|
||||||
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
|
containerHeightNum() {
|
||||||
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
|
const sizes = this.renderedBtnSizes;
|
||||||
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
|
if (sizes.length === 0) return 0;
|
||||||
// 关联:按钮尺寸与 to-top 同步(72rpx),所以槽位高度也是 100rpx;
|
return sizes.reduce((sum, s) => sum + s + 28, 0) + 40;
|
||||||
// 之前按钮 80rpx 时槽位 108rpx,改为 72rpx 后槽位缩小 8rpx,容器整体更紧凑
|
},
|
||||||
containerHeight() {
|
containerHeight() {
|
||||||
if (!this.fixBtnShow) return '320rpx';
|
return this.rpxStr(this.containerHeightNum);
|
||||||
|
},
|
||||||
let buttonCount = 1;
|
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
|
||||||
if (this.isLanguageSwitchEnabled) buttonCount++;
|
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
|
||||||
if (this.showWeappSystemKefu) buttonCount++;
|
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
|
||||||
buttonCount++;
|
containerWidthNum() {
|
||||||
const totalRpx = buttonCount * 100 + 40;
|
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
|
||||||
|
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
|
||||||
return `${totalRpx}rpx`;
|
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
|
||||||
|
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
|
||||||
|
},
|
||||||
|
containerWidth() {
|
||||||
|
return this.rpxStr(this.containerWidthNum);
|
||||||
},
|
},
|
||||||
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
||||||
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
||||||
// containerBottom = Top 顶部边界 + space
|
// containerBottom = Top 顶部边界 + space
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// space 是经验调优值(非数学推导),手调结果:
|
// space 两端统一为 18rpx:
|
||||||
// H5 : 18 * 5 = 90rpx
|
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
|
||||||
// MP-WEIXIN: 18 * 1 = 18rpx
|
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
|
||||||
// 为什么 H5 反而大、微信反而小:
|
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
|
||||||
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
|
|
||||||
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
|
|
||||||
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
|
|
||||||
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
|
|
||||||
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
|
|
||||||
// 18rpx 就能有清晰的呼吸感。
|
|
||||||
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||||||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||||||
// 两者会被安全区同步上推,space 保持不变
|
// 两者会被安全区同步上推,space 保持不变
|
||||||
containerBottom() {
|
containerBottom() {
|
||||||
if (!this.fixBtnShow) return '240rpx';
|
const space = 18;
|
||||||
let space = 18 * 5;
|
const base = this.layoutTopBottom + this.layoutTopSize + space;
|
||||||
// #ifdef MP-WEIXIN
|
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
|
||||||
space = 18 * 1;
|
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
|
||||||
// #endif
|
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
|
||||||
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
|
// 若直接 (winH - rect.bottom) 算, 滚动时按钮会跟着公告栏上下移动。
|
||||||
|
// 这里把 rect 加上当前滚动距离得到"公告栏在文档中的底边",
|
||||||
|
// 再算 (winH - 文档底边): 该值与滚动无关, 按钮固定在"页面顶部时公告栏正下方",
|
||||||
|
// 滚动时按钮静止(公告栏自行滚走), 不再来回滑动。
|
||||||
|
const rect = this.noticeRect;
|
||||||
|
let target = null;
|
||||||
|
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
|
||||||
|
// 当前滚动距离(H5: window.pageYOffset; 小程序无 window 时回退 0)
|
||||||
|
let scrollY = 0;
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && 'pageYOffset' in window) {
|
||||||
|
scrollY = window.pageYOffset || 0;
|
||||||
|
} else if (typeof document !== 'undefined' && document.documentElement) {
|
||||||
|
scrollY = document.documentElement.scrollTop || 0;
|
||||||
|
}
|
||||||
|
} catch (e) { scrollY = 0; }
|
||||||
|
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
|
||||||
|
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
|
||||||
|
// 公告栏底边在文档中的坐标 = 视口坐标 + 滚动距离(滚动无关, 恒定)
|
||||||
|
const docBottom = rect.bottom + scrollY;
|
||||||
|
const distFromBottom = (this.winH - docBottom) * scaleInv;
|
||||||
|
// 按钮 bottom = 文档底边距视口底部 + 间距 - 按钮自身高度
|
||||||
|
const below = distFromBottom - gap - this.containerHeightNum;
|
||||||
|
if (Number.isFinite(below)) {
|
||||||
|
target = Math.max(0, below);
|
||||||
|
cachedAvoidBottom = target; // 缓存: 跨页面/重挂载复用, 位置恒定
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无公告栏的页面(如分类页)复用上一次算出的避让值, 使悬浮按钮在所有页面位置一致
|
||||||
|
if (target === null && cachedAvoidBottom !== null) target = cachedAvoidBottom;
|
||||||
|
return this.rpxStr(target === null ? base : target);
|
||||||
},
|
},
|
||||||
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
||||||
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
||||||
@@ -357,9 +473,10 @@ export default {
|
|||||||
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
||||||
// center 对齐不仅中心精准,突出量也更小
|
// center 对齐不仅中心精准,突出量也更小
|
||||||
containerRight() {
|
containerRight() {
|
||||||
if (!this.fixBtnShow) return '0rpx';
|
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
|
||||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
|
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
|
||||||
return `${offset}rpx`;
|
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
|
||||||
|
return this.rpxStr(offset);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -420,9 +537,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
|
||||||
|
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
|
||||||
|
onShow() {
|
||||||
|
this.refreshRoute();
|
||||||
|
},
|
||||||
created() {
|
created() {
|
||||||
this.initLanguage();
|
this.initLanguage();
|
||||||
this.pageCount = getCurrentPages().length;
|
this.refreshRoute();
|
||||||
|
|
||||||
// 三源兜底(store / 同步 storage / 异步 storage),
|
// 三源兜底(store / 同步 storage / 异步 storage),
|
||||||
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
||||||
@@ -512,6 +634,23 @@ export default {
|
|||||||
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
||||||
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
||||||
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
||||||
|
|
||||||
|
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
|
||||||
|
this.updateViewport();
|
||||||
|
this.measureNotice();
|
||||||
|
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
|
||||||
|
this._noticeTimers = [
|
||||||
|
setTimeout(() => this.measureNotice(), 300),
|
||||||
|
setTimeout(() => this.measureNotice(), 800),
|
||||||
|
];
|
||||||
|
// #ifdef H5
|
||||||
|
// 不再监听 scroll: containerBottom 已改用「文档坐标」(视口坐标 + 滚动距离)计算,
|
||||||
|
// 得到与滚动无关的恒定 bottom —— 按钮固定在"页面顶部时公告栏正下方",
|
||||||
|
// 滚动时按钮静止、公告栏自行滚走, 不再来回滑动。
|
||||||
|
// 仅监听 resize(旋转/尺寸变化), 让按钮在视口尺寸变化时重新避让一次。
|
||||||
|
this._scrollHandler = this._onScroll.bind(this);
|
||||||
|
window.addEventListener('resize', this._scrollHandler);
|
||||||
|
// #endif
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||||||
@@ -520,9 +659,163 @@ export default {
|
|||||||
this._origParent.appendChild(this.$el);
|
this._origParent.appendChild(this.$el);
|
||||||
this._origParent = null;
|
this._origParent = null;
|
||||||
}
|
}
|
||||||
|
// 清理公告栏避让的滚动/缩放监听与定时器
|
||||||
|
if (this._scrollHandler) {
|
||||||
|
window.removeEventListener('scroll', this._scrollHandler);
|
||||||
|
window.removeEventListener('resize', this._scrollHandler);
|
||||||
|
this._scrollHandler = null;
|
||||||
|
}
|
||||||
|
if (this._noticeTimers) {
|
||||||
|
this._noticeTimers.forEach((t) => clearTimeout(t));
|
||||||
|
this._noticeTimers = null;
|
||||||
|
}
|
||||||
|
if (this._scrollRAF) {
|
||||||
|
cancelAnimationFrame(this._scrollRAF);
|
||||||
|
this._scrollRAF = null;
|
||||||
|
}
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// ============================================================
|
||||||
|
// rpx 换算工具(内联样式单位跨端兼容的关键)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
|
||||||
|
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
|
||||||
|
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
|
||||||
|
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
|
||||||
|
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
|
||||||
|
// 小程序端保持 rpx 原样输出。
|
||||||
|
// ============================================================
|
||||||
|
// #ifdef H5
|
||||||
|
rpxNum(n) {
|
||||||
|
const n2 = Number(n) || 0;
|
||||||
|
try {
|
||||||
|
const w = (typeof window !== 'undefined' && window.innerWidth) || 375;
|
||||||
|
return (n2 * w) / 750;
|
||||||
|
} catch (e) {
|
||||||
|
return n2 / 2; // window 异常时按 375px 视口兜底
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rpxStr(n) {
|
||||||
|
return this.rpxNum(n) + 'px';
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
rpxNum(n) {
|
||||||
|
return Number(n) || 0;
|
||||||
|
},
|
||||||
|
rpxStr(n) {
|
||||||
|
return (Number(n) || 0) + 'rpx';
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
|
||||||
|
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
|
||||||
|
// containerBottom 据此把按钮放到公告栏底边之下。
|
||||||
|
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
|
||||||
|
// ============================================================
|
||||||
|
// 更新视口尺寸(换算 rpx 用)
|
||||||
|
updateViewport() {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth) {
|
||||||
|
this.winW = window.innerWidth;
|
||||||
|
this.winH = window.innerHeight;
|
||||||
|
} else {
|
||||||
|
const info = uni.getSystemInfoSync();
|
||||||
|
this.winW = info.windowWidth || 375;
|
||||||
|
this.winH = info.windowHeight || 667;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.winW = this.winW || 375;
|
||||||
|
this.winH = this.winH || 667;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
|
||||||
|
measureNotice() {
|
||||||
|
try {
|
||||||
|
const q = uni.createSelectorQuery();
|
||||||
|
q.select('.diy-notice').boundingClientRect((rect) => {
|
||||||
|
if (rect && rect.height > 0) {
|
||||||
|
this.noticeRect = {
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.noticeRect = null; // 无公告栏 → 基础定位
|
||||||
|
}
|
||||||
|
}).exec();
|
||||||
|
} catch (e) {
|
||||||
|
this.noticeRect = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #ifdef H5
|
||||||
|
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
|
||||||
|
_onScroll() {
|
||||||
|
if (this._scrollRAF) return;
|
||||||
|
this._scrollRAF = requestAnimationFrame(() => {
|
||||||
|
this._scrollRAF = null;
|
||||||
|
this.updateViewport();
|
||||||
|
this.measureNotice();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 读取逐按钮尺寸 token(rpx 数值):
|
||||||
|
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
|
||||||
|
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
|
||||||
|
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
|
||||||
|
btnSizeRpx(type) {
|
||||||
|
let theme = null;
|
||||||
|
try {
|
||||||
|
theme = (this.$store && this.$store.state) ? this.$store.state.themeStyle : null;
|
||||||
|
} catch (e) { theme = null; }
|
||||||
|
const keys = ['hover_nav_' + type + '_btn_size', 'hover_nav_' + type + '_size'];
|
||||||
|
if (theme && typeof theme === 'object') {
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const raw = theme[keys[i]];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 72;
|
||||||
|
},
|
||||||
|
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
|
||||||
|
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
|
||||||
|
btnItemStyle(type) {
|
||||||
|
const s = this.rpxStr(this.btnSizeRpx(type));
|
||||||
|
return {
|
||||||
|
width: s,
|
||||||
|
height: s,
|
||||||
|
'min-width': s,
|
||||||
|
'min-height': s,
|
||||||
|
'max-width': s,
|
||||||
|
'max-height': s,
|
||||||
|
// 内部图标(.iconfont/.icox)通过该变量跟随按钮尺寸:
|
||||||
|
// 图标盒子里原本锁死 72rpx, 按钮调大后图标仍 72rpx,
|
||||||
|
// 又因列 flex 横轴默认贴左 → 图标偏在按钮左上角
|
||||||
|
'--btn-size': s,
|
||||||
|
'aspect-ratio': '1',
|
||||||
|
// border-radius 保留作 clip-path 不支持时的 fallback
|
||||||
|
'border-radius': '50%',
|
||||||
|
'flex-shrink': '0',
|
||||||
|
'flex-grow': '0',
|
||||||
|
'align-self': 'center',
|
||||||
|
'box-sizing': 'border-box',
|
||||||
|
padding: '0',
|
||||||
|
margin: this.rpxStr(14) + ' 0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// 刷新当前路由与页面栈深度(供 fbVisible 的页面范围判定 / pageCount 老判定)
|
||||||
|
refreshRoute() {
|
||||||
|
this.currentRoute = getCurrentRoute();
|
||||||
|
this.pageCount = getCurrentPages().length;
|
||||||
|
},
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
/**
|
/**
|
||||||
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
||||||
@@ -534,9 +827,13 @@ export default {
|
|||||||
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const computed = getComputedStyle(root);
|
const computed = getComputedStyle(root);
|
||||||
|
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
|
||||||
|
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
|
||||||
|
const scale = ((typeof window !== 'undefined' && window.innerWidth) || 375) / 750;
|
||||||
const getNum = (name, fallback) => {
|
const getNum = (name, fallback) => {
|
||||||
const v = parseFloat(computed.getPropertyValue(name));
|
const v = parseFloat(computed.getPropertyValue(name));
|
||||||
return Number.isFinite(v) ? v : fallback;
|
if (!Number.isFinite(v) || scale <= 0) return fallback;
|
||||||
|
return v / scale; // px → 设计稿 rpx 值
|
||||||
};
|
};
|
||||||
this.layoutTopRight = getNum('--top-btn-right', 46);
|
this.layoutTopRight = getNum('--top-btn-right', 46);
|
||||||
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
||||||
@@ -743,7 +1040,53 @@ export default {
|
|||||||
this.$langConfig.change(targetLang, url);
|
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() {
|
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({
|
this.customerService.handleUnifiedKefuClick({
|
||||||
sendMessageTitle: '来自悬浮按钮的咨询',
|
sendMessageTitle: '来自悬浮按钮的咨询',
|
||||||
sendMessagePath: '/pages/index/index'
|
sendMessagePath: '/pages/index/index'
|
||||||
@@ -775,8 +1118,13 @@ export default {
|
|||||||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||||||
width: var(--floating-btn-width, 100rpx);
|
width: var(--floating-btn-width, 100rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: 0.3s;
|
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
|
||||||
overflow: hidden;
|
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
|
||||||
|
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
|
||||||
|
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
|
||||||
|
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
|
||||||
|
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
|
||||||
|
overflow: visible;
|
||||||
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
||||||
margin-bottom: var(--window-bottom);
|
margin-bottom: var(--window-bottom);
|
||||||
}
|
}
|
||||||
@@ -784,6 +1132,9 @@ export default {
|
|||||||
.btn-item {
|
.btn-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
|
||||||
|
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
|
||||||
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -818,22 +1169,42 @@ export default {
|
|||||||
background-color: var(--hover-nav-bg-color);
|
background-color: var(--hover-nav-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 逐按钮背景/文字颜色覆写(与后台 9 个 key 对应, 未配置时回退全局 --hover-nav-*):
|
||||||
|
* hover_nav_lang_bg_color / hover_nav_lang_text_color → --hover-nav-lang-bg-color / --hover-nav-lang-text-color
|
||||||
|
* hover_nav_kefu_bg_color / hover_nav_kefu_text_color → --hover-nav-kefu-bg-color / --hover-nav-kefu-text-color
|
||||||
|
* hover_nav_phone_bg_color / hover_nav_phone_text_color → --hover-nav-phone-bg-color / --hover-nav-phone-text-color
|
||||||
|
* 文字色经 .btn-item 的 color 继承给内部 text/图标(.lang-btn-text 为 color: inherit) */
|
||||||
|
.btn-lang {
|
||||||
|
background-color: var(--hover-nav-lang-bg-color, var(--hover-nav-bg-color));
|
||||||
|
color: var(--hover-nav-lang-text-color, var(--hover-nav-text-color));
|
||||||
|
}
|
||||||
|
.btn-kefu {
|
||||||
|
background-color: var(--hover-nav-kefu-bg-color, var(--hover-nav-bg-color));
|
||||||
|
color: var(--hover-nav-kefu-text-color, var(--hover-nav-text-color));
|
||||||
|
}
|
||||||
|
.btn-phone {
|
||||||
|
background-color: var(--hover-nav-phone-bg-color, var(--hover-nav-bg-color));
|
||||||
|
color: var(--hover-nav-phone-text-color, var(--hover-nav-text-color));
|
||||||
|
}
|
||||||
|
|
||||||
.btn-item text {
|
.btn-item text {
|
||||||
font-size: 28rpx;
|
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
|
||||||
|
font-size: var(--hover-nav-font-size, 36rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont,
|
.iconfont,
|
||||||
.icox {
|
.icox {
|
||||||
font-size: 36rpx;
|
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
|
||||||
|
font-size: var(--hover-nav-font-size, 36rpx);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
|
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
|
||||||
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
|
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
|
||||||
* 直接固定 72x72 更稳。 */
|
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
|
||||||
width: 72rpx;
|
width: var(--btn-size, 72rpx);
|
||||||
height: 72rpx;
|
height: var(--btn-size, 72rpx);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -851,9 +1222,17 @@ export default {
|
|||||||
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 逐按钮字号覆写: 后台 hover_nav_phone/kefu_font_size 可调, 未设时回退全局 --hover-nav-font-size */
|
||||||
|
.iconfont.icon-dianhua {
|
||||||
|
font-size: var(--hover-nav-phone-font-size, var(--hover-nav-font-size, 36rpx));
|
||||||
|
}
|
||||||
|
.icox.icox-kefu {
|
||||||
|
font-size: var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx));
|
||||||
|
}
|
||||||
|
|
||||||
.iconfont text,
|
.iconfont text,
|
||||||
.icox text {
|
.icox text {
|
||||||
font-size: 36rpx;
|
font-size: var(--hover-nav-font-size, 36rpx);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +1244,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-icon {
|
.ai-icon {
|
||||||
font-size: 40rpx;
|
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 ×1.11(原 40/36) */
|
||||||
|
font-size: calc(var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx)) * 1.11);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -884,7 +1264,8 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 24rpx;
|
/* 语言短码文字: 基准字号用 hover_nav_lang_font_size(回退全局 hover_nav_font_size), 文字实际按 0.667 缩放 */
|
||||||
|
font-size: calc(var(--hover-nav-lang-font-size, var(--hover-nav-font-size, 36rpx)) * 0.667);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box">
|
<view class="box">
|
||||||
<input
|
<input
|
||||||
@@ -22,28 +22,21 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
||||||
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box">
|
<view class="box">
|
||||||
<!-- #ifdef MP-HARMONY -->
|
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||||
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
|
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
|
||||||
|
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
|
||||||
|
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
|
||||||
<textarea
|
<textarea
|
||||||
:rows="item.value.rows || 2"
|
:rows="item.value.rows || textareaRowsValue"
|
||||||
:placeholder="item.value.placeholder"
|
:placeholder="item.value.placeholder"
|
||||||
placeholder-class="placeholder color-tip"
|
placeholder-class="placeholder color-tip"
|
||||||
v-model="item.val"
|
v-model="item.val"
|
||||||
:style="inputStyle"
|
:style="textareaStyle"
|
||||||
></textarea>
|
></textarea>
|
||||||
<!-- #endif -->
|
|
||||||
<!-- #ifndef MP-HARMONY -->
|
|
||||||
<textarea
|
|
||||||
:placeholder="item.value.placeholder"
|
|
||||||
placeholder-class="placeholder color-tip"
|
|
||||||
v-model="item.val"
|
|
||||||
:style="inputStyle"
|
|
||||||
></textarea>
|
|
||||||
<!-- #endif -->
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -55,23 +48,23 @@
|
|||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box">
|
<view :class="['box', 'box-flex']">
|
||||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||||
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
||||||
</view>
|
|
||||||
<text class="iconfont icon-right"></text>
|
<text class="iconfont icon-right"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box check-group-box">
|
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||||
<checkbox-group @change="checkboxChange($event, index)">
|
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
||||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||||
<checkbox :value="v.value" :checked="v.checked" />
|
<checkbox :value="v.value" :checked="v.checked" />
|
||||||
<view class="checkbox">
|
<view class="checkbox">
|
||||||
@@ -87,11 +80,11 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box radio-group-box">
|
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||||
<radio-group @change="radioChange($event, index)">
|
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
||||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||||
<radio :value="v.value" :checked="item.val == v.value" />
|
<radio :value="v.value" :checked="item.val == v.value" />
|
||||||
<view class="radio-box">
|
<view class="radio-box">
|
||||||
@@ -107,8 +100,8 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
||||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box img-boxs">
|
<view class="box img-boxs">
|
||||||
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
||||||
@@ -123,23 +116,23 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box box-flex">
|
<view :class="['box', 'box-flex']">
|
||||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
<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>
|
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
|
||||||
<text class="iconfont icon-right"></text>
|
<text class="iconfont icon-right"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
||||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box date-boxs">
|
<view class="box date-boxs">
|
||||||
<view class="date-box">
|
<view class="date-box">
|
||||||
@@ -164,23 +157,23 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box box-flex">
|
<view :class="['box', 'box-flex']">
|
||||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
<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>
|
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
|
||||||
<text class="iconfont icon-right"></text>
|
<text class="iconfont icon-right"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
||||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box date-boxs">
|
<view class="box date-boxs">
|
||||||
<view class="date-box">
|
<view class="date-box">
|
||||||
@@ -203,21 +196,21 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||||
<view class="order-cell box-flex" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box">
|
<view :class="['box', 'box-flex']">
|
||||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
<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 }">
|
<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')) }}
|
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||||
</view>
|
</view>
|
||||||
</pick-regions>
|
</pick-regions>
|
||||||
</view>
|
|
||||||
<text class="iconfont icon-right"></text>
|
<text class="iconfont icon-right"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -270,21 +263,33 @@
|
|||||||
config() {
|
config() {
|
||||||
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
||||||
const defaults = {
|
const defaults = {
|
||||||
formPaddingX: 0,
|
formPaddingX: 28,
|
||||||
formBgColor: '#ffffff',
|
formBgColor: '#ffffff',
|
||||||
rowPaddingY: 20,
|
rowPaddingY: 24,
|
||||||
rowMinHeight: 60,
|
rowMinHeight: 60,
|
||||||
labelGap: 16,
|
labelGap: 20,
|
||||||
inputHeight: 60,
|
inputHeight: 84,
|
||||||
inputRadius: 0,
|
inputRadius: 16,
|
||||||
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
|
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
|
||||||
labelFixedWidth: 180,
|
labelFixedWidth: 180,
|
||||||
labelFontSize: 28,
|
labelFontSize: 32,
|
||||||
labelColor: '#1f2937',
|
labelColor: '#1f2937',
|
||||||
|
labelFontWeight: 700,
|
||||||
|
labelAutoMaxWidth: 200,
|
||||||
requiredColor: '#ef4444',
|
requiredColor: '#ef4444',
|
||||||
checkedColor: '#ff4544',
|
checkedColor: '#3b82f6',
|
||||||
placeholderColor: '#909399',
|
placeholderColor: '#9ca3af',
|
||||||
placeholderFontSize: 28,
|
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,
|
showRowDivider: true,
|
||||||
rowDividerColor: '#f0f0f0'
|
rowDividerColor: '#f0f0f0'
|
||||||
};
|
};
|
||||||
@@ -308,10 +313,17 @@
|
|||||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||||
labelColor: pick('labelColor', 'label_color'),
|
labelColor: pick('labelColor', 'label_color'),
|
||||||
|
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
|
||||||
requiredColor: pick('requiredColor', 'required_color'),
|
requiredColor: pick('requiredColor', 'required_color'),
|
||||||
checkedColor: pick('checkedColor', 'checked_color'),
|
checkedColor: pick('checkedColor', 'checked_color'),
|
||||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
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'),
|
showRowDivider: pick('showRowDivider', 'show_row_divider'),
|
||||||
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
|
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
|
||||||
};
|
};
|
||||||
@@ -363,10 +375,18 @@
|
|||||||
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
||||||
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
||||||
'--label-color': cfg.labelColor,
|
'--label-color': cfg.labelColor,
|
||||||
|
'--label-font-weight': cfg.labelFontWeight,
|
||||||
'--required-color': cfg.requiredColor,
|
'--required-color': cfg.requiredColor,
|
||||||
'--checked-color': cfg.checkedColor,
|
'--checked-color': cfg.checkedColor,
|
||||||
'--placeholder-color': cfg.placeholderColor,
|
'--placeholder-color': cfg.placeholderColor,
|
||||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
'--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,
|
'--row-divider-color': cfg.rowDividerColor,
|
||||||
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
||||||
});
|
});
|
||||||
@@ -384,13 +404,10 @@
|
|||||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
|
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
|
||||||
|
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
|
||||||
nameWidthStyle() {
|
nameWidthStyle() {
|
||||||
const cfg = this.config;
|
return {};
|
||||||
if (cfg.labelWidthMode === 'fixed') {
|
|
||||||
return { width: this.$util.rpx(cfg.labelFixedWidth) };
|
|
||||||
}
|
|
||||||
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
|
|
||||||
},
|
},
|
||||||
// 输入框样式(input / textarea 共用)
|
// 输入框样式(input / textarea 共用)
|
||||||
inputStyle() {
|
inputStyle() {
|
||||||
@@ -402,6 +419,42 @@
|
|||||||
'line-height': this.$util.rpx(this.config.inputHeight)
|
'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() {
|
requiredColor() {
|
||||||
return this.config.requiredColor;
|
return this.config.requiredColor;
|
||||||
@@ -431,10 +484,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
initFormData() {
|
||||||
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
|
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
|
||||||
this.formData = JSON.parse(JSON.stringify(this.data || []));
|
this.formData = JSON.parse(JSON.stringify(this.data || []));
|
||||||
this.processFormData();
|
this.processFormData();
|
||||||
|
// 预计算每项 inline style(与 ns-newform 对齐,跨端兼容)
|
||||||
|
this.recomputeItemStyles();
|
||||||
},
|
},
|
||||||
setFormData() {
|
setFormData() {
|
||||||
this.initFormData();
|
this.initFormData();
|
||||||
@@ -442,7 +518,9 @@
|
|||||||
},
|
},
|
||||||
processFormData() {
|
processFormData() {
|
||||||
this.formData.forEach(item => {
|
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) {
|
if (item.value.options) {
|
||||||
item.option_lists = [];
|
item.option_lists = [];
|
||||||
@@ -867,22 +945,31 @@
|
|||||||
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
||||||
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
||||||
.form-wrap {
|
.form-wrap {
|
||||||
--form-padding-x: 0rpx;
|
--form-padding-x: 28rpx;
|
||||||
--form-bg-color: #ffffff;
|
--form-bg-color: #ffffff;
|
||||||
--row-padding-y: 20rpx;
|
--row-padding-y: 24rpx;
|
||||||
--row-min-height: 60rpx;
|
--row-min-height: 60rpx;
|
||||||
--label-gap: 16rpx;
|
--label-gap: 20rpx;
|
||||||
--input-height: 60rpx;
|
--input-height: 84rpx;
|
||||||
--input-radius: 0rpx;
|
--input-radius: 16rpx;
|
||||||
--label-fixed-width: 180rpx;
|
--label-fixed-width: 180rpx;
|
||||||
--label-font-size: 28rpx;
|
--label-font-size: 32rpx;
|
||||||
--label-color: #1f2937;
|
--label-color: #1f2937;
|
||||||
|
--label-font-weight: 700;
|
||||||
--required-color: #ef4444;
|
--required-color: #ef4444;
|
||||||
--placeholder-font-size: 28rpx;
|
--placeholder-font-size: 28rpx;
|
||||||
--checked-color: #ff4544;
|
--checked-color: #3b82f6;
|
||||||
--placeholder-color: #909399;
|
--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;
|
--row-divider-color: #f0f0f0;
|
||||||
--show-row-divider: 1;
|
--show-row-divider: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-wrap {
|
.order-wrap {
|
||||||
@@ -900,7 +987,8 @@
|
|||||||
|
|
||||||
.order-cell {
|
.order-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
|
||||||
|
align-items: flex-start;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: var(--row-min-height, 60rpx);
|
min-height: var(--row-min-height, 60rpx);
|
||||||
@@ -933,17 +1021,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
width: var(--label-fixed-width, 180rpx);
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-right: var(--label-gap, 16rpx);
|
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 {
|
.tit {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
|
font-weight: var(--label-font-weight, 700);
|
||||||
|
|
||||||
text {
|
text {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
@@ -953,36 +1047,49 @@
|
|||||||
.required {
|
.required {
|
||||||
color: var(--required-color, #ef4444);
|
color: var(--required-color, #ef4444);
|
||||||
font-size: var(--label-font-size, 28rpx);
|
font-size: var(--label-font-size, 28rpx);
|
||||||
margin-left: 4rpx;
|
margin-right: 6rpx;
|
||||||
width: 14rpx;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0 10rpx;
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
|
// H5 端 <input> 可能被 uni-app 编译为 <uni-input> 包装组件,::v-deep 穿透 scoped 命中真实原生 input
|
||||||
|
::v-deep input,
|
||||||
input {
|
input {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||||
// 避免 placeholder / 文字下半部分被裁切
|
// 避免 placeholder / 文字下半部分被裁切
|
||||||
line-height: var(--input-height, 60rpx);
|
line-height: var(--input-height, 84rpx);
|
||||||
height: var(--input-height, 60rpx);
|
height: var(--input-height, 84rpx);
|
||||||
min-height: var(--input-height, 60rpx);
|
min-height: var(--input-height, 84rpx);
|
||||||
border-radius: var(--input-radius, 0rpx);
|
border-radius: var(--input-radius, 16rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
width: 100%;
|
||||||
background: transparent;
|
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 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--placeholder-color, #909399);
|
color: var(--placeholder-color, #9ca3af);
|
||||||
font-size: var(--placeholder-font-size, 28rpx);
|
font-size: var(--placeholder-font-size, 28rpx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -990,32 +1097,47 @@
|
|||||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||||
.placeholder {
|
.placeholder {
|
||||||
color: var(--placeholder-color, #909399);
|
color: var(--placeholder-color, #9ca3af);
|
||||||
font-size: var(--placeholder-font-size, 28rpx);
|
font-size: var(--placeholder-font-size, 28rpx);
|
||||||
line-height: var(--input-height, 60rpx);
|
line-height: var(--input-height, 84rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::v-deep textarea,
|
||||||
textarea {
|
textarea {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
|
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
|
||||||
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
|
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
||||||
min-height: 88rpx;
|
// 真正做到了"rows 改 → 高度自动改"。
|
||||||
height: 88rpx;
|
line-height: var(--textarea-line-height, 48rpx);
|
||||||
line-height: 44rpx;
|
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;
|
text-align: left;
|
||||||
border-radius: var(--input-radius, 0rpx);
|
border-radius: var(--input-radius, 16rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 12rpx 24rpx;
|
||||||
background: transparent;
|
// 输入框视觉增强(与 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.uni-input {
|
.uni-input {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
line-height: var(--input-height, 60rpx);
|
line-height: var(--input-height, 84rpx);
|
||||||
min-height: var(--input-height, 60rpx);
|
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 {
|
.select-address {
|
||||||
@@ -1024,17 +1146,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-tip {
|
.color-tip {
|
||||||
color: var(--placeholder-color, #909399);
|
color: var(--placeholder-color, #9ca3af);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkbox-group {
|
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
|
||||||
display: flex;
|
picker,
|
||||||
flex-wrap: wrap;
|
.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 {
|
radio-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
@@ -1044,6 +1185,39 @@
|
|||||||
margin-right: 30rpx;
|
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 {
|
&.img-boxs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1120,8 +1294,9 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 32rpx;
|
font-size: 40rpx;
|
||||||
margin-right: 10rpx;
|
margin-right: 10rpx;
|
||||||
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1137,8 +1312,9 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 32rpx;
|
font-size: 40rpx;
|
||||||
margin-right: 10rpx;
|
margin-right: 10rpx;
|
||||||
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
return {
|
return {
|
||||||
list: [],
|
list: [],
|
||||||
config: {
|
config: {
|
||||||
title: '猜你喜欢',
|
title: '',
|
||||||
sources: 'sort',
|
sources: 'sort',
|
||||||
supportPage: [],
|
supportPage: [],
|
||||||
goodsIds: [],
|
goodsIds: [],
|
||||||
fontWeight: false,
|
fontWeight: false,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cartEvent: 'detail',
|
cartEvent: 'detail',
|
||||||
text: '购买',
|
text: '',
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
aroundRadius: 25,
|
aroundRadius: 25,
|
||||||
@@ -67,6 +67,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
// 初始化 i18n 默认文案,实际展示以服务端配置 config.title / config.text 为准
|
||||||
|
if (!this.config.title) this.config.title = this.$lang('common.goodsRecommendTitle');
|
||||||
|
if (!this.config.text) this.config.text = this.$lang('buyNow');
|
||||||
if (this.auto) {
|
if (this.auto) {
|
||||||
this.getLikeList();
|
this.getLikeList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,18 @@
|
|||||||
|
|
||||||
<!-- 手机号授权按钮 -->
|
<!-- 手机号授权按钮 -->
|
||||||
<block v-if="isAgree">
|
<block v-if="isAgree">
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
|
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
|
||||||
@getphonenumber="getPhoneNumber">
|
@getphonenumber="getPhoneNumber">
|
||||||
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
|
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
|
||||||
</button>
|
</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>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
|
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
|
||||||
@@ -67,6 +75,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</block>
|
</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"
|
<view @tap="closeLogin"
|
||||||
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
|
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
|
||||||
{{ $lang('components.ns-login.notNow') }}
|
{{ $lang('components.ns-login.notNow') }}
|
||||||
@@ -109,11 +138,20 @@
|
|||||||
|
|
||||||
<view class="item-wrap" v-if="forceBindingMobileControl">
|
<view class="item-wrap" v-if="forceBindingMobileControl">
|
||||||
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
|
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
|
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
|
||||||
@getphonenumber="getPhoneNumber">
|
@getphonenumber="getPhoneNumber">
|
||||||
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
|
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
|
||||||
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
|
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
|
||||||
</button>
|
</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>
|
</view>
|
||||||
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
|
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -174,7 +212,11 @@ export default {
|
|||||||
iv: '',
|
iv: '',
|
||||||
encryptedData: ''
|
encryptedData: ''
|
||||||
},
|
},
|
||||||
site_name: ''
|
site_name: '',
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
// 鸿蒙专属:控制手机号+验证码表单显示
|
||||||
|
showHarmonyMobileForm: false,
|
||||||
|
// #endif
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -203,7 +245,7 @@ export default {
|
|||||||
if (this.forceBindingMobileControl) {
|
if (this.forceBindingMobileControl) {
|
||||||
if (this.formData.mobile.length == 0) return true;
|
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;
|
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
|
||||||
// 动态码
|
// 动态码
|
||||||
@@ -283,14 +325,69 @@ export default {
|
|||||||
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
|
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
|
||||||
|
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
|
||||||
|
// "privacy permission is not authorized or buttonId is invalid"。
|
||||||
|
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
|
||||||
|
this.ensureWxPrivacyAuthorized(() => {
|
||||||
this.getCode(authData => {
|
this.getCode(authData => {
|
||||||
this.authLogin(authData, 'authOnlyLogin');
|
this.authLogin(authData, 'authOnlyLogin');
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
callback && callback(err);
|
callback && callback(err);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
// #endif
|
// #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') {
|
toLoginH5Page(mode = 'navigateTo') {
|
||||||
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
||||||
@@ -343,7 +440,7 @@ export default {
|
|||||||
openCompleteInfoPop() {
|
openCompleteInfoPop() {
|
||||||
this.getRegisterConfig();
|
this.getRegisterConfig();
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5 || MP-HARMONY
|
||||||
if (!this.storeToken) this.getCaptchaConfig();
|
if (!this.storeToken) this.getCaptchaConfig();
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
@@ -572,8 +669,24 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
this.$util.showToast({
|
||||||
title: e.detail.errMsg
|
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: errMsg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -676,6 +789,26 @@ export default {
|
|||||||
this.forceBindMobile();
|
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() {
|
saveMp() {
|
||||||
if (this.formData.mobile === '') {
|
if (this.formData.mobile === '') {
|
||||||
|
|||||||
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
自绘导航栏:把原生导航栏左侧的 "<" 换成 "‹ 返回" 文字
|
||||||
|
|
||||||
|
背景:微信小程序原生导航栏的返回按钮是客户端系统控件,pages.json 的任何配置、
|
||||||
|
官方 <navigation-bar> 组件(只支持 title / front-color / background-color / loading)
|
||||||
|
都无法修改它的内容。要显示"返回"二字,只能关闭原生栏后自绘。
|
||||||
|
|
||||||
|
使用前提:
|
||||||
|
1. 该页面在 pages.json 中开启 "navigationStyle": "custom"(组件只负责渲染,不自动改配置)
|
||||||
|
2. 该页面不能是 web-view 页面 —— 官方明确 "navigationStyle: custom 对 web-view 组件无效",
|
||||||
|
且 web-view 会铺满整页覆盖其他组件,自绘的导航栏不会显示
|
||||||
|
|
||||||
|
布局:组件是普通流式节点(非 fixed),放在页面顶部即可把内容自然下推。
|
||||||
|
-->
|
||||||
|
<view
|
||||||
|
v-if="visible"
|
||||||
|
class="ns-mp-navbar"
|
||||||
|
:class="{ 'is-fixed': fixed }"
|
||||||
|
:style="{ background: bgColor, paddingTop: statusBarHeight + 'px' }"
|
||||||
|
>
|
||||||
|
<view class="ns-mp-navbar__inner" :style="{ height: navBarHeight + 'px' }">
|
||||||
|
<view class="ns-mp-navbar__side">
|
||||||
|
<view
|
||||||
|
v-if="showBack && canBack"
|
||||||
|
class="ns-mp-navbar__back"
|
||||||
|
hover-class="ns-mp-navbar__back--hover"
|
||||||
|
hover-stay-time="80"
|
||||||
|
@tap="onBack"
|
||||||
|
>
|
||||||
|
<text class="iconfont icon-back_light ns-mp-navbar__arrow" :style="{ color }"></text>
|
||||||
|
<text class="ns-mp-navbar__text" :style="{ color }">{{ backText }}</text>
|
||||||
|
</view>
|
||||||
|
<slot name="left"></slot>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ns-mp-navbar__title" :style="{ color }">{{ title }}</view>
|
||||||
|
|
||||||
|
<view class="ns-mp-navbar__side ns-mp-navbar__side--right">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 状态栏 / 胶囊信息:用于算出与原生导航栏一致的布局高度
|
||||||
|
let systemInfo = {};
|
||||||
|
let menuButtonInfo = { top: 0, height: 32, right: 0, width: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
systemInfo = uni.getSystemInfoSync() || {};
|
||||||
|
} catch (e) {
|
||||||
|
systemInfo = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
||||||
|
try {
|
||||||
|
menuButtonInfo = uni.getMenuButtonBoundingClientRect() || menuButtonInfo;
|
||||||
|
} catch (e) {}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ns-mp-navbar',
|
||||||
|
props: {
|
||||||
|
// 中间标题
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 导航栏背景色
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#ffffff'
|
||||||
|
},
|
||||||
|
// 文字 / 图标颜色
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#000000'
|
||||||
|
},
|
||||||
|
// 返回按钮文字(原生导航栏只能显示 "<",这里可自定义)
|
||||||
|
backText: {
|
||||||
|
type: String,
|
||||||
|
default: '返回'
|
||||||
|
},
|
||||||
|
// 是否显示返回按钮
|
||||||
|
showBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 是否悬浮在页面之上(沉浸式页面用;false 时占文档流,会把内容往下推)
|
||||||
|
fixed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 无页面栈时(如分享直接进入)跳转的首页地址
|
||||||
|
homePath: {
|
||||||
|
type: String,
|
||||||
|
default: '/pages/index/index'
|
||||||
|
},
|
||||||
|
// 自定义返回逻辑
|
||||||
|
customBack: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
// H5 端是否渲染(H5 由 uni-app 自带头像栏,默认不渲染,避免双导航栏)
|
||||||
|
showOnH5: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
statusBarHeight: systemInfo.statusBarHeight || 20,
|
||||||
|
navBarHeight: 44,
|
||||||
|
visible: true,
|
||||||
|
// 页面栈里是否还有上级页面(无上级页面时原生导航栏也不显示返回键)
|
||||||
|
canBack: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 导航栏总高度(状态栏 + 内容区),页面如需自行占位可取该值
|
||||||
|
navbarHeight() {
|
||||||
|
return this.statusBarHeight + this.navBarHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// 导航栏内容区高度:MP 端按胶囊位置推算,与原生导航栏保持一致;其余端用 44
|
||||||
|
// #ifdef MP
|
||||||
|
const statusBarHeight = this.statusBarHeight;
|
||||||
|
const h = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height;
|
||||||
|
// 兜底:胶囊信息异常时退回 44
|
||||||
|
this.navBarHeight = h > 0 ? h : 44;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
this.visible = this.showOnH5;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// 页面栈只有一页(分享/扫码直达)时,原生导航栏也无返回键,这里保持一致
|
||||||
|
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||||
|
this.canBack = pages.length > 1;
|
||||||
|
// 把实际总高度抛给页面,便于页面修正内容区高度,避免多出一屏空白
|
||||||
|
this.$emit('ready', this.statusBarHeight + this.navBarHeight);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onBack() {
|
||||||
|
if (typeof this.customBack === 'function') {
|
||||||
|
this.customBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 顶层页面(分享 / 扫码直接进入):回首页
|
||||||
|
uni.switchTab({
|
||||||
|
url: this.homePath,
|
||||||
|
fail: () => {
|
||||||
|
uni.reLaunch({ url: this.homePath });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ns-mp-navbar {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar.is-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__side {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 160rpx;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__side--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10rpx 20rpx 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__back--hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__arrow {
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__text {
|
||||||
|
margin-left: 2rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__title {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 160rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
let systemInfo = uni.getSystemInfoSync();
|
let systemInfo = uni.getSystemInfoSync();
|
||||||
let menuButtonInfo = {};
|
let menuButtonInfo = {};
|
||||||
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
|
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
|
||||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
||||||
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
|
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -70,37 +70,11 @@ export default {
|
|||||||
return {
|
return {
|
||||||
isIphoneX: false,
|
isIphoneX: false,
|
||||||
payIndex: 0,
|
payIndex: 0,
|
||||||
// #ifdef H5
|
payTypeList: [],
|
||||||
payTypeList: [{
|
|
||||||
name: this.$lang('components.nsPayment.alipay'),
|
|
||||||
icon: 'icon-zhifubaozhifu-',
|
|
||||||
type: 'alipay'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: this.$lang('components.nsPayment.wechatpay'),
|
|
||||||
icon: 'icon-weixin1',
|
|
||||||
type: 'wechatpay'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
timer: null,
|
timer: null,
|
||||||
// #endif
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
payTypeList: [{
|
|
||||||
name: this.$lang('components.nsPayment.wechatpay'),
|
|
||||||
provider: 'wxpay',
|
|
||||||
icon: 'icon-weixin1',
|
|
||||||
type: 'wechatpay'
|
|
||||||
}],
|
|
||||||
isMatched: 0, // 支付是否需要校验
|
isMatched: 0, // 支付是否需要校验
|
||||||
// #endif
|
// #endif
|
||||||
// #ifdef MP-ALIPAY
|
|
||||||
payTypeList: [{
|
|
||||||
name: this.$lang('components.nsPayment.alipay'),
|
|
||||||
icon: 'icon-zhifubaozhifu-',
|
|
||||||
type: 'alipay',
|
|
||||||
provider: 'alipay'
|
|
||||||
}],
|
|
||||||
// #endif
|
|
||||||
payInfo: {},
|
payInfo: {},
|
||||||
balanceConfig: 0,
|
balanceConfig: 0,
|
||||||
// 预售页面判断
|
// 预售页面判断
|
||||||
@@ -108,6 +82,30 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
created(e) {
|
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;
|
let url;
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
url = window.location.href
|
url = window.location.href
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
<view class="video-popup-body">
|
<view class="video-popup-body">
|
||||||
<video
|
<video
|
||||||
v-if="videoUrl"
|
v-if="videoUrl"
|
||||||
|
id="popupVideoPlayer"
|
||||||
:src="videoUrl"
|
:src="videoUrl"
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
x5-playsinline
|
||||||
class="video-player"
|
class="video-player"
|
||||||
></video>
|
></video>
|
||||||
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
||||||
@@ -22,6 +26,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { playVideoInContext } from '@/common/js/video-helper.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ns-video-player-popup',
|
name: 'ns-video-player-popup',
|
||||||
props: {
|
props: {
|
||||||
@@ -76,6 +82,10 @@ export default {
|
|||||||
* @param {Object} e - 弹窗状态变化事件
|
* @param {Object} e - 弹窗状态变化事件
|
||||||
*/
|
*/
|
||||||
onVideoPopupChange(e) {
|
onVideoPopupChange(e) {
|
||||||
|
// H5 端弹窗显示后触发播放:避免 autoplay 被浏览器拦截 / 弹窗动画未完成被 pause() 打断
|
||||||
|
if (e && e.show) {
|
||||||
|
playVideoInContext('popupVideoPlayer', this);
|
||||||
|
}
|
||||||
this.$emit('popup-change', e);
|
this.$emit('popup-change', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,16 @@
|
|||||||
i18nKey: 'components.payment.alipay'
|
i18nKey: 'components.payment.alipay'
|
||||||
}],
|
}],
|
||||||
// #endif
|
// #endif
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
// 鸿蒙元服务的支付通道: provider='wxpay' 由鸿蒙 JSSDK 接管
|
||||||
|
payTypeList: [{
|
||||||
|
name: '微信支付',
|
||||||
|
provider: 'wxpay',
|
||||||
|
icon: 'icon-weixin1',
|
||||||
|
type: 'wechatpay',
|
||||||
|
i18nKey: 'components.payment.wechatpay'
|
||||||
|
}],
|
||||||
|
// #endif
|
||||||
payInfo: null,
|
payInfo: null,
|
||||||
balanceConfig: 0,
|
balanceConfig: 0,
|
||||||
// 预售页面判断
|
// 预售页面判断
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- #ifdef MP-HARMONY -->
|
||||||
|
<!--
|
||||||
|
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
||||||
|
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
|
||||||
|
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
|
||||||
|
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
|
||||||
|
-->
|
||||||
|
<!-- #endif-->
|
||||||
|
<!-- #ifndef MP-HARMONY -->
|
||||||
<view>
|
<view>
|
||||||
<view class="privacy-mask" v-if="showPop">
|
<view class="privacy-mask" v-if="showPop">
|
||||||
<view class="privacy-wrap">
|
<view class="privacy-wrap">
|
||||||
@@ -6,7 +15,7 @@
|
|||||||
<view class="privacy-desc">
|
<view class="privacy-desc">
|
||||||
{{ i18n.descPrefix }}
|
{{ i18n.descPrefix }}
|
||||||
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
|
<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>
|
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
{{ i18n.descSuffix }}
|
{{ i18n.descSuffix }}
|
||||||
@@ -17,13 +26,15 @@
|
|||||||
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
|
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
|
||||||
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
|
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifdef H5 || MP-HARMONY -->
|
<!-- #ifdef H5 -->
|
||||||
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button>
|
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
|
||||||
|
}}</button>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -48,6 +59,12 @@ export default {
|
|||||||
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
|
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
|
||||||
// 弹窗前才参与 i18n.privacyContractName 的填充
|
// 弹窗前才参与 i18n.privacyContractName 的填充
|
||||||
platformContractName: '',
|
platformContractName: '',
|
||||||
|
// 平台协议名称就绪 Promise:
|
||||||
|
// created() 中发起异步查询, success/fail 时 resolve
|
||||||
|
// popUp() 中 await 此 Promise 后再调 prepareI18n(), 保证 $lang() 与
|
||||||
|
// platformContractName 都已经准备好, 不会出现"空串被永久缓存"的问题
|
||||||
|
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"是同源契约)
|
||||||
|
platformContractNameReady: null,
|
||||||
// 弹窗文案: 模板只读 i18n.xxx
|
// 弹窗文案: 模板只读 i18n.xxx
|
||||||
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
|
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
|
||||||
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
|
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
|
||||||
@@ -70,10 +87,22 @@ export default {
|
|||||||
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
||||||
created() {
|
created() {
|
||||||
try {
|
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
|
// #ifdef MP-WEIXIN
|
||||||
wx.getPrivacySetting({
|
wx.getPrivacySetting({
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
this.platformContractName = res.privacyContractName || ''
|
this.platformContractName = res.privacyContractName || ''
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
@@ -85,13 +114,28 @@ export default {
|
|||||||
if (res.privacyContractName) {
|
if (res.privacyContractName) {
|
||||||
this.platformContractName = res.privacyContractName
|
this.platformContractName = res.privacyContractName
|
||||||
}
|
}
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 无 API 时立即 resolve (避免 await 永久阻塞)
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
|
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
|
||||||
|
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
|
||||||
|
// 立即 resolve, 让 popUp 的 await 不永久阻塞
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
// H5 无平台隐私 API, 立即 resolve
|
||||||
|
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
||||||
@@ -179,14 +223,14 @@ export default {
|
|||||||
* 统一弹窗 + 关闭其他页面的弹窗
|
* 统一弹窗 + 关闭其他页面的弹窗
|
||||||
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
||||||
*/
|
*/
|
||||||
showPrivacyPopup() {
|
async showPrivacyPopup() {
|
||||||
// 先通知其他实例关闭
|
// 先通知其他实例关闭
|
||||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||||
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
||||||
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
||||||
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
||||||
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
||||||
this.popUp()
|
await this.popUp()
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,18 +281,18 @@ export default {
|
|||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
if (wx.onNeedPrivacyAuthorization) {
|
if (wx.onNeedPrivacyAuthorization) {
|
||||||
wx.onNeedPrivacyAuthorization((resolve) => {
|
wx.onNeedPrivacyAuthorization((resolve) => {
|
||||||
// 检查是否已同意
|
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
|
||||||
if (!PrivacyManager.needShow()) {
|
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
|
||||||
// 已同意, 直接放行
|
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
|
||||||
resolve({ event: 'agree' })
|
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
|
||||||
} else {
|
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
|
||||||
// 需要弹窗, 存 resolve 等用户操作
|
|
||||||
if (typeof this.privacyAuthorization === 'function') {
|
if (typeof this.privacyAuthorization === 'function') {
|
||||||
|
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
|
||||||
this.privacyAuthorization(resolve)
|
this.privacyAuthorization(resolve)
|
||||||
} else {
|
} else {
|
||||||
// 兜底: 组件未就绪, 直接放行避免 API 卡死
|
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
|
||||||
resolve({ event: 'agree' })
|
// (disagree 无需 buttonId, 不会产生非法 resolve)
|
||||||
}
|
resolve({ event: 'disagree' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -265,7 +309,8 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
|
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
|
||||||
|
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -307,8 +352,8 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 使用应用内协议页面
|
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
||||||
this.$util.redirectTo(privacyContractPage);
|
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -329,7 +374,7 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
this.$util.redirectTo(privacyServicePage);
|
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -392,7 +437,7 @@ export default {
|
|||||||
let appName, privacyContractName, privacyServiceName;
|
let appName, privacyContractName, privacyServiceName;
|
||||||
|
|
||||||
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
||||||
// #ifdef H5 || MP-HARMONY
|
// #ifdef H5
|
||||||
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
||||||
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
||||||
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
||||||
@@ -416,10 +461,23 @@ export default {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
popUp() {
|
async popUp() {
|
||||||
// 弹窗前**一次性**计算所有 i18n 文案
|
// 弹窗前**一次性**计算所有 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.prepareI18n();
|
||||||
this.showPop = true;
|
this.showPop = true;
|
||||||
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
||||||
@@ -438,6 +496,11 @@ export default {
|
|||||||
uni.$off('privacy:show', this.handlePrivacyShow)
|
uni.$off('privacy:show', this.handlePrivacyShow)
|
||||||
uni.$off('privacy:forceClose', this.handleForceClose)
|
uni.$off('privacy:forceClose', this.handleForceClose)
|
||||||
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
||||||
|
// 销毁时强制 resolve, 避免 popUp() 中的 await 永久挂起 (例如用户提前离开页面)
|
||||||
|
if (typeof this._resolvePlatformContractName === 'function') {
|
||||||
|
this._resolvePlatformContractName()
|
||||||
|
this._resolvePlatformContractName = null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -491,7 +554,7 @@ export default {
|
|||||||
.privacy-button-btn {
|
.privacy-button-btn {
|
||||||
color: #FFF;
|
color: #FFF;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
|
/* #ifdef QUICKAPP-WEBVIEW || H5 */
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
/* #endif */
|
/* #endif */
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -504,6 +567,12 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 30rpx;
|
margin-right: 30rpx;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* 关键修复: 微信 <button> 原生默认 padding-left/right: 14px 没有被重置,
|
||||||
|
* 会挤压按钮内容区。真机窄屏(375px 以下)上「同意并继续」5 个字被裁成
|
||||||
|
* 「同意并继」。这里显式清零 padding 并锁定 border-box。 */
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-button-btn::after {
|
.privacy-button-btn::after {
|
||||||
|
|||||||
2204
docs/ai-agent-integration.md
Normal file
2204
docs/ai-agent-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# 国际化(i18n)全量审计报告
|
# 国际化(i18n)全量审计报告
|
||||||
|
|
||||||
> 审计范围:`z:\D\projects\shop-projects\frontend` 下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
> 审计范围:项目根目录下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
||||||
> 审计时间:2026-06-29
|
> 审计时间:2026-06-29
|
||||||
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
||||||
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
||||||
|
|||||||
@@ -106,8 +106,9 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t
|
|||||||
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 以下命令均在项目根目录(含 package.json)下执行
|
||||||
|
|
||||||
# 1. 静态语法检查
|
# 1. 静态语法检查
|
||||||
cd D:/projects/shop-projects/frontend
|
|
||||||
node --check common/js/theme-override.js
|
node --check common/js/theme-override.js
|
||||||
node --check store/index.js
|
node --check store/index.js
|
||||||
# App.vue 走 vue 编译器,不在 node 范畴
|
# App.vue 走 vue 编译器,不在 node 范畴
|
||||||
|
|||||||
5163
lang-bundle.json
5163
lang-bundle.json
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,29 @@ export const lang = {
|
|||||||
timeFormatToday: 'Today',
|
timeFormatToday: 'Today',
|
||||||
timeFormatYesterday: 'Yesterday',
|
timeFormatYesterday: 'Yesterday',
|
||||||
timeFormatBeforeYesterday: 'Day before yesterday',
|
timeFormatBeforeYesterday: 'Day before yesterday',
|
||||||
|
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
|
||||||
|
timeJustNow: 'Just now',
|
||||||
|
timeMinutesAgo: '{n} min ago',
|
||||||
|
timeHoursAgo: '{n} h ago',
|
||||||
|
timeMonthDay: '{m}/{d}',
|
||||||
|
timeYearMonthDay: '{y}/{m}/{d}',
|
||||||
|
|
||||||
// ====== ai-chat-message ======
|
// ====== ai-chat-message ======
|
||||||
aiNickname: 'AI Assistant',
|
aiNickname: 'AI Assistant',
|
||||||
defaultNickname: 'Me',
|
defaultNickname: 'Me',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): query text for attachment-only messages
|
||||||
|
// audioMessage / videoMessage: short label for the user-bubble filename fallback (still used by bubble template)
|
||||||
|
// *Instruction* keys: explicit directive sent to the AI as the query.
|
||||||
|
// Just a label gets ignored — the AI needs an explicit "please listen/look"
|
||||||
|
// to actually invoke STT/vision/file parsing on the attachment payload.
|
||||||
audioMessage: 'Voice Message',
|
audioMessage: 'Voice Message',
|
||||||
videoMessage: 'Video Message',
|
videoMessage: 'Video Message',
|
||||||
|
audioInstruction: 'I sent a voice message. Please listen to it and reply.',
|
||||||
|
audioInstructionWithDuration: 'I sent a voice message ({seconds}s). Please listen to it and reply.',
|
||||||
|
imageInstruction: 'I sent an image. Please look at it and reply.',
|
||||||
|
videoInstruction: 'I sent a video. Please watch it and reply.',
|
||||||
|
fileInstruction: 'I sent a file. Please review it and reply.',
|
||||||
|
attachmentInstruction: 'I sent attachments. Please review them and reply.',
|
||||||
inputPlaceholder: 'Please enter your question...',
|
inputPlaceholder: 'Please enter your question...',
|
||||||
expandEditorTitle: 'Edit Content',
|
expandEditorTitle: 'Edit Content',
|
||||||
loadMoreHistory: 'Load more history',
|
loadMoreHistory: 'Load more history',
|
||||||
@@ -112,15 +129,48 @@ export const lang = {
|
|||||||
// ====== ai-chat-tools-panel ======
|
// ====== ai-chat-tools-panel ======
|
||||||
moreFunctions: 'More Features',
|
moreFunctions: 'More Features',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
|
video: 'Video/Audio',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
voice: 'Voice',
|
voice: 'Voice',
|
||||||
|
recordVoice: 'Record Voice',
|
||||||
|
uploadAudio: 'Upload Audio',
|
||||||
|
pickVideo: 'Choose Video',
|
||||||
|
pickAudio: 'Choose Audio',
|
||||||
|
chooseAudioFail: 'Failed to select audio',
|
||||||
|
chooseVideoFail: 'Failed to select video',
|
||||||
|
audioNotReady: 'Audio not ready',
|
||||||
location: 'Location',
|
location: 'Location',
|
||||||
|
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
|
||||||
|
locationPermissionTitle: 'Location Permission Required',
|
||||||
|
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
|
||||||
|
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
|
||||||
|
locationSettingBtn: 'Open Settings',
|
||||||
|
locationSkipBtn: 'Not Now',
|
||||||
|
locationH5GuideBtn: 'Got it',
|
||||||
|
|
||||||
// ====== ai-chat-voice-panel ======
|
// ====== ai-chat-voice-panel ======
|
||||||
voiceInputTitle: 'Voice Input',
|
voiceInputTitle: 'Voice Input',
|
||||||
recordingTip: 'Recording, release to end',
|
recordingTip: 'Recording, release to end',
|
||||||
startRecordTip: 'Tap to start recording',
|
startRecordTip: 'Tap to start recording',
|
||||||
voiceInputFailed: 'Voice input failed',
|
voiceInputFailed: 'Voice input failed',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
|
||||||
|
holdToTalk: 'Hold to talk',
|
||||||
|
releaseToSend: 'Release to send',
|
||||||
|
releaseToCancel: 'Release to cancel',
|
||||||
|
slideUpToCancel: '↑ Slide up to cancel',
|
||||||
|
slideUpToCancelHint: '↑ Slide up to cancel',
|
||||||
|
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): dynamic input placeholder by context
|
||||||
|
placeholderDefault: 'Type a message or hold to talk',
|
||||||
|
placeholderWithAudio: 'Add a note for your voice (optional)',
|
||||||
|
placeholderWithMedia: 'Add a caption (optional)',
|
||||||
|
slideUpToCancelHint: '↑ Release to cancel',
|
||||||
|
voiceStartFailed: 'Cannot start recording',
|
||||||
|
voiceStopFailed: 'Recording failed, please try again',
|
||||||
|
voiceNotSupported: 'Voice input is not supported on this platform',
|
||||||
|
voiceTooShort: 'Speaking time too short',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
|
||||||
|
uploadFailed: 'Upload failed',
|
||||||
|
uploadFailedWithReason: 'Upload failed: {reason}',
|
||||||
|
|
||||||
// ====== index.vue - Menu ======
|
// ====== index.vue - Menu ======
|
||||||
menuClearChat: 'Clear Chat',
|
menuClearChat: 'Clear Chat',
|
||||||
@@ -136,4 +186,8 @@ export const lang = {
|
|||||||
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
||||||
logChatComponentNotInit: 'Chat component not initialized',
|
logChatComponentNotInit: 'Chat component not initialized',
|
||||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
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: {
|
tabBar: {
|
||||||
home: 'index',
|
home: 'Home',
|
||||||
category: 'category',
|
category: 'Category',
|
||||||
cart: 'cart',
|
cart: 'Cart',
|
||||||
member: 'member',
|
member: 'Me',
|
||||||
},
|
},
|
||||||
|
|
||||||
//公共
|
//公共
|
||||||
@@ -57,6 +57,7 @@ export const lang = {
|
|||||||
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
|
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
|
||||||
diy: {
|
diy: {
|
||||||
networkErrorTitle: 'Notice',
|
networkErrorTitle: 'Notice',
|
||||||
|
networkError: 'Network error, please try again later',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
noDiyData: 'Custom page data not configured',
|
noDiyData: 'Custom page data not configured',
|
||||||
locationNotAvailable: 'Location not available',
|
locationNotAvailable: 'Location not available',
|
||||||
@@ -111,6 +112,7 @@ export const lang = {
|
|||||||
notSupported: 'Current environment does not support WeChat Channel',
|
notSupported: 'Current environment does not support WeChat Channel',
|
||||||
missingFields: 'Missing required field: {field}',
|
missingFields: 'Missing required field: {field}',
|
||||||
playFailed: 'WeChat video playback failed!',
|
playFailed: 'WeChat video playback failed!',
|
||||||
|
wechatMpOnly: 'WeChat Channel only plays in WeChat Mini Program',
|
||||||
},
|
},
|
||||||
|
|
||||||
// uniapp utility functions
|
// uniapp utility functions
|
||||||
@@ -256,7 +258,14 @@ export const lang = {
|
|||||||
addToCartFailMin: 'Minimum purchase quantity not met',
|
addToCartFailMin: 'Minimum purchase quantity not met',
|
||||||
addToCartFailStock: 'Insufficient Stock',
|
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',
|
consult: 'Consult Now',
|
||||||
consultSuccess: 'Consultation Successful',
|
consultSuccess: 'Consultation Successful',
|
||||||
consultFail: 'Consultation Failed',
|
consultFail: 'Consultation Failed',
|
||||||
|
|||||||
83
lang/en-us/components-diy/diy-recommend-code.js
Normal file
83
lang/en-us/components-diy/diy-recommend-code.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-recommend-code referral code component
|
||||||
|
// Title
|
||||||
|
title: 'My Referral Code',
|
||||||
|
// Share now button
|
||||||
|
buttonText: 'Share Now',
|
||||||
|
// Copy code button
|
||||||
|
copyButtonText: 'Copy Code',
|
||||||
|
// Get a new code
|
||||||
|
placeholderRegenerateText: 'Get a New Code',
|
||||||
|
// Consent description
|
||||||
|
consentText: 'Your referral code is used to track enterprise registrations via your shared link. We record each application completed through your link for commission settlement.',
|
||||||
|
// Agree and get code button
|
||||||
|
consentButtonText: 'Agree & Get Code',
|
||||||
|
// Login guide tip
|
||||||
|
loginTipText: 'Log in to view your referral code',
|
||||||
|
// Log in button
|
||||||
|
loginBtnText: 'Log In',
|
||||||
|
// Poster QR tip
|
||||||
|
posterQrTip: 'Scan to enter and fill in registration info',
|
||||||
|
// Exclusive code label
|
||||||
|
exclusiveCode: 'Exclusive Code',
|
||||||
|
// Code pending label
|
||||||
|
pendingCode: 'Code Pending',
|
||||||
|
// Pending
|
||||||
|
pending: 'Pending',
|
||||||
|
// Loading code
|
||||||
|
loading: 'Loading code...',
|
||||||
|
// Code obtained label
|
||||||
|
obtainedCode: 'Code Obtained',
|
||||||
|
// Obtained
|
||||||
|
obtained: 'Obtained',
|
||||||
|
// Share invite reward description
|
||||||
|
shareInviteReward: 'Share with friends and earn rewards for invites',
|
||||||
|
// Active
|
||||||
|
activated: '✓ Active',
|
||||||
|
// Code generated description
|
||||||
|
codeGenerated: 'Code generated. Use it to invite friends or bind referral for enterprise registration',
|
||||||
|
// Submitting
|
||||||
|
submitting: 'Submitting...',
|
||||||
|
// Share title
|
||||||
|
shareTitle: 'Inviting you to join the low-altitude economy platform',
|
||||||
|
// Get failed
|
||||||
|
getFail: 'Failed to get code. Please retry later',
|
||||||
|
// Get failed (business code)
|
||||||
|
getFailCode: 'Failed to get code (business code={code})',
|
||||||
|
// Get success
|
||||||
|
getSuccess: 'Obtained',
|
||||||
|
// Network error
|
||||||
|
netError: 'Network error. Please retry later',
|
||||||
|
// Renew code modal title
|
||||||
|
regenTitle: 'Renew Code',
|
||||||
|
// Renew code modal content
|
||||||
|
regenContent: 'After renewal, the old code becomes invalid immediately. All registrations via the old link will no longer be attributed to you. Continue?',
|
||||||
|
// Confirm renew
|
||||||
|
regenConfirm: 'Confirm Renew',
|
||||||
|
// Cancel
|
||||||
|
regenCancel: 'Cancel',
|
||||||
|
// Renew failed
|
||||||
|
regenFail: 'Renewal failed. Please retry later',
|
||||||
|
// Renew success (old disabled)
|
||||||
|
regenSuccessOld: 'New code issued. Old code {old} disabled',
|
||||||
|
// Renew success
|
||||||
|
regenSuccess: 'Renewed',
|
||||||
|
// Code not ready yet
|
||||||
|
codeNotReady: 'Code not ready yet. Please wait',
|
||||||
|
// Code copied
|
||||||
|
codeCopied: 'Code copied',
|
||||||
|
// Copy failed
|
||||||
|
copyFail: 'Copy failed',
|
||||||
|
// Copy content is empty
|
||||||
|
copyEmpty: 'Content is empty',
|
||||||
|
// Copy after code finishes loading
|
||||||
|
loadThenCopy: 'Copy after code finishes loading',
|
||||||
|
// Share link copied
|
||||||
|
linkCopied: 'Share link copied',
|
||||||
|
// Poster subtitle
|
||||||
|
posterShareInvite: 'Share with friends, earn rewards for invites',
|
||||||
|
// Poster link prefix
|
||||||
|
posterLinkPrefix: 'Link:',
|
||||||
|
// Poster link not generated
|
||||||
|
posterLinkEmpty: '(not generated)',
|
||||||
|
}
|
||||||
5
lang/en-us/components-diy/diy-webview.js
Normal file
5
lang/en-us/components-diy/diy-webview.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-webview webview component
|
||||||
|
emptyTip: 'Please configure the web link',
|
||||||
|
loadError: 'Failed to load web page',
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export const lang = {
|
|||||||
loggingIn: 'Logging in',
|
loggingIn: 'Logging in',
|
||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
||||||
|
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
|
||||||
pleaseEnterNickname: 'Please enter nickname',
|
pleaseEnterNickname: 'Please enter nickname',
|
||||||
avatarPullFailed: 'Avatar pull failed',
|
avatarPullFailed: 'Avatar pull failed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const lang = {
|
|||||||
fileUrlNotExist: 'File URL does not exist',
|
fileUrlNotExist: 'File URL does not exist',
|
||||||
// Video URL does not exist
|
// Video URL does not exist
|
||||||
videoUrlNotExist: 'Video URL does not exist',
|
videoUrlNotExist: 'Video URL does not exist',
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): audio related
|
||||||
|
audioUrlNotExist: 'Audio URL does not exist',
|
||||||
|
audioDefaultTitle: 'Voice message',
|
||||||
|
audioPlaying: 'Playing…',
|
||||||
|
audioPaused: 'Paused',
|
||||||
// This file type preview is not supported
|
// This file type preview is not supported
|
||||||
unsupportedFileType: 'This file type preview is not supported',
|
unsupportedFileType: 'This file type preview is not supported',
|
||||||
}
|
}
|
||||||
|
|||||||
4
lang/en-us/files/detail.js
Normal file
4
lang/en-us/files/detail.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const lang = {
|
||||||
|
//title为每个页面的标题
|
||||||
|
title: 'File Details',
|
||||||
|
}
|
||||||
@@ -7,5 +7,18 @@ export const lang = {
|
|||||||
submitSuccess: 'Submitted successfully',
|
submitSuccess: 'Submitted successfully',
|
||||||
// Form information not available
|
// Form information not available
|
||||||
noFormInfo: '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',
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export const lang = {
|
|||||||
title: 'Payment Method',
|
title: 'Payment Method',
|
||||||
paymentAmount: 'Payment Amount',
|
paymentAmount: 'Payment Amount',
|
||||||
confirmPayment: 'Confirm Payment',
|
confirmPayment: 'Confirm Payment',
|
||||||
seeOrder: 'View Order'
|
seeOrder: 'View Order',
|
||||||
|
payInfoNotFound: 'Payment info not found!'
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
//title为每个页面的标题
|
//title为每个页面的标题
|
||||||
title: 'AI智能客服',
|
title: 'AI智能',
|
||||||
|
|
||||||
// ====== 通用 ======
|
// ====== 通用 ======
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
@@ -12,12 +12,28 @@ export const lang = {
|
|||||||
timeFormatToday: '今天',
|
timeFormatToday: '今天',
|
||||||
timeFormatYesterday: '昨天',
|
timeFormatYesterday: '昨天',
|
||||||
timeFormatBeforeYesterday: '前天',
|
timeFormatBeforeYesterday: '前天',
|
||||||
|
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
|
||||||
|
timeJustNow: '刚刚',
|
||||||
|
timeMinutesAgo: '{n}分钟前',
|
||||||
|
timeHoursAgo: '{n}小时前',
|
||||||
|
timeMonthDay: '{m}月{d}日',
|
||||||
|
timeYearMonthDay: '{y}年{m}月{d}日',
|
||||||
|
|
||||||
// ====== ai-chat-message ======
|
// ====== ai-chat-message ======
|
||||||
aiNickname: '智能助手',
|
aiNickname: '智能助手',
|
||||||
defaultNickname: '我',
|
defaultNickname: '我',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
|
||||||
|
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
|
||||||
|
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
|
||||||
|
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
|
||||||
audioMessage: '语音消息',
|
audioMessage: '语音消息',
|
||||||
videoMessage: '视频消息',
|
videoMessage: '视频消息',
|
||||||
|
audioInstruction: '我发送了一段语音,请听一下并回复我',
|
||||||
|
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
|
||||||
|
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
|
||||||
|
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
|
||||||
|
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
|
||||||
|
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
|
||||||
inputPlaceholder: '请输入您的问题...',
|
inputPlaceholder: '请输入您的问题...',
|
||||||
expandEditorTitle: '编辑内容',
|
expandEditorTitle: '编辑内容',
|
||||||
loadMoreHistory: '加载更多历史消息',
|
loadMoreHistory: '加载更多历史消息',
|
||||||
@@ -112,15 +128,49 @@ export const lang = {
|
|||||||
// ====== ai-chat-tools-panel ======
|
// ====== ai-chat-tools-panel ======
|
||||||
moreFunctions: '更多功能',
|
moreFunctions: '更多功能',
|
||||||
image: '图片',
|
image: '图片',
|
||||||
|
video: '视频/音频',
|
||||||
file: '文件',
|
file: '文件',
|
||||||
voice: '语音',
|
voice: '语音',
|
||||||
|
recordVoice: '录制语音',
|
||||||
|
uploadAudio: '上传音频',
|
||||||
|
pickVideo: '选择视频',
|
||||||
|
pickAudio: '选择音频',
|
||||||
|
chooseAudioFail: '选择音频失败',
|
||||||
|
chooseVideoFail: '选择视频失败',
|
||||||
|
audioNotReady: '音频尚未就绪',
|
||||||
location: '位置',
|
location: '位置',
|
||||||
|
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
|
||||||
|
locationPermissionTitle: '需要位置权限',
|
||||||
|
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
|
||||||
|
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
|
||||||
|
locationSettingBtn: '去设置',
|
||||||
|
locationSkipBtn: '暂不开启',
|
||||||
|
locationH5GuideBtn: '我知道了',
|
||||||
|
|
||||||
// ====== ai-chat-voice-panel ======
|
// ====== ai-chat-voice-panel ======
|
||||||
voiceInputTitle: '语音输入',
|
voiceInputTitle: '语音输入',
|
||||||
recordingTip: '正在录音,松开结束',
|
recordingTip: '正在录音,松开结束',
|
||||||
startRecordTip: '点击开始录音',
|
startRecordTip: '点击开始录音',
|
||||||
voiceInputFailed: '语音输入失败',
|
voiceInputFailed: '语音输入失败',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
|
||||||
|
holdToTalk: '按住说话',
|
||||||
|
releaseToSend: '松开发送',
|
||||||
|
releaseToCancel: '松开取消',
|
||||||
|
slideUpToCancel: '↑ 上滑取消',
|
||||||
|
slideUpToCancelHint: '↑ 松开取消',
|
||||||
|
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
|
||||||
|
// 默认 / 有语音附件 / 有其他媒体附件, 三态文案互不冲突
|
||||||
|
placeholderDefault: '发消息或按住说话',
|
||||||
|
placeholderWithAudio: '给语音留言加个说明(可选)',
|
||||||
|
placeholderWithMedia: '添加文字说明(可选)',
|
||||||
|
voiceStartFailed: '无法开始录音',
|
||||||
|
voiceStopFailed: '录音失败,请重试',
|
||||||
|
voiceNotSupported: '当前平台不支持语音输入',
|
||||||
|
voiceTooShort: '说话时间太短',
|
||||||
|
voiceRecorderBusy: '录音器正在初始化,请稍后再试',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
||||||
|
uploadFailed: '上传失败',
|
||||||
|
uploadFailedWithReason: '上传失败:{reason}',
|
||||||
|
|
||||||
// ====== index.vue - 菜单 ======
|
// ====== index.vue - 菜单 ======
|
||||||
menuClearChat: '清空聊天',
|
menuClearChat: '清空聊天',
|
||||||
@@ -136,4 +186,8 @@ export const lang = {
|
|||||||
logInitNavbarFailed: '初始化导航栏失败',
|
logInitNavbarFailed: '初始化导航栏失败',
|
||||||
logChatComponentNotInit: '聊天组件未初始化',
|
logChatComponentNotInit: '聊天组件未初始化',
|
||||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
||||||
}
|
|
||||||
|
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
||||||
|
aiAgentDefaultTitle: 'AI 智能体',
|
||||||
|
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
|
||||||
|
aiAgentNotConfigured: '该 AI 智能体暂未启用'}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const lang = {
|
|||||||
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
|
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
|
||||||
diy: {
|
diy: {
|
||||||
networkErrorTitle: '提示',
|
networkErrorTitle: '提示',
|
||||||
|
networkError: '网络异常,请稍后重试',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
noDiyData: '未配置自定义页面数据',
|
noDiyData: '未配置自定义页面数据',
|
||||||
locationNotAvailable: '未获取到定位',
|
locationNotAvailable: '未获取到定位',
|
||||||
@@ -111,6 +112,7 @@ export const lang = {
|
|||||||
notSupported: '当前环境不支持微信视频号',
|
notSupported: '当前环境不支持微信视频号',
|
||||||
missingFields: '缺少必要字段: {field}',
|
missingFields: '缺少必要字段: {field}',
|
||||||
playFailed: '微信视频播放失败!',
|
playFailed: '微信视频播放失败!',
|
||||||
|
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
|
||||||
},
|
},
|
||||||
|
|
||||||
// uniapp 工具函数
|
// uniapp 工具函数
|
||||||
@@ -254,6 +256,14 @@ export const lang = {
|
|||||||
addToCartFailMin: '已达到最小购买数量',
|
addToCartFailMin: '已达到最小购买数量',
|
||||||
addToCartFailStock: '库存不足',
|
addToCartFailStock: '库存不足',
|
||||||
|
|
||||||
|
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
|
||||||
|
submitSuccess: '提交成功',
|
||||||
|
submitFail: '提交失败',
|
||||||
|
versionInfo: '版本信息',
|
||||||
|
commitId: '提交 ID',
|
||||||
|
buildTime: '构建时间',
|
||||||
|
loading: '加载中...',
|
||||||
|
|
||||||
consult: '立即咨询',
|
consult: '立即咨询',
|
||||||
consultSuccess: '咨询成功',
|
consultSuccess: '咨询成功',
|
||||||
consultFail: '咨询失败',
|
consultFail: '咨询失败',
|
||||||
|
|||||||
83
lang/zh-cn/components-diy/diy-recommend-code.js
Normal file
83
lang/zh-cn/components-diy/diy-recommend-code.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-recommend-code 推荐码组件
|
||||||
|
// 标题
|
||||||
|
title: '我的推荐码',
|
||||||
|
// 立即分享按钮
|
||||||
|
buttonText: '立即分享',
|
||||||
|
// 复制推荐码按钮
|
||||||
|
copyButtonText: '复制推荐码',
|
||||||
|
// 换一个新码
|
||||||
|
placeholderRegenerateText: '换一个新码',
|
||||||
|
// 同意说明文案
|
||||||
|
consentText: '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。',
|
||||||
|
// 同意并获取推荐码按钮
|
||||||
|
consentButtonText: '同意并获取推荐码',
|
||||||
|
// 未登录引导提示
|
||||||
|
loginTipText: '登录后查看我的推荐码',
|
||||||
|
// 去登录按钮
|
||||||
|
loginBtnText: '去登录',
|
||||||
|
// 海报二维码提示
|
||||||
|
posterQrTip: '扫码进入并填写入驻信息',
|
||||||
|
// 专属推荐码标签
|
||||||
|
exclusiveCode: '专属推荐码',
|
||||||
|
// 待获取推荐码标签
|
||||||
|
pendingCode: '待获取推荐码',
|
||||||
|
// 待获取
|
||||||
|
pending: '待获取',
|
||||||
|
// 推荐码加载中
|
||||||
|
loading: '推荐码加载中...',
|
||||||
|
// 已获得推荐码标签
|
||||||
|
obtainedCode: '已获得推荐码',
|
||||||
|
// 已获得
|
||||||
|
obtained: '已获得',
|
||||||
|
// 分享邀请奖励描述
|
||||||
|
shareInviteReward: '分享给好友,邀请注册即可获得奖励',
|
||||||
|
// 已生效
|
||||||
|
activated: '✓ 已生效',
|
||||||
|
// 推荐码已生成描述
|
||||||
|
codeGenerated: '推荐码已生成,可用于邀请好友或企业入驻绑定推荐关系',
|
||||||
|
// 提交中
|
||||||
|
submitting: '提交中...',
|
||||||
|
// 分享标题
|
||||||
|
shareTitle: '邀您入驻低空经济产业平台',
|
||||||
|
// 获取失败
|
||||||
|
getFail: '推荐码获取失败,请稍后重试',
|
||||||
|
// 获取失败(业务码)
|
||||||
|
getFailCode: '推荐码获取失败(业务码={code})',
|
||||||
|
// 获取成功
|
||||||
|
getSuccess: '获取成功',
|
||||||
|
// 网络异常
|
||||||
|
netError: '网络异常,请稍后重试',
|
||||||
|
// 换新码弹窗标题
|
||||||
|
regenTitle: '换新码',
|
||||||
|
// 换新码弹窗内容
|
||||||
|
regenContent: '换新码后旧码会立即失效,后续所有使用旧分享链接的入驻申请都不会归属到您,确认继续?',
|
||||||
|
// 确认换新
|
||||||
|
regenConfirm: '确认换新',
|
||||||
|
// 取消
|
||||||
|
regenCancel: '取消',
|
||||||
|
// 换新失败
|
||||||
|
regenFail: '换新失败,请稍后重试',
|
||||||
|
// 换新成功(旧码停用)
|
||||||
|
regenSuccessOld: '已换新码,旧码 {old} 已停用',
|
||||||
|
// 换新成功
|
||||||
|
regenSuccess: '换新成功',
|
||||||
|
// 推荐码还未生成
|
||||||
|
codeNotReady: '推荐码还未生成,请稍候',
|
||||||
|
// 推荐码已复制
|
||||||
|
codeCopied: '推荐码已复制',
|
||||||
|
// 复制失败
|
||||||
|
copyFail: '复制失败',
|
||||||
|
// 复制内容为空
|
||||||
|
copyEmpty: '内容为空',
|
||||||
|
// 推荐码加载完成后再复制
|
||||||
|
loadThenCopy: '推荐码加载完成后再复制',
|
||||||
|
// 推广链接已复制
|
||||||
|
linkCopied: '推广链接已复制',
|
||||||
|
// 海报副标题
|
||||||
|
posterShareInvite: '分享给好友,邀请入驻获得奖励',
|
||||||
|
// 海报链接前缀
|
||||||
|
posterLinkPrefix: '链接:',
|
||||||
|
// 海报链接未生成
|
||||||
|
posterLinkEmpty: '(未生成)',
|
||||||
|
}
|
||||||
5
lang/zh-cn/components-diy/diy-webview.js
Normal file
5
lang/zh-cn/components-diy/diy-webview.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const lang = {
|
||||||
|
// diy-webview 网页组件
|
||||||
|
emptyTip: '请配置网页链接',
|
||||||
|
loadError: '网页加载失败',
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export const lang = {
|
|||||||
loggingIn: '登录中',
|
loggingIn: '登录中',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
pleaseAuthorizeMobile: '请授权您的手机号码',
|
pleaseAuthorizeMobile: '请授权您的手机号码',
|
||||||
|
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
|
||||||
pleaseEnterNickname: '请输入昵称',
|
pleaseEnterNickname: '请输入昵称',
|
||||||
avatarPullFailed: '头像拉取失败',
|
avatarPullFailed: '头像拉取失败',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const lang = {
|
|||||||
fileUrlNotExist: '文件地址不存在',
|
fileUrlNotExist: '文件地址不存在',
|
||||||
// 视频地址不存在
|
// 视频地址不存在
|
||||||
videoUrlNotExist: '视频地址不存在',
|
videoUrlNotExist: '视频地址不存在',
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
|
||||||
|
audioUrlNotExist: '音频地址不存在',
|
||||||
|
audioDefaultTitle: '语音消息',
|
||||||
|
audioPlaying: '播放中…',
|
||||||
|
audioPaused: '已暂停',
|
||||||
// 暂不支持该类型文件预览
|
// 暂不支持该类型文件预览
|
||||||
unsupportedFileType: '暂不支持该类型文件预览',
|
unsupportedFileType: '暂不支持该类型文件预览',
|
||||||
}
|
}
|
||||||
|
|||||||
4
lang/zh-cn/files/detail.js
Normal file
4
lang/zh-cn/files/detail.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const lang = {
|
||||||
|
//title为每个页面的标题
|
||||||
|
title: '文件详情',
|
||||||
|
}
|
||||||
@@ -6,4 +6,17 @@ export const lang = {
|
|||||||
submitSuccess: '提交成功',
|
submitSuccess: '提交成功',
|
||||||
// 未获取到表单信息
|
// 未获取到表单信息
|
||||||
noFormInfo: '未获取到表单信息',
|
noFormInfo: '未获取到表单信息',
|
||||||
|
// 行为设置相关
|
||||||
|
// 请输入验证码
|
||||||
|
captchaPlaceholder: '请输入验证码',
|
||||||
|
// 请输入验证码(校验提示)
|
||||||
|
captchaRequired: '请输入验证码',
|
||||||
|
// 验证码错误
|
||||||
|
captchaError: '验证码错误,请重试',
|
||||||
|
// 提交次数已达上限
|
||||||
|
submitLimit: '提交次数已达上限',
|
||||||
|
// 表单未开放
|
||||||
|
notOpen: '表单未开放',
|
||||||
|
// 请先登录
|
||||||
|
loginRequired: '请先登录',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export const lang = {
|
|||||||
title: '支付方式',
|
title: '支付方式',
|
||||||
paymentAmount: '支付金额',
|
paymentAmount: '支付金额',
|
||||||
confirmPayment: '确认支付',
|
confirmPayment: '确认支付',
|
||||||
seeOrder: '查看订单'
|
seeOrder: '查看订单',
|
||||||
|
payInfoNotFound: '未获取到支付信息!'
|
||||||
}
|
}
|
||||||
|
|||||||
10
main.js
10
main.js
@@ -6,6 +6,7 @@ import App from './App'
|
|||||||
import store from './store'
|
import store from './store'
|
||||||
import Util from './common/js/util.js'
|
import Util from './common/js/util.js'
|
||||||
import Http from './common/js/http.js'
|
import Http from './common/js/http.js'
|
||||||
|
import CardApi from './common/js/card-api.js'
|
||||||
import Lang from './common/js/lang.js'
|
import Lang from './common/js/lang.js'
|
||||||
import Config from './common/js/config.js'
|
import Config from './common/js/config.js'
|
||||||
import EventBus from './common/js/event-bus.js'
|
import EventBus from './common/js/event-bus.js'
|
||||||
@@ -25,6 +26,7 @@ Vue.config.productionTip = false
|
|||||||
|
|
||||||
Vue.prototype.$util = Util;
|
Vue.prototype.$util = Util;
|
||||||
Vue.prototype.$api = Http;
|
Vue.prototype.$api = Http;
|
||||||
|
Vue.prototype.$cardApi = CardApi; // 名片云(card addon) API 收口层
|
||||||
|
|
||||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||||
@@ -80,10 +82,18 @@ Vue.mixin(MpShareDefault);
|
|||||||
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
|
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
|
||||||
// golbalConfig.js 已把 componentRefresh 注册为全局 computed,
|
// golbalConfig.js 已把 componentRefresh 注册为全局 computed,
|
||||||
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
|
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
|
||||||
|
// 同时调用 lang.refresh() 让当前页面的 navigationBarTitleText 跟随语种变化
|
||||||
|
// (pages.json 里的硬编码中文标题需要被 $lang('title') 覆盖)
|
||||||
Vue.mixin({
|
Vue.mixin({
|
||||||
watch: {
|
watch: {
|
||||||
componentRefresh() {
|
componentRefresh() {
|
||||||
this.$forceUpdate();
|
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 失败不影响主流程 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,9 @@
|
|||||||
},
|
},
|
||||||
"scope.writePhotosAlbum" : {
|
"scope.writePhotosAlbum" : {
|
||||||
"desc" : "用于从相册选择和保存头像"
|
"desc" : "用于从相册选择和保存头像"
|
||||||
|
},
|
||||||
|
"scope.record" : {
|
||||||
|
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||||
@@ -136,7 +139,23 @@
|
|||||||
"sassImplementationName" : "dart-sass",
|
"sassImplementationName" : "dart-sass",
|
||||||
"mp-harmony" : {
|
"mp-harmony" : {
|
||||||
"distribute" : {
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
||||||
|
"pack:harmony": "node scripts/pack-harmony-project.js",
|
||||||
"i18n:check": "node scripts/i18n-check.js",
|
"i18n:check": "node scripts/i18n-check.js",
|
||||||
"test": "node scripts/test-gate.js",
|
"test": "node scripts/test-gate.js",
|
||||||
"test:util": "node --test tests/util.test.js",
|
"test:util": "node --test tests/util.test.js",
|
||||||
@@ -66,6 +67,26 @@
|
|||||||
"define": {
|
"define": {
|
||||||
"PRODUCTION": true
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
251
pages.json
251
pages.json
@@ -51,6 +51,7 @@
|
|||||||
"diy-pintuan": "view",
|
"diy-pintuan": "view",
|
||||||
"diy-presale": "view",
|
"diy-presale": "view",
|
||||||
"diy-quick-nav": "view",
|
"diy-quick-nav": "view",
|
||||||
|
"diy-recommend-code": "view",
|
||||||
"diy-rich-text": "view",
|
"diy-rich-text": "view",
|
||||||
"diy-rubik-cube": "view",
|
"diy-rubik-cube": "view",
|
||||||
"diy-search": "view",
|
"diy-search": "view",
|
||||||
@@ -1085,7 +1086,64 @@
|
|||||||
{
|
{
|
||||||
"path": "ai-chat/index",
|
"path": "ai-chat/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "AI智能客服"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1097,7 +1155,10 @@
|
|||||||
{
|
{
|
||||||
"path": "vr/index",
|
"path": "vr/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": ""
|
// #ifdef APP-PLUS
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
// #endif
|
||||||
|
"navigationBarTitleText": "工厂展示"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1291,6 +1352,7 @@
|
|||||||
"diy-pintuan": "view",
|
"diy-pintuan": "view",
|
||||||
"diy-presale": "view",
|
"diy-presale": "view",
|
||||||
"diy-quick-nav": "view",
|
"diy-quick-nav": "view",
|
||||||
|
"diy-recommend-code": "view",
|
||||||
"diy-rich-text": "view",
|
"diy-rich-text": "view",
|
||||||
"diy-rubik-cube": "view",
|
"diy-rubik-cube": "view",
|
||||||
"diy-search": "view",
|
"diy-search": "view",
|
||||||
@@ -1404,6 +1466,118 @@
|
|||||||
// #endif
|
// #endif
|
||||||
"navigationBarTitleText": ""
|
"navigationBarTitleText": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/list",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"navigationBarTextStyle": "black"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/exchange",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/style",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/edit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/apply",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/leads",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/contacts",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/views",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/contact-edit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/share",
|
||||||
|
"style": {
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
// 鸿蒙元服务的 web-view 是原生全屏组件, 只能自绘头部(见 share.vue)
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
// #endif
|
||||||
|
// 微信小程序必须用原生导航栏: web-view 会自动排在导航栏下方,
|
||||||
|
// 若用 custom + cover-view 自绘头部, 头部会浮在 web-view 上遮住名片顶部
|
||||||
|
"navigationBarTitleText": "名片分享"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/save-contact",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "保存到通讯录"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/enterprise",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "企业名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/admin/manage",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/admin/contacts",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "card/admin/leads",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "名片",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "member/card",
|
"path": "member/card",
|
||||||
@@ -2141,13 +2315,72 @@
|
|||||||
// #ifdef APP-PLUS
|
// #ifdef APP-PLUS
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
// #endif
|
// #endif
|
||||||
"navigationBarTitleText": ""
|
"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-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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "form/formdata",
|
"path": "form/formdata",
|
||||||
"style": {
|
"style": {
|
||||||
// #ifdef APP-PLUS
|
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
|
||||||
|
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
|
||||||
|
// #ifdef APP-PLUS || MP
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
// #endif
|
// #endif
|
||||||
"navigationBarTitleText": "",
|
"navigationBarTitleText": "",
|
||||||
@@ -2378,10 +2611,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "files/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "文件详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "file-preview/file-preview",
|
"path": "file-preview/file-preview",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "文件预览"
|
"navigationBarTitleText": "文件预览",
|
||||||
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2429,7 +2669,6 @@
|
|||||||
"pages/index/index": {
|
"pages/index/index": {
|
||||||
"network": "all",
|
"network": "all",
|
||||||
"packages": [
|
"packages": [
|
||||||
"pages_tool",
|
|
||||||
"components-diy"
|
"components-diy"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<ns-copyright v-show="isShowCopyRight" />
|
<ns-copyright v-show="isShowCopyRight" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-if="adv.advshow != -1">
|
<template v-if="adv && adv.advshow != -1">
|
||||||
<view @touchmove.prevent.stop>
|
<view @touchmove.prevent.stop>
|
||||||
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
||||||
<view class="small-bot">
|
<view class="small-bot">
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ export default {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (v && v.commitId) {
|
if (v && v.commitId) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '版本信息',
|
title: this.$lang('versionInfo'),
|
||||||
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
|
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
|
||||||
showCancel: false
|
showCancel: false
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<view>
|
<view>
|
||||||
|
<!-- 骨架屏:商品详情加载时按结构分段占位(banner / 价格 / 标题 / 规格 / 商家 / 详情 / 推荐)
|
||||||
|
关闭条件: loading=false 且已拿到真实商品数据(goods_id>0); 任一不满足都保留骨架屏 -->
|
||||||
|
<view v-if="loading || !hasRealData" class="goods-detail-skeleton">
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-banner" :loading="true" :configs="skeletonBanner"></x-skeleton>
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-price" :loading="true" :configs="skeletonPrice"></x-skeleton>
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-title" :loading="true" :configs="skeletonTitle"></x-skeleton>
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-sku" :loading="true" :configs="skeletonSku"></x-skeleton>
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-merch" :loading="true" :configs="skeletonMerch"></x-skeleton>
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-detail" :loading="true" :configs="skeletonDetail"></x-skeleton>
|
||||||
|
<x-skeleton data-component-name="goods-detail-view-recommend" :loading="true" :configs="skeletonRecommend"></x-skeleton>
|
||||||
|
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
|
||||||
|
<view class="goods-detail-skeleton-action">
|
||||||
|
<view class="skeleton-action-icons">
|
||||||
|
<view class="skeleton-action-icon" v-for="i in 2" :key="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> 的指令属性)
|
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
|
||||||
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 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-detail" :class="isIphoneX ? 'active' : ''">
|
||||||
<view class="goods-container">
|
<view class="goods-container">
|
||||||
<!-- AI客服悬浮按钮 -->
|
<!-- AI客服悬浮按钮 -->
|
||||||
@@ -34,10 +56,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 商品视频 -->
|
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
|
||||||
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
|
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
|
||||||
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
|
<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>
|
</view>
|
||||||
|
|
||||||
<!-- 切换视频、图片 -->
|
<!-- 切换视频、图片 -->
|
||||||
@@ -461,7 +484,7 @@
|
|||||||
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
|
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 || MP-HARMONY -->
|
||||||
<view class="share-box" @click="copyUrl">
|
<view class="share-box" @click="copyUrl">
|
||||||
<button class="share-btn" :plain="true">
|
<button class="share-btn" :plain="true">
|
||||||
<view class="iconfont icon-fuzhilianjie"></view>
|
<view class="iconfont icon-fuzhilianjie"></view>
|
||||||
@@ -478,9 +501,8 @@
|
|||||||
<slot name="fixedbtn"></slot>
|
<slot name="fixedbtn"></slot>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 操作区域 -->
|
|
||||||
<slot name="action"></slot>
|
|
||||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||||
<ns-login ref="login"></ns-login>
|
<ns-login ref="login"></ns-login>
|
||||||
</view>
|
</view>
|
||||||
@@ -503,11 +525,142 @@ export default {
|
|||||||
default: () => {
|
default: () => {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// 是否显示骨架屏(true:展示骨架;false:展示真实内容)
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
pengpaiFadeinOut
|
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: {
|
computed: {
|
||||||
aiAvatarUrl() {
|
aiAvatarUrl() {
|
||||||
return this.$util.img(IMAGE_CONFIG.aiAvatar);
|
return this.$util.img(IMAGE_CONFIG.aiAvatar);
|
||||||
@@ -526,6 +679,11 @@ export default {
|
|||||||
hasDetailContent() {
|
hasDetailContent() {
|
||||||
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
|
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
|
||||||
return hasPdf || this.hasHtmlTab;
|
return hasPdf || this.hasHtmlTab;
|
||||||
|
},
|
||||||
|
// 是否有真实商品数据: goods_id > 0 才算拿到商品详情
|
||||||
|
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
|
||||||
|
hasRealData() {
|
||||||
|
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [scroll, detail]
|
mixins: [scroll, detail]
|
||||||
@@ -534,6 +692,79 @@ export default {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '@/common/css/goods_detail.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>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ai-chat-float-btn {
|
.ai-chat-float-btn {
|
||||||
|
|||||||
@@ -384,5 +384,14 @@ export default {
|
|||||||
border-radius: 20rpx 20rpx 0 0;
|
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';
|
@import './public/css/cart.scss';
|
||||||
</style>
|
</style>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<view :style="themeColor">
|
<view :style="themeColor">
|
||||||
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
|
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
|
||||||
<view v-if="diyData && diyData.value && diyData.value.length">
|
<view v-if="diyData && diyData.value && diyData.value.length">
|
||||||
<block v-for="(item, index) in diyData.value" :key="item.id || item.componentName || index">
|
<block v-for="(item, index) in diyData.value" :key="index">
|
||||||
<view v-if="item.componentName == 'GoodsCategory'">
|
<view v-if="item.componentName == 'GoodsCategory'">
|
||||||
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
|
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</view> -->
|
</view> -->
|
||||||
<!-- #endif -->
|
<!-- #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>
|
<template v-slot:price>
|
||||||
<!-- 限时折扣 -->
|
<!-- 限时折扣 -->
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
|
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
|
||||||
<view class="price-box">
|
<view class="price-box">
|
||||||
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view>
|
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view>
|
||||||
<view class="sale-num" v-if="goodsSkuDetail.sale_show">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
|
<view class="sale-num" v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -74,7 +74,9 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="follow-and-share">
|
<view class="follow-and-share">
|
||||||
<text class="follow iconfont icon-share" @click="openSharePopup()"></text>
|
<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>
|
<text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text>
|
||||||
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
@@ -92,8 +94,8 @@
|
|||||||
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
|
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="logistics-wrap">
|
<view class="logistics-wrap">
|
||||||
<text v-if="goodsSkuDetail.stock_show">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
|
<text v-if="goodsSkuDetail.stock_show && !isPhoneConsult">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
|
||||||
<text v-if="goodsSkuDetail.sale_show">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
|
<text v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 会员卡 -->
|
<!-- 会员卡 -->
|
||||||
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
|
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
|
||||||
@@ -460,6 +462,7 @@
|
|||||||
|
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<ns-goods-action-icon
|
<ns-goods-action-icon
|
||||||
|
v-if="!isPhoneConsult"
|
||||||
:text="$lang('goods.detail.cart')"
|
:text="$lang('goods.detail.cart')"
|
||||||
:cornerMarkBg="themeStyle.goods_cart_num_corner"
|
:cornerMarkBg="themeStyle.goods_cart_num_corner"
|
||||||
icon="icon-gouwuche2"
|
icon="icon-gouwuche2"
|
||||||
@@ -468,6 +471,9 @@
|
|||||||
/>
|
/>
|
||||||
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
|
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
<!-- #ifdef MP-HARMONY -->
|
||||||
|
<!-- 鸿蒙平台: 隐藏购物车、客服、收藏按钮 -->
|
||||||
|
<!-- #endif -->
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 -->
|
||||||
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
|
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
@@ -523,7 +529,9 @@
|
|||||||
</goods-detail-view>
|
</goods-detail-view>
|
||||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||||
<ns-login ref="login"></ns-login>
|
<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>
|
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||||
@@ -558,6 +566,10 @@ export default {
|
|||||||
},
|
},
|
||||||
mixins: [base, detail, scroll],
|
mixins: [base, detail, scroll],
|
||||||
computed: {
|
computed: {
|
||||||
|
// 后台选择"电话立即咨询"模式(isinformation == 1)时,隐藏购物车图标、库存和销量
|
||||||
|
isPhoneConsult() {
|
||||||
|
return this.goodsSkuDetail && this.goodsSkuDetail.isinformation == 1;
|
||||||
|
},
|
||||||
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
||||||
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
||||||
formDiyValue() {
|
formDiyValue() {
|
||||||
@@ -596,7 +608,7 @@ export default {
|
|||||||
uni.setStorageSync('selectedProduct', productInfo);
|
uni.setStorageSync('selectedProduct', productInfo);
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages_tool/ai-chat/index'
|
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export default {
|
|||||||
goodsRoute: '/pages_goods/detail',
|
goodsRoute: '/pages_goods/detail',
|
||||||
couponList: [], //优惠券列表
|
couponList: [], //优惠券列表
|
||||||
couponBtnSwitch: false, //获取优惠券防止重复提交
|
couponBtnSwitch: false, //获取优惠券防止重复提交
|
||||||
|
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
|
||||||
|
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
|
||||||
|
loading: true,
|
||||||
|
|
||||||
posterApi: '/api/goods/poster',
|
posterApi: '/api/goods/poster',
|
||||||
|
|
||||||
@@ -103,13 +106,12 @@ export default {
|
|||||||
await this.getGoodsSkuDetail();
|
await this.getGoodsSkuDetail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGoodsSkuDetail failed:', e);
|
console.error('getGoodsSkuDetail failed:', e);
|
||||||
// 即使失败也要保证 loading-cover 关闭, 避免空白屏
|
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
|
||||||
setTimeout(() => {
|
// 避免错误地把页面切到"空详情"状态
|
||||||
try {
|
uni.showToast({
|
||||||
const ref = this.$refs && this.$refs.loadingCover;
|
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
|
||||||
if (ref && typeof ref.hide === 'function') ref.hide();
|
icon: 'none'
|
||||||
} catch (e2) { /* swallow */ }
|
});
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
@@ -179,7 +181,10 @@ export default {
|
|||||||
this.chatRoomParams = {
|
this.chatRoomParams = {
|
||||||
sku_id: this.goodsSkuDetail.sku_id
|
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) {
|
||||||
// 限时折扣
|
// 限时折扣
|
||||||
if (typeId.discount_id) {
|
if (typeId.discount_id) {
|
||||||
@@ -197,20 +202,21 @@ export default {
|
|||||||
// 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
|
// 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
await new Promise(resolve => setTimeout(resolve, 16));
|
await new Promise(resolve => setTimeout(resolve, 16));
|
||||||
|
// 修复鸿蒙元服务: 不依赖 setTimeout 关闭骨架屏.
|
||||||
|
// 鸿蒙 JSVM 在 onShow 早期窗口的 setTimeout 回调有时被推迟/丢弃, 表现为"数据已回来但永远显示骨架屏".
|
||||||
|
// 数据已赋值 + 首屏让步, 同步直接关闭骨架屏, 由 hasRealData 兜底避免空详情.
|
||||||
|
this.loading = false;
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifndef MP-HARMONY
|
// #ifndef MP-HARMONY
|
||||||
await this.$nextTick();
|
await this.$nextTick();
|
||||||
// #endif
|
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
|
||||||
|
// 用户会看到骨架屏 → 真实详情 的清晰过渡
|
||||||
// 关闭 loading-cover 提前到 nextTick 后, 让用户看到首屏内容
|
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
|
||||||
// 用 setTimeout 0 推迟到下一个 task, 避开当前 sync 阶段 vendor 报错导致 hide 失败
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
this.loading = false;
|
||||||
const ref = this.$refs && this.$refs.loadingCover;
|
}, 300);
|
||||||
if (ref && typeof ref.hide === 'function') ref.hide();
|
// #endif
|
||||||
} catch (e) { /* swallow */ }
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
|
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
|
||||||
await this.handleGoodsSkuData();
|
await this.handleGoodsSkuData();
|
||||||
@@ -249,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 = this.goodsSkuDetail.coupon_list;
|
||||||
this.couponList.forEach(v => {
|
this.couponList.forEach(v => {
|
||||||
if (v.count == v.lead_count) v.useState = 2;
|
if (v.count == v.lead_count) v.useState = 2;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default {
|
|||||||
fontWeight: false,
|
fontWeight: false,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cartEvent: "detail",
|
cartEvent: "detail",
|
||||||
text: "购买",
|
text: "",
|
||||||
textColor: "#FFFFFF",
|
textColor: "#FFFFFF",
|
||||||
theme: "default",
|
theme: "default",
|
||||||
aroundRadius: 25,
|
aroundRadius: 25,
|
||||||
@@ -67,7 +67,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
this.config.text = this.$lang('goods.list.buyText');
|
this.config.text = this.$lang('goods.list.buyText') || this.$lang('buyNow');
|
||||||
|
|
||||||
this.$api.sendRequest({
|
this.$api.sendRequest({
|
||||||
url: '/api/lucky/status',
|
url: '/api/lucky/status',
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
<view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1">
|
<view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1">
|
||||||
<text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text>
|
<text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text>
|
||||||
<view class="box text-overflow"@click="openSiteCoupon(calculateGoodsData.merch_id)">
|
<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="money price-font" style="max-width: 100%;">{{ $lang('order.payment.orderMoney.couponSelected', { count: 1 }) }}</text>
|
||||||
<!-- <text class="unit price-font">优惠</text>
|
<!-- <text class="unit price-font">优惠</text>
|
||||||
<text class="money price-font">{{ (calculateData && calculateData.coupon_money ? calculateData.coupon_money : 0) | moneyFormat }}</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 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 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>
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export default {
|
|||||||
merch_id: 0,
|
merch_id: 0,
|
||||||
data: []
|
data: []
|
||||||
},
|
},
|
||||||
selectCouponId:0
|
// 按 merch_id 隔离,避免多商户之间互相污染
|
||||||
|
selectCouponMap: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
inject: ['promotion'],
|
inject: ['promotion'],
|
||||||
@@ -85,7 +86,7 @@ export default {
|
|||||||
goodsData() {
|
goodsData() {
|
||||||
if (this.paymentData) {
|
if (this.paymentData) {
|
||||||
this.paymentData.goods_list.forEach(item => {
|
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;
|
return this.paymentData;
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ export default {
|
|||||||
calculateGoodsData() {
|
calculateGoodsData() {
|
||||||
if (this.calculateData) {
|
if (this.calculateData) {
|
||||||
this.calculateData.goods_list.forEach(item => {
|
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;
|
return this.calculateData;
|
||||||
}
|
}
|
||||||
@@ -189,7 +190,7 @@ export default {
|
|||||||
//多商户商品分类
|
//多商户商品分类
|
||||||
goodsSpecFormat(goods_list){
|
goodsSpecFormat(goods_list){
|
||||||
goods_list.forEach(item => {
|
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
|
return goods_list
|
||||||
},
|
},
|
||||||
@@ -249,7 +250,11 @@ export default {
|
|||||||
this.modules = data.modules;
|
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();
|
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;
|
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
|
||||||
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
|
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
|
||||||
|
|
||||||
@@ -740,10 +755,14 @@ export default {
|
|||||||
|
|
||||||
this.tempData = this.$util.deepClone(this.orderCreateData);
|
this.tempData = this.$util.deepClone(this.orderCreateData);
|
||||||
this.merchCoupon.merch_id = merch_id;
|
this.merchCoupon.merch_id = merch_id;
|
||||||
this.selectCouponId = this.orderCreateData.coupon[merch_id].coupon_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.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
|
||||||
|
|
||||||
this.merchCoupon.data = this.coupon_list[merch_id];
|
// 兜底: 列表为空时给一个空数组,弹窗不会渲染空指针
|
||||||
|
this.merchCoupon.data = (this.coupon_list && this.coupon_list[merch_id]) || [];
|
||||||
this.$refs.couponPopup.open();
|
this.$refs.couponPopup.open();
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
},
|
},
|
||||||
@@ -755,17 +774,21 @@ export default {
|
|||||||
// }else{
|
// }else{
|
||||||
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
||||||
// };
|
// };
|
||||||
if (this.selectCouponId != data.coupon_id) {
|
const cur = this.selectCouponMap[merch_id] || 0;
|
||||||
this.selectCouponId = data.coupon_id;
|
this.selectCouponMap[merch_id] = (cur != data.coupon_id) ? data.coupon_id : 0;
|
||||||
} else {
|
|
||||||
this.selectCouponId = 0;
|
|
||||||
}
|
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
||||||
},
|
},
|
||||||
//确定使用优惠券
|
//确定使用优惠券
|
||||||
useCpopon() {
|
useCpopon() {
|
||||||
this.orderCreateData.coupon[this.merchCoupon.merch_id].coupon_id = this.selectCouponId;
|
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.$refs.couponPopup.close();
|
||||||
this.calculate();
|
this.calculate();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
fontWeight: false,
|
fontWeight: false,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cartEvent: "detail",
|
cartEvent: "detail",
|
||||||
text: this.$lang('merch.list.buyText'),
|
text: '',
|
||||||
textColor: "#FFFFFF",
|
textColor: "#FFFFFF",
|
||||||
theme: "default",
|
theme: "default",
|
||||||
aroundRadius: 25,
|
aroundRadius: 25,
|
||||||
@@ -66,6 +66,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
|
this.config.text = this.$lang('merch.list.buyText');
|
||||||
this.categoryId = options.category_id || 0;
|
this.categoryId = options.category_id || 0;
|
||||||
this.keyword = options.keyword || '';
|
this.keyword = options.keyword || '';
|
||||||
this.coupon = options.coupon || 0;
|
this.coupon = options.coupon || 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -263,7 +263,10 @@ export default {
|
|||||||
// 搜索入口按钮
|
// 搜索入口按钮
|
||||||
.search-entrance {
|
.search-entrance {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 44rpx;
|
/* 与新对话按钮垂直居中对齐:
|
||||||
|
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
|
||||||
|
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
|
||||||
|
top: 14rpx;
|
||||||
right: 16rpx;
|
right: 16rpx;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -9,27 +9,38 @@
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="tools-grid">
|
<view class="tools-grid">
|
||||||
<view class="tool-item" @click="selectImage">
|
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
|
||||||
|
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
|
||||||
|
location 不属于附件, 不受此开关控制 -->
|
||||||
|
<view v-if="isTypeEnabled('image')" class="tool-item tool-item-new tool-item-image" @click="selectImage">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="iconfont icon-image"></text>
|
<view class="image-icon"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tool-item" @click="selectFile">
|
<view v-if="isTypeEnabled('video')" class="tool-item tool-item-new tool-item-video" @click="selectVideo">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="iconfont icon-file"></text>
|
<text class="icondiy icon-system-video"></text>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||||
|
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tool-item" @click="startRecord">
|
<view v-if="isTypeEnabled('file')" class="tool-item tool-item-new tool-item-file" @click="selectFile">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="iconfont icon-voice"></text>
|
<view class="paperclip-icon"></view>
|
||||||
|
</view>
|
||||||
|
<text class="tool-text">添加文件</text>
|
||||||
|
</view>
|
||||||
|
<!-- 更多功能里的语音入口暂时隐藏 -->
|
||||||
|
<view v-if="false" class="tool-item" @click="selectVoiceOrAudio">
|
||||||
|
<view class="tool-icon">
|
||||||
|
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tool-item" @click="selectLocation">
|
<view v-if="false" class="tool-item tool-item-new tool-item-location" @click="selectLocation">
|
||||||
<view class="tool-icon">
|
<view class="tool-icon">
|
||||||
<text class="iconfont icon-location"></text>
|
<text class="icondiy icon-system-map-pin-line"></text>
|
||||||
</view>
|
</view>
|
||||||
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -39,16 +50,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||||
/**
|
/**
|
||||||
* ai-chat-tools-panel - 更多工具面板组件
|
* ai-chat-tools-panel - 更多工具面板组件
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* visible: Boolean - 控制显示/隐藏
|
* visible: Boolean - 控制显示/隐藏
|
||||||
|
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
|
||||||
|
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
|
||||||
|
* location 不属于附件, 不受此 prop 影响
|
||||||
*
|
*
|
||||||
* Events:
|
* Events:
|
||||||
* update:visible - 同步关闭状态
|
* update:visible - 同步关闭状态
|
||||||
* image-selected - 选择图片,参数:图片消息对象
|
* image-selected - 选择图片,参数:图片消息对象
|
||||||
* file-selected - 选择文件,参数:文件消息对象
|
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
|
||||||
* voice-start - 开始语音
|
* voice-start - 开始语音
|
||||||
* location-selected - 选择位置,参数:位置消息对象
|
* location-selected - 选择位置,参数:位置消息对象
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +74,12 @@ export default {
|
|||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
attachmentTypes: {
|
||||||
|
type: Array,
|
||||||
|
default: function () {
|
||||||
|
return ['image', 'file', 'audio', 'video']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -73,75 +94,308 @@ export default {
|
|||||||
this.$emit('update:visible', false)
|
this.$emit('update:visible', false)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
|
||||||
|
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
|
||||||
|
isTypeEnabled(kind) {
|
||||||
|
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
|
||||||
|
return this.attachmentTypes.indexOf(kind) !== -1
|
||||||
|
},
|
||||||
|
|
||||||
selectImage() {
|
selectImage() {
|
||||||
|
const that = this
|
||||||
uni.chooseImage({
|
uni.chooseImage({
|
||||||
count: 1,
|
count: 9,
|
||||||
sizeType: ['compressed'],
|
sizeType: ['compressed'],
|
||||||
sourceType: ['album', 'camera'],
|
sourceType: ['album', 'camera'],
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
const imageMessage = {
|
that.hideToolsPanel()
|
||||||
id: ++this.messageId,
|
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
|
||||||
role: 'user',
|
// 用户可继续输入文字, 点发送时图文一起发出
|
||||||
type: 'image',
|
that.$emit('image-picked', res.tempFilePaths || [])
|
||||||
content: '',
|
},
|
||||||
imageUrl: res.tempFilePaths[0],
|
fail: (err) => {
|
||||||
timestamp: Date.now()
|
if (err.errMsg && !err.errMsg.includes('cancel')) {
|
||||||
|
uni.showToast({ title: '选图失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
this.hideToolsPanel()
|
|
||||||
this.$emit('image-selected', imageMessage)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
selectFile() {
|
selectFile() {
|
||||||
|
const that = this
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = '*/*'
|
input.accept = '*/*'
|
||||||
input.onchange = (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (file) {
|
if (!file) return
|
||||||
const fileMessage = {
|
that.hideToolsPanel()
|
||||||
id: ++this.messageId,
|
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
|
||||||
role: 'user',
|
|
||||||
type: 'file',
|
|
||||||
content: '',
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
fileType: file.type,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}
|
|
||||||
this.hideToolsPanel()
|
|
||||||
this.$emit('file-selected', fileMessage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 微信小程序需要用 wx.chooseMessageFile
|
||||||
|
// 原代码 selectFile 只有 H5 + APP-PLUS 分支, 微信小程序里 "文件" 按钮是死按钮
|
||||||
|
// uni-app 3.x 暴露了 uni.chooseMessageFile; 老版本/部分编译目标只有 wx.chooseMessageFile, 都兼容
|
||||||
|
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||||
|
? uni.chooseMessageFile
|
||||||
|
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||||
|
if (!chooseFileApi) {
|
||||||
|
uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chooseFileApi({
|
||||||
|
count: 1,
|
||||||
|
type: 'all', // 'all' | 'video' | 'image' | 'file' — 'all' 最宽松, 用户可发任何文件
|
||||||
|
success: async (res) => {
|
||||||
|
that.hideToolsPanel()
|
||||||
|
// wx.chooseMessageFile 返回 tempFiles; uni 端 res.tempFiles 兼容
|
||||||
|
const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && res.tempFiles[0])
|
||||||
|
if (!file) return
|
||||||
|
// 微信选文件返回 path + size + name (无 type); 用 ext 推断
|
||||||
|
const ext = (file.name || file.path || '').split('.').pop().toLowerCase()
|
||||||
|
const kind = ['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','tif','tiff'].includes(ext) ? 'image' : 'file'
|
||||||
|
await that._uploadOneFile(file, kind)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
if (err.errMsg && !err.errMsg.includes('cancel')) {
|
||||||
|
uni.showToast({ title: '选文件失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
// #ifdef APP-PLUS
|
// #ifdef APP-PLUS
|
||||||
uni.chooseFile({
|
uni.chooseFile({
|
||||||
count: 1,
|
count: 1,
|
||||||
success: (res) => {
|
success: async (res) => {
|
||||||
|
that.hideToolsPanel()
|
||||||
const file = res.tempFiles[0]
|
const file = res.tempFiles[0]
|
||||||
const fileMessage = {
|
await that._uploadOneFile(file, 'file')
|
||||||
id: ++this.messageId,
|
|
||||||
role: 'user',
|
|
||||||
type: 'file',
|
|
||||||
content: '',
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
fileType: file.type,
|
|
||||||
url: file.path,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}
|
|
||||||
this.hideToolsPanel()
|
|
||||||
this.$emit('file-selected', fileMessage)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
|
||||||
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
|
||||||
|
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
|
||||||
|
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
|
||||||
|
selectVideo() {
|
||||||
|
const that = this
|
||||||
|
// #ifdef H5
|
||||||
|
const mediaInput = document.createElement('input')
|
||||||
|
mediaInput.type = 'file'
|
||||||
|
mediaInput.accept = 'video/*,audio/*'
|
||||||
|
mediaInput.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
that.hideToolsPanel()
|
||||||
|
await that._uploadOneFile(file, that._guessMediaKind(file))
|
||||||
|
}
|
||||||
|
mediaInput.click()
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN || APP-PLUS
|
||||||
|
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: [
|
||||||
|
that.$lang('ai-chat.index.pickVideo', '选择视频'),
|
||||||
|
that.$lang('ai-chat.index.pickAudio', '选择音频')
|
||||||
|
],
|
||||||
|
success: (res) => {
|
||||||
|
if (res.tapIndex === 0) that._chooseVideo()
|
||||||
|
else if (res.tapIndex === 1) that.selectAudio()
|
||||||
|
},
|
||||||
|
fail: () => {}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按 mime / 扩展名判断选中的文件是视频还是音频
|
||||||
|
_guessMediaKind(file) {
|
||||||
|
const type = (file && file.type ? file.type : '').toLowerCase()
|
||||||
|
if (type.indexOf('audio/') === 0) return 'audio'
|
||||||
|
if (type.indexOf('video/') === 0) return 'video'
|
||||||
|
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
||||||
|
// PR-AV-FULL-SUPPORT (2026-09-24): 与后端 MediaUploadPolicy::allowedUploadExtensions 音频段对齐
|
||||||
|
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3']
|
||||||
|
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
||||||
|
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
||||||
|
return type ? 'video' : 'audio'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 选视频 (微信小程序 / App 共用)
|
||||||
|
_chooseVideo() {
|
||||||
|
const that = this
|
||||||
|
uni.chooseVideo({
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
compressed: true,
|
||||||
|
maxDuration: 60,
|
||||||
|
success: async (res) => {
|
||||||
|
that.hideToolsPanel()
|
||||||
|
await that._uploadOneFile({
|
||||||
|
path: res.tempFilePath,
|
||||||
|
size: res.size || 0,
|
||||||
|
duration: res.duration || 0,
|
||||||
|
name: 'video_' + Date.now() + '.mp4',
|
||||||
|
type: 'video/mp4'
|
||||||
|
}, 'video')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||||
|
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
|
||||||
|
selectVoiceOrAudio() {
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: [
|
||||||
|
this.$lang('ai-chat.index.recordVoice'),
|
||||||
|
this.$lang('ai-chat.index.uploadAudio')
|
||||||
|
],
|
||||||
|
success: (res) => {
|
||||||
|
if (res.tapIndex === 0) {
|
||||||
|
this.startRecord()
|
||||||
|
} else if (res.tapIndex === 1) {
|
||||||
|
this.selectAudio()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 选择音频文件并上传 (H5 / 微信小程序 / App)
|
||||||
|
selectAudio() {
|
||||||
|
const that = this
|
||||||
|
// #ifdef H5
|
||||||
|
const audioInput = document.createElement('input')
|
||||||
|
audioInput.type = 'file'
|
||||||
|
audioInput.accept = 'audio/*'
|
||||||
|
audioInput.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
that.hideToolsPanel()
|
||||||
|
await that._uploadOneFile(file, 'audio')
|
||||||
|
}
|
||||||
|
audioInput.click()
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
|
||||||
|
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||||
|
? uni.chooseMessageFile
|
||||||
|
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||||
|
if (!chooseFileApi) {
|
||||||
|
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chooseFileApi({
|
||||||
|
count: 1,
|
||||||
|
type: 'file',
|
||||||
|
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
|
||||||
|
success: async (res) => {
|
||||||
|
const file = (res.tempFiles && res.tempFiles[0]) || null
|
||||||
|
if (!file) return
|
||||||
|
that.hideToolsPanel()
|
||||||
|
await that._uploadOneFile(file, 'audio')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||||
|
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
uni.chooseFile({
|
||||||
|
count: 1,
|
||||||
|
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
|
||||||
|
success: async (res) => {
|
||||||
|
const file = res.tempFiles[0]
|
||||||
|
if (!file) return
|
||||||
|
that.hideToolsPanel()
|
||||||
|
await that._uploadOneFile(file, 'audio')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
async _uploadOneFile(file, kind) {
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
|
||||||
|
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
|
||||||
|
// 统一: H5 创建 blob URL 用于预览, 上传完 (成功/失败都) revoke
|
||||||
|
let blobUrl = ''
|
||||||
|
let displayPath = ''
|
||||||
|
// #ifdef H5
|
||||||
|
// H5 file 对象没有 .path, 用 blob URL 给 imageUrl 预览, 并把 file 本身传给 uploadImage
|
||||||
|
blobUrl = file.name ? URL.createObjectURL(file) : ''
|
||||||
|
displayPath = blobUrl
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
// MP-WEIXIN / APP-PLUS 都用 file.path (tempFilePath)
|
||||||
|
displayPath = file.path || ''
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
const pending = {
|
||||||
|
id: ++this.messageId,
|
||||||
|
role: 'user',
|
||||||
|
type: kind,
|
||||||
|
content: '',
|
||||||
|
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
|
||||||
|
fileSize: file.size || 0,
|
||||||
|
fileType: file.type || '',
|
||||||
|
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
|
||||||
|
url: displayPath,
|
||||||
|
duration: file.duration || 0,
|
||||||
|
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
|
||||||
|
playing: false,
|
||||||
|
currentTime: 0,
|
||||||
|
imageUrl: displayPath,
|
||||||
|
uploading: true,
|
||||||
|
uploadError: null,
|
||||||
|
uploaded: null,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
this.$emit('file-selected', pending)
|
||||||
|
try {
|
||||||
|
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
|
||||||
|
// 非 H5 传 file.path (uni.uploadFile 需要)
|
||||||
|
const inputArg = blobUrl || file.path || file
|
||||||
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
|
||||||
|
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
|
||||||
|
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
|
||||||
|
const uploaded = kind === 'image'
|
||||||
|
? await uploadImage(inputArg)
|
||||||
|
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
|
||||||
|
this.$set(pending, 'uploading', false)
|
||||||
|
this.$set(pending, 'uploaded', uploaded)
|
||||||
|
if (uploaded.url) {
|
||||||
|
this.$set(pending, 'imageUrl', uploaded.url)
|
||||||
|
this.$set(pending, 'url', uploaded.url)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$set(pending, 'uploading', false)
|
||||||
|
this.$set(pending, 'uploadError', e.message || '上传失败')
|
||||||
|
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX: blob URL 用完即释放 (无论成败)
|
||||||
|
if (blobUrl) {
|
||||||
|
try { URL.revokeObjectURL(blobUrl) } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
startRecord() {
|
startRecord() {
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
|
||||||
|
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
|
||||||
this.$emit('voice-start')
|
this.$emit('voice-start')
|
||||||
this.hideToolsPanel()
|
this.hideToolsPanel()
|
||||||
},
|
},
|
||||||
@@ -209,11 +463,12 @@ export default {
|
|||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
border-bottom: 2rpx solid #f0f4f8;
|
border-bottom: 2rpx solid #f0f4f8;
|
||||||
padding-bottom: 16rpx;
|
padding-bottom: 16rpx;
|
||||||
|
position: relative; // 为 .close-btn 绝对定位提供参考
|
||||||
|
|
||||||
text {
|
text {
|
||||||
font-size: 34rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
color: $color-text;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
@@ -225,17 +480,19 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
|
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
|
||||||
|
top: -8rpx;
|
||||||
|
right: 10rpx; //
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: $color-text-light;
|
color: #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tools-grid {
|
.tools-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
|
|
||||||
.tool-item {
|
.tool-item {
|
||||||
@@ -257,7 +514,8 @@ export default {
|
|||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
box-shadow: $shadow-sm;
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
.iconfont {
|
.iconfont,
|
||||||
|
.icondiy {
|
||||||
font-size: 48rpx;
|
font-size: 48rpx;
|
||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
}
|
}
|
||||||
@@ -269,6 +527,62 @@ export default {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新样式: 图片 / 添加文件 大卡片, 一比一还原参考图
|
||||||
|
.tool-item-new {
|
||||||
|
background-color: #f2f3f5;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 36rpx 0;
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
.iconfont,
|
||||||
|
.icondiy {
|
||||||
|
font-size: 48rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片图标: 左上角开口 + 大四角星在开口处 + 右侧实心圆点 (参考图样式)
|
||||||
|
.image-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTEuNCA1IEgxNy41IEEzLjUgMy41IDAgMCAxIDIxIDguNSBWMTUuNSBBMy41IDMuNSAwIDAgMSAxNy41IDE5IEg2LjUgQTMuNSAzLjUgMCAwIDEgMyAxNS41IFY4LjUiLz48cGF0aCBkPSJNNS4yIDEuMyBRNS45IDQuMzUgOC45IDUgUTUuOSA1LjY1IDUuMiA4LjcgUTQuNSA1LjY1IDEuNSA1IFE0LjUgNC4zNSA1LjIgMS4zIFoiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iOS44IiByPSIxLjUiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxwYXRoIGQ9Ik01LjUgMTEuNiBRMTAgOS42IDE0LjUgMTYuNCIvPjwvc3ZnPg==");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加文件图标: 斜向回形针 (参考图样式)
|
||||||
|
.paperclip-icon {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEuNDQgMTEuMDUgMTIuMjUgMjAuMjRhNiA2IDAgMCAxLTguNDktOC40OWw4LjU3LTguNTdhNCA0IDAgMSAxIDUuNjYgNS42NmwtOC41OSA4LjU3YTIgMiAwIDAgMS0yLjgzLTIuODNsOC40OS04LjQ4Ii8+PC9zdmc+");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频/音频图标小一号 (tool-item-video 与该元素同级, 必须用 & 连接)
|
||||||
|
&.tool-item-video .tool-icon .icondiy {
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,29 +14,39 @@
|
|||||||
v-for="i in 8"
|
v-for="i in 8"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="wave-bar"
|
class="wave-bar"
|
||||||
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
|
:style="waveBarStyle(i)"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
|
<text class="voice-tip">{{ currentTip }}</text>
|
||||||
|
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="voice-actions">
|
<view class="voice-actions">
|
||||||
<button class="voice-btn" @click="toggleVoiceInput">
|
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
|
||||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||||
</button>
|
</button>
|
||||||
|
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
||||||
/**
|
/**
|
||||||
* ai-chat-voice-panel - 语音输入面板组件
|
* ai-chat-voice-panel - 语音输入面板组件
|
||||||
*
|
*
|
||||||
|
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
|
||||||
|
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
|
||||||
|
* - 波形振幅由 progress 事件 (level 0..1) 驱动
|
||||||
|
* - 录音上限 60s,自动停止
|
||||||
|
* - 关闭面板时如果还在录音,先 cancel 释放资源
|
||||||
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* visible: Boolean - 控制显示/隐藏
|
* visible: Boolean - 控制显示/隐藏
|
||||||
*
|
*
|
||||||
* Events:
|
* Events:
|
||||||
* update:visible - 同步关闭状态
|
* update:visible - 同步关闭状态
|
||||||
* close - 请求关闭面板
|
* close - 请求关闭面板
|
||||||
|
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
name: 'AiChatVoicePanel',
|
name: 'AiChatVoicePanel',
|
||||||
@@ -50,51 +60,192 @@ export default {
|
|||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
voiceInputing: false
|
voiceInputing: false,
|
||||||
|
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
|
||||||
|
voiceLevel: 0,
|
||||||
|
// 录音累计时长 (秒,整数)
|
||||||
|
voiceDuration: 0,
|
||||||
|
_durationTimer: null,
|
||||||
|
_voiceRecorder: null,
|
||||||
|
_onProgress: null,
|
||||||
|
_onError: null,
|
||||||
|
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
|
||||||
|
_resolved: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
currentTip() {
|
||||||
|
if (this.voiceInputing) {
|
||||||
|
return this.$lang('ai-chat.index.recordingTip')
|
||||||
|
}
|
||||||
|
return this.$lang('ai-chat.index.startRecordTip')
|
||||||
|
},
|
||||||
|
formattedDuration() {
|
||||||
|
const s = Math.max(0, Math.floor(this.voiceDuration))
|
||||||
|
return s + '″'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
visible(val) {
|
visible(val) {
|
||||||
if (!val) {
|
if (val) {
|
||||||
|
// 每次重新打开都重置
|
||||||
this.voiceInputing = false
|
this.voiceInputing = false
|
||||||
|
this.voiceLevel = 0
|
||||||
|
this.voiceDuration = 0
|
||||||
|
this._resolved = false
|
||||||
|
} else {
|
||||||
|
// 关闭时如果还在录音,丢弃
|
||||||
|
this._discardRecorderIfAny()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
this._discardRecorderIfAny()
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
hideVoicePanel() {
|
hideVoicePanel() {
|
||||||
this.voiceInputing = false
|
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
|
||||||
|
this._discardRecorderIfAny()
|
||||||
this.$emit('update:visible', false)
|
this.$emit('update:visible', false)
|
||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleVoiceInput() {
|
toggleVoiceInput() {
|
||||||
this.voiceInputing = !this.voiceInputing
|
|
||||||
if (this.voiceInputing) {
|
if (this.voiceInputing) {
|
||||||
this.startVoiceInput()
|
this._finishRecording()
|
||||||
} else {
|
} else {
|
||||||
this.stopVoiceInput()
|
this._beginRecording()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
startVoiceInput() {
|
cancelVoiceInput() {
|
||||||
// #ifdef H5
|
this._discardRecorderIfAny()
|
||||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
this.hideVoicePanel()
|
||||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
.then(stream => {
|
|
||||||
// 录音中,可扩展实时转文字
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
|
||||||
this.voiceInputing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
},
|
},
|
||||||
|
|
||||||
stopVoiceInput() {
|
waveBarStyle(i) {
|
||||||
|
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
|
||||||
|
const base = 24
|
||||||
|
const offset = ((i * 17) % 32) + 12 // 12..44
|
||||||
|
const levelPx = Math.round(this.voiceLevel * 80)
|
||||||
|
const h = Math.max(16, base + offset + levelPx) + 'rpx'
|
||||||
|
const delay = (i * 0.08) + 's'
|
||||||
|
return { height: h, animationDelay: delay }
|
||||||
|
},
|
||||||
|
|
||||||
|
_beginRecording() {
|
||||||
|
if (this._voiceRecorder) return
|
||||||
|
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
|
||||||
|
this._voiceRecorder = rec
|
||||||
|
this._onProgress = ({ level }) => {
|
||||||
|
// 平滑一下,避免波形抖动太狠
|
||||||
|
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
|
||||||
|
}
|
||||||
|
this._onError = (err) => {
|
||||||
|
console.warn('[voice-panel] recorder error:', err)
|
||||||
|
console.error('[voice-panel] recorder error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
||||||
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
|
this._discardRecorderIfAny()
|
||||||
|
}
|
||||||
|
rec.on('progress', this._onProgress)
|
||||||
|
rec.on('error', this._onError)
|
||||||
|
|
||||||
|
rec.start()
|
||||||
|
.then(() => {
|
||||||
|
this.voiceInputing = true
|
||||||
|
this.voiceDuration = 0
|
||||||
|
this._startDurationTimer()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this._teardownRecorder()
|
||||||
|
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async _finishRecording() {
|
||||||
|
if (!this._voiceRecorder || this._resolved) return
|
||||||
|
this._resolved = true
|
||||||
|
this._stopDurationTimer()
|
||||||
|
try {
|
||||||
|
const result = await this._voiceRecorder.stop()
|
||||||
|
if (!result || !result.filePath) {
|
||||||
|
// 录音产物为空,可能是被系统打断
|
||||||
|
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
||||||
|
this._teardownRecorder()
|
||||||
this.voiceInputing = false
|
this.voiceInputing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._teardownRecorder()
|
||||||
|
this.voiceInputing = false
|
||||||
|
this.$emit('audio-ready', {
|
||||||
|
filePath: result.filePath,
|
||||||
|
blob: result.blob || null,
|
||||||
|
fileName: result.fileName || '',
|
||||||
|
duration: result.duration,
|
||||||
|
mime: result.mime,
|
||||||
|
size: result.size
|
||||||
|
})
|
||||||
|
this.hideVoicePanel()
|
||||||
|
} catch (e) {
|
||||||
|
this._teardownRecorder()
|
||||||
|
this.voiceInputing = false
|
||||||
|
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_discardRecorderIfAny() {
|
||||||
|
if (!this._voiceRecorder) return
|
||||||
|
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
||||||
|
this._teardownRecorder()
|
||||||
|
this.voiceInputing = false
|
||||||
|
},
|
||||||
|
|
||||||
|
_teardownRecorder() {
|
||||||
|
this._stopDurationTimer()
|
||||||
|
if (this._voiceRecorder) {
|
||||||
|
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
|
||||||
|
if (this._onError) this._voiceRecorder.off('error', this._onError)
|
||||||
|
}
|
||||||
|
this._voiceRecorder = null
|
||||||
|
this._onProgress = null
|
||||||
|
this._onError = null
|
||||||
|
this._resolved = false
|
||||||
|
this.voiceLevel = 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
|
||||||
|
_voiceErrMsg(err) {
|
||||||
|
if (!err) return ''
|
||||||
|
switch (err.code) {
|
||||||
|
case 'NOT_SUPPORTED':
|
||||||
|
case 'NO_RECORDER_MANAGER':
|
||||||
|
return this.$lang('ai-chat.index.voiceNotSupported')
|
||||||
|
case 'H5_NO_MIC_API':
|
||||||
|
case 'ALREADY_STARTED':
|
||||||
|
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||||
|
case 'UNI_RECORD_ERROR':
|
||||||
|
return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed')
|
||||||
|
default:
|
||||||
|
return err.message || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_startDurationTimer() {
|
||||||
|
this._stopDurationTimer()
|
||||||
|
this._durationTimer = setInterval(() => {
|
||||||
|
this.voiceDuration += 1
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopDurationTimer() {
|
||||||
|
if (this._durationTimer) {
|
||||||
|
clearInterval(this._durationTimer)
|
||||||
|
this._durationTimer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +320,7 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 48rpx;
|
margin-bottom: 32rpx;
|
||||||
|
|
||||||
.voice-wave {
|
.voice-wave {
|
||||||
width: 280rpx;
|
width: 280rpx;
|
||||||
@@ -180,25 +331,16 @@ export default {
|
|||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
&.recording .wave-bar {
|
&.recording .wave-bar {
|
||||||
animation: wave 1.2s infinite ease-in-out;
|
animation: wave 1.0s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wave-bar {
|
.wave-bar {
|
||||||
width: 8rpx;
|
width: 8rpx;
|
||||||
height: 40rpx;
|
|
||||||
margin: 0 4rpx;
|
margin: 0 4rpx;
|
||||||
background-color: $color-primary;
|
background-color: $color-primary;
|
||||||
border-radius: 4rpx;
|
border-radius: 4rpx;
|
||||||
|
transition: height 0.12s ease-out;
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|
||||||
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
|
|
||||||
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
|
|
||||||
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
|
|
||||||
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
|
|
||||||
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
|
|
||||||
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
|
|
||||||
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
|
|
||||||
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +348,19 @@ export default {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: $color-text-light;
|
color: $color-text-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-duration {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-primary;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-actions {
|
.voice-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.voice-btn {
|
.voice-btn {
|
||||||
@@ -222,18 +373,31 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||||
border: none;
|
border: none;
|
||||||
|
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||||
|
|
||||||
|
&.is-recording {
|
||||||
|
background-color: #e94e4e;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 56rpx;
|
font-size: 56rpx;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cancel-link {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-light;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes wave {
|
@keyframes wave {
|
||||||
0%, 100% { transform: scaleY(0.5); }
|
0%, 100% { transform: scaleY(0.6); }
|
||||||
50% { transform: scaleY(1.2); }
|
50% { transform: scaleY(1.15); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -13,9 +13,12 @@
|
|||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
|
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
|
||||||
<view class="chat-content">
|
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
|
||||||
|
<view class="chat-content" :style="{ bottom: '0rpx' }">
|
||||||
<ai-chat-message
|
<ai-chat-message
|
||||||
|
v-if="uiCfgReady"
|
||||||
ref="chat"
|
ref="chat"
|
||||||
|
:preloaded-ui-config="preloadedUiCfg"
|
||||||
:initial-messages="initialMessages"
|
:initial-messages="initialMessages"
|
||||||
:fallback-welcome="fallbackWelcome"
|
:fallback-welcome="fallbackWelcome"
|
||||||
:user-avatar="userAvatar"
|
:user-avatar="userAvatar"
|
||||||
@@ -68,6 +71,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
|
||||||
|
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
|
||||||
|
<diy-bottom-nav v-if="false"></diy-bottom-nav>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,6 +94,15 @@ export default {
|
|||||||
return {
|
return {
|
||||||
initialMessages: [],
|
initialMessages: [],
|
||||||
fallbackWelcome: '',
|
fallbackWelcome: '',
|
||||||
|
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
|
||||||
|
agentId: 'agent_aikefu',
|
||||||
|
pageTitle: '',
|
||||||
|
|
||||||
|
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
|
||||||
|
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
|
||||||
|
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
|
||||||
|
uiCfgReady: false,
|
||||||
|
preloadedUiCfg: null,
|
||||||
|
|
||||||
// --- 系统导航栏/状态栏相关 ---
|
// --- 系统导航栏/状态栏相关 ---
|
||||||
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
|
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
|
||||||
@@ -105,7 +121,12 @@ export default {
|
|||||||
keyboardHeight: 0,
|
keyboardHeight: 0,
|
||||||
|
|
||||||
// 事件处理器引用(用于清理)
|
// 事件处理器引用(用于清理)
|
||||||
safeEventHandlers: new Map()
|
safeEventHandlers: new Map(),
|
||||||
|
|
||||||
|
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
|
||||||
|
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
|
||||||
|
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
|
||||||
|
isFromFloatBtn: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -127,6 +148,49 @@ export default {
|
|||||||
async onLoad(options) {
|
async onLoad(options) {
|
||||||
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
|
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
|
||||||
|
|
||||||
|
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
|
||||||
|
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
|
||||||
|
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
|
||||||
|
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
|
||||||
|
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
|
||||||
|
try {
|
||||||
|
const cached = aiService.getChatUiConfigSync();
|
||||||
|
if (cached) {
|
||||||
|
this.preloadedUiCfg = cached;
|
||||||
|
this.uiCfgReady = true;
|
||||||
|
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
|
||||||
|
}
|
||||||
|
} catch (e) { /* 缓存读取失败走预取 */ }
|
||||||
|
if (!this.uiCfgReady) {
|
||||||
|
try {
|
||||||
|
const cfg = await Promise.race([
|
||||||
|
aiService.getChatUiConfig(true),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(null), 6000))
|
||||||
|
]);
|
||||||
|
if (cfg) this.preloadedUiCfg = cfg;
|
||||||
|
else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新');
|
||||||
|
} catch (e) { /* 失败兜底默认值 */ }
|
||||||
|
this.uiCfgReady = true;
|
||||||
|
console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg);
|
||||||
|
}
|
||||||
|
// 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取,
|
||||||
|
// 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求
|
||||||
|
|
||||||
|
// ====== 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();
|
// const hasAccess = this.checkAccessPermission();
|
||||||
// if (!hasAccess) {
|
// if (!hasAccess) {
|
||||||
@@ -134,7 +198,19 @@ export default {
|
|||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
this.$langConfig.title(this.$lang('ai-chat.index.title'));
|
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
|
||||||
|
|
||||||
|
// ====== 进入 AI 智能页始终隐藏底部 tabBar ======
|
||||||
|
// 原因:AI 智能页是全屏聊天体验,不论是
|
||||||
|
// 1) 底部导航栏"智能客服"项直接点击进入
|
||||||
|
// 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
|
||||||
|
// 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
|
||||||
|
// 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
|
||||||
|
this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
|
||||||
|
try {
|
||||||
|
this.$store.commit('setBottomNavHidden', true);
|
||||||
|
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
|
||||||
|
|
||||||
this.initChat();
|
this.initChat();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -180,6 +256,15 @@ export default {
|
|||||||
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
|
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
aiService.disconnectWebSocket();
|
aiService.disconnectWebSocket();
|
||||||
|
|
||||||
|
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
|
||||||
|
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
|
||||||
|
if (this.isFromFloatBtn) {
|
||||||
|
try {
|
||||||
|
this.$store.commit('setBottomNavHidden', false);
|
||||||
|
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
|
||||||
|
this.isFromFloatBtn = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
@@ -647,7 +732,9 @@ export default {
|
|||||||
/* #endif */
|
/* #endif */
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
/* bottom 由模板动态绑定到 tabBarHeight:
|
||||||
|
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
|
||||||
|
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部tabBar占位样式 */
|
/* 底部tabBar占位样式 */
|
||||||
|
|||||||
@@ -40,20 +40,39 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
list: []
|
list: [],
|
||||||
|
// 从跳转 URL 上携带的参数
|
||||||
|
categoryId: '',
|
||||||
|
title: '',
|
||||||
|
pageKey: '',
|
||||||
|
pageParams: {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
// 还原跳转时携带的过滤/展示参数,避免请求 API 时丢失
|
||||||
|
if (options.category_id) this.categoryId = options.category_id;
|
||||||
|
if (options.title) {
|
||||||
|
this.title = decodeURIComponent(options.title);
|
||||||
|
if (this.title) uni.setNavigationBarTitle({ title: this.title });
|
||||||
|
}
|
||||||
|
if (options.page_key) this.pageKey = options.page_key;
|
||||||
|
this.pageParams = this.$util.parsePageParams(options.page_params) || {};
|
||||||
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.setPublicShare();
|
this.setPublicShare();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getData(mescroll) {
|
getData(mescroll) {
|
||||||
this.$api.sendRequest({
|
// 合并 page_params 与显式参数,保证后端拿到完整过滤条件
|
||||||
url: '/api/article/page',
|
let data = Object.assign({}, this.pageParams, {
|
||||||
data: {
|
|
||||||
page_size: mescroll.size,
|
page_size: mescroll.size,
|
||||||
page: mescroll.num
|
page: mescroll.num
|
||||||
},
|
});
|
||||||
|
if (this.categoryId) data.category_id = this.categoryId;
|
||||||
|
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: '/api/article/page',
|
||||||
|
data: data,
|
||||||
success: res => {
|
success: res => {
|
||||||
let newArr = [];
|
let newArr = [];
|
||||||
let msg = res.message;
|
let msg = res.message;
|
||||||
@@ -87,17 +106,32 @@ export default {
|
|||||||
// 设置公众号分享
|
// 设置公众号分享
|
||||||
setPublicShare() {
|
setPublicShare() {
|
||||||
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
|
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
|
||||||
|
let query = this.shareQuery();
|
||||||
|
if (query) shareUrl += '?' + query;
|
||||||
this.$util.setPublicShare({
|
this.$util.setPublicShare({
|
||||||
title: this.$lang('article.list.title'),
|
title: this.title || this.$lang('article.list.title'),
|
||||||
desc: '',
|
desc: '',
|
||||||
link: shareUrl,
|
link: shareUrl,
|
||||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
// 组装带回过滤参数的 query 串,保证分享/跳转后参数不丢失
|
||||||
|
shareQuery() {
|
||||||
|
let params = {};
|
||||||
|
if (this.categoryId) params.category_id = this.categoryId;
|
||||||
|
if (this.title) params.title = encodeURIComponent(this.title);
|
||||||
|
if (this.pageKey) params.page_key = this.pageKey;
|
||||||
|
if (Object.keys(this.pageParams).length) params.page_params = encodeURIComponent(JSON.stringify(this.pageParams));
|
||||||
|
return Object.keys(params)
|
||||||
|
.map(k => k + '=' + params[k])
|
||||||
|
.join('&');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShareAppMessage(res) {
|
onShareAppMessage(res) {
|
||||||
var title = this.$lang('article.list.title');
|
var title = this.title || this.$lang('article.list.title');
|
||||||
var path = '/pages_tool/article/list';
|
var path = '/pages_tool/article/list';
|
||||||
|
var query = this.shareQuery();
|
||||||
|
if (query) path += '?' + query;
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
path: path,
|
path: path,
|
||||||
@@ -107,8 +141,10 @@ export default {
|
|||||||
},
|
},
|
||||||
//分享到朋友圈
|
//分享到朋友圈
|
||||||
onShareTimeline() {
|
onShareTimeline() {
|
||||||
var title = this.$lang('article.list.title');
|
var title = this.title || this.$lang('article.list.title');
|
||||||
var query = '/pages_tool/article/list';
|
var query = '/pages_tool/article/list';
|
||||||
|
var q = this.shareQuery();
|
||||||
|
if (q) query += '?' + q;
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
query: query,
|
query: query,
|
||||||
|
|||||||
481
pages_tool/card/admin/contacts.vue
Normal file
481
pages_tool/card/admin/contacts.vue
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-contacts">
|
||||||
|
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
|
||||||
|
<view class="page-switch">
|
||||||
|
<view class="switch-item" @tap="goManage">名片管理</view>
|
||||||
|
<view class="switch-item active" @tap="goSelf">人脉管理</view>
|
||||||
|
<view class="switch-item" @tap="goLeads">潜在客户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索栏 + 添加入口 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
|
||||||
|
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||||
|
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||||
|
<view class="add-btn" @tap="goEdit()">+ 添加</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 来源筛选(与后端 Contact::SOURCES 对齐) -->
|
||||||
|
<scroll-view scroll-x class="tab-bar">
|
||||||
|
<view class="tab-inner">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||||
|
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 人脉列表 -->
|
||||||
|
<view class="contact-list">
|
||||||
|
<view v-for="(item, index) in list" :key="item.id" class="contact-item">
|
||||||
|
<view class="item-top" @tap="callPhone(item.mobile)">
|
||||||
|
<view class="item-name">{{ item.name }}</view>
|
||||||
|
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-company" v-if="item.company || item.job_title">
|
||||||
|
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
|
||||||
|
</view>
|
||||||
|
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
|
||||||
|
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
|
||||||
|
<view class="item-meta">
|
||||||
|
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||||
|
<text class="meta-status" v-if="item.status == 1">已归档</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-actions">
|
||||||
|
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
|
||||||
|
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
|
||||||
|
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||||
|
:text="'暂无人脉,点右上角添加'"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 商家端 · 人脉管理
|
||||||
|
*
|
||||||
|
* 接口全部来自后端已就绪的 /card/api/contact/*(收口在 common/js/card-api.js):
|
||||||
|
* contact/lists 列表 / contact/add+update 增改(复用 contact-edit 页) /
|
||||||
|
* contact/toLead 转线索 / contact/delete 真删。
|
||||||
|
* 与员工端「我的人脉」的区别: 本页是商家后台视角, 带来源筛选与三大入口互链;
|
||||||
|
* 编辑页共用 contact-edit.vue(from=admin 时保存后回本页)。
|
||||||
|
*/
|
||||||
|
import { CONTACT_SOURCES } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
// 来源筛选: scan/exchange 暂无产生入口, 只放出 manual / lead_in 两个 tab
|
||||||
|
const SOURCE_TABS = [{ value: '', label: '全部' }].concat(
|
||||||
|
CONTACT_SOURCES.filter(s => s.value === 'manual' || s.value === 'lead_in')
|
||||||
|
);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: SOURCE_TABS,
|
||||||
|
source: '',
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
// 从编辑页保存返回(redirectTo 回本页)时刷新
|
||||||
|
if (this._loadedOnce) this.refresh();
|
||||||
|
this._loadedOnce = true;
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
|
||||||
|
goManage() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/manage');
|
||||||
|
},
|
||||||
|
goLeads() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/leads');
|
||||||
|
},
|
||||||
|
goSelf() {},
|
||||||
|
// 编辑页共用 contact-edit; from=admin 时保存后回本页(见 contact-edit.vue#backToList)
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||||
|
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number' || typeof payload === 'string') return null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
goEdit(payload) {
|
||||||
|
let id = payload;
|
||||||
|
if (payload && payload.currentTarget) {
|
||||||
|
const i = Number(payload.currentTarget.dataset.index);
|
||||||
|
const item = this.list[i];
|
||||||
|
id = item ? item.id : '';
|
||||||
|
}
|
||||||
|
const url = id
|
||||||
|
? '/pages_tool/card/contact-edit?from=admin&id=' + id
|
||||||
|
: '/pages_tool/card/contact-edit?from=admin';
|
||||||
|
this.$util.redirectTo(url);
|
||||||
|
},
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.source === value) return;
|
||||||
|
this.source = value;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
doSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
// 空字符串 = 全部, 后端对 '' 不加 source 条件
|
||||||
|
if (this.source !== '') data.source = this.source;
|
||||||
|
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
|
||||||
|
|
||||||
|
this.$cardApi.contactLists({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
convertToLead(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
uni.showModal({
|
||||||
|
title: '转为潜在客户',
|
||||||
|
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
this.doConvert(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doConvert(item) {
|
||||||
|
uni.showLoading({ title: '转换中…', mask: true });
|
||||||
|
// 后端: 写一条线索且不删人脉; 同员工同手机号已转过则报错(message 可直接展示)
|
||||||
|
this.$cardApi.contactToLead({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
uni.hideLoading();
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.hideLoading();
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delContact(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item) return;
|
||||||
|
// 真删不可恢复, 二次确认
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除人脉',
|
||||||
|
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
|
||||||
|
confirmColor: '#f5222d',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
this.doDelete(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doDelete(item) {
|
||||||
|
this.$cardApi.contactDelete({
|
||||||
|
data: { id: item.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
this.list = this.list.filter(c => c.id !== item.id);
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
// 后端对"不存在"与"非本人"同一口径, 按"不存在或已删除"提示
|
||||||
|
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
callPhone(mobile) {
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.makePhoneCall({ phoneNumber: mobile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-contacts {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.tab-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-source {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
|
||||||
|
&.src-manual {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.src-lead_in {
|
||||||
|
color: #1daa39;
|
||||||
|
background-color: #e8f8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.src-scan,
|
||||||
|
&.src-exchange {
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-company {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-mobile {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-remark {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 10rpx 26rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
|
||||||
|
&.plain {
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: #f5222d;
|
||||||
|
border: 1rpx solid #f5222d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
431
pages_tool/card/admin/leads.vue
Normal file
431
pages_tool/card/admin/leads.vue
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-leads">
|
||||||
|
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
|
||||||
|
<view class="page-switch">
|
||||||
|
<view class="switch-item" @tap="goManage">名片管理</view>
|
||||||
|
<view class="switch-item" @tap="goContacts">人脉管理</view>
|
||||||
|
<view class="switch-item active" @tap="goSelf">潜在客户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机"
|
||||||
|
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||||
|
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 跟进状态筛选(与后端 Lead::STATUSES 对齐, setStatus 仅接受 0-3) -->
|
||||||
|
<scroll-view scroll-x class="tab-bar">
|
||||||
|
<view class="tab-inner">
|
||||||
|
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||||
|
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 线索列表 -->
|
||||||
|
<view class="lead-list">
|
||||||
|
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
|
||||||
|
<view class="lead-top" @tap="callPhone(item.mobile)">
|
||||||
|
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
|
||||||
|
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
|
||||||
|
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
|
||||||
|
<view class="lead-meta">
|
||||||
|
<text class="meta-card">来自名片:{{ item.card_name }}</text>
|
||||||
|
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="lead-actions">
|
||||||
|
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
|
||||||
|
<view class="action-btn primary" :data-index="index" @tap="changeStatus">更新状态</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||||
|
:text="emptyText"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 商家端 · 潜在客户
|
||||||
|
*
|
||||||
|
* 接口全部来自后端已就绪的 /card/api/lead/*(收口在 common/js/card-api.js):
|
||||||
|
* lead/lists 列表(可见范围=自己名下名片收到的线索 + 自己从人脉转入的线索;
|
||||||
|
* 没有名片时列表为空属正常现象) / lead/setStatus 更新跟进状态(0-3)。
|
||||||
|
*/
|
||||||
|
import { LEAD_STATUS } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tabs: [{ value: '', label: '全部' }].concat(LEAD_STATUS),
|
||||||
|
status: '',
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false,
|
||||||
|
emptyText: '暂无意向客户,访客在你的名片页留资后会出现在这里'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
|
||||||
|
goManage() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/manage');
|
||||||
|
},
|
||||||
|
goContacts() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/contacts');
|
||||||
|
},
|
||||||
|
goSelf() {},
|
||||||
|
switchTab(value) {
|
||||||
|
if (this.status === value) return;
|
||||||
|
this.status = value;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
doSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
const data = { page: pageNo, page_size: this.pageSize };
|
||||||
|
// 空字符串 = 全部, 后端对 '' 不加 status 条件; status 只传 0-3
|
||||||
|
if (this.status !== '') data.status = this.status;
|
||||||
|
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
|
||||||
|
|
||||||
|
this.$cardApi.leadLists({
|
||||||
|
data: data,
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
this.emptyText = this.keyword.trim() || this.status !== ''
|
||||||
|
? '未找到匹配的客户'
|
||||||
|
: '暂无意向客户,访客在你的名片页留资后会出现在这里';
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||||
|
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.list[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.id ? payload : null;
|
||||||
|
},
|
||||||
|
changeStatus(payload) {
|
||||||
|
let item, index;
|
||||||
|
if (payload && payload.currentTarget) {
|
||||||
|
index = Number(payload.currentTarget.dataset.index);
|
||||||
|
item = this.list[index];
|
||||||
|
} else if (typeof payload === 'number') {
|
||||||
|
index = payload;
|
||||||
|
item = this.list[index];
|
||||||
|
} else {
|
||||||
|
item = payload;
|
||||||
|
index = this.list.indexOf(item);
|
||||||
|
}
|
||||||
|
if (!item) return;
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: LEAD_STATUS.map(s => s.label),
|
||||||
|
success: res => {
|
||||||
|
const target = LEAD_STATUS[res.tapIndex];
|
||||||
|
if (!target || target.value === item.status) return;
|
||||||
|
this.doSetStatus(item, index, target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doSetStatus(item, index, status) {
|
||||||
|
this.$cardApi.leadSetStatus({
|
||||||
|
data: { id: item.id, status: status },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
// 就地更新, 不整页刷新
|
||||||
|
this.list[index].status = status;
|
||||||
|
this.list[index].status_text = LEAD_STATUS.find(s => s.value === status).label;
|
||||||
|
uni.showToast({ title: '已更新', icon: 'success' });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
callPhone(mobile) {
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.makePhoneCall({ phoneNumber: mobile });
|
||||||
|
},
|
||||||
|
copyText(text) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: text,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-leads {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.tab-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-item {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.lead-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.lead-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
|
||||||
|
&.st-0 {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-1 {
|
||||||
|
color: #e6a23c;
|
||||||
|
background-color: #fdf3e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-2 {
|
||||||
|
color: #1daa39;
|
||||||
|
background-color: #e8f8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.st-3 {
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-mobile {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-note {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
max-width: 60%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
padding: 10rpx 30rpx;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
|
||||||
|
&.copy {
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
432
pages_tool/card/admin/manage.vue
Normal file
432
pages_tool/card/admin/manage.vue
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-manage">
|
||||||
|
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo, 禁裸 uni.switchTab) -->
|
||||||
|
<view class="page-switch">
|
||||||
|
<view class="switch-item active" @tap="goSelf">名片管理</view>
|
||||||
|
<view class="switch-item" @tap="goContacts">人脉管理</view>
|
||||||
|
<view class="switch-item" @tap="goLeads">潜在客户</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 功能预告: 查重合并/批量制作/导出等依赖后台接口, 就绪后开放 -->
|
||||||
|
<view class="notice-bar">
|
||||||
|
<text class="notice-icon">i</text>
|
||||||
|
<text class="notice-text">查重合并、批量制作、导出 CSV/vCard 等功能即将上线</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 搜索区 -->
|
||||||
|
<view class="search-bar">
|
||||||
|
<view class="search-input-wrap">
|
||||||
|
<input class="search-input" confirm-type="search" :value="keyword"
|
||||||
|
placeholder="搜索姓名 / 手机 / 公司" @confirm="onSearch" @input="onKeywordInput" />
|
||||||
|
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
|
||||||
|
<text class="iconfont icon-sousuo3" @tap="onSearch"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 名片列表 -->
|
||||||
|
<view class="card-list">
|
||||||
|
<view v-for="(item, index) in list" :key="item.card_id || index" :data-index="index" class="card-item"
|
||||||
|
@tap="goDetail">
|
||||||
|
<view class="card-avatar">
|
||||||
|
<image v-if="item.avatar" :src="$util.img(item.avatar)" class="avatar-img" mode="aspectFill" />
|
||||||
|
<text v-else>{{ avatarText(item.name) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-info">
|
||||||
|
<view class="card-name line1">{{ item.name || '未命名名片' }}</view>
|
||||||
|
<view class="card-sub line1" v-if="item.job_title || item.company">
|
||||||
|
{{ [item.job_title, item.company].filter(Boolean).join(' · ') }}
|
||||||
|
</view>
|
||||||
|
<view class="card-mobile" v-if="item.mobile">📱 {{ item.mobile }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-side">
|
||||||
|
<text v-if="sourceLabel(item)" class="source-tag">{{ sourceLabel(item) }}</text>
|
||||||
|
<view class="card-actions" @tap.stop="">
|
||||||
|
<text class="act-btn" :data-index="index" @tap="goEdit">编辑</text>
|
||||||
|
<text class="act-btn primary" :data-index="index" @tap="goDetail">详情</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view class="load-more" v-if="list.length > 0">
|
||||||
|
<text v-if="isLoading">加载中…</text>
|
||||||
|
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||||
|
<text v-else class="load-end">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 商家端 · 名片管理(基础版)
|
||||||
|
*
|
||||||
|
* 范围: 本轮仅开放现有接口可支撑的能力——搜索 / 详情 / 编辑。
|
||||||
|
* 列表/详情/编辑复用 card/lists + 既有 /pages_tool/card/detail、edit 页
|
||||||
|
* (路由参数 card_id, 字符串主键)。
|
||||||
|
* 查重合并 / 批量制作 / 新建数字名片 / 导出 CSV/vCard / 标签·状态·类型·跟进·收藏
|
||||||
|
* 筛选依赖后台未就绪接口, 后台文档(CARD_API_REFERENCE.md)到位后在本页扩展,
|
||||||
|
* 端点届时收口进 common/js/card-api.js, 页面只改 this.$cardApi 调用。
|
||||||
|
*/
|
||||||
|
import { CARD_SOURCE_TYPES } from '@/common/js/card-api.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
list: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
pageCount: 1,
|
||||||
|
isLoading: false,
|
||||||
|
emptyText: '暂无名片刻'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
// 从编辑页返回时刷新(编辑成功后数据已变)
|
||||||
|
if (this._loadedOnce) this.refresh();
|
||||||
|
this._loadedOnce = true;
|
||||||
|
},
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh(() => uni.stopPullDownRefresh());
|
||||||
|
},
|
||||||
|
onReachBottom() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
if (this.page >= this.pageCount) return;
|
||||||
|
this.loadPage(this.page + 1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
avatarText(name) {
|
||||||
|
return (name || '?').trim().charAt(0).toUpperCase();
|
||||||
|
},
|
||||||
|
// 来源标签: 优先后端译文(source_type_text), 缺失时按字典兜底(与后端 source_type 对齐)
|
||||||
|
sourceLabel(item) {
|
||||||
|
if (!item) return '';
|
||||||
|
if (item.source_type_text) return item.source_type_text;
|
||||||
|
const hit = CARD_SOURCE_TYPES.find(s => s.value === item.source_type);
|
||||||
|
return hit ? hit.label : '';
|
||||||
|
},
|
||||||
|
onKeywordInput(e) {
|
||||||
|
this.keyword = e.detail.value;
|
||||||
|
},
|
||||||
|
onSearch() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
clearKeyword() {
|
||||||
|
this.keyword = '';
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
refresh(done) {
|
||||||
|
this.page = 1;
|
||||||
|
this.loadPage(1, done);
|
||||||
|
},
|
||||||
|
loadPage(pageNo, done) {
|
||||||
|
this.isLoading = true;
|
||||||
|
// page_size 上限 100(后端硬限制), 这里 20 足够
|
||||||
|
this.$cardApi.cardLists({
|
||||||
|
data: {
|
||||||
|
page: pageNo,
|
||||||
|
page_size: this.pageSize,
|
||||||
|
keyword: this.keyword.trim()
|
||||||
|
},
|
||||||
|
success: res => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const rows = d.list || [];
|
||||||
|
this.pageCount = d.page_count || 1;
|
||||||
|
this.page = pageNo;
|
||||||
|
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||||
|
this.emptyText = this.keyword.trim() ? '未找到匹配的名片' : '暂无名片刻';
|
||||||
|
} else {
|
||||||
|
// 后端 code!=0 的 message 可直接 toast
|
||||||
|
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
typeof done === 'function' && done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 导航 ---------- */
|
||||||
|
/**
|
||||||
|
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 goDetail(item))会丢参,
|
||||||
|
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||||
|
*/
|
||||||
|
resolveItem(payload) {
|
||||||
|
if (payload == null) return null;
|
||||||
|
if (typeof payload === 'number') return this.list[payload] || null;
|
||||||
|
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||||
|
if (ds) {
|
||||||
|
const i = Number(ds.index);
|
||||||
|
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||||
|
}
|
||||||
|
return payload.card_id ? payload : null;
|
||||||
|
},
|
||||||
|
// 名片路由参数一律 card_id(字符串), encodeURIComponent 幂等
|
||||||
|
goDetail(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item || !item.card_id) return;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.card_id)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
goEdit(payload) {
|
||||||
|
const item = this.resolveItem(payload);
|
||||||
|
if (!item || !item.card_id) return;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(item.card_id)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 平级入口: 鸿蒙元服务上 uni.switchTab 静默失败, 必须走 $util.redirectTo
|
||||||
|
goContacts() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/contacts');
|
||||||
|
},
|
||||||
|
goLeads() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/admin/leads');
|
||||||
|
},
|
||||||
|
goSelf() {
|
||||||
|
// 当前页, 无需跳转
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-manage {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部平级切换(三页互链) */
|
||||||
|
.page-switch {
|
||||||
|
display: flex;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 功能预告条 */
|
||||||
|
.notice-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 20rpx 24rpx 0;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
.notice-icon {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
padding: 0 12rpx;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #999;
|
||||||
|
font-size: 32rpx;
|
||||||
|
padding-left: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--main-color-shallow, #e8f1fb);
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mobile {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
|
||||||
|
.source-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.act-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
border: 1rpx solid #ddd;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 8rpx 22rpx;
|
||||||
|
|
||||||
|
& + .act-btn {
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: var(--main-color, #0054a5);
|
||||||
|
border-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
|
||||||
|
.load-end {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
pages_tool/card/apply.vue
Normal file
213
pages_tool/card/apply.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-apply">
|
||||||
|
<!-- 申请状态条: 提交过申请或已有名片时替代表单 -->
|
||||||
|
<view class="tips" v-if="statusLoaded && statusTip">
|
||||||
|
<text class="tips-title">{{ statusTip }}</text>
|
||||||
|
<text class="tips-sub" v-if="statusSub">{{ statusSub }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-if="formEditable">
|
||||||
|
<view class="tips">
|
||||||
|
<text class="tips-title">向商家申请一张数字名片</text>
|
||||||
|
<text class="tips-sub">提交后商家会收到通知,同意后名片自动出现在你的名片夹</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||||
|
<input class="row-input" :value="form.name" placeholder="你希望名片上显示的姓名"
|
||||||
|
@input="e => form.name = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>手机号</view>
|
||||||
|
<input class="row-input" type="number" :value="form.mobile" placeholder="你注册本店使用的手机号"
|
||||||
|
@input="e => form.mobile = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="save-bar" v-if="formEditable">
|
||||||
|
<button class="save-btn" :disabled="submitting" @tap="submit">{{ submitting ? '提交中…' : '提交申请' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: { name: '', mobile: '' },
|
||||||
|
submitting: false,
|
||||||
|
// 状态回查: null=未加载, 否则 { apply, has_card }
|
||||||
|
applyStatus: null,
|
||||||
|
statusLoaded: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 已有名片 / 申请待审 → 隐藏表单只显示状态; 被拒/无记录 → 允许(重新)提交
|
||||||
|
formEditable() {
|
||||||
|
const s = this.applyStatus;
|
||||||
|
if (!s) return true;
|
||||||
|
if (s.has_card) return false;
|
||||||
|
if (s.apply && s.apply.status === 0) return false; // 待审核
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
statusTip() {
|
||||||
|
const s = this.applyStatus;
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.has_card) return '您已拥有名片,去名片夹查看吧';
|
||||||
|
if (s.apply && s.apply.status === 0) return '申请审核中,商家同意后名片会自动出现';
|
||||||
|
if (s.apply && s.apply.status === 2) return '上次申请未通过,可修改后重新提交';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
statusSub() {
|
||||||
|
const s = this.applyStatus;
|
||||||
|
if (!s || !s.apply) return '';
|
||||||
|
if (s.apply.status === 2) return '提交于 ' + (s.apply.create_time_txt || '');
|
||||||
|
return s.apply.create_time_txt ? ('提交于 ' + s.apply.create_time_txt) : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
this.loadStatus();
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadStatus() {
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: '/card/api/apply/status',
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
this.applyStatus = res.data;
|
||||||
|
}
|
||||||
|
this.statusLoaded = true;
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// 回查失败不阻塞表单
|
||||||
|
this.statusLoaded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
if (this.submitting) return;
|
||||||
|
const name = String(this.form.name || '').trim();
|
||||||
|
const mobile = String(this.form.mobile || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^1\d{10}$/.test(mobile)) {
|
||||||
|
uni.showToast({ title: '请填写正确的 11 位手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
this.$api.sendJson({
|
||||||
|
url: '/card/api/apply/submit',
|
||||||
|
data: { name: name, mobile: mobile },
|
||||||
|
success: res => {
|
||||||
|
this.submitting = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '申请已提交', icon: 'success' });
|
||||||
|
setTimeout(() => uni.navigateBack(), 600);
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '提交失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.submitting = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-apply {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding: 24rpx 24rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
padding: 20rpx 8rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-sub {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.req-star {
|
||||||
|
color: #f5222d;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
padding: 40rpx 0 0;
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
pages_tool/card/contact-edit.vue
Normal file
255
pages_tool/card/contact-edit.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<view class="contact-edit">
|
||||||
|
<view class="form-group">
|
||||||
|
<view class="group-title">{{ isEdit ? '编辑人脉' : '添加人脉' }}</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||||
|
<input class="row-input" :value="form.name" placeholder="请输入姓名"
|
||||||
|
@input="e => form.name = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">手机</view>
|
||||||
|
<input class="row-input" type="number" :value="form.mobile" placeholder="手机号(选填)"
|
||||||
|
@input="e => form.mobile = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">公司</view>
|
||||||
|
<input class="row-input" :value="form.company" placeholder="公司名称(选填)"
|
||||||
|
@input="e => form.company = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">职位</view>
|
||||||
|
<input class="row-input" :value="form.job_title" placeholder="职位(选填)"
|
||||||
|
@input="e => form.job_title = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">微信号</view>
|
||||||
|
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
|
||||||
|
@input="e => form.wechat = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">邮箱</view>
|
||||||
|
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
|
||||||
|
@input="e => form.email = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">网站</view>
|
||||||
|
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
|
||||||
|
@input="e => form.website = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="row-label">地址</view>
|
||||||
|
<input class="row-input" :value="form.address" placeholder="地址(选填)"
|
||||||
|
@input="e => form.address = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row remark-row">
|
||||||
|
<view class="row-label">备注</view>
|
||||||
|
<textarea class="remark-input" :value="form.remark" placeholder="备注(选填)"
|
||||||
|
@input="e => form.remark = e.detail.value" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 保存 -->
|
||||||
|
<view class="save-bar">
|
||||||
|
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 与后端 Contact::EDITABLE_FIELDS 对齐(status 由列表归档操作管理, 编辑页不放)
|
||||||
|
const FIELDS = ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark'];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
isEdit: false,
|
||||||
|
form: {
|
||||||
|
name: '', mobile: '', company: '', job_title: '',
|
||||||
|
wechat: '', email: '', website: '', address: '', remark: ''
|
||||||
|
},
|
||||||
|
saving: false,
|
||||||
|
fromAdmin: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.id = parseInt(options.id || 0, 10) || 0;
|
||||||
|
this.isEdit = this.id > 0;
|
||||||
|
// from=admin: 商家端人脉管理页复用本编辑页, 保存后回商家端(缺省回员工端)
|
||||||
|
this.fromAdmin = options.from === 'admin';
|
||||||
|
if (this.isEdit) {
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReady() {
|
||||||
|
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||||
|
uni.setNavigationBarTitle({ title: '名片' });
|
||||||
|
// #ifdef H5
|
||||||
|
document.title = '名片';
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadDetail() {
|
||||||
|
this.$cardApi.contactInfo({
|
||||||
|
data: { id: this.id },
|
||||||
|
success: res => {
|
||||||
|
if (res.code == 0) {
|
||||||
|
const r = res.data || {};
|
||||||
|
FIELDS.forEach(k => {
|
||||||
|
if (r[k] !== undefined && r[k] !== null) {
|
||||||
|
this.form[k] = String(r[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: res.message || '人脉不存在',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => this.backToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
if (this.saving) return;
|
||||||
|
// 防御式取值: 任何非字符串/undefined 都不会让校验误判或抛错
|
||||||
|
const name = String(this.form.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.form.name = name;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
const data = { id: this.id, fields: {} };
|
||||||
|
FIELDS.forEach(k => { data.fields[k] = this.form[k]; });
|
||||||
|
|
||||||
|
const onOk = res => {
|
||||||
|
this.saving = false;
|
||||||
|
if (res.code == 0) {
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' });
|
||||||
|
setTimeout(() => this.backToList(), 600);
|
||||||
|
} else {
|
||||||
|
// 后端错误文案有时放在 message, 有时放在 data(字符串), 两者都兜住
|
||||||
|
const msg = res.message || (typeof res.data === 'string' ? res.data : '') || '保存失败';
|
||||||
|
uni.showToast({ title: msg, icon: 'none' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onFail = () => {
|
||||||
|
this.saving = false;
|
||||||
|
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// add/update 都走 JSON body,跨端序列化零歧义。
|
||||||
|
// ⚠️ 契约差异(见 docs/CARD_API_REFERENCE.md §1.4.2 / §1.4.4):
|
||||||
|
// contact/add → 入参是「顶层平铺字段」, name 必须在 body 顶层;
|
||||||
|
// contact/update→ 额外兼容 fields 对象。
|
||||||
|
// 历史 bug: 新增时也包成 { fields: {...} }, 后端在顶层读不到 name,
|
||||||
|
// 返回「请填写姓名」→ 表现为"明明填了姓名还提示填写姓名"。
|
||||||
|
// 这里新增按契约平铺, 同时保留一份 fields 兼容旧后端(多余键会被白名单忽略)。
|
||||||
|
if (this.isEdit) {
|
||||||
|
this.$cardApi.contactUpdate({ data: data, success: onOk, fail: onFail });
|
||||||
|
} else {
|
||||||
|
const addPayload = Object.assign({}, data.fields, { fields: data.fields });
|
||||||
|
this.$cardApi.contactAdd({ data: addPayload, success: onOk, fail: onFail });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 鸿蒙安全回列表: $util.redirectTo(禁裸 uni.switchTab/uni.navigateBack 兜底)
|
||||||
|
// 商家端(from=admin)回商家端人脉管理, 员工端回「我的人脉」
|
||||||
|
backToList() {
|
||||||
|
const target = this.fromAdmin ? '/pages_tool/card/admin/contacts' : '/pages_tool/card/contacts';
|
||||||
|
this.$util.redirectTo(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.contact-edit {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f7f9;
|
||||||
|
padding: 24rpx 24rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
padding: 24rpx 24rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 必填红星(与 card/edit.vue 同款) */
|
||||||
|
.req-star {
|
||||||
|
color: #f5222d;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.remark-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
padding: 20rpx 0 0;
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--main-color, #0054a5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user