Compare commits
379 Commits
be40bb19b3
...
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 | |||
| 9d187bb8a4 | |||
| 24cb3a096d | |||
| 1610f3f75d | |||
| 837bfbfd77 | |||
| e24a6b25f2 | |||
| 318ed4028e | |||
| 1cda06573e | |||
| be1e175471 | |||
| 0598f97efe | |||
| cd91ff9d49 | |||
| b038175f07 | |||
| f18577a46c | |||
| 9915fb9bb1 | |||
| bb34e32f87 | |||
| 45e997adae | |||
| 2e4f11d108 |
18
.gitignore
vendored
18
.gitignore
vendored
@@ -5,8 +5,9 @@
|
||||
/iconfont-preview.html
|
||||
|
||||
.codebuddy
|
||||
.workbuddy
|
||||
.vscode
|
||||
.trae
|
||||
.trae*
|
||||
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
|
||||
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
|
||||
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
|
||||
@@ -61,6 +62,21 @@ 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
|
||||
|
||||
191
.local.config.js
191
.local.config.js
@@ -21,7 +21,7 @@ const localDevConfig = ({
|
||||
'2881': { // 防爆电器
|
||||
uniacid: 2881,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'2724': { // 生物菌肥
|
||||
uniacid: 2724,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
@@ -45,28 +45,30 @@ const localDevConfig = ({
|
||||
'11-test': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
},
|
||||
'18-test': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'local-2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'192.168.197.1-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://192.168.197.1:8050/',
|
||||
},
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'192.168.8.118-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://192.168.8.118:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-11': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'100': {
|
||||
},
|
||||
'100': {// 口译教学
|
||||
uniacid: 100,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -74,51 +76,55 @@ const localDevConfig = ({
|
||||
uniacid: 1,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'115': {
|
||||
'115': {// 低压断路器
|
||||
uniacid: 115,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-11': {
|
||||
'5g-11': {// 铝箔袋包装
|
||||
uniacid: 11,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-206': {
|
||||
},
|
||||
'5g-206': {// 气体探测
|
||||
uniacid: 206,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||
uniacid: 165,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'47': { // 修改弹窗
|
||||
},
|
||||
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
|
||||
uniacid: 47,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'145': { // 表单链接转义问题
|
||||
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
|
||||
uniacid: 145,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-213': { // 单图组点击链接无法跳转
|
||||
},
|
||||
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
|
||||
uniacid: 213,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-4': { // 公告点击链接无法跳转
|
||||
},
|
||||
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
|
||||
uniacid: 4,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
uniacid: 97,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'5g-50': { // 燃气安全产品机器人,购物车无反应
|
||||
uniacid: 50,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-237': { // 商品分类,没有立即咨询文字
|
||||
},
|
||||
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
|
||||
uniacid: 237,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2847': { //
|
||||
},
|
||||
'5g-248': { //中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2847': { // 家居装饰品
|
||||
uniacid: 2847,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -126,34 +132,101 @@ const localDevConfig = ({
|
||||
uniacid: 2482,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2485': { // 验证电子表单前后端数据一致性
|
||||
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
|
||||
uniacid: 2485,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2821': { // 离型纸硅油纸
|
||||
uniacid: 2821,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'18': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'48': { // 测试平台
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2821': { // 离型纸硅油纸
|
||||
uniacid: 2821,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'18': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'48': { // 测试平台
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'test-12': { // 户外鞋,测试优惠券
|
||||
uniacid: 12,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
},
|
||||
'228': { // 反渗透阻垢剂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-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
@@ -166,11 +239,21 @@ const localDevConfig = ({
|
||||
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||
// })['2724']; // 生物菌肥
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['5g-351']; //洗车机
|
||||
// })['5g-204']; //热固性塑料
|
||||
// })['5g-59']; //合成孔径雷达
|
||||
// })['2709']; //中央厨房
|
||||
// })['3007']; //钎焊材料
|
||||
// })['2505']; //煤矿钻机
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['248']; // 本地
|
||||
// })['2811']; // 本地
|
||||
// })['5g-317']; // 电磁兼容
|
||||
// })['5g-351']; //
|
||||
})['2811']; // 本地
|
||||
// })['5g-248']; // 中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||
// })['2881']; // 本地
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
})['local-1']; // 本地
|
||||
// })['1-test']; // test
|
||||
// })['test-12']; // test
|
||||
export default localDevConfig;
|
||||
27
App.vue
27
App.vue
@@ -5,6 +5,15 @@
|
||||
import ThemeOverride from 'common/js/theme-override.js';
|
||||
import PrivacyManager from 'common/js/privacy.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 {
|
||||
Weixin
|
||||
@@ -463,6 +472,14 @@
|
||||
if (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');
|
||||
}
|
||||
@@ -491,6 +508,16 @@
|
||||
},
|
||||
onShow: function(options) {
|
||||
// #ifdef MP
|
||||
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
|
||||
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
|
||||
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
|
||||
if (_appHasShown) {
|
||||
try { Lang.onAppReentry(); } catch (e) {}
|
||||
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
|
||||
try { markNeedStartupAd(); } catch (e) {}
|
||||
}
|
||||
_appHasShown = true;
|
||||
|
||||
// 自动授权登录
|
||||
this.getAuthInfo();
|
||||
if (this.$store.state.token) {
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
.goods-video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.goods-video .uni-video-cover {
|
||||
@@ -1223,6 +1225,7 @@
|
||||
}
|
||||
|
||||
.popup-layer {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
.head-wrap {
|
||||
font-size: $font-size-toolbar;
|
||||
|
||||
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
|
||||
|
||||
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||
|
||||
export default {
|
||||
/**
|
||||
@@ -80,6 +80,10 @@ export default {
|
||||
//api请求地址
|
||||
baseUrl: domain,
|
||||
|
||||
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
|
||||
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
|
||||
aikefuConfigUuid: aikefuConfigUuid,
|
||||
|
||||
// 图片域名
|
||||
imgDomain: domain,
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import IMAGE_CONFIG from './image-config.js';
|
||||
import Platform from './platform.js';
|
||||
import PAGE_KEY_MAP from './util.js';
|
||||
|
||||
export class CustomerService {
|
||||
constructor(vueInstance, externalConfig = null) {
|
||||
@@ -118,6 +119,16 @@ export class CustomerService {
|
||||
|
||||
/**
|
||||
* 跳转到 AI智能客服页面(AiChat)
|
||||
*
|
||||
* 注意:不在此处注入 __dyu=FloatBtn。
|
||||
* 该标记原本是 diy-float-btn.vue 用来告诉 ai-chat "我是从浮动按钮来的,请隐藏底部 tabBar"。
|
||||
* 此方法是 customer-service.js 的**通用 AI 客服入口**,被多处复用:
|
||||
* - hover-nav(悬浮客服按钮)
|
||||
* - 商品详情页 / 会员中心 / 个人中心 等的"联系客服"按钮
|
||||
* - DIY 浮动按钮(diy-float-btn 内部已自行注入 __dyu=FloatBtn,这里再注入会重复)
|
||||
* 若在此处统一注入,会让所有 AI 客服入口都隐藏底部 tabBar,语义错误且侵入性过强。
|
||||
* hover-nav 需要隐藏 tabBar 的场景,由 hover-nav.vue#handleUnifiedKefuClick
|
||||
* 自行注入 __dyu=FloatBtn(与 diy-float-btn.vue#onFloatBtnClick 一致)。
|
||||
*/
|
||||
openAiChatService() {
|
||||
try {
|
||||
@@ -127,7 +138,7 @@ export class CustomerService {
|
||||
}
|
||||
|
||||
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
||||
|
||||
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
||||
uni.navigateTo({
|
||||
@@ -551,6 +562,9 @@ export class CustomerService {
|
||||
|
||||
/**
|
||||
* 打开 AI 智能助手
|
||||
*
|
||||
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
|
||||
* 由调用方(hover-nav.vue 等)按需注入。
|
||||
*/
|
||||
openAIChat() {
|
||||
try {
|
||||
@@ -558,7 +572,7 @@ export class CustomerService {
|
||||
this.vm.setAiUnreadCount(0);
|
||||
}
|
||||
|
||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
||||
|
||||
uni.navigateTo({
|
||||
url: aiChatUrl,
|
||||
|
||||
125
common/js/diy.js
125
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 util from '@/common/js/util.js';
|
||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
|
||||
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
|
||||
@@ -297,25 +298,84 @@ export default {
|
||||
data: {},
|
||||
async: false
|
||||
});
|
||||
this.adv = res.value
|
||||
// 弹框形式,首次弹出 1,每次弹出 0
|
||||
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
|
||||
// 避免取到 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) {
|
||||
setTimeout(() => {
|
||||
if (res.value.advtype == 1) {
|
||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
||||
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
||||
setTimeout(async () => {
|
||||
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
|
||||
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
|
||||
await this._waitPrivacyResolved();
|
||||
if (!this.$refs.uniPopupWindow) return;
|
||||
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||
// 先判定本次是否需要弹出, 避免无谓的图片预加载
|
||||
let shouldOpen = false;
|
||||
let isFirstType = false;
|
||||
if (this.adv.advtype == 1) {
|
||||
// 首次弹出: 从未展示过才弹
|
||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||
if (popwindow_count == '' || popwindow_count == 0) {
|
||||
shouldOpen = true;
|
||||
isFirstType = true;
|
||||
}
|
||||
} else if (res.value.advtype == 0) {
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||
} 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();
|
||||
if (isFirstType) {
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||
}
|
||||
}, 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 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
||||
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
|
||||
@@ -542,9 +602,46 @@ export default {
|
||||
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() {
|
||||
this.$refs.uniPopupWindow.close();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
|
||||
},
|
||||
closeCollectPopupWindow() {
|
||||
this.$refs.collectPopupWindow.close();
|
||||
|
||||
@@ -68,6 +68,10 @@ export default {
|
||||
|
||||
hover_nav_bg_color: '#FFFC',
|
||||
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_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); // 老行为兜底
|
||||
}
|
||||
@@ -272,6 +272,59 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
upload(params) {
|
||||
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
|
||||
uni.uploadFile({
|
||||
|
||||
@@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) {
|
||||
const version = String(payload.version || '');
|
||||
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) 签名校验(可选)
|
||||
const secret = langConfig.remote.signSecret || '';
|
||||
const signValid = validateSign(tree, version, sign, secret);
|
||||
|
||||
@@ -628,6 +628,26 @@ function loadLangPackSync(lang, path) {
|
||||
* 获得当前本地语言
|
||||
* @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() {
|
||||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||||
const val = cachedStorageGet('lang', null);
|
||||
@@ -973,6 +993,13 @@ export default {
|
||||
*/
|
||||
getCurrentLocale,
|
||||
|
||||
/**
|
||||
* 语言切换功能开关(后台 shop_info.ischina 决定)。
|
||||
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
|
||||
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
|
||||
*/
|
||||
setLangSwitchEnabled,
|
||||
|
||||
/**
|
||||
* 从后端数据中提取当前语言的文案(向后兼容)
|
||||
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
||||
@@ -1054,6 +1081,13 @@ export default {
|
||||
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
||||
let isComplete1 = isBundleComplete(result.tree);
|
||||
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 路径残留旧数据
|
||||
delete remoteTreeCache[_preloadMk];
|
||||
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
||||
@@ -1064,6 +1098,13 @@ export default {
|
||||
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
||||
let isComplete2 = isBundleComplete(result.tree);
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1103,10 +1144,14 @@ export default {
|
||||
setRemoteStatus('fail', { reason: _reason, lang });
|
||||
bootCompleted = true;
|
||||
emitUpdated(lang);
|
||||
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
|
||||
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
|
||||
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
|
||||
return {
|
||||
ok: true,
|
||||
source: 'cache',
|
||||
status: 'fail',
|
||||
status: 'ok',
|
||||
degraded: true,
|
||||
reason: _errMsg || 'fallback to cache',
|
||||
lang,
|
||||
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
||||
@@ -1176,6 +1221,42 @@ export default {
|
||||
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 分类)
|
||||
* @param {(status:string, detail:object) => void} fn
|
||||
|
||||
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 }
|
||||
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_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 一致, 远端可覆写) =====
|
||||
@@ -71,6 +77,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -264,6 +274,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -456,6 +470,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -648,6 +666,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -840,6 +862,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -1032,6 +1058,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -1224,6 +1254,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
@@ -1416,6 +1450,10 @@ export default {
|
||||
...{
|
||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||
|
||||
@@ -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 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 LOGIN_PAGE_URL = '/pages_tool/login/login';
|
||||
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 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
||||
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
||||
@@ -169,6 +200,16 @@ export const PAGE_KEY_MAP = {
|
||||
// === 电子名片 ===
|
||||
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 ===
|
||||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||||
|
||||
@@ -457,6 +498,9 @@ export default {
|
||||
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
||||
parsePageParams,
|
||||
|
||||
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
|
||||
coerceBool,
|
||||
|
||||
/** HTML 实体解码 */
|
||||
decodeEntity,
|
||||
|
||||
@@ -1170,6 +1214,23 @@ export default {
|
||||
* @param {Object} 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));
|
||||
//if (link == null || Object.keys(link).length == 1) return;
|
||||
|
||||
@@ -1203,6 +1264,21 @@ export default {
|
||||
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)
|
||||
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
||||
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
||||
@@ -1376,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()
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 获取默认图
|
||||
*/
|
||||
@@ -1593,59 +1786,118 @@ 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) {
|
||||
//如果没有特别指定 则获取当前页面的路由
|
||||
// 如果没有特别指定 则获取当前页面的路由
|
||||
if (!path) {
|
||||
let route = this.getCurrentRoute();
|
||||
path = route.path;
|
||||
if (path == '/pages_tool/member/index') {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((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({
|
||||
url: "/weapp/api/weapp/share",
|
||||
data: {
|
||||
path: path
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
let shareConfig = res.data.data;
|
||||
if (shareConfig) {
|
||||
//分享给好友
|
||||
let appMessageData = {
|
||||
title: shareConfig.title,
|
||||
path: shareConfig.path,
|
||||
imageUrl: shareConfig.imageUrl,
|
||||
success: res => {
|
||||
},
|
||||
fail: res => {
|
||||
try {
|
||||
if (!res || typeof res !== 'object') {
|
||||
resolve(fallback);
|
||||
return;
|
||||
}
|
||||
if (res.code >= 0) {
|
||||
// 响应可能是两种形态:
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
//分享到朋友圈
|
||||
let query = '';
|
||||
if (shareConfig.path.indexOf('?') > 0) {
|
||||
query = shareConfig.path.split('?')[1];
|
||||
if (shareConfig) {
|
||||
resolve(buildFromConfig(shareConfig));
|
||||
} else {
|
||||
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
|
||||
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
|
||||
resolve(fallback);
|
||||
}
|
||||
let timeLineData = {
|
||||
title: shareConfig.title,
|
||||
query: shareConfig.path,
|
||||
imageUrl: shareConfig.imageUrl,
|
||||
}
|
||||
resolve({
|
||||
appMessage: appMessageData,
|
||||
timeLine: timeLineData,
|
||||
})
|
||||
} else {
|
||||
reject(res.data);
|
||||
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
|
||||
resolve(fallback);
|
||||
}
|
||||
} else {
|
||||
reject(res.data)
|
||||
} catch (e) {
|
||||
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
|
||||
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
|
||||
resolve(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
|
||||
resolve(fallback);
|
||||
},
|
||||
complete: () => {}
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,25 @@
|
||||
<template>
|
||||
<template>
|
||||
<view :style="componentStyle" data-component-name="diy-digit">
|
||||
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||
:scroll-x="value.showStyle == 'singleSlide'">
|
||||
<view class="uni-scroll-view-content">
|
||||
<view v-for="(item, index) in value.list" :key="index"
|
||||
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
|
||||
:style="{ width: (100 / value.rowCount + '%') + ';' }">
|
||||
<view style="display:flex;">
|
||||
<view :style="{
|
||||
'line-height': '1.2;',
|
||||
'font-size': (value.font.titlesize * 2 + 'rpx') + ';',
|
||||
'font-weight': '600;',
|
||||
'color': value.font.titlecolor + ';'
|
||||
}">
|
||||
:style="itemStyle">
|
||||
<view :style="innerWrapStyle">
|
||||
<view :style="innerRowStyle">
|
||||
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
|
||||
:decimals="getvalue(item.title)" decimal="."></uv-count-to>
|
||||
<text :style="{
|
||||
'margin-left': '4rpx;',
|
||||
'font-size': (value.font.unitsize * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.unitcolor + ';'
|
||||
}">{{ item.unit }}</text>
|
||||
:decimals="getvalue(item.title)" decimal="."
|
||||
:color="value.font.titlecolor"
|
||||
:fontSize="titleFontSize"
|
||||
:bold="isBold(value.font.weight)"></uv-count-to>
|
||||
<text :style="unitStyle">{{ item.unit }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="graphic-text">
|
||||
<text :style="{
|
||||
'font-size': (value.font.descsize * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.desccolor + ';'
|
||||
}">{{ item.desc }}</text>
|
||||
<view class="graphic-text">
|
||||
<text :style="descStyle">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
@@ -54,10 +43,20 @@ export default {
|
||||
pageWidth: '',
|
||||
indicatorDots: false,
|
||||
swiperCurrent: 0
|
||||
// #ifndef MP
|
||||
,windowWidth: 375
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
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: {
|
||||
componentRefresh(newValue) {
|
||||
@@ -65,6 +64,42 @@ export default {
|
||||
}
|
||||
},
|
||||
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() {
|
||||
let style = '';
|
||||
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
|
||||
@@ -83,11 +118,25 @@ export default {
|
||||
}
|
||||
},
|
||||
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) {
|
||||
return value % 1 !== 0 ? 2 : 0;
|
||||
},
|
||||
|
||||
// 是否加粗
|
||||
isBold(weight) {
|
||||
return weight === 'bold' || (weight && Number(weight) >= 600);
|
||||
},
|
||||
|
||||
// 页面跳转
|
||||
redirectTo(item) {
|
||||
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 }"
|
||||
:style="style">
|
||||
<block v-for="(item, index) in value.list" :key="index">
|
||||
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)"
|
||||
<view class="button-box" @tap.stop="onFloatBtnClick(item)"
|
||||
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
|
||||
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
|
||||
show-menu-by-longpress />
|
||||
@@ -44,7 +44,44 @@ export default {
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
components: {},
|
||||
methods: {},
|
||||
methods: {
|
||||
/**
|
||||
* 浮动按钮点击处理:
|
||||
* 在跳转前给 link 注入 __dyu=FloatBtn 来源标记,
|
||||
* 让目标页面(尤其是 ai-chat)能识别"来自浮动按钮"从而隐藏底部 tabBar,
|
||||
* 与"从底部导航栏点击进入时显示并高亮 tabBar"的场景并存。
|
||||
*
|
||||
* 为什么用 __dyu 这种 3 字符命名:
|
||||
* - 双下划线 __ 前缀 + 3 位字母(dy-ui 框架) 是前端约定俗成的"系统内部保留字段"标志
|
||||
* (如 __proto__, __esModule),业务方配置 link 时几乎不可能使用这种短命名 → 与业务参数零冲突
|
||||
* - URL 中占用空间极小,不污染业务 query 字符串
|
||||
* - 未来如需扩展其他组件的入口来源,可继续使用 __dyu 前缀(如 __dyu=Banner / __dyu=Nav)
|
||||
*
|
||||
* 实现要点:
|
||||
* - 浅拷贝 item.link 避免污染原数据(可能被 v-for 复用)
|
||||
* - 把标记写入 page_params,diyRedirectTo → redirectTo 会自动拼到 URL query,
|
||||
* 目标页 onLoad(options) 可直接读取
|
||||
* - 同时在 wap_url 末尾追加/合并 __dyu=FloatBtn 兜底:某些路径不走 page_params 拼接
|
||||
*/
|
||||
onFloatBtnClick(item) {
|
||||
if (!item || !item.link) return;
|
||||
const link = { ...item.link };
|
||||
link.page_params = Object.assign({}, link.page_params || {}, {
|
||||
__dyu: 'FloatBtn'
|
||||
});
|
||||
|
||||
// 兜底:直接把标记拼到 wap_url query,防止 page_params 在某些分支未被透传
|
||||
if (link.wap_url && typeof link.wap_url === 'string' && link.wap_url.indexOf('http') === -1) {
|
||||
const sep = link.wap_url.indexOf('?') === -1 ? '?' : '&';
|
||||
// 避免重复追加(精确匹配 __dyu= 而不是 __dyu,避免误伤可能的 __dyu_other)
|
||||
if (link.wap_url.indexOf('__dyu=') === -1) {
|
||||
link.wap_url = link.wap_url + sep + '__dyu=FloatBtn';
|
||||
}
|
||||
}
|
||||
|
||||
this.$util.diyRedirectTo(link);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
let style = {},
|
||||
|
||||
@@ -6,32 +6,79 @@
|
||||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
||||
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
||||
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
||||
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
|
||||
</view>
|
||||
<!-- 提示语 -->
|
||||
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'horizontal'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
|
||||
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
|
||||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
<view v-if="value && value.show_banner && value.form_banner" class="form-banner is-card-overlap" :style="bannerStyle + bannerBleedStyle">
|
||||
<!-- 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>
|
||||
<!-- 卡片浮起模式: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>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'vertical'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义表单
|
||||
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
|
||||
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
|
||||
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
|
||||
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
|
||||
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
|
||||
import DiyMinx from './minx.js'
|
||||
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
||||
|
||||
// 横幅裁剪比例白名单(与后端 form.js syncFormConfigToCSS 保持一致)
|
||||
const BANNER_CROP_RATIOS = { '16:9': 16/9, '4:3': 4/3, '1:1': 1, '3:4': 3/4 }
|
||||
|
||||
export default {
|
||||
name: 'diy-from',
|
||||
components: { NsNewform },
|
||||
@@ -52,6 +99,20 @@ export default {
|
||||
showSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 父级容器左右内边距(rpx)。卡片模式下让 banner 图片突破这个内边距左右铺满(如 system-form-wrap 的 padding-x)。
|
||||
// 仅在 hasBannerMode=true 时生效;默认 0(不铺满)。
|
||||
// 引入原因:formdata.vue 的 .system-form-wrap 默认 padding: 0 50rpx,banner 图片被左右各 50rpx 收缩,
|
||||
// 后台配 form_banner 后图片应跨越屏幕宽度,而不是被容器内缩。
|
||||
containerInset: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
|
||||
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
@@ -97,24 +158,292 @@ export default {
|
||||
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() {
|
||||
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 = {
|
||||
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`,
|
||||
'color': cfg.tipColor || '#000000',
|
||||
'background-color': cfg.tipBgColor || 'transparent'
|
||||
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
|
||||
'background-color': cfg.tipBgColor || 'transparent',
|
||||
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
|
||||
};
|
||||
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
|
||||
if (cfg.tipBorderColor) {
|
||||
// 边框:配置了宽度(>0)输出;未配宽度但配了颜色 → 旧数据兼容,2rpx 底边(仅经典态)
|
||||
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}`;
|
||||
}
|
||||
// 圆角(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);
|
||||
},
|
||||
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
|
||||
// card + inside(默认)时按钮已在内容卡内:padding 收窄为卡内边距(≈后端预览 14px 12px 20px),不设独立背景(继承卡白底)
|
||||
submitWrapStyle() {
|
||||
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({
|
||||
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
||||
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
|
||||
@@ -138,7 +467,7 @@ export default {
|
||||
width: widthPct + '%',
|
||||
padding: `${submitPaddingY}rpx 0`,
|
||||
color: cfg.submitColor || '#ffffff',
|
||||
'background-color': cfg.submitBgColor || '#ff4544',
|
||||
'background-color': cfg.submitBgColor || '#3b82f6',
|
||||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||||
});
|
||||
@@ -148,6 +477,10 @@ export default {
|
||||
submitText() {
|
||||
return this.mergedFormConfig.submitText || '提交';
|
||||
},
|
||||
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
|
||||
hasCustomSubmit() {
|
||||
return typeof this.onSubmit === 'function';
|
||||
},
|
||||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||||
formCfg() {
|
||||
@@ -169,12 +502,33 @@ export default {
|
||||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||||
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 字段名一致)
|
||||
const base = {
|
||||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||||
// Banner 高度(rpx)。后台可配,默认 180rpx ≈ 页面 1/8 高度(适配主流手机屏高)
|
||||
// 配合 mode="aspectFill" 居中裁剪,保证横幅只占页面八分之一左右
|
||||
bannerHeight: pick('bannerHeight', 'banner_height'),
|
||||
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'),
|
||||
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'),
|
||||
labelGap: pick('labelGap', 'label_gap'),
|
||||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||||
@@ -190,10 +544,16 @@ export default {
|
||||
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
|
||||
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
|
||||
requiredColor: pick('requiredColor', 'required_color'),
|
||||
// 必填星号位置:before=标签前(默认)/ after=标签后(终端无关,存 base 层)
|
||||
requiredPosition: pick('requiredPosition', 'required_position'),
|
||||
checkedColor: pick('checkedColor', 'checked_color'),
|
||||
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
|
||||
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
|
||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
||||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
||||
@@ -206,21 +566,71 @@ export default {
|
||||
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
||||
submitSize: pick('submitSize', 'submit_size'),
|
||||
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
||||
// 提示语
|
||||
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
||||
tipColor: pick('tipColor', 'tip_color'),
|
||||
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
||||
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
|
||||
};
|
||||
// 提示语(与后端 form.js syncFormConfigToCSS 的 --tip-* 变量一一对应)
|
||||
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
||||
tipColor: pick('tipColor', 'tip_color'),
|
||||
tipBgColor: pick('tipBgColor', 'tip_bg_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)
|
||||
Object.keys(base).forEach(function(k) {
|
||||
if (base[k] === undefined || base[k] === null || 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 子对象内)
|
||||
// 优先级: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;
|
||||
if (termBlock && typeof termBlock === 'object') {
|
||||
Object.keys(termBlock).forEach(function(platform) {
|
||||
@@ -286,24 +696,26 @@ export default {
|
||||
return true;
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
submitForm() {
|
||||
var self = this;
|
||||
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
|
||||
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
|
||||
handleSubmit() {
|
||||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||||
if (!this.verify()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
|
||||
if (this.$listeners && this.$listeners.submit) {
|
||||
this.$emit('submit', this.getFormData());
|
||||
return;
|
||||
const formData = this.getFormData();
|
||||
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
|
||||
if (this.hasCustomSubmit) {
|
||||
return this.onSubmit(formData);
|
||||
}
|
||||
// 兜底:内置 sendRequest 流程
|
||||
this.defaultSubmit(formData);
|
||||
},
|
||||
|
||||
// 获得保单数据
|
||||
const submitFormData = this.getFormData();
|
||||
|
||||
// 提交到后端
|
||||
// 公共兜底实现:默认的 /form/api/form/create 流程;
|
||||
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
|
||||
defaultSubmit(submitFormData) {
|
||||
var self = this;
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
@@ -347,12 +759,139 @@ export default {
|
||||
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
||||
margin: 0;
|
||||
|
||||
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
|
||||
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
|
||||
&.is-card-overlap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 横幅图片容器:默认高度由 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 {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片容器:浮在 banner 图片上方(参考设计左图)。
|
||||
// 仅在 hasBannerMode=true 时渲染(见 template v-if);
|
||||
// 阴影 + margin-top(负偏移)+ 圆角 + 白底 全部由 cardWrapStyle 内联写入(读取后台 formConfig.card*)。
|
||||
// 关键:卡片必须自带白底并 z-index 高于 banner,才能真正"盖住"banner 图片底部形成浮起;
|
||||
// 父级容器(formdata.vue 的 .system-form-wrap)虽也是白底,但它是 banner 的祖先、背景会被 banner 覆盖,
|
||||
// 因此本容器的白底不可省略(与 .system-form-wrap 同色,视觉上从卡片向下自然延续到页面底部)。
|
||||
.form-card {
|
||||
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||||
// 关键:裁剪卡内子元素(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 {
|
||||
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
||||
padding: 0;
|
||||
|
||||
@@ -64,6 +64,13 @@
|
||||
<diy-rich-text :value="item"></diy-rich-text>
|
||||
</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'">
|
||||
<!-- 辅助线 -->
|
||||
<diy-horz-line :value="item"></diy-horz-line>
|
||||
@@ -159,7 +166,10 @@
|
||||
<!-- 商品推荐 -->
|
||||
<diy-goods-recommend :value="item"></diy-goods-recommend>
|
||||
</template>
|
||||
|
||||
<template v-if="item.componentName == 'DiyRecommendCode'">
|
||||
<!-- 自定义推荐码组件 -->
|
||||
<diy-recommend-code :value="item"></diy-recommend-code>
|
||||
</template>
|
||||
<template v-if="item.componentName == 'GoodsBrand'">
|
||||
<!-- 商品品牌 -->
|
||||
<diy-goods-brand :value="item"></diy-goods-brand>
|
||||
@@ -374,14 +384,41 @@ export default {
|
||||
: index + 1;
|
||||
|
||||
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;`;
|
||||
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
|
||||
}
|
||||
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
|
||||
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
|
||||
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
|
||||
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
|
||||
// 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;
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
<template>
|
||||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||
:scroll-x="value.showStyle == 'singleSlide'">
|
||||
<view class="uni-scroll-view-content">
|
||||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||
style="margin-right: 28rpx;">
|
||||
<!-- 图片部分 -->
|
||||
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
||||
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}">
|
||||
<image v-if="item.link.wap_url" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'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>
|
||||
<image v-else :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'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>
|
||||
</view>
|
||||
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
|
||||
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 v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||
:style="imageItemStyle">
|
||||
<!-- 图片部分 -->
|
||||
<!-- PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||||
图片按后台上传/配置的尺寸渲染,避免 uni <image> 取不到尺寸时
|
||||
回退到浏览器默认 320x240 而被"撑到最大";mode=aspectFit 保证不拉伸变形 -->
|
||||
<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)"
|
||||
mode="aspectFit"
|
||||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||||
<image v-else class="image-content" :style="imageInnerStyle(item)" mode="aspectFit"
|
||||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
||||
</view>
|
||||
|
||||
<!-- 文字部分 -->
|
||||
<text class="image-text" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.color + ';'
|
||||
}">{{ item.title }}</text>
|
||||
<!-- 文字部分 -->
|
||||
<text class="image-text" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.color + ';'
|
||||
}">{{ item.title }}</text>
|
||||
</view>
|
||||
</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>
|
||||
</scroll-view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
@@ -60,7 +74,12 @@ export default {
|
||||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||||
slideStartX: 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() {
|
||||
@@ -75,23 +94,94 @@ export default {
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
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
|
||||
},
|
||||
beforeDestroy() {
|
||||
// #ifdef H5
|
||||
this.unbindSlideMouseEvents();
|
||||
this.unbindSlideScrollbar();
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
if (this._mpMeasureTimers) {
|
||||
this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); });
|
||||
this._mpMeasureTimers = null;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
watch: {
|
||||
componentRefresh(newValue) {
|
||||
// 监听组件刷新
|
||||
},
|
||||
// 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条
|
||||
'value.list'(newVal, oldVal) {
|
||||
// #ifdef MP
|
||||
if (newVal !== oldVal) {
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
self.measureScrollbar();
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
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() {
|
||||
let style = '';
|
||||
style += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
|
||||
|
||||
if (this.value.componentAngle == 'round') {
|
||||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||
@@ -123,9 +213,446 @@ export default {
|
||||
isIndicatorDots() {
|
||||
return this.value.carousel.type != 'hide' &&
|
||||
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: {
|
||||
// ========== 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) {
|
||||
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||
@@ -153,14 +680,10 @@ export default {
|
||||
// #ifdef H5
|
||||
// 防止重复绑定
|
||||
if (this._slideEventsBound) return;
|
||||
var outerEl = this.getSlideOuterEl();
|
||||
var innerEl = this.getSlideContainer();
|
||||
if (!outerEl || !innerEl) return;
|
||||
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
|
||||
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
|
||||
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
|
||||
this._slideOuterEl = outerEl;
|
||||
this._slideEl = innerEl;
|
||||
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
|
||||
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
|
||||
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
|
||||
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
|
||||
this._slideEventsBound = true;
|
||||
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
||||
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
||||
@@ -183,50 +706,79 @@ export default {
|
||||
this._slideEventsBound = false;
|
||||
// #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
|
||||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
|
||||
// 嵌套结构:
|
||||
// <uni-scroll-view> ← 自定义元素
|
||||
// <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;
|
||||
var el = this.$el;
|
||||
if (el && el.nodeType === 1) return el;
|
||||
return null;
|
||||
// #endif
|
||||
},
|
||||
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
|
||||
getSlideOuterEl() {
|
||||
// #ifdef H5
|
||||
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
|
||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||
if (!componentRoot) return null;
|
||||
var sc = componentRoot.querySelector('uni-scroll-view');
|
||||
return sc || null;
|
||||
return this.getSlideRootEl();
|
||||
// #endif
|
||||
},
|
||||
getSlideContainer() {
|
||||
// #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
|
||||
},
|
||||
onSlideMouseDown(e) {
|
||||
// #ifdef H5
|
||||
if (this.value.showStyle != 'singleSlide') return;
|
||||
// document 级别监听,只处理落在容器内的事件
|
||||
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
|
||||
// document 级别监听,只处理落在"本实例"容器内的事件
|
||||
// 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致),
|
||||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||||
if (!this._slideOuterEl) return;
|
||||
if (!this.ensureSlideEls()) return;
|
||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
var x = e.clientX, y = e.clientY;
|
||||
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;
|
||||
this.slideIsDragging = true;
|
||||
this.slideStartX = x;
|
||||
@@ -240,7 +792,7 @@ export default {
|
||||
onSlideMouseMove(e) {
|
||||
// #ifdef H5
|
||||
if (!this.slideIsDragging) return;
|
||||
var el = this.getSlideContainer();
|
||||
var el = this._slideEl;
|
||||
if (!el) return;
|
||||
var x = (e.clientX != null) ? e.clientX : 0;
|
||||
var deltaX = x - this.slideStartX;
|
||||
@@ -267,7 +819,7 @@ export default {
|
||||
// #ifdef H5
|
||||
if (!this._suppressNextClick) return;
|
||||
if (!e.target) return;
|
||||
var outer = this._slideOuterEl;
|
||||
var outer = this._slideOuterEl || this.getSlideOuterEl();
|
||||
if (!outer || !outer.contains(e.target)) return;
|
||||
this._suppressNextClick = false;
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
@@ -278,13 +830,13 @@ export default {
|
||||
onSlideWheel(e) {
|
||||
// #ifdef H5
|
||||
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();
|
||||
if (!rect) return;
|
||||
var x = e.clientX, y = e.clientY;
|
||||
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;
|
||||
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
||||
var dx = e.deltaX || 0;
|
||||
@@ -301,6 +853,36 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
@@ -327,17 +909,72 @@ export default {
|
||||
// #ifdef H5
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge legacy
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
|
||||
cursor: grab;
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
|
||||
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
|
||||
::v-deep .uni-scroll-view {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||||
-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
|
||||
}
|
||||
|
||||
@@ -402,6 +1039,15 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
position: absolute;
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="fui-list-inner">
|
||||
<view class="title" style="height: 75rpx;">
|
||||
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
||||
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
||||
</view>
|
||||
<view class="title">
|
||||
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
|
||||
<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 class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
|
||||
<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"
|
||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||
</view>
|
||||
@@ -35,15 +40,31 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
|
||||
mpTitleSize: 0,
|
||||
mpDescSize: 0
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
|
||||
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
|
||||
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
|
||||
mounted() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
value: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -66,9 +87,130 @@ export default {
|
||||
current: 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>
|
||||
|
||||
<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 -->
|
||||
<block
|
||||
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>
|
||||
<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"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
<!-- <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>
|
||||
</block>
|
||||
<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>
|
||||
<view class="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>
|
||||
</view>
|
||||
<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>
|
||||
|
||||
@@ -367,6 +367,26 @@ export default {
|
||||
isDisabled() {
|
||||
if (this.nickName.length > 0) return false;
|
||||
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: {
|
||||
|
||||
@@ -137,8 +137,8 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
@error="imgError(index)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc">{{ item.desc }}</text>
|
||||
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
|
||||
<view class="read-wrap"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -179,6 +179,25 @@ export default {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
|
||||
|
||||
// flex: 1;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// justify-content: space-between;
|
||||
.desc {
|
||||
color: #888
|
||||
color: #888;
|
||||
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -311,8 +336,9 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
font-size: 30rpx;
|
||||
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>
|
||||
<view data-component-name="diy-search" class="diy-search">
|
||||
<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"
|
||||
@tap.stop="handlerSearchClick">
|
||||
<block v-if="[1, 2].includes(value.searchStyle)">
|
||||
@@ -13,20 +15,20 @@
|
||||
<view class="search-content" :style="inputStyle">
|
||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||
:placeholderStyle="placeholderStyle" />
|
||||
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
||||
<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>
|
||||
</block>
|
||||
<block v-if="value.searchStyle == 3">
|
||||
<view class="search-content" :style="inputStyle"
|
||||
@tap.stop="handlerSearchClick">
|
||||
<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"
|
||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||
@tap.stop="handlerSearchClick"
|
||||
:placeholderStyle="placeholderStyle" />
|
||||
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
||||
<text class="search-content-btn" @tap.stop="handlerSearchClick"
|
||||
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
|
||||
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
|
||||
@@ -41,6 +43,7 @@
|
||||
@tap.stop="handlerRedirectToClick(value.searchLink)"></diy-icon>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 解决fixed定位后导航栏塌陷的问题 -->
|
||||
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
|
||||
@@ -114,20 +117,90 @@ export default {
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 搜索行框(紧包搜索输入框的一行, 含左侧 logo/图标): 背景色优先取后台"行框背景色",
|
||||
// 未配置时回退到组件背景色(componentBgColor), 保持旧装修视觉; 兼容嵌套字段命名
|
||||
searchWrapCss() {
|
||||
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 += 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;
|
||||
},
|
||||
// 搜索输入框: 背景色优先取后台"输入框背景色"(含旧字段 elementBgColor), 文本色同步 textColor;
|
||||
// 高度/内边距/圆角(框体样式)取后台字段, 兼容嵌套命名与缺省
|
||||
inputStyle() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.value.borderType == 2) {
|
||||
obj += 'border-radius:' + '40rpx;';
|
||||
var inputBg = this._probe([
|
||||
'elementBgColor', 'inputBgColor', 'searchInputBgColor', 'inputBackground'
|
||||
]) || 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;
|
||||
},
|
||||
// 卡片底板: 搜索框背后一圈底板; 后台"底板背景色/卡片底版"真实字段名为 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() {
|
||||
var obj = '';
|
||||
if (this.value.textColor) {
|
||||
@@ -192,6 +265,93 @@ export default {
|
||||
this.setModuleLocationFn();
|
||||
},
|
||||
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() {
|
||||
this.$util.redirectTo('/pages_tool/goods/search');
|
||||
},
|
||||
@@ -264,6 +424,11 @@ export default {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-board {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
<!-- 顶部标签位置:标签导航 + 指示器 + 内容区域 -->
|
||||
<template v-if="mergedValue.tabPosition === 'top'">
|
||||
<!-- 标签导航 -->
|
||||
<view class="tab-nav" :style="[tabNavStyle]">
|
||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
||||
:class="{ active: activeTab === idx }"
|
||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
|
||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
|
||||
{{ getTabTitle(tab.title) }}
|
||||
</text>
|
||||
<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"
|
||||
:class="{ active: activeTab === idx }" :id="getNavItemId(idx)"
|
||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
|
||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
|
||||
{{ getTabTitle(tab.title) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 指示器 -->
|
||||
<view v-if="mergedValue.showIndicator" class="tab-indicator" :style="[
|
||||
@@ -74,30 +77,36 @@
|
||||
]"></view>
|
||||
|
||||
<!-- 标签导航 -->
|
||||
<view class="tab-nav" :style="[tabNavStyle]">
|
||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||
{{ getTabTitle(tab.title) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
|
||||
<template v-else-if="mergedValue.tabPosition === 'left'">
|
||||
<view class="tab-layout-horizontal">
|
||||
<!-- 左侧标签导航 -->
|
||||
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
|
||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
||||
<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"
|
||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||
:id="getNavItemId(idx)"
|
||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||
{{ getTabTitle(tab.title) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
|
||||
<template v-else-if="mergedValue.tabPosition === 'left'">
|
||||
<view class="tab-layout-horizontal">
|
||||
<!-- 左侧标签导航 -->
|
||||
<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"
|
||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||
:id="getNavItemId(idx)"
|
||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||
{{ getTabTitle(tab.title) }}
|
||||
</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 左侧指示器 -->
|
||||
<view v-if="mergedValue.showIndicator" class="tab-indicator tab-indicator-vertical" :style="[
|
||||
@@ -161,15 +170,17 @@
|
||||
]"></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"
|
||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||
:id="getNavItemId(idx)"
|
||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||
{{ getTabTitle(tab.title) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
@@ -208,6 +219,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
activeTab: this.value?.activeTabIndex ?? 0, // 设置当前激活的标签索引
|
||||
scrollIntoId: '', // scroll-view 滚动定位的导航项 id
|
||||
indicatorVars: { // 指示器CSS变量
|
||||
top: '',
|
||||
left: '',
|
||||
@@ -229,6 +241,8 @@ export default {
|
||||
// 初始化指示器位置
|
||||
this.$nextTick(() => {
|
||||
this.updateIndicatorPosition();
|
||||
// 初始化时将激活标签滚动到可视区域
|
||||
this.syncScrollIntoId();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -417,14 +431,6 @@ export default {
|
||||
// 对于水平布局(顶部和底部),设置固定高度
|
||||
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
||||
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 {
|
||||
@@ -451,6 +457,24 @@ export default {
|
||||
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() {
|
||||
let style = {
|
||||
@@ -601,9 +625,10 @@ export default {
|
||||
|
||||
// 监听器
|
||||
watch: {
|
||||
// 监听激活标签变化,更新指示器
|
||||
// 监听激活标签变化,更新指示器并滚动导航
|
||||
activeTab: {
|
||||
handler() {
|
||||
this.syncScrollIntoId();
|
||||
this.$nextTick(() => {
|
||||
this.updateIndicatorPosition();
|
||||
});
|
||||
@@ -724,6 +749,16 @@ export default {
|
||||
getTabKey(tab, 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() {
|
||||
this.$nextTick(() => {
|
||||
@@ -1034,8 +1069,6 @@ export default {
|
||||
|
||||
// ===== 标签导航栏样式 =====
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
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 {
|
||||
flex-direction: column;
|
||||
@@ -1068,6 +1111,7 @@ export default {
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
flex-shrink: 0; // 防止标签在 scroll-view 内被压缩,保证可完整展示并横向滑动
|
||||
|
||||
&.active {
|
||||
color: var(--active-color, #339af0);
|
||||
|
||||
@@ -54,17 +54,21 @@
|
||||
<!-- 视频播放弹窗 -->
|
||||
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
||||
<view class="video-container" :style="popupVideoStyle">
|
||||
<video
|
||||
<video
|
||||
:key="video_url"
|
||||
ref="popupVideoRef"
|
||||
class="adaptive-video"
|
||||
:autoPauseIfNavigate="true"
|
||||
class="adaptive-video"
|
||||
:autoPauseIfNavigate="true"
|
||||
:autoPauseIfOpenNative="true"
|
||||
:autoplay="false"
|
||||
:enableAutoRotation="true"
|
||||
id="myVideo"
|
||||
:src="video_url"
|
||||
:autoplay="false"
|
||||
:enableAutoRotation="true"
|
||||
id="myVideo"
|
||||
:src="video_url"
|
||||
:controls="true"
|
||||
object-fit="contain"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
@loadedmetadata="onPopupVideoLoaded"
|
||||
@error="onPopupVideoError"
|
||||
></video>
|
||||
@@ -144,41 +148,21 @@ export default {
|
||||
|
||||
// 弹窗视频容器样式
|
||||
popupVideoStyle() {
|
||||
let style = 'position:fixed;width:100%;left:0;';
|
||||
const defaultRatio = 9 / 16;
|
||||
|
||||
let ratio = defaultRatio;
|
||||
if (this.popupVideoWidth && this.popupVideoHeight) {
|
||||
ratio = this.popupVideoHeight / this.popupVideoWidth;
|
||||
}
|
||||
|
||||
const isPortrait = ratio > 1;
|
||||
|
||||
if (isPortrait) {
|
||||
style += 'top:50%;transform:translateY(-50%);';
|
||||
} else {
|
||||
style += 'top:20%;';
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
|
||||
// #endif
|
||||
|
||||
// 容器横向铺满,黑色背景填满左右边界
|
||||
// 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
|
||||
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
|
||||
return style;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 显示视频播放弹窗
|
||||
showVideo(item) {
|
||||
this.video_url = item.videoUrl;
|
||||
let url = item.videoUrl || '';
|
||||
// 处理视频URL:完整链接直接使用,否则通过 $util.img() 转换
|
||||
if (url && url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
url = this.$util.img(url);
|
||||
}
|
||||
this.video_url = url;
|
||||
this.popupVideoWidth = 0;
|
||||
this.popupVideoHeight = 0;
|
||||
this.$refs.videoPopup.open();
|
||||
@@ -625,22 +609,6 @@ export default {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
.adaptive-video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* #ifdef MP-HARMONY */
|
||||
.adaptive-video {
|
||||
position: static;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
.video-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
:style="videoWarpCss"
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
@click="handlerClick(videoUrl)"
|
||||
></video>
|
||||
@@ -106,11 +109,11 @@ export default {
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
|
||||
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 {
|
||||
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}) {
|
||||
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)
|
||||
@@ -18,4 +69,4 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||
<view>
|
||||
<view v-if="pageCount === 1 || need" class="fixed-box"
|
||||
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
||||
:style="[customContainerStyle, {
|
||||
height: containerHeight,
|
||||
width: containerWidth,
|
||||
bottom: containerBottom,
|
||||
right: containerRight,
|
||||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||||
@@ -11,18 +12,18 @@
|
||||
}]">
|
||||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||||
<view
|
||||
v-if="isLanguageSwitchEnabled && fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="isLanguageSwitchEnabled && hasLangButton"
|
||||
class="btn-item common-bg btn-lang"
|
||||
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
|
||||
@click="handleLangButtonClick"
|
||||
>
|
||||
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
|
||||
<view class="lang-btn-text">{{ langButtonText }}</view>
|
||||
</view>
|
||||
<!-- 微信官方客服按钮-->
|
||||
<button
|
||||
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
open-type="contact"
|
||||
>
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
@@ -30,9 +31,9 @@
|
||||
|
||||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||||
<view
|
||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
@click="handleUnifiedKefuClick"
|
||||
>
|
||||
<image
|
||||
@@ -48,9 +49,9 @@
|
||||
|
||||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||||
<view
|
||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
@click="handleUnifiedKefuClick"
|
||||
>
|
||||
<image
|
||||
@@ -66,9 +67,9 @@
|
||||
|
||||
<!-- 小程序系统客服按钮 -->
|
||||
<button
|
||||
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
open-type="contact"
|
||||
>
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
@@ -76,9 +77,9 @@
|
||||
|
||||
<!-- 电话按钮 -->
|
||||
<view
|
||||
v-if="fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
v-if="hasPhoneButton"
|
||||
class="btn-item common-bg btn-phone"
|
||||
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
@click="call"
|
||||
>
|
||||
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
||||
@@ -109,6 +110,10 @@
|
||||
<script>
|
||||
import { createCustomerService } from '@/common/js/customer-service.js';
|
||||
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
||||
import {
|
||||
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
|
||||
resolveLangButtonText, isLangButtonVisible,
|
||||
} from '@/common/js/floating-button.js';
|
||||
|
||||
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
||||
// key: 语言 value,value: 显示短码(按钮上展示)
|
||||
@@ -161,6 +166,20 @@ function getLangShortCode(langValue, customMap) {
|
||||
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 {
|
||||
name: 'hover-nav',
|
||||
mixins: [i18nLazyMixin],
|
||||
@@ -170,7 +189,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
pageCount: 0,
|
||||
fixBtnShow: true,
|
||||
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
|
||||
shopInfo: null,
|
||||
currentLangIndex: 0,
|
||||
langIndexMap: {},
|
||||
@@ -190,6 +209,12 @@ export default {
|
||||
layoutTopBottom: 272, // --top-btn-bottom
|
||||
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
||||
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 字段(初值空串),
|
||||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||||
i18n: {
|
||||
@@ -237,32 +262,75 @@ export default {
|
||||
customButtonStyle() {
|
||||
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 兜底:
|
||||
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
||||
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
||||
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
||||
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
|
||||
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||
btnItemStyle() {
|
||||
return {
|
||||
width: '72rpx',
|
||||
height: '72rpx',
|
||||
'min-width': '72rpx',
|
||||
'min-height': '72rpx',
|
||||
'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',
|
||||
};
|
||||
},
|
||||
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
|
||||
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
|
||||
// hover_nav_lang_btn_size / hover_nav_lang_size
|
||||
// hover_nav_kefu_btn_size / hover_nav_kefu_size
|
||||
// hover_nav_phone_btn_size / hover_nav_phone_size
|
||||
// 未配置时回退 72rpx(与原行为一致)。
|
||||
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
|
||||
unreadCount() {
|
||||
const customerService = createCustomerService(this);
|
||||
return customerService.getUnreadCount();
|
||||
@@ -296,53 +364,101 @@ export default {
|
||||
return customerService.hasValidKefuConfig();
|
||||
},
|
||||
|
||||
//根据显示的按钮数量动态计算容器高度
|
||||
// 计算明细(buttonCount 按钮场景):
|
||||
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
|
||||
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
|
||||
// 总高度 (rpx):buttonCount × 100 + 40
|
||||
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
|
||||
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
|
||||
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
|
||||
// 关联:按钮尺寸与 to-top 同步(72rpx),所以槽位高度也是 100rpx;
|
||||
// 之前按钮 80rpx 时槽位 108rpx,改为 72rpx 后槽位缩小 8rpx,容器整体更紧凑
|
||||
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
|
||||
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
|
||||
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
|
||||
// 旧公式 buttonCount*100+40 是"所有按钮 72rpx"的特例, 现按逐按钮实际尺寸累加
|
||||
containerHeightNum() {
|
||||
const sizes = this.renderedBtnSizes;
|
||||
if (sizes.length === 0) return 0;
|
||||
return sizes.reduce((sum, s) => sum + s + 28, 0) + 40;
|
||||
},
|
||||
containerHeight() {
|
||||
if (!this.fixBtnShow) return '320rpx';
|
||||
|
||||
let buttonCount = 1;
|
||||
if (this.isLanguageSwitchEnabled) buttonCount++;
|
||||
if (this.showWeappSystemKefu) buttonCount++;
|
||||
buttonCount++;
|
||||
const totalRpx = buttonCount * 100 + 40;
|
||||
|
||||
return `${totalRpx}rpx`;
|
||||
return this.rpxStr(this.containerHeightNum);
|
||||
},
|
||||
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
|
||||
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
|
||||
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
|
||||
containerWidthNum() {
|
||||
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
|
||||
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
|
||||
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
|
||||
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
|
||||
},
|
||||
containerWidth() {
|
||||
return this.rpxStr(this.containerWidthNum);
|
||||
},
|
||||
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
||||
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
||||
// containerBottom = Top 顶部边界 + space
|
||||
// ------------------------------------------------------------
|
||||
// space 是经验调优值(非数学推导),手调结果:
|
||||
// H5 : 18 * 5 = 90rpx
|
||||
// MP-WEIXIN: 18 * 1 = 18rpx
|
||||
// 为什么 H5 反而大、微信反而小:
|
||||
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
|
||||
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
|
||||
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
|
||||
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
|
||||
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
|
||||
// 18rpx 就能有清晰的呼吸感。
|
||||
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
|
||||
// space 两端统一为 18rpx:
|
||||
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
|
||||
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
|
||||
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
|
||||
// ------------------------------------------------------------
|
||||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||||
// 两者会被安全区同步上推,space 保持不变
|
||||
containerBottom() {
|
||||
if (!this.fixBtnShow) return '240rpx';
|
||||
let space = 18 * 5;
|
||||
// #ifdef MP-WEIXIN
|
||||
space = 18 * 1;
|
||||
// #endif
|
||||
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
|
||||
const space = 18;
|
||||
const base = this.layoutTopBottom + this.layoutTopSize + space;
|
||||
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
|
||||
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
|
||||
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
|
||||
// 若直接 (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 按钮中心对齐):
|
||||
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
||||
@@ -357,9 +473,10 @@ export default {
|
||||
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
||||
// center 对齐不仅中心精准,突出量也更小
|
||||
containerRight() {
|
||||
if (!this.fixBtnShow) return '0rpx';
|
||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
|
||||
return `${offset}rpx`;
|
||||
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
|
||||
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
|
||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
|
||||
return this.rpxStr(offset);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -420,9 +537,14 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
|
||||
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
|
||||
onShow() {
|
||||
this.refreshRoute();
|
||||
},
|
||||
created() {
|
||||
this.initLanguage();
|
||||
this.pageCount = getCurrentPages().length;
|
||||
this.refreshRoute();
|
||||
|
||||
// 三源兜底(store / 同步 storage / 异步 storage),
|
||||
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
||||
@@ -512,6 +634,23 @@ export default {
|
||||
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
||||
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
||||
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() {
|
||||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||||
@@ -520,9 +659,163 @@ export default {
|
||||
this._origParent.appendChild(this.$el);
|
||||
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
|
||||
},
|
||||
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
|
||||
/**
|
||||
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
||||
@@ -534,9 +827,13 @@ export default {
|
||||
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
||||
const root = document.documentElement;
|
||||
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 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.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
||||
@@ -743,7 +1040,53 @@ export default {
|
||||
this.$langConfig.change(targetLang, url);
|
||||
},
|
||||
|
||||
/**
|
||||
* 客服按钮点击的统一入口。
|
||||
*
|
||||
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
|
||||
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
|
||||
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
|
||||
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
|
||||
*
|
||||
* 为什么不在 customer-service.js#openAiChatService 统一注入:
|
||||
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
|
||||
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
|
||||
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
|
||||
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
|
||||
* 其他入口保持原样。
|
||||
*
|
||||
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
|
||||
*/
|
||||
handleUnifiedKefuClick() {
|
||||
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
|
||||
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
|
||||
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
|
||||
// 与底部 tabBar 无关,继续走原统一入口。
|
||||
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
|
||||
// 清除未读数(与 openAiChatService 行为保持一致)
|
||||
if (typeof this.setAiUnreadCount === 'function') {
|
||||
this.setAiUnreadCount(0);
|
||||
} else if (this.$store && typeof this.$store.commit === 'function') {
|
||||
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
|
||||
}
|
||||
|
||||
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
|
||||
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
|
||||
uni.navigateTo({
|
||||
url: aiChatUrl,
|
||||
fail: (err) => {
|
||||
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
|
||||
// H5 兜底
|
||||
// #ifdef H5
|
||||
window.location.href = aiChatUrl;
|
||||
// #endif
|
||||
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
|
||||
this.customerService.handleUnifiedKefuClick({
|
||||
sendMessageTitle: '来自悬浮按钮的咨询',
|
||||
sendMessagePath: '/pages/index/index'
|
||||
@@ -775,8 +1118,13 @@ export default {
|
||||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||||
width: var(--floating-btn-width, 100rpx);
|
||||
box-sizing: border-box;
|
||||
transition: 0.3s;
|
||||
overflow: hidden;
|
||||
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
|
||||
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
|
||||
/* 关键: 不裁剪子元素。按钮尺寸由后台 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 等安全区 */
|
||||
margin-bottom: var(--window-bottom);
|
||||
}
|
||||
@@ -784,6 +1132,9 @@ export default {
|
||||
.btn-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
|
||||
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
line-height: 1;
|
||||
@@ -818,22 +1169,42 @@ export default {
|
||||
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 {
|
||||
font-size: 28rpx;
|
||||
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
|
||||
font-size: var(--hover-nav-font-size, 36rpx);
|
||||
}
|
||||
|
||||
.iconfont,
|
||||
.icox {
|
||||
font-size: 36rpx;
|
||||
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
|
||||
font-size: var(--hover-nav-font-size, 36rpx);
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
|
||||
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
|
||||
* 直接固定 72x72 更稳。 */
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
|
||||
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
|
||||
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
|
||||
width: var(--btn-size, 72rpx);
|
||||
height: var(--btn-size, 72rpx);
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
@@ -851,9 +1222,17 @@ export default {
|
||||
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,
|
||||
.icox text {
|
||||
font-size: 36rpx;
|
||||
font-size: var(--hover-nav-font-size, 36rpx);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -865,7 +1244,8 @@ export default {
|
||||
}
|
||||
|
||||
.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%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -884,7 +1264,8 @@ export default {
|
||||
justify-content: center;
|
||||
width: 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;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<input
|
||||
@@ -22,42 +22,21 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
||||
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
|
||||
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
|
||||
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
|
||||
<textarea
|
||||
:rows="item.value.rows || 2"
|
||||
:rows="item.value.rows || textareaRowsValue"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-HARMONY -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序原生 textarea:
|
||||
不用 auto-height(会随内容无限扩展遮挡后续组件),改用 textareaStyle 中
|
||||
固定 height 176rpx(约 4 行)+ line-height 44rpx,可视区域比原 88rpx 翻倍,
|
||||
内容多时内部滚动条 thumb 比例改善一倍,且高度可控。 -->
|
||||
<textarea
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<textarea
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -69,11 +48,11 @@
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<view :class="['box', 'box-flex']">
|
||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
</picker>
|
||||
@@ -81,8 +60,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
||||
@@ -101,8 +80,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
||||
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
||||
@@ -121,8 +100,8 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box img-boxs">
|
||||
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
||||
@@ -137,23 +116,23 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -178,23 +157,23 @@
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box box-flex">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||
</picker>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view class="box date-boxs">
|
||||
<view class="date-box">
|
||||
@@ -217,20 +196,20 @@
|
||||
</view>
|
||||
|
||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||
<view class="order-cell box-flex" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<view class="order-cell" :style="rowCellStyle">
|
||||
<view class="name" :style="nameWidthStyle">
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
<text class="tit">{{ item.value.title }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'box-flex']">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
|
||||
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||
</view>
|
||||
</pick-regions>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="box">
|
||||
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
|
||||
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
|
||||
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
|
||||
</view>
|
||||
</pick-regions>
|
||||
</view>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
@@ -284,23 +263,28 @@
|
||||
config() {
|
||||
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
||||
const defaults = {
|
||||
formPaddingX: 0,
|
||||
formPaddingX: 28,
|
||||
formBgColor: '#ffffff',
|
||||
rowPaddingY: 20,
|
||||
rowPaddingY: 24,
|
||||
rowMinHeight: 60,
|
||||
labelGap: 16,
|
||||
inputHeight: 60,
|
||||
inputRadius: 0,
|
||||
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
|
||||
labelGap: 20,
|
||||
inputHeight: 84,
|
||||
inputRadius: 16,
|
||||
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
|
||||
labelFixedWidth: 180,
|
||||
labelFontSize: 28,
|
||||
labelFontSize: 32,
|
||||
labelColor: '#1f2937',
|
||||
labelFontWeight: 600,
|
||||
labelFontWeight: 700,
|
||||
labelAutoMaxWidth: 200,
|
||||
requiredColor: '#ef4444',
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#909399',
|
||||
checkedColor: '#3b82f6',
|
||||
placeholderColor: '#9ca3af',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-newform 对齐)
|
||||
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
|
||||
// line-height 用于在 textareaStyle 里算出对应的 px 高度;不再写死 176rpx。
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 44,
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#f9fafb',
|
||||
@@ -334,6 +318,8 @@
|
||||
checkedColor: pick('checkedColor', 'checked_color'),
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
||||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
||||
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
|
||||
@@ -394,6 +380,8 @@
|
||||
'--checked-color': cfg.checkedColor,
|
||||
'--placeholder-color': cfg.placeholderColor,
|
||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||
'--textarea-rows': num(cfg.textareaRows, 4),
|
||||
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
'--input-border-color': cfg.inputBorderColor,
|
||||
'--input-bg-color': cfg.inputBgColor,
|
||||
@@ -416,13 +404,10 @@
|
||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||
});
|
||||
},
|
||||
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
|
||||
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
|
||||
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
|
||||
nameWidthStyle() {
|
||||
const cfg = this.config;
|
||||
if (cfg.labelWidthMode === 'fixed') {
|
||||
return { width: this.$util.rpx(cfg.labelFixedWidth) };
|
||||
}
|
||||
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
|
||||
return {};
|
||||
},
|
||||
// 输入框样式(input / textarea 共用)
|
||||
inputStyle() {
|
||||
@@ -434,21 +419,37 @@
|
||||
'line-height': this.$util.rpx(this.config.inputHeight)
|
||||
});
|
||||
},
|
||||
// textarea 专用 inline style(仅微信小程序端使用,见 template 中 #ifdef MP-WEIXIN 分支)
|
||||
// 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 单行过高、可视区域过小,
|
||||
// 内容稍多即溢出导致原生滚动条 thumb 异常小;
|
||||
// 2) 不使用 auto-height:在微信小程序原生 textarea 上,auto-height 会让 textarea 随内容
|
||||
// 无限扩展,遮挡后续组件,导致"看不到下面的其他组件";
|
||||
// 3) 改用固定 height 176rpx(约 4 行,line-height 44rpx),相比原 CSS height: 88rpx(2 行)
|
||||
// 可视区域翻倍,内容多时内部滚动条 thumb 比例改善一倍,且高度可控不会遮挡后续组件。
|
||||
// 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 h = this.$util.rpx(176);
|
||||
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': this.$util.rpx(44),
|
||||
'line-height': lh,
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius),
|
||||
'font-size': this.$util.rpx(this.config.labelFontSize),
|
||||
'color': this.config.labelColor
|
||||
@@ -944,28 +945,31 @@
|
||||
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
||||
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
||||
.form-wrap {
|
||||
--form-padding-x: 0rpx;
|
||||
--form-padding-x: 28rpx;
|
||||
--form-bg-color: #ffffff;
|
||||
--row-padding-y: 20rpx;
|
||||
--row-padding-y: 24rpx;
|
||||
--row-min-height: 60rpx;
|
||||
--label-gap: 16rpx;
|
||||
--input-height: 60rpx;
|
||||
--input-radius: 0rpx;
|
||||
--label-gap: 20rpx;
|
||||
--input-height: 84rpx;
|
||||
--input-radius: 16rpx;
|
||||
--label-fixed-width: 180rpx;
|
||||
--label-font-size: 28rpx;
|
||||
--label-font-size: 32rpx;
|
||||
--label-color: #1f2937;
|
||||
--label-font-weight: 600;
|
||||
--label-font-weight: 700;
|
||||
--required-color: #ef4444;
|
||||
--placeholder-font-size: 28rpx;
|
||||
--checked-color: #ff4544;
|
||||
--placeholder-color: #909399;
|
||||
--checked-color: #3b82f6;
|
||||
--placeholder-color: #9ca3af;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 48rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #f9fafb;
|
||||
--input-focus-border-color: #ff4544;
|
||||
--input-bg-color: #ffffff;
|
||||
--input-focus-border-color: #3b82f6;
|
||||
--input-focus-bg-color: #ffffff;
|
||||
--row-divider-color: #f0f0f0;
|
||||
--show-row-divider: 1;
|
||||
--show-row-divider: 0;
|
||||
}
|
||||
|
||||
.order-wrap {
|
||||
@@ -983,7 +987,8 @@
|
||||
|
||||
.order-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
min-height: var(--row-min-height, 60rpx);
|
||||
@@ -1016,18 +1021,23 @@
|
||||
}
|
||||
|
||||
.name {
|
||||
width: var(--label-fixed-width, 180rpx);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
text-align: left;
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
font-weight: var(--label-font-weight, 600);
|
||||
font-weight: var(--label-font-weight, 700);
|
||||
|
||||
text {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
@@ -1037,15 +1047,17 @@
|
||||
.required {
|
||||
color: var(--required-color, #ef4444);
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
margin-left: 4rpx;
|
||||
width: 14rpx;
|
||||
margin-right: 6rpx;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.box {
|
||||
flex: 1;
|
||||
padding: 0 10rpx;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
text-align: left;
|
||||
|
||||
@@ -1057,26 +1069,27 @@
|
||||
color: var(--label-color, #1f2937);
|
||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||
// 避免 placeholder / 文字下半部分被裁切
|
||||
line-height: var(--input-height, 60rpx);
|
||||
height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
height: var(--input-height, 84rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 0 12rpx;
|
||||
width: 100%;
|
||||
padding: 0 24rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
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, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
|
||||
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||
&::placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
}
|
||||
}
|
||||
@@ -1084,9 +1097,9 @@
|
||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||
.placeholder {
|
||||
color: var(--placeholder-color, #909399);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
font-size: var(--placeholder-font-size, 28rpx);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
}
|
||||
|
||||
::v-deep textarea,
|
||||
@@ -1094,35 +1107,36 @@
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
width: 100%;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
|
||||
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
|
||||
min-height: 88rpx;
|
||||
height: 88rpx;
|
||||
line-height: 44rpx;
|
||||
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
|
||||
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
||||
// 真正做到了"rows 改 → 高度自动改"。
|
||||
line-height: var(--textarea-line-height, 48rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
box-sizing: border-box;
|
||||
padding: 12rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
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, #ff4544);
|
||||
border-color: var(--input-focus-border-color, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.uni-input {
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
color: var(--label-color, #1f2937);
|
||||
line-height: var(--input-height, 60rpx);
|
||||
min-height: var(--input-height, 60rpx);
|
||||
line-height: var(--input-height, 84rpx);
|
||||
min-height: var(--input-height, 84rpx);
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
background: var(--input-bg-color, #f9fafb);
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
background: var(--input-bg-color, #ffffff);
|
||||
border-radius: var(--input-radius, 16rpx);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -1132,26 +1146,36 @@
|
||||
}
|
||||
|
||||
.color-tip {
|
||||
color: var(--placeholder-color, #909399);
|
||||
color: var(--placeholder-color, #9ca3af);
|
||||
}
|
||||
|
||||
checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
|
||||
picker,
|
||||
.pick-regions {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 在 box-flex(带右侧箭头)场景下,picker 不应占满,留出右侧箭头空间
|
||||
&.box-flex {
|
||||
picker,
|
||||
.pick-regions {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.iconfont.icon-right {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
||||
.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -1161,6 +1185,39 @@
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
||||
// 注意:option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 会选不中
|
||||
&.option-layout-vertical {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.option-layout-wrap {
|
||||
checkbox-group,
|
||||
radio-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-right: 30rpx;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.img-boxs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1236,10 +1293,11 @@
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,10 +1311,11 @@
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -325,14 +325,69 @@ export default {
|
||||
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
this.getCode(authData => {
|
||||
this.authLogin(authData, 'authOnlyLogin');
|
||||
}, (err) => {
|
||||
callback && callback(err);
|
||||
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
|
||||
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
|
||||
// "privacy permission is not authorized or buttonId is invalid"。
|
||||
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
|
||||
this.ensureWxPrivacyAuthorized(() => {
|
||||
this.getCode(authData => {
|
||||
this.authLogin(authData, 'authOnlyLogin');
|
||||
}, (err) => {
|
||||
callback && callback(err);
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
|
||||
ensureWxPrivacyAuthorized(done) {
|
||||
try {
|
||||
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
|
||||
// 低版本基础库无隐私 API, 直接放行
|
||||
done();
|
||||
return;
|
||||
}
|
||||
wx.getPrivacySetting({
|
||||
success: (res) => {
|
||||
if (!res || !res.needAuthorization) {
|
||||
// 已授权或无隐私指引配置, 直接放行
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
|
||||
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
|
||||
const popup = this.$refs.privacyPopup;
|
||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
||||
const onAgreed = () => {
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
done();
|
||||
};
|
||||
const onDisagreed = () => {
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
|
||||
};
|
||||
uni.$off('privacy:agreed', onAgreed);
|
||||
uni.$off('privacy:disagreed', onDisagreed);
|
||||
uni.$on('privacy:agreed', onAgreed);
|
||||
uni.$on('privacy:disagreed', onDisagreed);
|
||||
popup.showPrivacyPopup();
|
||||
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
|
||||
// 无 privacy-popup 实例时退回微信官方隐私弹窗
|
||||
wx.requirePrivacyAuthorize({
|
||||
success: () => done(),
|
||||
fail: () => { }
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => done()
|
||||
});
|
||||
} catch (e) {
|
||||
done();
|
||||
}
|
||||
},
|
||||
|
||||
// 跳转去登录页
|
||||
toLoginH5Page(mode = 'navigateTo') {
|
||||
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
||||
@@ -614,8 +669,24 @@ export default {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const errMsg = (e.detail && e.detail.errMsg) || '';
|
||||
// #ifdef MP-WEIXIN
|
||||
// 隐私协议未授权时 getPhoneNumber 会直接失败:
|
||||
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
|
||||
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
|
||||
if (errMsg.indexOf('privacy') !== -1) {
|
||||
this.$util.showToast({
|
||||
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
|
||||
});
|
||||
const popup = this.$refs.privacyPopup;
|
||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
||||
popup.showPrivacyPopup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
this.$util.showToast({
|
||||
title: e.detail.errMsg
|
||||
title: errMsg
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,13 @@
|
||||
<view class="video-popup-body">
|
||||
<video
|
||||
v-if="videoUrl"
|
||||
id="popupVideoPlayer"
|
||||
:src="videoUrl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
class="video-player"
|
||||
></video>
|
||||
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
||||
@@ -22,6 +26,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { playVideoInContext } from '@/common/js/video-helper.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-video-player-popup',
|
||||
props: {
|
||||
@@ -76,6 +82,10 @@ export default {
|
||||
* @param {Object} e - 弹窗状态变化事件
|
||||
*/
|
||||
onVideoPopupChange(e) {
|
||||
// H5 端弹窗显示后触发播放:避免 autoplay 被浏览器拦截 / 弹窗动画未完成被 pause() 打断
|
||||
if (e && e.show) {
|
||||
playVideoInContext('popupVideoPlayer', this);
|
||||
}
|
||||
this.$emit('popup-change', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,18 +281,18 @@ export default {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (wx.onNeedPrivacyAuthorization) {
|
||||
wx.onNeedPrivacyAuthorization((resolve) => {
|
||||
// 检查是否已同意
|
||||
if (!PrivacyManager.needShow()) {
|
||||
// 已同意, 直接放行
|
||||
resolve({ event: 'agree' })
|
||||
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
|
||||
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
|
||||
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
|
||||
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
|
||||
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
|
||||
if (typeof this.privacyAuthorization === 'function') {
|
||||
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
|
||||
this.privacyAuthorization(resolve)
|
||||
} else {
|
||||
// 需要弹窗, 存 resolve 等用户操作
|
||||
if (typeof this.privacyAuthorization === 'function') {
|
||||
this.privacyAuthorization(resolve)
|
||||
} else {
|
||||
// 兜底: 组件未就绪, 直接放行避免 API 卡死
|
||||
resolve({ event: 'agree' })
|
||||
}
|
||||
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
|
||||
// (disagree 无需 buttonId, 不会产生非法 resolve)
|
||||
resolve({ event: 'disagree' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -567,6 +567,12 @@ export default {
|
||||
flex: 1;
|
||||
margin-right: 30rpx;
|
||||
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 {
|
||||
|
||||
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)全量审计报告
|
||||
|
||||
> 审计范围:`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
|
||||
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
||||
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
||||
|
||||
@@ -106,8 +106,9 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t
|
||||
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
||||
|
||||
```bash
|
||||
# 以下命令均在项目根目录(含 package.json)下执行
|
||||
|
||||
# 1. 静态语法检查
|
||||
cd D:/projects/shop-projects/frontend
|
||||
node --check common/js/theme-override.js
|
||||
node --check store/index.js
|
||||
# App.vue 走 vue 编译器,不在 node 范畴
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"loggingIn": "登录中",
|
||||
"loginFailed": "登录失败",
|
||||
"pleaseAuthorizeMobile": "请授权您的手机号码",
|
||||
"pleaseAgreePrivacy": "请先同意《隐私条款》后重试",
|
||||
"pleaseEnterNickname": "请输入昵称",
|
||||
"avatarPullFailed": "头像拉取失败"
|
||||
},
|
||||
|
||||
@@ -12,12 +12,29 @@ export const lang = {
|
||||
timeFormatToday: 'Today',
|
||||
timeFormatYesterday: '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 ======
|
||||
aiNickname: 'AI Assistant',
|
||||
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',
|
||||
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...',
|
||||
expandEditorTitle: 'Edit Content',
|
||||
loadMoreHistory: 'Load more history',
|
||||
@@ -112,15 +129,48 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: 'More Features',
|
||||
image: 'Image',
|
||||
video: 'Video/Audio',
|
||||
file: 'File',
|
||||
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',
|
||||
// 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 ======
|
||||
voiceInputTitle: 'Voice Input',
|
||||
recordingTip: 'Recording, release to end',
|
||||
startRecordTip: 'Tap to start recording',
|
||||
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 ======
|
||||
menuClearChat: 'Clear Chat',
|
||||
@@ -136,4 +186,8 @@ export const lang = {
|
||||
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
||||
logChatComponentNotInit: 'Chat component not initialized',
|
||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
||||
}
|
||||
|
||||
// ====== AI Agent general copy (customer service is the first agent) ======
|
||||
aiAgentDefaultTitle: 'AI Agent',
|
||||
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
|
||||
aiAgentNotConfigured: 'This AI Agent is not enabled'}
|
||||
|
||||
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',
|
||||
loginFailed: 'Login failed',
|
||||
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
||||
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
|
||||
pleaseEnterNickname: 'Please enter nickname',
|
||||
avatarPullFailed: 'Avatar pull failed',
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const lang = {
|
||||
fileUrlNotExist: 'File URL does not exist',
|
||||
// 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
|
||||
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',
|
||||
// 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',
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const lang = {
|
||||
//title为每个页面的标题
|
||||
title: 'AI智能客服',
|
||||
title: 'AI智能',
|
||||
|
||||
// ====== 通用 ======
|
||||
cancel: '取消',
|
||||
@@ -12,12 +12,28 @@ export const lang = {
|
||||
timeFormatToday: '今天',
|
||||
timeFormatYesterday: '昨天',
|
||||
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 ======
|
||||
aiNickname: '智能助手',
|
||||
defaultNickname: '我',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
|
||||
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
|
||||
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
|
||||
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
|
||||
audioMessage: '语音消息',
|
||||
videoMessage: '视频消息',
|
||||
audioInstruction: '我发送了一段语音,请听一下并回复我',
|
||||
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
|
||||
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
|
||||
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
|
||||
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
|
||||
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
|
||||
inputPlaceholder: '请输入您的问题...',
|
||||
expandEditorTitle: '编辑内容',
|
||||
loadMoreHistory: '加载更多历史消息',
|
||||
@@ -112,15 +128,49 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: '更多功能',
|
||||
image: '图片',
|
||||
video: '视频/音频',
|
||||
file: '文件',
|
||||
voice: '语音',
|
||||
recordVoice: '录制语音',
|
||||
uploadAudio: '上传音频',
|
||||
pickVideo: '选择视频',
|
||||
pickAudio: '选择音频',
|
||||
chooseAudioFail: '选择音频失败',
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
location: '位置',
|
||||
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
|
||||
locationPermissionTitle: '需要位置权限',
|
||||
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
|
||||
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
|
||||
locationSettingBtn: '去设置',
|
||||
locationSkipBtn: '暂不开启',
|
||||
locationH5GuideBtn: '我知道了',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
voiceInputTitle: '语音输入',
|
||||
recordingTip: '正在录音,松开结束',
|
||||
startRecordTip: '点击开始录音',
|
||||
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 - 菜单 ======
|
||||
menuClearChat: '清空聊天',
|
||||
@@ -136,4 +186,8 @@ export const lang = {
|
||||
logInitNavbarFailed: '初始化导航栏失败',
|
||||
logChatComponentNotInit: '聊天组件未初始化',
|
||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
||||
}
|
||||
|
||||
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
||||
aiAgentDefaultTitle: 'AI 智能体',
|
||||
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
|
||||
aiAgentNotConfigured: '该 AI 智能体暂未启用'}
|
||||
|
||||
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: '登录中',
|
||||
loginFailed: '登录失败',
|
||||
pleaseAuthorizeMobile: '请授权您的手机号码',
|
||||
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
|
||||
pleaseEnterNickname: '请输入昵称',
|
||||
avatarPullFailed: '头像拉取失败',
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const lang = {
|
||||
fileUrlNotExist: '文件地址不存在',
|
||||
// 视频地址不存在
|
||||
videoUrlNotExist: '视频地址不存在',
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
|
||||
audioUrlNotExist: '音频地址不存在',
|
||||
audioDefaultTitle: '语音消息',
|
||||
audioPlaying: '播放中…',
|
||||
audioPaused: '已暂停',
|
||||
// 暂不支持该类型文件预览
|
||||
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: '提交成功',
|
||||
// 未获取到表单信息
|
||||
noFormInfo: '未获取到表单信息',
|
||||
// 行为设置相关
|
||||
// 请输入验证码
|
||||
captchaPlaceholder: '请输入验证码',
|
||||
// 请输入验证码(校验提示)
|
||||
captchaRequired: '请输入验证码',
|
||||
// 验证码错误
|
||||
captchaError: '验证码错误,请重试',
|
||||
// 提交次数已达上限
|
||||
submitLimit: '提交次数已达上限',
|
||||
// 表单未开放
|
||||
notOpen: '表单未开放',
|
||||
// 请先登录
|
||||
loginRequired: '请先登录',
|
||||
}
|
||||
|
||||
2
main.js
2
main.js
@@ -6,6 +6,7 @@ import App from './App'
|
||||
import store from './store'
|
||||
import Util from './common/js/util.js'
|
||||
import Http from './common/js/http.js'
|
||||
import CardApi from './common/js/card-api.js'
|
||||
import Lang from './common/js/lang.js'
|
||||
import Config from './common/js/config.js'
|
||||
import EventBus from './common/js/event-bus.js'
|
||||
@@ -25,6 +26,7 @@ Vue.config.productionTip = false
|
||||
|
||||
Vue.prototype.$util = Util;
|
||||
Vue.prototype.$api = Http;
|
||||
Vue.prototype.$cardApi = CardApi; // 名片云(card addon) API 收口层
|
||||
|
||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
},
|
||||
"scope.writePhotosAlbum" : {
|
||||
"desc" : "用于从相册选择和保存头像"
|
||||
},
|
||||
"scope.record" : {
|
||||
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
||||
"pack:harmony": "node scripts/pack-harmony-project.js",
|
||||
"i18n:check": "node scripts/i18n-check.js",
|
||||
"test": "node scripts/test-gate.js",
|
||||
"test:util": "node --test tests/util.test.js",
|
||||
@@ -89,4 +90,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
246
pages.json
246
pages.json
@@ -51,6 +51,7 @@
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-recommend-code": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
@@ -1085,7 +1086,64 @@
|
||||
{
|
||||
"path": "ai-chat/index",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1294,6 +1352,7 @@
|
||||
"diy-pintuan": "view",
|
||||
"diy-presale": "view",
|
||||
"diy-quick-nav": "view",
|
||||
"diy-recommend-code": "view",
|
||||
"diy-rich-text": "view",
|
||||
"diy-rubik-cube": "view",
|
||||
"diy-search": "view",
|
||||
@@ -1408,6 +1467,118 @@
|
||||
"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",
|
||||
"style": {
|
||||
@@ -2144,13 +2315,72 @@
|
||||
// #ifdef APP-PLUS
|
||||
"navigationStyle": "custom",
|
||||
// #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",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
|
||||
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
|
||||
// #ifdef APP-PLUS || MP
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "",
|
||||
@@ -2381,10 +2611,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "files/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件预览"
|
||||
"navigationBarTitleText": "文件预览",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2432,7 +2669,6 @@
|
||||
"pages/index/index": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"pages_tool",
|
||||
"components-diy"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<ns-copyright v-show="isShowCopyRight" />
|
||||
</view>
|
||||
|
||||
<template v-if="adv.advshow != -1">
|
||||
<template v-if="adv && adv.advshow != -1">
|
||||
<view @touchmove.prevent.stop>
|
||||
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
||||
<view class="small-bot">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
|
||||
<view class="goods-detail-skeleton-action">
|
||||
<view class="skeleton-action-icons">
|
||||
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
|
||||
<view 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>
|
||||
@@ -56,10 +56,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品视频 -->
|
||||
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
|
||||
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
|
||||
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
|
||||
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"></video>
|
||||
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
|
||||
playsinline webkit-playsinline x5-playsinline></video>
|
||||
</view>
|
||||
|
||||
<!-- 切换视频、图片 -->
|
||||
|
||||
@@ -384,5 +384,14 @@ export default {
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
}
|
||||
|
||||
// 购物车页面SKU弹窗底部按钮上移,避开底部导航栏
|
||||
::v-deep .goods-sku .sku-info .footer {
|
||||
bottom: calc(30rpx + env(safe-area-inset-bottom) + 112rpx) !important;
|
||||
}
|
||||
|
||||
::v-deep .goods-sku .sku-info .body-item {
|
||||
padding-bottom: 112rpx;
|
||||
}
|
||||
|
||||
@import './public/css/cart.scss';
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@
|
||||
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
|
||||
<view class="price-box">
|
||||
<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>
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
|
||||
</view>
|
||||
<view class="logistics-wrap">
|
||||
<text v-if="goodsSkuDetail.stock_show">{{$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.stock_show && !isPhoneConsult">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
|
||||
<text v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
|
||||
</view>
|
||||
<!-- 会员卡 -->
|
||||
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
|
||||
@@ -461,8 +461,9 @@
|
||||
<ns-goods-action-icon :text="$lang('goods.detail.home')" icon="icon-shouye1" @click="goHome" />
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<ns-goods-action-icon
|
||||
:text="$lang('goods.detail.cart')"
|
||||
<ns-goods-action-icon
|
||||
v-if="!isPhoneConsult"
|
||||
:text="$lang('goods.detail.cart')"
|
||||
:cornerMarkBg="themeStyle.goods_cart_num_corner"
|
||||
icon="icon-gouwuche2"
|
||||
:corner-mark="cartNumber > 0 ? cartNumber + '' : ''"
|
||||
@@ -565,6 +566,10 @@ export default {
|
||||
},
|
||||
mixins: [base, detail, scroll],
|
||||
computed: {
|
||||
// 后台选择"电话立即咨询"模式(isinformation == 1)时,隐藏购物车图标、库存和销量
|
||||
isPhoneConsult() {
|
||||
return this.goodsSkuDetail && this.goodsSkuDetail.isinformation == 1;
|
||||
},
|
||||
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
||||
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
||||
formDiyValue() {
|
||||
@@ -603,7 +608,7 @@ export default {
|
||||
uni.setStorageSync('selectedProduct', productInfo);
|
||||
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/ai-chat/index'
|
||||
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,7 +263,10 @@ export default {
|
||||
// 搜索入口按钮
|
||||
.search-entrance {
|
||||
position: absolute;
|
||||
top: 44rpx;
|
||||
/* 与新对话按钮垂直居中对齐:
|
||||
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
|
||||
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
|
||||
top: 14rpx;
|
||||
right: 16rpx;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
|
||||
@@ -9,27 +9,38 @@
|
||||
</button>
|
||||
</view>
|
||||
<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">
|
||||
<text class="iconfont icon-image"></text>
|
||||
<view class="image-icon"></view>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||
</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">
|
||||
<text class="iconfont icon-file"></text>
|
||||
<text class="icondiy icon-system-video"></text>
|
||||
</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 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">
|
||||
<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>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||
</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">
|
||||
<text class="iconfont icon-location"></text>
|
||||
<text class="icondiy icon-system-map-pin-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
||||
</view>
|
||||
@@ -39,16 +50,20 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||
/**
|
||||
* ai-chat-tools-panel - 更多工具面板组件
|
||||
*
|
||||
* Props:
|
||||
* 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:
|
||||
* update:visible - 同步关闭状态
|
||||
* image-selected - 选择图片,参数:图片消息对象
|
||||
* file-selected - 选择文件,参数:文件消息对象
|
||||
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
|
||||
* voice-start - 开始语音
|
||||
* location-selected - 选择位置,参数:位置消息对象
|
||||
*/
|
||||
@@ -59,6 +74,12 @@ export default {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
attachmentTypes: {
|
||||
type: Array,
|
||||
default: function () {
|
||||
return ['image', 'file', 'audio', 'video']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -73,75 +94,308 @@ export default {
|
||||
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() {
|
||||
const that = this
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
count: 9,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const imageMessage = {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'image',
|
||||
content: '',
|
||||
imageUrl: res.tempFilePaths[0],
|
||||
timestamp: Date.now()
|
||||
that.hideToolsPanel()
|
||||
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
|
||||
// 用户可继续输入文字, 点发送时图文一起发出
|
||||
that.$emit('image-picked', res.tempFilePaths || [])
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: '选图失败', icon: 'none' })
|
||||
}
|
||||
this.hideToolsPanel()
|
||||
this.$emit('image-selected', imageMessage)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
selectFile() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '*/*'
|
||||
input.onchange = (e) => {
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const fileMessage = {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'file',
|
||||
content: '',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.hideToolsPanel()
|
||||
this.$emit('file-selected', fileMessage)
|
||||
}
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
|
||||
}
|
||||
input.click()
|
||||
// #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
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
const file = res.tempFiles[0]
|
||||
const fileMessage = {
|
||||
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)
|
||||
await that._uploadOneFile(file, 'file')
|
||||
}
|
||||
})
|
||||
// #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() {
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
|
||||
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
|
||||
this.$emit('voice-start')
|
||||
this.hideToolsPanel()
|
||||
},
|
||||
@@ -209,11 +463,12 @@ export default {
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
position: relative; // 为 .close-btn 绝对定位提供参考
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
font-size: 30rpx;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@@ -225,17 +480,19 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
|
||||
top: -8rpx;
|
||||
right: 10rpx; //
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24rpx;
|
||||
|
||||
.tool-item {
|
||||
@@ -257,7 +514,8 @@ export default {
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.iconfont {
|
||||
.iconfont,
|
||||
.icondiy {
|
||||
font-size: 48rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
@@ -269,6 +527,62 @@ export default {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,239 +1,403 @@
|
||||
<template>
|
||||
<view class="voice-popup" v-if="visible">
|
||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||
<view class="voice-input-panel">
|
||||
<view class="voice-header">
|
||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||
<button class="close-btn" @click="hideVoicePanel">
|
||||
<text class="iconfont icon-close"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="voice-content">
|
||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||
<view
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="wave-bar"
|
||||
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
|
||||
</view>
|
||||
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
|
||||
</view>
|
||||
<view class="voice-actions">
|
||||
<button class="voice-btn" @click="toggleVoiceInput">
|
||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* ai-chat-voice-panel - 语音输入面板组件
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* close - 请求关闭面板
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatVoicePanel',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
voiceInputing: false
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (!val) {
|
||||
this.voiceInputing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
hideVoicePanel() {
|
||||
this.voiceInputing = false
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
toggleVoiceInput() {
|
||||
this.voiceInputing = !this.voiceInputing
|
||||
if (this.voiceInputing) {
|
||||
this.startVoiceInput()
|
||||
} else {
|
||||
this.stopVoiceInput()
|
||||
}
|
||||
},
|
||||
|
||||
startVoiceInput() {
|
||||
// #ifdef H5
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
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() {
|
||||
this.voiceInputing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.voice-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
|
||||
.voice-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.voice-input-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: 48rpx;
|
||||
width: 60%;
|
||||
max-width: 520rpx;
|
||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 48rpx;
|
||||
|
||||
.voice-wave {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&.recording .wave-bar {
|
||||
animation: wave 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
height: 40rpx;
|
||||
margin: 0 4rpx;
|
||||
background-color: $color-primary;
|
||||
border-radius: 4rpx;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tip {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.voice-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 56rpx;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<view class="voice-popup" v-if="visible">
|
||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||
<view class="voice-input-panel">
|
||||
<view class="voice-header">
|
||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||
<button class="close-btn" @click="hideVoicePanel">
|
||||
<text class="iconfont icon-close"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="voice-content">
|
||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||
<view
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="wave-bar"
|
||||
:style="waveBarStyle(i)"></view>
|
||||
</view>
|
||||
<text class="voice-tip">{{ currentTip }}</text>
|
||||
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
|
||||
</view>
|
||||
<view class="voice-actions">
|
||||
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
|
||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||
</button>
|
||||
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
||||
/**
|
||||
* 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:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* close - 请求关闭面板
|
||||
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatVoicePanel',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
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: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
// 每次重新打开都重置
|
||||
this.voiceInputing = false
|
||||
this.voiceLevel = 0
|
||||
this.voiceDuration = 0
|
||||
this._resolved = false
|
||||
} else {
|
||||
// 关闭时如果还在录音,丢弃
|
||||
this._discardRecorderIfAny()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this._discardRecorderIfAny()
|
||||
},
|
||||
|
||||
methods: {
|
||||
hideVoicePanel() {
|
||||
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
|
||||
this._discardRecorderIfAny()
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
toggleVoiceInput() {
|
||||
if (this.voiceInputing) {
|
||||
this._finishRecording()
|
||||
} else {
|
||||
this._beginRecording()
|
||||
}
|
||||
},
|
||||
|
||||
cancelVoiceInput() {
|
||||
this._discardRecorderIfAny()
|
||||
this.hideVoicePanel()
|
||||
},
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.voice-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
|
||||
.voice-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.voice-input-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: 48rpx;
|
||||
width: 60%;
|
||||
max-width: 520rpx;
|
||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
|
||||
.voice-wave {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&.recording .wave-bar {
|
||||
animation: wave 1.0s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
margin: 0 4rpx;
|
||||
background-color: $color-primary;
|
||||
border-radius: 4rpx;
|
||||
transition: height 0.12s ease-out;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tip {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.voice-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||
border: none;
|
||||
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||
|
||||
&.is-recording {
|
||||
background-color: #e94e4e;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 56rpx;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-link {
|
||||
margin-top: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-light;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.6); }
|
||||
50% { transform: scaleY(1.15); }
|
||||
}
|
||||
</style>
|
||||
@@ -13,9 +13,12 @@
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
|
||||
<view class="chat-content">
|
||||
<ai-chat-message
|
||||
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
|
||||
<view class="chat-content" :style="{ bottom: '0rpx' }">
|
||||
<ai-chat-message
|
||||
v-if="uiCfgReady"
|
||||
ref="chat"
|
||||
:preloaded-ui-config="preloadedUiCfg"
|
||||
:initial-messages="initialMessages"
|
||||
:fallback-welcome="fallbackWelcome"
|
||||
:user-avatar="userAvatar"
|
||||
@@ -68,6 +71,10 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
|
||||
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
|
||||
<diy-bottom-nav v-if="false"></diy-bottom-nav>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -87,6 +94,15 @@ export default {
|
||||
return {
|
||||
initialMessages: [],
|
||||
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
|
||||
@@ -105,7 +121,12 @@ export default {
|
||||
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) {
|
||||
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();
|
||||
// if (!hasAccess) {
|
||||
@@ -134,7 +198,19 @@ export default {
|
||||
// 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();
|
||||
},
|
||||
|
||||
@@ -180,6 +256,15 @@ export default {
|
||||
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
|
||||
this.cleanup();
|
||||
aiService.disconnectWebSocket();
|
||||
|
||||
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
|
||||
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
|
||||
if (this.isFromFloatBtn) {
|
||||
try {
|
||||
this.$store.commit('setBottomNavHidden', false);
|
||||
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
|
||||
this.isFromFloatBtn = false;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -647,7 +732,9 @@ export default {
|
||||
/* #endif */
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* bottom 由模板动态绑定到 tabBarHeight:
|
||||
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
|
||||
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
|
||||
}
|
||||
|
||||
/* 底部tabBar占位样式 */
|
||||
|
||||
@@ -40,20 +40,39 @@
|
||||
export default {
|
||||
data() {
|
||||
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() {
|
||||
this.setPublicShare();
|
||||
},
|
||||
methods: {
|
||||
getData(mescroll) {
|
||||
// 合并 page_params 与显式参数,保证后端拿到完整过滤条件
|
||||
let data = Object.assign({}, this.pageParams, {
|
||||
page_size: mescroll.size,
|
||||
page: mescroll.num
|
||||
});
|
||||
if (this.categoryId) data.category_id = this.categoryId;
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/article/page',
|
||||
data: {
|
||||
page_size: mescroll.size,
|
||||
page: mescroll.num
|
||||
},
|
||||
data: data,
|
||||
success: res => {
|
||||
let newArr = [];
|
||||
let msg = res.message;
|
||||
@@ -87,17 +106,32 @@ export default {
|
||||
// 设置公众号分享
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
|
||||
let query = this.shareQuery();
|
||||
if (query) shareUrl += '?' + query;
|
||||
this.$util.setPublicShare({
|
||||
title: this.$lang('article.list.title'),
|
||||
title: this.title || this.$lang('article.list.title'),
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
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) {
|
||||
var title = this.$lang('article.list.title');
|
||||
var title = this.title || this.$lang('article.list.title');
|
||||
var path = '/pages_tool/article/list';
|
||||
var query = this.shareQuery();
|
||||
if (query) path += '?' + query;
|
||||
return {
|
||||
title: title,
|
||||
path: path,
|
||||
@@ -107,8 +141,10 @@ export default {
|
||||
},
|
||||
//分享到朋友圈
|
||||
onShareTimeline() {
|
||||
var title = this.$lang('article.list.title');
|
||||
var title = this.title || this.$lang('article.list.title');
|
||||
var query = '/pages_tool/article/list';
|
||||
var q = this.shareQuery();
|
||||
if (q) query += '?' + q;
|
||||
return {
|
||||
title: title,
|
||||
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>
|
||||
452
pages_tool/card/contacts.vue
Normal file
452
pages_tool/card/contacts.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<view class="contact-page">
|
||||
<!-- 平级入口: 与潜在客户页互链(导航合并第一步) -->
|
||||
<view class="page-switch" @tap="goLeads">
|
||||
<text class="switch-label">潜在客户</text>
|
||||
<text class="switch-arrow">›</text>
|
||||
</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>
|
||||
|
||||
<!-- 来源筛选 -->
|
||||
<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 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>
|
||||
// 来源筛选: 与后端 Contact::SOURCES 一致; scan/exchange 暂无产生入口, 本轮只放出 3 个 tab
|
||||
const SOURCE_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'manual', label: '手工录入' },
|
||||
{ value: 'lead_in', label: '潜在客户转入' }
|
||||
];
|
||||
|
||||
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 回来会重走 onLoad; 从其它页返回时兜底刷新
|
||||
if (this.list.length > 0) {
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
|
||||
goLeads() {
|
||||
this.$util.redirectTo('/pages_tool/card/leads');
|
||||
},
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 convertToLead(item))会丢参,
|
||||
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
|
||||
*/
|
||||
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;
|
||||
},
|
||||
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?id=' + id : '/pages_tool/card/contact-edit';
|
||||
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 => {
|
||||
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' });
|
||||
}
|
||||
this.isLoading = false;
|
||||
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 });
|
||||
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>
|
||||
.contact-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16rpx 24rpx 0;
|
||||
|
||||
.switch-label {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.switch-arrow {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
1394
pages_tool/card/detail.vue
Normal file
1394
pages_tool/card/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
585
pages_tool/card/edit.vue
Normal file
585
pages_tool/card/edit.vue
Normal file
@@ -0,0 +1,585 @@
|
||||
<template>
|
||||
<view class="card-edit">
|
||||
<!-- 基本信息 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">基本信息</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">头像</view>
|
||||
<view class="avatar-box" @tap="chooseAvatar">
|
||||
<image v-if="form.avatar" :src="$util.img(form.avatar)" class="avatar-img" mode="aspectFill" />
|
||||
<text v-else class="avatar-plus">+</text>
|
||||
</view>
|
||||
<view class="avatar-tip">点击从相册选择</view>
|
||||
</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" :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>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">联系方式</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>
|
||||
<!-- 附加手机号(动态行, 与主手机号并列提交 mobiles) -->
|
||||
<view v-for="(m, i) in mobiles" :key="'mob' + i" class="form-row dyn-row">
|
||||
<view class="row-label">手机{{ i + 2 }}</view>
|
||||
<input class="row-input" type="number" :value="m" placeholder="其他手机号"
|
||||
@input="e => onMobileInput(i, e)" />
|
||||
<text class="dyn-del" @tap="delMobile(i)">×</text>
|
||||
</view>
|
||||
<view class="form-row" v-if="mobiles.length < MAX_MOBILES">
|
||||
<text class="dyn-add" @tap="addMobile">+ 添加手机号(最多{{ MAX_MOBILES }}个)</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">电话</view>
|
||||
<input class="row-input" :value="form.phone" placeholder="固定电话(选填)"
|
||||
@input="e => form.phone = 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.wechat" placeholder="微信号(选填)"
|
||||
@input="e => form.wechat = e.detail.value" />
|
||||
</view>
|
||||
<!-- Telegram / Messenger 已于 2026-10-13 软下线为独立字段, 统一在下方「社交媒体账号」添加 -->
|
||||
</view>
|
||||
|
||||
<!-- 社交媒体账号 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">社交媒体账号</view>
|
||||
<view v-for="(s, i) in socials" :key="'soc' + i" class="form-row dyn-row">
|
||||
<picker class="dyn-picker" :range="socialTypeLabels" :value="socialTypeIndex(s.type)"
|
||||
@change="e => onSocialTypeChange(i, e)">
|
||||
<view class="picker-text">{{ socialTypeLabel(s.type) }} ▾</view>
|
||||
</picker>
|
||||
<input class="row-input" :value="s.value" placeholder="账号 / 链接"
|
||||
@input="e => onSocialValueInput(i, e)" />
|
||||
<text class="dyn-del" @tap="delSocial(i)">×</text>
|
||||
</view>
|
||||
<view class="form-row" v-if="socials.length < MAX_SOCIALS">
|
||||
<text class="dyn-add" @tap="addSocial">+ 添加社交媒体(最多{{ MAX_SOCIALS }}个)</text>
|
||||
</view>
|
||||
<view class="group-tip" v-if="socials.length === 0">还没有社交媒体账号;Telegram / Messenger 也已并入此处添加</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义字段 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">自定义字段</view>
|
||||
<view v-for="(c, i) in customFields" :key="'cst' + i" class="form-row dyn-row">
|
||||
<input class="cf-label" :value="c.label" placeholder="名称"
|
||||
@input="e => onCustomLabelInput(i, e)" />
|
||||
<input class="row-input" :value="c.value" placeholder="内容"
|
||||
@input="e => onCustomValueInput(i, e)" />
|
||||
<text class="dyn-del" @tap="delCustom(i)">×</text>
|
||||
</view>
|
||||
<view class="form-row" v-if="customFields.length < MAX_CUSTOM">
|
||||
<text class="dyn-add" @tap="addCustom">+ 添加自定义字段(最多{{ MAX_CUSTOM }}个)</text>
|
||||
</view>
|
||||
<view class="group-tip" v-if="customFields.length === 0">如「展厅号 E3-108」「收款方式」等个性信息</view>
|
||||
</view>
|
||||
|
||||
<!-- 地址 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">地址</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">国家</view>
|
||||
<input class="row-input" :value="form.country" placeholder="选填"
|
||||
@input="e => form.country = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">省份</view>
|
||||
<input class="row-input" :value="form.province" placeholder="选填"
|
||||
@input="e => form.province = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">城市</view>
|
||||
<input class="row-input" :value="form.city" placeholder="选填"
|
||||
@input="e => form.city = 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">
|
||||
<view class="row-label">邮编</view>
|
||||
<input class="row-input" :value="form.postal_code" placeholder="选填"
|
||||
@input="e => form.postal_code = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 个人简介 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">个人简介</view>
|
||||
<view class="form-row bio-row">
|
||||
<textarea class="bio-input" :value="form.bio" placeholder="介绍一下自己…(保留换行)"
|
||||
@input="e => form.bio = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 保存 -->
|
||||
<view class="save-bar">
|
||||
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 与后端 BusinessCardFieldNormalizer 输出对齐的标量字段(含 2026-10 补的 bio)。
|
||||
* ⚠️ 2026-10-13: telegram / messenger 软下线为独立标量(传了也被后端忽略),
|
||||
* 统一走下方社交媒体动态行; 旧数据读取时已由后端合并进 socials_list。
|
||||
* 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
|
||||
* 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
|
||||
*
|
||||
* SOCIAL_TYPES / CARD_FIELDS / RICH_FIELD_LIMITS 统一从收口层导入,
|
||||
* 与后端白名单单点同步(见 common/js/card-api.js)。
|
||||
*/
|
||||
import { SOCIAL_TYPES, CARD_FIELDS, RICH_FIELD_LIMITS } from '@/common/js/card-api.js';
|
||||
|
||||
// 注:telegram/messenger 已软下线(2026-10-13), 不再作为独立标量字段,
|
||||
// 统一在「社交媒体账号」动态行里以 SOCIAL_TYPES 中的类型方式添加。
|
||||
|
||||
// 上限与后端 CardRichFields::MAX_* 一致
|
||||
const MAX_MOBILES = RICH_FIELD_LIMITS.MAX_MOBILES;
|
||||
const MAX_SOCIALS = RICH_FIELD_LIMITS.MAX_SOCIALS;
|
||||
const MAX_CUSTOM = RICH_FIELD_LIMITS.MAX_CUSTOM;
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '',
|
||||
// 新建模式: 无 card_id 进入, 提交 /card/api/card/create
|
||||
isCreate: false,
|
||||
form: {
|
||||
name: '', company: '', job_title: '', avatar: '',
|
||||
mobile: '', phone: '', email: '', website: '', wechat: '',
|
||||
country: '', province: '', city: '', address: '', postal_code: '',
|
||||
bio: ''
|
||||
},
|
||||
mobiles: [],
|
||||
socials: [],
|
||||
customFields: [],
|
||||
socialTypeLabels: SOCIAL_TYPES.map(t => t.label),
|
||||
MAX_MOBILES,
|
||||
MAX_SOCIALS,
|
||||
MAX_CUSTOM,
|
||||
isLoading: false,
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.isCreate = this.cardId === '';
|
||||
if (this.isCreate) {
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
} else {
|
||||
this.loadDetail();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
loadDetail() {
|
||||
if (!this.cardId) {
|
||||
uni.showToast({ title: '缺少名片编号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.$cardApi.cardDetail({
|
||||
data: { card_id: this.cardId },
|
||||
success: res => {
|
||||
this.isLoading = false;
|
||||
if (res.code == 0) {
|
||||
const r = (res.data && res.data.relation) || {};
|
||||
CARD_FIELDS.forEach(k => {
|
||||
if (r[k] !== undefined && r[k] !== null) {
|
||||
this.form[k] = String(r[k]);
|
||||
}
|
||||
});
|
||||
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
|
||||
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
|
||||
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
|
||||
this.socials = (r.socials_list || []).map(s => ({
|
||||
type: s.type || 'other',
|
||||
value: String(s.value || '')
|
||||
}));
|
||||
this.customFields = (r.custom_list || []).map(c => ({
|
||||
label: String(c.label || ''),
|
||||
value: String(c.value || '')
|
||||
}));
|
||||
} else {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: res.message || '卡片不存在',
|
||||
showCancel: false,
|
||||
success: () => uni.navigateBack()
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------- 头像: 相册选择 → 系统上传 → 回填路径 ---------- */
|
||||
chooseAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
success: res => {
|
||||
const filePath = res.tempFilePaths && res.tempFilePaths[0];
|
||||
if (!filePath) return;
|
||||
uni.showLoading({ title: '上传中…', mask: true });
|
||||
this.$api.upload({
|
||||
url: '/api/upload/headimg',
|
||||
filePath: filePath,
|
||||
fileType: 'image',
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
if (r && r.code == 0 && r.data && r.data.pic_path) {
|
||||
this.form.avatar = r.data.pic_path;
|
||||
} else {
|
||||
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '上传失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------- 附加手机号 ---------- */
|
||||
onMobileInput(i, e) {
|
||||
this.$set(this.mobiles, i, e.detail.value);
|
||||
},
|
||||
addMobile() {
|
||||
if (this.mobiles.length >= MAX_MOBILES) {
|
||||
uni.showToast({ title: '最多 ' + MAX_MOBILES + ' 个手机号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.mobiles.push('');
|
||||
},
|
||||
delMobile(i) {
|
||||
this.mobiles.splice(i, 1);
|
||||
},
|
||||
|
||||
/* ---------- 社交媒体 ---------- */
|
||||
socialTypeLabel(key) {
|
||||
const t = SOCIAL_TYPES.find(x => x.key === key);
|
||||
return t ? t.label : '其他';
|
||||
},
|
||||
socialTypeIndex(key) {
|
||||
const i = SOCIAL_TYPES.findIndex(x => x.key === key);
|
||||
return i < 0 ? SOCIAL_TYPES.length - 1 : i;
|
||||
},
|
||||
onSocialTypeChange(i, e) {
|
||||
const idx = Number(e.detail.value);
|
||||
const t = SOCIAL_TYPES[idx];
|
||||
if (t) this.$set(this.socials, i, { type: t.key, value: this.socials[i].value });
|
||||
},
|
||||
onSocialValueInput(i, e) {
|
||||
const s = this.socials[i];
|
||||
this.$set(this.socials, i, { type: s.type, value: e.detail.value });
|
||||
},
|
||||
addSocial() {
|
||||
if (this.socials.length >= MAX_SOCIALS) {
|
||||
uni.showToast({ title: '最多 ' + MAX_SOCIALS + ' 个社交媒体', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.socials.push({ type: 'other', value: '' });
|
||||
},
|
||||
delSocial(i) {
|
||||
this.socials.splice(i, 1);
|
||||
},
|
||||
|
||||
/* ---------- 自定义字段 ---------- */
|
||||
onCustomLabelInput(i, e) {
|
||||
const c = this.customFields[i];
|
||||
this.$set(this.customFields, i, { label: e.detail.value, value: c.value });
|
||||
},
|
||||
onCustomValueInput(i, e) {
|
||||
const c = this.customFields[i];
|
||||
this.$set(this.customFields, i, { label: c.label, value: e.detail.value });
|
||||
},
|
||||
addCustom() {
|
||||
if (this.customFields.length >= MAX_CUSTOM) {
|
||||
uni.showToast({ title: '最多 ' + MAX_CUSTOM + ' 个自定义字段', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.customFields.push({ label: '', value: '' });
|
||||
},
|
||||
delCustom(i) {
|
||||
this.customFields.splice(i, 1);
|
||||
},
|
||||
|
||||
/* ---------- 保存 ---------- */
|
||||
save() {
|
||||
if (this.saving) return;
|
||||
if (!this.form.name.trim()) {
|
||||
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
|
||||
// 标量全量提交(空值=清空, 与后端 normalizer 语义一致);
|
||||
// 富化字段始终显式携带(空数组=清空), 后端 richFieldSaves 只对携带的键清洗落库。
|
||||
// 走 sendJson(JSON body): 嵌套数组序列化跨端零歧义, TP6 原生解析
|
||||
const fields = {};
|
||||
CARD_FIELDS.forEach(k => { fields[k] = this.form[k]; });
|
||||
fields.mobiles = this.mobiles.map(v => String(v || '').trim());
|
||||
fields.socials = this.socials.map(s => ({ type: s.type, value: String(s.value || '').trim() }));
|
||||
fields.custom_fields = this.customFields.map(c => ({
|
||||
label: String(c.label || '').trim(),
|
||||
value: String(c.value || '').trim()
|
||||
}));
|
||||
|
||||
// 新建模式走 card/create, 编辑模式走 card/update(均经 $cardApi 收口层)
|
||||
// ⚠️ 必须包在 data 里: sendJson 只合并 params.data(见 common/js/http.js),
|
||||
// 顶层直接挂 fields 会被丢弃 → 后端收不到 name, 报「请填写姓名」
|
||||
const payload = this.isCreate
|
||||
? { fields: fields }
|
||||
: { card_id: this.cardId, fields: fields };
|
||||
(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({
|
||||
data: payload,
|
||||
success: res => {
|
||||
this.saving = false;
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: this.isCreate ? '创建成功' : '保存成功', icon: 'success' });
|
||||
if (this.isCreate) {
|
||||
// 新建成功 → 跳详情页(redirectTo 替换当前编辑页, 返回栈回到列表)
|
||||
const newCardId = (res.data && res.data.card_id) || '';
|
||||
setTimeout(() => {
|
||||
if (newCardId) {
|
||||
uni.redirectTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(newCardId)
|
||||
});
|
||||
} else {
|
||||
uni.navigateBack();
|
||||
}
|
||||
}, 600);
|
||||
} else {
|
||||
// 返回详情页; detail.vue 的 onShow 会自动刷新
|
||||
setTimeout(() => uni.navigateBack(), 600);
|
||||
}
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.saving = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-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;
|
||||
}
|
||||
|
||||
.group-tip {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
padding: 0 24rpx 20rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 必填红星 */
|
||||
.req-star {
|
||||
color: #f5222d;
|
||||
margin-right: 4rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 头像行 */
|
||||
.avatar-box {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 12rpx;
|
||||
background-color: #f2f3f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-plus {
|
||||
font-size: 44rpx;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-tip {
|
||||
margin-left: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 动态行(附加手机号/社交媒体/自定义字段) */
|
||||
.dyn-row {
|
||||
.row-label {
|
||||
width: 160rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.dyn-del {
|
||||
width: 44rpx;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
color: #c0c4cc;
|
||||
flex-shrink: 0;
|
||||
padding: 6rpx 0 6rpx 12rpx;
|
||||
}
|
||||
|
||||
.dyn-add {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.dyn-picker {
|
||||
flex-shrink: 0;
|
||||
width: 170rpx;
|
||||
height: 44rpx;
|
||||
line-height: 44rpx;
|
||||
border: 1rpx solid #dcdfe6;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 16rpx;
|
||||
|
||||
.picker-text {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.cf-label {
|
||||
width: 170rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
background-color: #f6f7f9;
|
||||
border-radius: 8rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
/* 简介 */
|
||||
.bio-row {
|
||||
align-items: flex-start;
|
||||
|
||||
.bio-input {
|
||||
width: 100%;
|
||||
min-height: 160rpx;
|
||||
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>
|
||||
830
pages_tool/card/enterprise.vue
Normal file
830
pages_tool/card/enterprise.vue
Normal file
@@ -0,0 +1,830 @@
|
||||
<template>
|
||||
<view class="ent-card" :style="[designVars, { fontFamily: ds.fontFamily }]">
|
||||
<view class="page-layer">
|
||||
<!-- 顶部英雄区: 蓝色渐变圆角卡(对齐后端 wap 参考图) -->
|
||||
<view class="hero" v-if="headerSrc || staffList.length">
|
||||
<view class="hero-pill">
|
||||
<view class="hero-pill-dot"></view>
|
||||
<text>数字名片</text>
|
||||
</view>
|
||||
|
||||
<!-- staff 模式: 员工名片轮播 -->
|
||||
<swiper v-if="headerMode === 'staff' && staffList.length" class="hero-swiper"
|
||||
:indicator-dots="staffList.length > 1" indicator-active-color="rgba(255,255,255,0.9)"
|
||||
indicator-color="rgba(255,255,255,0.4)" :autoplay="staffList.length > 1" circular
|
||||
@change="onStaffChange">
|
||||
<swiper-item v-for="(s, i) in staffList" :key="i">
|
||||
<view class="hero-id">
|
||||
<image v-if="s.avatar" class="hi-avatar" :src="$util.img(s.avatar)" mode="aspectFill" />
|
||||
<view v-else class="hi-avatar hi-avatar-ph">
|
||||
<text class="hi-avatar-char">企</text>
|
||||
</view>
|
||||
<view class="hi-info">
|
||||
<view class="hi-name">{{ s.name }}</view>
|
||||
<view class="hi-job" v-if="s.job_title">{{ s.job_title }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- official / self: 单人信息 -->
|
||||
<view v-else class="hero-id">
|
||||
<image v-if="headerSrc && headerSrc.avatar" class="hi-avatar" :src="$util.img(headerSrc.avatar)" mode="aspectFill" />
|
||||
<view v-else class="hi-avatar hi-avatar-ph">
|
||||
<text class="hi-avatar-char">企</text>
|
||||
</view>
|
||||
<view class="hi-info">
|
||||
<view class="hi-name">{{ headerSrc.name || '企业名片' }}</view>
|
||||
<view class="hi-job" v-if="headerSrc.job_title">{{ headerSrc.job_title }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作条: 压在英雄卡下沿(参考图: 打电话/发邮件/官方网站/存通讯录) -->
|
||||
<view class="quick" v-if="headerSrc">
|
||||
<view class="q-item" @tap="onQuickCall">
|
||||
<view class="q-icon q-icon-blue">
|
||||
<text class="iconfont icon-dianhua"></text>
|
||||
</view>
|
||||
<text class="q-label">打电话</text>
|
||||
</view>
|
||||
<view class="q-item" @tap="onQuickEmail">
|
||||
<view class="q-icon q-icon-cyan">
|
||||
<text class="q-char">@</text>
|
||||
</view>
|
||||
<text class="q-label">发邮件</text>
|
||||
</view>
|
||||
<view class="q-item" @tap="onQuickWebsite">
|
||||
<view class="q-icon q-icon-blue">
|
||||
<text class="q-char">网</text>
|
||||
</view>
|
||||
<text class="q-label">官方网站</text>
|
||||
</view>
|
||||
<view class="q-item" @tap="onQuickSave">
|
||||
<view class="q-icon q-icon-orange">
|
||||
<text class="iconfont icon-yonghu"></text>
|
||||
</view>
|
||||
<text class="q-label">存通讯录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 企业简介 -->
|
||||
<view class="bio" v-if="fields.bio" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
|
||||
<text class="bio-text" :style="{ color: ds.textColor }">{{ fields.bio }}</text>
|
||||
<text class="bio-en" v-if="fields.bio_en" :style="{ color: ds.textSoft }">{{ fields.bio_en }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 联系资料(企业字段) -->
|
||||
<view class="contact" v-if="contentRows.length" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
|
||||
<view class="ct-title" :style="{ borderLeftColor: ds.themeColor }">联系资料</view>
|
||||
<view v-for="(row, idx) in contentRows" :key="idx" class="ct-row">
|
||||
<view class="ct-icon" :style="{ backgroundColor: ds.themeSoft }">
|
||||
<text v-if="row.icon" class="iconfont" :class="row.icon" :style="{ color: ds.themeColor }"></text>
|
||||
<text v-else class="ct-char" :style="{ color: ds.themeColor }">{{ row.label.slice(0, 1) }}</text>
|
||||
</view>
|
||||
<text class="ct-label" :style="{ color: ds.textSoft }">{{ row.label }}</text>
|
||||
<text class="ct-value" :class="{ 'ct-multi': row.multiline }" :style="{ color: ds.textColor }">{{ row.value }}</text>
|
||||
<text v-if="row.copyable" class="ct-copy" @tap="copyText(row.value)">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- self 模式未自选提示 -->
|
||||
<view class="self-hint" v-if="headerMode === 'self' && headerSelf && headerSelf.chosen === 0" @tap="openChooseSelf">
|
||||
<text class="sh-icon iconfont icon-gonggao" :style="{ color: ds.themeColor }"></text>
|
||||
<text class="sh-text">你还没有选定展示名片,当前自动取最新一张</text>
|
||||
<text class="sh-btn" :style="{ color: ds.themeColor }">选择</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="ent-loading" v-if="isLoading">加载中…</view>
|
||||
|
||||
<!-- 不存在 / 无权查看 -->
|
||||
<ns-empty v-if="!isLoading && !headerSrc && !staffList.length" :isIndex="false"
|
||||
:text="errorMsg || '企业名片不存在或未生效,请在管理端完善资料并设为生效'"></ns-empty>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作条(参考图: 保存到通讯录 + 分享; 收藏/交换名片为企业卡无对应后端能力, 暂不提供) -->
|
||||
<view class="bottom-bar" v-if="headerSrc">
|
||||
<view class="bb-btn bb-main" @tap="onQuickSave">保存到通讯录</view>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="bb-btn bb-share" open-type="share">分享</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
|
||||
<view class="bb-btn bb-share" @tap="openShare">分享</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 员工自选展示名片弹层 -->
|
||||
<view class="choose-mask" v-if="showChoose" @tap="closeChoose"></view>
|
||||
<view class="choose-sheet" :class="{ 'choose-show': showChoose }">
|
||||
<view class="cs-bar"></view>
|
||||
<view class="cs-title">选择展示名片</view>
|
||||
<view class="cs-list">
|
||||
<view v-for="(c, idx) in myCards" :key="idx" class="cs-item" @tap="chooseSelf(c)">
|
||||
<view class="cs-avatar" :style="{ backgroundColor: ds.themeColor }">{{ (c.name || '?').slice(0, 1) }}</view>
|
||||
<view class="cs-info">
|
||||
<view class="cs-name">{{ c.name }}</view>
|
||||
<view class="cs-sub" v-if="c.job_title || c.company">{{ [c.job_title, c.company].filter(Boolean).join(' · ') }}</view>
|
||||
</view>
|
||||
<text v-if="headerSelf && headerSelf.card_id === c.card_id" class="cs-chosen iconfont icon-dui" :style="{ color: ds.themeColor }"></text>
|
||||
</view>
|
||||
<view class="cs-empty" v-if="!myCards.length">你还没有个人名片</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function mixHex(a, b, t) {
|
||||
const toRgb = h => {
|
||||
h = (h || '').replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
if (isNaN(n)) return [0, 0, 0];
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [r1, g1, b1] = toRgb(a);
|
||||
const [r2, g2, b2] = toRgb(b);
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const bl = Math.round(b1 + (b2 - b1) * t);
|
||||
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '',
|
||||
isLoading: true,
|
||||
errorMsg: '',
|
||||
data: null, // 企业名片详情(后端 ENTERPRISE_UNIAPP_GUIDE §三)
|
||||
header: null, // {mode,official_card_id,official,self,staff}
|
||||
fields: {}, // 企业信息(内容区)
|
||||
design: null, // {theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id}
|
||||
staffIndex: 0, // staff 轮播当前项(存通讯录按当前展示的员工取字段)
|
||||
showChoose: false,
|
||||
myCards: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
headerMode() {
|
||||
return (this.header && this.header.mode) || 'official';
|
||||
},
|
||||
headerSrc() {
|
||||
const h = this.header || {};
|
||||
let src;
|
||||
if (this.headerMode === 'official') src = h.official || this.fields;
|
||||
else if (this.headerMode === 'self') src = h.self || this.fields;
|
||||
else return null; // staff 走轮播
|
||||
// 注意: 空对象 {} 在 JS 里是真值, 必须显式判空,
|
||||
// 否则后端返回空数据时会渲染出"有壳没内容"的空页面且空态提示也不出现
|
||||
return src && Object.keys(src).length ? src : null;
|
||||
},
|
||||
staffList() {
|
||||
return (this.header && this.header.staff) || [];
|
||||
},
|
||||
staffCurrent() {
|
||||
return this.staffList[this.staffIndex] || null;
|
||||
},
|
||||
headerSelf() {
|
||||
return (this.header && this.header.self) || null;
|
||||
},
|
||||
// 名片区当前展示的那个人(official/self 单人 或 staff 当前轮播项)
|
||||
displayPerson() {
|
||||
if (this.headerMode === 'staff') return this.staffCurrent || {};
|
||||
return this.headerSrc || {};
|
||||
},
|
||||
contentRows() {
|
||||
const f = this.fields || {};
|
||||
const rows = [];
|
||||
const push = (label, value, opts) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
rows.push(Object.assign({ label, value: String(value) }, opts || {}));
|
||||
}
|
||||
};
|
||||
push('手机', f.mobile, { icon: 'icon-shouji1', copyable: true });
|
||||
push('邮箱', f.email, { copyable: true });
|
||||
push('网站', f.website, { copyable: true });
|
||||
push('地址', f.address, { icon: 'icon-dizhi' });
|
||||
push('企业名称', f.name);
|
||||
return rows;
|
||||
},
|
||||
/* ============ design 归一化(与 detail.vue 同源, 仅做有限主题化) ============ */
|
||||
ds() {
|
||||
const d = this.design || {};
|
||||
const FONTS = {
|
||||
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
|
||||
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
|
||||
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
|
||||
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
|
||||
};
|
||||
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
|
||||
const theme = isHex(d.theme_color) ? d.theme_color : '#119dff';
|
||||
const text = isHex(d.text_color) ? d.text_color : '#1f2329';
|
||||
const bg = isHex(d.bg_color) ? d.bg_color : '#ffffff';
|
||||
const fontKey = FONTS[d.font_family] ? d.font_family : 'system';
|
||||
const fontScale = Math.min(160, Math.max(60, Number(d.font_scale) || 100));
|
||||
return {
|
||||
themeColor: theme,
|
||||
textColor: text,
|
||||
bgColor: bg,
|
||||
fontFamily: FONTS[fontKey],
|
||||
cardRadius: Number(d.card_radius) || 16,
|
||||
fontScaleNum: fontScale / 100,
|
||||
textSoft: mixHex(text, bg, 0.55),
|
||||
themeSoft: mixHex(theme, '#ffffff', 0.85),
|
||||
themeBorder: mixHex(theme, '#ffffff', 0.55),
|
||||
headerGradientEnd: typeof d.header_gradient_end === 'string' ? d.header_gradient_end : ''
|
||||
};
|
||||
},
|
||||
designVars() {
|
||||
const s = this.ds;
|
||||
return {
|
||||
'--card-theme': s.themeColor,
|
||||
'--card-theme-end': s.headerGradientEnd || s.themeColor,
|
||||
'--card-theme-soft': s.themeSoft,
|
||||
'--card-theme-border': s.themeBorder,
|
||||
'--card-text': s.textColor,
|
||||
'--card-text-soft': s.textSoft,
|
||||
'--card-bg': s.bgColor,
|
||||
'--card-radius': s.cardRadius + 'px',
|
||||
'--card-font': s.fontFamily,
|
||||
'--card-font-scale': s.fontScaleNum
|
||||
};
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = (options && options.card_id) ? decodeURIComponent(options.card_id) : '';
|
||||
this.loadData();
|
||||
},
|
||||
onReady() {
|
||||
uni.setNavigationBarTitle({ title: '企业名片' });
|
||||
},
|
||||
onShow() {
|
||||
// 每次显示都兜底标题(微信返回本页时可能恢复成旧值); 从自选弹层返回也刷新
|
||||
uni.setNavigationBarTitle({ title: '企业名片' });
|
||||
// #ifdef H5
|
||||
document.title = '企业名片';
|
||||
// #endif
|
||||
if (this._loadedOnce) this.loadData();
|
||||
this._loadedOnce = true;
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.loadData();
|
||||
setTimeout(() => uni.stopPullDownRefresh(), 300);
|
||||
},
|
||||
onShareAppMessage() {
|
||||
const name = (this.fields && this.fields.name) || '企业名片';
|
||||
return {
|
||||
title: name + ' - 企业名片',
|
||||
path: '/pages_tool/card/enterprise' + (this.cardId ? '?card_id=' + encodeURIComponent(this.cardId) : ''),
|
||||
imageUrl: this.displayPerson && this.displayPerson.avatar ? this.$util.img(this.displayPerson.avatar) : ''
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
loadData(retry) {
|
||||
this.isLoading = true;
|
||||
this.$cardApi.enterpriseDetail({
|
||||
data: this.cardId ? { card_id: this.cardId } : {},
|
||||
success: res => {
|
||||
// 输出原始响应, 便于真机排查"刷新后数据变空"类问题(控制台看后端到底回了什么)
|
||||
console.log('[enterprise] detail resp', this.cardId || '(无card_id)', res);
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const emptyCard = !d.header && (!d.fields || !Object.keys(d.fields).length);
|
||||
if (emptyCard && (this.header || Object.keys(this.fields).length)) {
|
||||
// 已有数据时刷新却拿到空卡(多为后端"当前生效卡"与所查卡不一致):
|
||||
// 保留原数据, 避免"刷新一下内容就没了"
|
||||
console.warn('[enterprise] 刷新返回空卡, 保留原数据', d);
|
||||
} else {
|
||||
this.data = d;
|
||||
this.header = d.header || null;
|
||||
this.fields = d.fields || {};
|
||||
this.design = d.design || null;
|
||||
}
|
||||
this.errorMsg = '';
|
||||
this.isLoading = false;
|
||||
} else if (res.code == -1 && !retry) {
|
||||
// -1 = 尚未登录: 冷启动/刚返回本页时 store 的 token 可能还没恢复完成,
|
||||
// 属瞬时态, 静默重试一次再决定是否展示错误(与 detail.vue 同策略)
|
||||
setTimeout(() => this.loadData(true), 800);
|
||||
} else {
|
||||
// 登录类错误(-1/-10009 等)不展示"去登录"死路:
|
||||
// 本页是客户视角分享页, 按后端对接文档 §五 游客也应可看(待后端放开),
|
||||
// 这里给中性可重试文案; 其余错误原样带出
|
||||
const msg = res.message || '';
|
||||
const isLoginErr = res.code == -1 || /登录/.test(msg);
|
||||
this.errorMsg = isLoginErr ? '名片加载失败,请下拉重试' : (msg || '加载失败');
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 失败时保留原数据(不清空), 仅在首次无数据时展示错误
|
||||
if (!this.header && !Object.keys(this.fields).length) {
|
||||
this.errorMsg = '网络异常,请下拉重试';
|
||||
}
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
onStaffChange(e) {
|
||||
this.staffIndex = (e.detail && e.detail.current) || 0;
|
||||
},
|
||||
copyText(v) {
|
||||
uni.setClipboardData({
|
||||
data: String(v || ''),
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
});
|
||||
},
|
||||
/* ===== 快捷操作(参考图四项) ===== */
|
||||
onQuickCall() {
|
||||
const m = this.displayPerson.mobile || this.fields.mobile;
|
||||
if (!m) return uni.showToast({ title: '未填写手机号', icon: 'none' });
|
||||
uni.makePhoneCall({ phoneNumber: String(m) });
|
||||
},
|
||||
onQuickEmail() {
|
||||
const e = this.displayPerson.email || this.fields.email;
|
||||
if (!e) return uni.showToast({ title: '未填写邮箱', icon: 'none' });
|
||||
this.copyText(e);
|
||||
},
|
||||
onQuickWebsite() {
|
||||
const w = this.displayPerson.website || this.fields.website;
|
||||
if (!w) return uni.showToast({ title: '未填写官网', icon: 'none' });
|
||||
// 小程序内无法直接打开外部网页(web-view 中转体验差), 复制链接到浏览器打开
|
||||
this.copyText(w);
|
||||
uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' });
|
||||
},
|
||||
onQuickSave() {
|
||||
// 跳原生页直接调 addPhoneContact(web-view 内 H5 才受限, 原生页可直存)
|
||||
const p = this.displayPerson || {};
|
||||
const q = [
|
||||
'name=' + encodeURIComponent(p.name || this.fields.name || ''),
|
||||
'mobile=' + encodeURIComponent(p.mobile || this.fields.mobile || ''),
|
||||
'company=' + encodeURIComponent(this.fields.name || ''),
|
||||
'job_title=' + encodeURIComponent(p.job_title || ''),
|
||||
'email=' + encodeURIComponent(p.email || this.fields.email || ''),
|
||||
'website=' + encodeURIComponent(p.website || this.fields.website || ''),
|
||||
'address=' + encodeURIComponent(p.address || this.fields.address || ''),
|
||||
'wechat=' + encodeURIComponent(p.wechat || '')
|
||||
].join('&');
|
||||
uni.navigateTo({ url: '/pages_tool/card/save-contact?' + q });
|
||||
},
|
||||
openChooseSelf() {
|
||||
this.$cardApi.cardLists({
|
||||
data: { page: 1, page_size: 100 },
|
||||
success: res => {
|
||||
const d = res.data || {};
|
||||
this.myCards = d.list || [];
|
||||
this.showChoose = true;
|
||||
},
|
||||
fail: () => uni.showToast({ title: '加载名片失败', icon: 'none' })
|
||||
});
|
||||
},
|
||||
closeChoose() {
|
||||
this.showChoose = false;
|
||||
},
|
||||
chooseSelf(c) {
|
||||
if (!c || !c.card_id) return;
|
||||
this.$cardApi.enterpriseChooseSelf({
|
||||
data: { card_id: c.card_id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已选择展示名片', icon: 'success' });
|
||||
this.showChoose = false;
|
||||
this.loadData();
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '选择失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => uni.showToast({ title: '网络异常', icon: 'none' })
|
||||
});
|
||||
},
|
||||
openShare() {
|
||||
uni.showToast({ title: '请点击右上角或浏览器菜单分享', icon: 'none' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ent-card {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f6fa;
|
||||
padding-bottom: 140rpx;
|
||||
}
|
||||
|
||||
.page-layer {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
/* ===== 英雄区 ===== */
|
||||
.hero {
|
||||
position: relative;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 32rpx 140rpx;
|
||||
background:
|
||||
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.14) 0, rgba(255, 255, 255, 0.14) 120rpx, transparent 121rpx),
|
||||
linear-gradient(150deg, var(--card-theme, #1e6fd9) 0%, var(--card-theme-end, #123f8f) 100%);
|
||||
overflow: visible;
|
||||
margin-bottom: 90rpx;
|
||||
}
|
||||
|
||||
.hero-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6rpx 20rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 26rpx;
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
|
||||
.hero-pill-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #6cf9c0;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-swiper {
|
||||
height: 180rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.hero-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 28rpx;
|
||||
|
||||
.hi-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 6rpx solid rgba(255, 255, 255, 0.85);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.hi-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.hi-avatar-char {
|
||||
font-size: 56rpx;
|
||||
font-weight: 600;
|
||||
color: var(--card-theme, #1e6fd9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hi-info {
|
||||
margin-left: 28rpx;
|
||||
min-width: 0;
|
||||
|
||||
.hi-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hi-job {
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 快捷操作条: 上移压住英雄卡下沿 ===== */
|
||||
.quick {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: -130rpx 24rpx 24rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 8rpx 30rpx rgba(30, 111, 217, 0.10);
|
||||
display: flex;
|
||||
padding: 28rpx 0;
|
||||
|
||||
.q-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.q-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.q-icon-blue {
|
||||
background: #e3f0ff;
|
||||
|
||||
.iconfont,
|
||||
.q-char {
|
||||
color: #3d8bff;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.q-icon-cyan {
|
||||
background: #e2f7f4;
|
||||
|
||||
.q-char {
|
||||
color: #2bb99e;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.q-icon-orange {
|
||||
background: #fff3e0;
|
||||
|
||||
.iconfont {
|
||||
color: #f5a623;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.q-label {
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: #444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 企业简介 ===== */
|
||||
.bio {
|
||||
margin: 0 0 24rpx;
|
||||
padding: 28rpx 28rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
.bio-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bio-en {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 联系资料 ===== */
|
||||
.contact {
|
||||
padding: 28rpx 28rpx 12rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.ct-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
padding-left: 16rpx;
|
||||
border-left-width: 6rpx;
|
||||
border-left-style: solid;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.ct-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid #f2f3f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ct-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.iconfont {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.ct-char {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-label {
|
||||
width: 88rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ct-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
word-break: break-all;
|
||||
|
||||
&.ct-multi {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-copy {
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #f2f3f5;
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== self 未自选提示 ===== */
|
||||
.self-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
background-color: var(--card-theme-soft, #e8f1fb);
|
||||
border: 1rpx solid var(--card-theme-border, #cfe3fb);
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.sh-icon {
|
||||
margin-right: 12rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.sh-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sh-btn {
|
||||
font-size: 26rpx;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.ent-loading {
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* ===== 底部操作条 ===== */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
z-index: 10;
|
||||
|
||||
.bb-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
border-radius: 44rpx;
|
||||
font-size: 28rpx;
|
||||
|
||||
&.bb-main {
|
||||
background: linear-gradient(150deg, #3d8bff, #1e6fd9);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.bb-share {
|
||||
background: #f2f3f5;
|
||||
color: #333;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 员工自选弹层 ===== */
|
||||
.choose-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.choose-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
z-index: 100;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s ease;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.choose-show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.cs-bar {
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
background: #ddd;
|
||||
border-radius: 4rpx;
|
||||
margin: 16rpx auto 8rpx;
|
||||
}
|
||||
|
||||
.cs-title {
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
padding: 12rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.cs-list {
|
||||
overflow-y: auto;
|
||||
padding: 0 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.cs-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f2f2f2;
|
||||
}
|
||||
|
||||
.cs-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.cs-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.cs-name {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cs-sub {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-chosen {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.cs-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
403
pages_tool/card/exchange.vue
Normal file
403
pages_tool/card/exchange.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<view class="ex-page">
|
||||
<!-- 状态筛选: 全部 / 待处理(N) / 已同意 -->
|
||||
<view class="ex-tabs">
|
||||
<view v-for="tab in tabs" :key="tab.value" class="ex-tab"
|
||||
:class="{ active: current === tab.value }" @tap="switchTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 交换请求列表 -->
|
||||
<view class="ex-list">
|
||||
<view v-for="(item, index) in shownList" :key="item.id" class="ex-item">
|
||||
<view class="ex-top">
|
||||
<view class="ex-avatar-wrap" :data-index="index" @tap.stop="viewCard">
|
||||
<image v-if="item.avatar" class="ex-avatar" :src="item.avatar" mode="aspectFill" />
|
||||
<view v-else class="ex-avatar ex-avatar-ph">{{ item.name.charAt(0) }}</view>
|
||||
</view>
|
||||
<view class="ex-info">
|
||||
<view class="ex-name-row">
|
||||
<text class="ex-name">{{ item.name }}</text>
|
||||
<text class="ex-badge iconfont icon-dui"></text>
|
||||
</view>
|
||||
<text class="ex-job">{{ item.job_title }}</text>
|
||||
<view class="ex-company-row">
|
||||
<text class="ex-company">{{ item.company }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.status === 'pending'" class="ex-agree" :data-index="index" @tap.stop="onAgree">同意</view>
|
||||
<text v-else class="ex-agreed">已同意</text>
|
||||
</view>
|
||||
|
||||
<view class="ex-reason">对方发起了名片交换请求</view>
|
||||
|
||||
<view class="ex-time">{{ item.create_time_txt }} 向您发起名片交换</view>
|
||||
</view>
|
||||
|
||||
<ns-empty v-if="shownList.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 名片交换请求列表
|
||||
// ⚠️ 目前为静态占位数据: 后端 /card/api/exchange/* 接口就绪后,
|
||||
// 改为在 onLoad 里用 this.$cardApi.exchangeLists({ data: { page: 1 }, ... }) 拉取,
|
||||
// 「同意」改成 cardApi.exchangeAgree({ data: { id: item.id } })。
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
current: 'pending',
|
||||
cardId: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.loadList();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
computed: {
|
||||
tabs() {
|
||||
const pending = this.list.filter(i => i.status === 'pending').length;
|
||||
return [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'pending', label: '待处理(' + pending + ')' },
|
||||
{ value: 'agreed', label: '已同意' }
|
||||
];
|
||||
},
|
||||
shownList() {
|
||||
if (this.current === 'all') return this.list;
|
||||
return this.list.filter(item => item.status === this.current);
|
||||
},
|
||||
emptyText() {
|
||||
return this.current === 'agreed' ? '暂无已同意的交换' : '暂无名片交换请求';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTab(value) {
|
||||
if (this.current === value) return;
|
||||
this.current = value;
|
||||
},
|
||||
// 待处理交换请求(后端只返回 status=0); 归一化 status 供模板区分
|
||||
loadList() {
|
||||
this.loading = true;
|
||||
this.$cardApi.exchangeLists({
|
||||
data: { page: this.page, page_size: 20 },
|
||||
success: res => {
|
||||
this.loading = false;
|
||||
if (res.code == 0 && res.data) {
|
||||
this.list = (res.data.list || []).map(it => ({
|
||||
id: it.id,
|
||||
from_card_id: it.from_card_id,
|
||||
name: it.name,
|
||||
avatar: it.avatar,
|
||||
job_title: it.job_title,
|
||||
company: it.company,
|
||||
create_time_txt: it.create_time_txt,
|
||||
status: it.status === 0 ? 'pending' : 'agreed'
|
||||
}));
|
||||
this.pageCount = res.data.page_count || 1;
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象(viewCard(item))会丢参,
|
||||
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number') return this.shownList[payload] || null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.shownList[i]) return this.shownList[i];
|
||||
}
|
||||
return payload.id ? payload : null;
|
||||
},
|
||||
// 点对方名片 → 跳转对方名片详情(用 from_card_id 字符串)
|
||||
viewCard(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item || !item.from_card_id) return;
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.from_card_id)
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 契约 §4(2026-10-15): 同意后跳对方公开名片页。
|
||||
* wap_url 原样使用, 仅追加 &back_url=encodeURIComponent(我方名片页完整地址);
|
||||
* wap_url 为空则不跳转(对方刚删卡等, 人脉已互建)。
|
||||
*/
|
||||
jumpToOtherPublic(wapUrl) {
|
||||
if (!wapUrl) return;
|
||||
const url = this.withBackUrl(wapUrl);
|
||||
// #ifdef H5
|
||||
window.location.href = url;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/share?url=' + encodeURIComponent(url)
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
/**
|
||||
* 给公开页地址追加 back_url=本页完整地址(契约 §4 前端唯一允许的改动)。
|
||||
* 对方名片页(PHP wap 模板)左上角返回键据此跳回本页; 已带 back_url 的原样返回, 不重复追加。
|
||||
*/
|
||||
withBackUrl(wapUrl) {
|
||||
if (!wapUrl) return '';
|
||||
if (wapUrl.indexOf('back_url=') >= 0) return wapUrl;
|
||||
const back = this.myPageFullUrl();
|
||||
if (!back) return wapUrl;
|
||||
return wapUrl + (wapUrl.indexOf('?') >= 0 ? '&' : '?') + 'back_url=' + encodeURIComponent(back);
|
||||
},
|
||||
// H5: 当前页面完整地址(禁止写死域名); 非 H5 无等价 H5 地址返回 '' 由 share.vue 中转页原生返回
|
||||
myPageFullUrl() {
|
||||
// #ifdef H5
|
||||
return window.location.href;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return '';
|
||||
// #endif
|
||||
},
|
||||
onAgree(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
this.$cardApi.exchangeAgree({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
// receiver_created=0 也是正常成功(同人脉已存在被跳过), 不当失败
|
||||
const d = res.data || {};
|
||||
const txt = d.receiver_created === 0 ? '已交换(人脉已存在)' : '已交换,人脉已互加';
|
||||
uni.showToast({ title: txt, icon: 'none' });
|
||||
// 需求: 点「同意」后对方名片必须出现在我的人脉中。
|
||||
// 后端虽声明 agree 双向建人脉, 实测出现「同意成功、人脉里却没有对方」,
|
||||
// 故不再用 receiver_created 判断, 一律在同意后校验/补建(幂等, 不会重复);
|
||||
// 实现见 card/detail 页同名方法(取对方名片详情 → 按手机号查我的人脉 → 没有才新建)。
|
||||
this.ensureExchangeContact(item.from_card_id, ensureRes => {
|
||||
// 命中同手机号人脉但姓名不同(多为对方改过名片名): 提示实际名称,
|
||||
// 避免「交换后对方没出现」的误判(该联系人其实已在人脉里, 只是显示旧名)
|
||||
if (ensureRes && ensureRes.existed && ensureRes.existed.name
|
||||
&& item.name && ensureRes.existed.name !== item.name) {
|
||||
uni.showToast({
|
||||
title: '人脉中已有同手机号联系人「' + ensureRes.existed.name + '」',
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
});
|
||||
}
|
||||
// 契约 §4(2026-10-15): 同意后用响应里的 wap_url 跳对方公开名片页,
|
||||
// 仅追加 &back_url=; wap_url 为空则退回刷新列表(对方刚删卡等)
|
||||
if (d.wap_url) {
|
||||
this.jumpToOtherPublic(d.wap_url);
|
||||
} else {
|
||||
this.loadList();
|
||||
}
|
||||
});
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 同意交换后校验并兜底建人脉: 保证对方名片出现在我的人脉中。
|
||||
* 走 card-api 的 ensureExchangeContact(取对方名片详情 → 按手机号去重 → 没有才 source=exchange 新建), 幂等。
|
||||
* 无论成败都执行 done(res) —— 兜底失败不阻塞同意后的跳转(交换本身已成功), 人脉页仍可手动补录。
|
||||
*/
|
||||
ensureExchangeContact(fromCardId, done) {
|
||||
if (!fromCardId) { typeof done === 'function' && done(null); return; }
|
||||
this.$cardApi.ensureExchangeContact({
|
||||
fromCardId: fromCardId,
|
||||
success: res => {
|
||||
if (res && res.created) {
|
||||
console.log('[card/exchange] 兜底新建人脉成功:', fromCardId);
|
||||
}
|
||||
typeof done === 'function' && done(res);
|
||||
},
|
||||
fail: err => {
|
||||
console.warn('[card/exchange] 兜底建人脉失败(不影响交换结果):', err);
|
||||
typeof done === 'function' && done(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
onIgnore(item) {
|
||||
this.$cardApi.exchangeIgnore({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已忽略', icon: 'none' });
|
||||
this.loadList();
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ex-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 筛选胶囊: 选中为浅蓝底蓝字, 未选中白底灰字
|
||||
.ex-tabs {
|
||||
display: flex;
|
||||
padding: 20rpx 24rpx;
|
||||
|
||||
.ex-tab {
|
||||
padding: 12rpx 28rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 32rpx;
|
||||
background-color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
|
||||
&.active {
|
||||
background-color: #e8f3ff;
|
||||
color: #2aa5ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ex-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.ex-item {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.ex-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ex-avatar-wrap {
|
||||
position: relative;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ex-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16rpx;
|
||||
background-color: #2aa5ff;
|
||||
}
|
||||
|
||||
.ex-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ex-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 16rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.ex-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ex-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.ex-badge {
|
||||
margin-left: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: #2aa5ff;
|
||||
}
|
||||
|
||||
.ex-job {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #7b8794;
|
||||
}
|
||||
|
||||
.ex-company-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.ex-company {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ex-agree {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 34rpx;
|
||||
border-radius: 34rpx;
|
||||
background-color: #2aa5ff;
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ex-agreed {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
color: #9aa4b0;
|
||||
}
|
||||
|
||||
.ex-reason {
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
background-color: #f5f6f8;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ex-time {
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9aa4b0;
|
||||
}
|
||||
</style>
|
||||
150
pages_tool/card/forward.vue
Normal file
150
pages_tool/card/forward.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<view class="forward-page">
|
||||
<!-- 名片预览 -->
|
||||
<view class="fp-card">
|
||||
<image v-if="avatar" class="fp-avatar" :src="avatar" mode="aspectFill" />
|
||||
<view v-else class="fp-avatar fp-avatar-ph">{{ (title || '?').slice(0, 1) }}</view>
|
||||
<view class="fp-name">{{ title || '数字名片' }}</view>
|
||||
</view>
|
||||
|
||||
<view class="fp-tip">点击下方按钮,把这张名片直接发给微信好友</view>
|
||||
|
||||
<!-- 转发: 微信小程序必须用原生 button open-type=share 才能拉起转发面板,
|
||||
纯 @tap 无法唤起; 内容取本页 onShareAppMessage -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="fp-btn" open-type="share">发送给朋友</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
|
||||
<view class="fp-btn" @tap="fallbackShare">发送给朋友</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="fp-sub" v-if="cardUrl">对方点开即可查看名片并保存联系方式</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 名片转发中转页(原生)
|
||||
*
|
||||
* 背景: 分享出去的 wap 名片页在 web-view 内, 其「分享」按钮无法拉起微信转发面板
|
||||
* (web-view 里的 H5 无此能力, 只能复制链接)。由 wap 页在小程序环境下把「分享」
|
||||
* 跳到本页, 本页用原生 button(open-type=share) 直接唤起「发送给朋友」。
|
||||
*
|
||||
* 入参(query, 均需 encodeURIComponent):
|
||||
* url wap 名片页地址(转发出去仍落地 card/share 的 web-view 页)
|
||||
* title 名片姓名(转发卡片标题)
|
||||
* avatar 头像(转发卡片封面, 可空)
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardUrl: '',
|
||||
title: '',
|
||||
avatar: ''
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardUrl = options && options.url ? decodeURIComponent(options.url) : '';
|
||||
this.title = options && options.title ? decodeURIComponent(options.title) : '';
|
||||
this.avatar = options && options.avatar ? decodeURIComponent(options.avatar) : '';
|
||||
uni.setNavigationBarTitle({ title: '分享名片' });
|
||||
},
|
||||
onReady() {
|
||||
uni.setNavigationBarTitle({ title: '分享名片' });
|
||||
},
|
||||
// 右上角菜单转发与按钮转发同参
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.title ? this.title + ' - 数字名片' : '名片分享',
|
||||
path: '/pages_tool/card/share' + (this.cardUrl ? '?url=' + encodeURIComponent(this.cardUrl) : ''),
|
||||
imageUrl: this.avatar || ''
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
fallbackShare() {
|
||||
// 非微信小程序端无转发面板, 退化为复制链接
|
||||
if (this.cardUrl) {
|
||||
uni.setClipboardData({
|
||||
data: this.cardUrl,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.forward-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 48rpx 40rpx;
|
||||
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 34%, #f5f7fa 34.2%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fp-card {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 56rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
|
||||
|
||||
.fp-avatar {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
background: #eef4fb;
|
||||
|
||||
&.fp-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1f8fef;
|
||||
font-size: 60rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.fp-name {
|
||||
margin-top: 24rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.fp-tip {
|
||||
margin-top: 56rpx;
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.fp-btn {
|
||||
margin-top: 40rpx;
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
line-height: 92rpx;
|
||||
border-radius: 46rpx;
|
||||
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fp-sub {
|
||||
margin-top: 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
418
pages_tool/card/leads.vue
Normal file
418
pages_tool/card/leads.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<view class="lead-page">
|
||||
<!-- 平级入口: 与我的人脉页互链(导航合并第一步) -->
|
||||
<view class="page-switch" @tap="goContacts">
|
||||
<text class="switch-label">我的人脉</text>
|
||||
<text class="switch-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<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">
|
||||
<!-- 点「别人的名片」= 打开对方名片; 拨号/按钮一律 .stop, 避免误触成打电话 -->
|
||||
<view v-for="(item, index) in list" :key="item.id || index" :data-index="index" class="lead-item" @tap="viewCard">
|
||||
<view class="lead-top">
|
||||
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
|
||||
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
|
||||
</view>
|
||||
<!-- 手机号是唯一拨号入口(H5 下调 tel: 浏览器会报 ERR_UNKNOWN_URL_SCHEME, 属正常) -->
|
||||
<view class="lead-mobile" v-if="item.mobile" @tap.stop="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
|
||||
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
|
||||
<view class="lead-meta">
|
||||
<view class="meta-card" :class="{ tappable: !!cardIdOf(item) }">
|
||||
<text class="meta-card-name">来自名片:{{ item.card_name || '—' }}</text>
|
||||
<text class="meta-card-arrow" v-if="cardIdOf(item)">查看名片 ›</text>
|
||||
</view>
|
||||
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||
</view>
|
||||
<view class="lead-actions">
|
||||
<view class="action-btn copy" v-if="item.wechat" @tap.stop="copyText(item.wechat)">复制微信</view>
|
||||
<view class="action-btn primary" :data-index="index" @tap.stop="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="'暂无意向客户,访客在你的名片页留资后会出现在这里'"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 与后端 Lead::STATUSES 一致: 0=新客户 1=已联系 2=已成交 3=无效
|
||||
const STATUS_MAP = [
|
||||
{ value: 0, label: '新客户' },
|
||||
{ value: 1, label: '已联系' },
|
||||
{ value: 2, label: '已成交' },
|
||||
{ value: 3, label: '无效' }
|
||||
];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tabs: [{ value: '', label: '全部' }].concat(STATUS_MAP),
|
||||
status: '',
|
||||
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
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
|
||||
goContacts() {
|
||||
this.$util.redirectTo('/pages_tool/card/contacts');
|
||||
},
|
||||
switchTab(value) {
|
||||
if (this.status === value) return;
|
||||
this.status = value;
|
||||
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 条件
|
||||
if (this.status !== '') {
|
||||
data.status = this.status;
|
||||
}
|
||||
this.$cardApi.leadLists({
|
||||
data: data,
|
||||
success: res => {
|
||||
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' });
|
||||
}
|
||||
this.isLoading = false;
|
||||
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: STATUS_MAP.map(s => s.label),
|
||||
success: res => {
|
||||
const target = STATUS_MAP[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 = STATUS_MAP.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' })
|
||||
});
|
||||
},
|
||||
// 该线索关联的名片编号(后端 lead/lists 行下发; 兼容常见字段名)
|
||||
cardIdOf(item) {
|
||||
if (!item) return '';
|
||||
return item.card_id || item.from_card_id || item.source_card_id || '';
|
||||
},
|
||||
// 点整条客户卡片 → 打开对方名片
|
||||
// 与交换名片(exchange.vue)同款写法: detail 接收 card_id(卡片编号字符串);
|
||||
// 本人名下的卡 → 正常详情页; 非本人名下 → detail 兜底跳公开名片页(后端返回的 wap_url)
|
||||
viewCard(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
const cardId = this.cardIdOf(item);
|
||||
if (!cardId) {
|
||||
// 后端 lead/lists 目前只下发 card_name(展示名), 没有名片编号 → 无法跳转。
|
||||
// 这里把该行真实字段打到控制台, 便于确认后端是否用了别的字段名(如 relation_id)
|
||||
console.warn('[card/leads] 该行缺少名片编号(card_id/from_card_id/source_card_id), 无法打开对方名片; 可用字段:',
|
||||
item ? Object.keys(item) : 'event', item);
|
||||
uni.showToast({ title: '该客户未关联名片,无法查看', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(cardId)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lead-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16rpx 24rpx 0;
|
||||
|
||||
.switch-label {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.switch-arrow {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 14rpx;
|
||||
|
||||
.meta-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 64%;
|
||||
|
||||
// 有名片编号时才高亮/可点, 否则退化为普通文字
|
||||
&.tappable .meta-card-name {
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.meta-card-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-card-arrow {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
1068
pages_tool/card/list.vue
Normal file
1068
pages_tool/card/list.vue
Normal file
File diff suppressed because it is too large
Load Diff
230
pages_tool/card/save-contact.vue
Normal file
230
pages_tool/card/save-contact.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<view class="save-contact">
|
||||
<!-- 名片摘要卡 -->
|
||||
<view class="sc-card">
|
||||
<view class="sc-avatar">{{ initial }}</view>
|
||||
<view class="sc-name">{{ form.name || '未命名名片' }}</view>
|
||||
<view class="sc-job" v-if="form.job_title">{{ form.job_title }}</view>
|
||||
<view class="sc-company" v-if="form.company">{{ form.company }}</view>
|
||||
<view class="sc-mobile" v-if="form.mobile" @tap="callNow">
|
||||
<text class="sc-mobile-icon">📞</text>
|
||||
<text>{{ form.mobile }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 保存按钮: 调系统 API 直接写入手机通讯录(系统会弹出确认框) -->
|
||||
<button class="sc-btn" :disabled="saving" @tap="saveToContacts">
|
||||
{{ saving ? '保存中…' : '保存到通讯录' }}
|
||||
</button>
|
||||
<view class="sc-tip">保存后可在手机通讯录中直接拨号</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 保存名片到手机通讯录(原生页)
|
||||
*
|
||||
* 背景: 分享出去的名片是后端 wap H5 页, 经 web-view 打开。H5 原来的
|
||||
* 「保存到通讯录」是下载 .vcf 文件, 而微信小程序 web-view 禁止下载文件,
|
||||
* 会提示「可在浏览器打开此网页来下载文件」。
|
||||
*
|
||||
* 方案: wap H5 检测到运行在小程序 web-view 内(/miniProgram/i.test(UA))时,
|
||||
* 不再触发 .vcf 下载, 改为调 wx.miniProgram.navigateTo 跳到本页,
|
||||
* 由本页(小程序原生)调 uni.addPhoneContact 直接写入系统通讯录。
|
||||
*
|
||||
* 入参(query, 均需 encodeURIComponent):
|
||||
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
|
||||
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
name: '',
|
||||
mobile: '',
|
||||
company: '',
|
||||
job_title: '',
|
||||
email: '',
|
||||
website: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
wechat: ''
|
||||
},
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
initial() {
|
||||
const n = this.form.name || this.form.mobile || '?';
|
||||
return n.slice(0, 1);
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
const keys = ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat'];
|
||||
keys.forEach(k => {
|
||||
if (options && options[k] != null) {
|
||||
this.form[k] = decodeURIComponent(options[k]);
|
||||
}
|
||||
});
|
||||
// 打电话动作: 直接唤起拨号盘并退出本页
|
||||
if (options && options.action === 'call' && this.form.mobile) {
|
||||
this.callNow();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
|
||||
uni.setNavigationBarTitle({ title: '保存到通讯录' });
|
||||
// 进入页面即自动调起系统"添加联系人"确认框:
|
||||
// web-view 内 H5 无法直接调 addPhoneContact, 必须经本原生页中转,
|
||||
// 若再让用户手动点一次等于两步操作, 所以这里自动触发, 用户零点击直接看到系统弹窗
|
||||
this.saveToContacts();
|
||||
},
|
||||
onShow() {
|
||||
uni.setNavigationBarTitle({ title: '保存到通讯录' });
|
||||
},
|
||||
methods: {
|
||||
callNow() {
|
||||
if (!this.form.mobile) return;
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: this.form.mobile,
|
||||
// 用户取消拨号会走 fail, 不提示
|
||||
fail: () => {}
|
||||
});
|
||||
},
|
||||
saveToContacts() {
|
||||
if (this.saving) return;
|
||||
if (!this.form.name && !this.form.mobile) {
|
||||
uni.showToast({ title: '名片资料缺失,无法保存', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
uni.addPhoneContact({
|
||||
firstName: this.form.name || this.form.mobile,
|
||||
mobilePhoneNumber: this.form.mobile || '',
|
||||
organization: this.form.company || '',
|
||||
title: this.form.job_title || '',
|
||||
email: this.form.email || '',
|
||||
url: this.form.website || '',
|
||||
weChatNumber: this.form.wechat || '',
|
||||
remark: [this.form.address, this.form.remark].filter(Boolean).join(' '),
|
||||
success: () => {
|
||||
uni.showToast({ title: '已保存到通讯录', icon: 'success' });
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({ fail: () => {} });
|
||||
}, 1200);
|
||||
},
|
||||
fail: err => {
|
||||
// 细分失败原因, 便于真机上快速定位(系统确认框点「取消」不算错误)
|
||||
const msg = (err && err.errMsg) || '';
|
||||
if (msg.indexOf('cancel') > -1) return;
|
||||
if (msg.indexOf('privacy') > -1 || msg.indexOf('declared') > -1) {
|
||||
// 小程序后台「用户隐私保护指引」未声明 addPhoneContact(见 docs/CARD_SAVE_CONTACT_BACKEND.md)
|
||||
uni.showToast({ title: '小程序隐私协议未声明该能力,请管理员配置', icon: 'none', duration: 3000 });
|
||||
} else if (msg.indexOf('auth') > -1 || msg.indexOf('deny') > -1) {
|
||||
uni.showToast({ title: '未获通讯录权限,请在系统设置中允许微信访问通讯录', icon: 'none', duration: 3000 });
|
||||
} else {
|
||||
// 未知错误把 errMsg 原样带出, 截图即可定位
|
||||
uni.showToast({ title: '保存失败: ' + (msg || '未知'), icon: 'none', duration: 3000 });
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.save-contact {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx 32rpx;
|
||||
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 40%, #f5f7fa 40.2%);
|
||||
}
|
||||
|
||||
.sc-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx 32rpx 40rpx;
|
||||
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.sc-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||
color: #ffffff;
|
||||
font-size: 56rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sc-name {
|
||||
margin-top: 24rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.sc-job {
|
||||
margin-top: 8rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sc-company {
|
||||
margin-top: 4rpx;
|
||||
font-size: 26rpx;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.sc-mobile {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 32rpx;
|
||||
color: #1f8fef;
|
||||
font-weight: 500;
|
||||
|
||||
.sc-mobile-icon {
|
||||
margin-right: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-btn {
|
||||
margin-top: 64rpx;
|
||||
height: 92rpx;
|
||||
line-height: 92rpx;
|
||||
border-radius: 46rpx;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-tip {
|
||||
margin-top: 24rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
217
pages_tool/card/share.vue
Normal file
217
pages_tool/card/share.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<view class="card-share">
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!-- 鸿蒙元服务:web-view 是原生全屏组件,必须用 cover-view 盖在上面(cover-view 不支持 iconfont,用文本) -->
|
||||
<cover-view class="mp-header">
|
||||
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
|
||||
<cover-view class="mp-header-inner">
|
||||
<cover-view class="nav-btn" @click="goBack">
|
||||
<cover-view class="nav-back-text">‹</cover-view>
|
||||
</cover-view>
|
||||
<cover-view class="header-title">名片分享</cover-view>
|
||||
<cover-view class="nav-btn nav-btn-placeholder"></cover-view>
|
||||
</cover-view>
|
||||
</cover-view>
|
||||
<!-- web-view 是全屏原生组件,父容器 padding 对它无效,用等高占位 view 顶下去 -->
|
||||
<view class="mp-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 小程序/鸿蒙:web-view 加载 wap H5 名片页(客户视角,含留资表单) -->
|
||||
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
|
||||
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
|
||||
<view v-else class="empty-tip">链接无效</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef H5 -->
|
||||
<!-- H5 直接就是浏览器,onLoad 时已 location 跳转,这里仅兜底展示 -->
|
||||
<view class="h5-fallback">
|
||||
<view class="h5-fallback-tip">正在打开名片…</view>
|
||||
<button class="h5-fallback-btn" @tap="openUrl">如未自动跳转,点此打开</button>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
|
||||
<view v-else class="empty-tip">链接无效</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 名片分享中转页
|
||||
*
|
||||
* 背景: 小程序 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接,
|
||||
* 而名片客户页(wap detail)是 H5。因此员工转发名片给微信好友时, 分享的是本页,
|
||||
* 本页再用 web-view 把客户带到 wap 名片页(含留资表单)。
|
||||
* H5 端分享就是普通 URL, 不需要中转, onLoad 直接跳转。
|
||||
* 鸿蒙元服务(MP-HARMONY): web-view 同为原生全屏组件(参考 pages_tool/webview/webview.vue
|
||||
* 的 cover-view 返回键方案), 与微信小程序共用同一套头部+占位+web-view 结构;
|
||||
* 无 onShareAppMessage 生命周期, 鸿蒙端分享入口是名片详情页内「分享」按钮。
|
||||
* 注意: web-view 加载的外链域名需在 manifest → HarmonyOS 网络配置里放行(元服务要求)。
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardUrl: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-HARMONY
|
||||
headerTotalHeightPx() {
|
||||
const rpxToPx = this.windowWidth / 750;
|
||||
return this.statusBarHeight + Math.round(88 * rpxToPx);
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardUrl = options.url ? decodeURIComponent(options.url) : '';
|
||||
// #ifdef MP-WEIXIN || MP-HARMONY
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.statusBarHeight = sys.statusBarHeight || 20;
|
||||
this.windowWidth = sys.windowWidth || 375;
|
||||
} catch (e) {
|
||||
// 鸿蒙个别版本 getSystemInfoSync 可能抛错, 用默认值兜底(头部高度略差不错位)
|
||||
console.warn('[card/share] getSystemInfoSync fail, use fallback', e);
|
||||
}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
// H5 端无 web-view 中转必要,直接跳转到名片页
|
||||
if (this.cardUrl) {
|
||||
window.location.href = this.cardUrl;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
goBack() {
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
// 栈底回首页必须走 $util.redirectTo: 鸿蒙元服务上 uni.switchTab 会静默失败
|
||||
// (success/fail 都不触发, .catch 兜底失效), util 内部有
|
||||
// switchTab → 500ms 强制 reLaunch 的兜底(见 util.js#redirectTo tabBar 分支)
|
||||
this.$util.redirectTo('/pages/index/index');
|
||||
}
|
||||
},
|
||||
// #ifdef H5
|
||||
openUrl() {
|
||||
if (this.cardUrl) window.location.href = this.cardUrl;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-share {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* #ifdef MP-HARMONY */
|
||||
.mp-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.mp-status-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 20rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.nav-back-text {
|
||||
font-size: 48rpx;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn-placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin: 0 16rpx;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.mp-spacer {
|
||||
width: 100%;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* #ifdef H5 */
|
||||
.h5-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80vh;
|
||||
|
||||
.h5-fallback-tip {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.h5-fallback-btn {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
border: 1rpx solid #dcdfe6;
|
||||
border-radius: 36rpx;
|
||||
padding: 0 40rpx;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
</style>
|
||||
819
pages_tool/card/style.vue
Normal file
819
pages_tool/card/style.vue
Normal file
@@ -0,0 +1,819 @@
|
||||
<template>
|
||||
<view class="card-detail" :style="[previewVars, { fontFamily: previewDs.fontFamily }]" :class="{ 'qs-square': previewDs.quickStyle === 'square' }">
|
||||
<view class="page-layer">
|
||||
<view class="ps-disabled" v-if="relation && !enabled">商家未开启员工端样式自选,暂不可选样式</view>
|
||||
|
||||
<!-- 实时预览卡: 与详情页同款 design 渲染(展示当前所选预设的实际落库效果) -->
|
||||
<view class="card-wrap" v-if="relation">
|
||||
<view class="card-wrap-bg" :style="previewHeaderBg"></view>
|
||||
<view class="detail-card" :style="{ backgroundColor: previewDs.bgColor, borderRadius: previewDs.cardRadius + 'px' }"
|
||||
:class="['layout-' + previewDs.layout, previewDs.cover && previewDs.layout !== 'classic' ? 'has-cover' : '']">
|
||||
<view v-if="previewDs.cover && previewDs.layout !== 'classic'" class="dc-cover"
|
||||
:style="{ borderTopLeftRadius: previewDs.cardRadius + 'px', borderTopRightRadius: previewDs.cardRadius + 'px' }"
|
||||
:class="previewDs.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
|
||||
<image class="dc-cover-img" :src="$util.img(previewDs.cover)" mode="aspectFill"></image>
|
||||
</view>
|
||||
<view class="dc-name" :style="{ color: previewDs.textColor }">{{ relation.name || '未命名名片' }}</view>
|
||||
<view class="dc-job" v-if="relation.job_title" :style="{ color: previewDs.textSoft, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
|
||||
<view class="dc-company" v-if="relation.company" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.company }}</view>
|
||||
|
||||
<view class="dc-rows" v-if="fieldRows.length > 0">
|
||||
<view v-for="(row, index) in fieldRows" :key="index" class="field-row"
|
||||
:class="{ 'field-row-multi': row.multiline }">
|
||||
<text v-if="row.icon" class="field-icon iconfont" :class="row.icon" :style="{ color: previewDs.textSoft }"></text>
|
||||
<text v-else class="field-tag" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ row.label }}</text>
|
||||
<view class="field-value" :class="{ 'field-value-multi': row.multiline }" :style="{ color: previewDs.textColor, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ row.value }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-loading" v-if="isLoading">加载中…</view>
|
||||
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
|
||||
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
|
||||
|
||||
<!-- 预设网格: 每个单元格 = 该预设的迷你预览(同源后台 CardTemplatePresets) -->
|
||||
<view class="preset-section" v-if="relation && enabled">
|
||||
<text class="ps-title">选择样式<text class="ps-count">(共 {{ presets.length }} 个)</text></text>
|
||||
<view class="ps-canvas-hint" v-if="isCanvasCard">
|
||||
当前名片为商家自定义画布设计,保存将不被允许
|
||||
</view>
|
||||
<view class="preset-grid">
|
||||
<view class="preset-cell" v-for="(p, idx) in presets" :key="p.key"
|
||||
:class="{ active: p.key === selectedKey }" :style="cellActiveStyle(p)"
|
||||
@tap="selectPreset(p.key)">
|
||||
<text class="preset-tag" v-if="p.source === 'canvas'">画布式</text>
|
||||
<!-- 缩略图: 彩色底 + 内嵌白卡。
|
||||
底色改由样式表 .pt-N(N=格序) 给出: 微信小程序会丢弃内联 background,
|
||||
只用内联会导致 14 个格子全部回落成同一个兜底蓝。 -->
|
||||
<view class="preset-thumb" :class="'pt-' + (idx % 14)" :style="cellVars(p)">
|
||||
<view class="pt-header"></view>
|
||||
<view class="pt-body" :style="cellBody(p)">
|
||||
<view class="pt-name" :style="cellText(p)"></view>
|
||||
<view class="pt-line" :style="cellSoft(p)"></view>
|
||||
<view class="pt-line pt-short" :style="cellSoft(p)"></view>
|
||||
</view>
|
||||
<view v-if="p.key === selectedKey" class="pt-check">
|
||||
<view class="pt-check-mark"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="preset-name">{{ p.name }}</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
<text v-if="p.key === currentKey" class="preset-current">当前</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!isLoading && presets.length === 0" class="ps-empty">
|
||||
预设列表为空(后端 design/presets 未返回)
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部保存 -->
|
||||
<view class="ps-footer" v-if="relation && enabled">
|
||||
<view class="ps-save" :class="{ disabled: !selectedKey || saving || isCanvasCard }" @tap="save">
|
||||
{{ saving ? '保存中…' : (isCanvasCard ? '不支持修改' : '保存') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 名片样式(可选样式页)· 后端 v2 对接
|
||||
* GET /card/api/design/presets → 与商家后台 CardTemplatePresets 同源的预设列表
|
||||
* POST /card/api/design/save → 按 preset_key 应用预设并保存到本卡
|
||||
* 关键点(v2):
|
||||
* - 预设列表由后端下发, 前端不自建; 数量不写死(当前 14 个, 直接渲染 list.length)
|
||||
* - save 只写入 10 个字段(theme_color/text_color/bg_color/font_family/font_scale/
|
||||
* card_radius/header_gradient_end/header_gradient_angle/quick_style/template_id),
|
||||
* 不覆盖 layout / cover; 预览据此合并(本卡 layout/cover + 预设 10 字段)
|
||||
* - source==='canvas' 的预设仅改配色; 本卡自身为 canvas 设计时 save 会被拒
|
||||
* - data.enabled 控制入口显隐(detail.vue 已据此隐藏按钮)
|
||||
*/
|
||||
function mixHex(a, b, t) {
|
||||
const toRgb = h => {
|
||||
h = (h || '').replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
if (isNaN(n)) return [0, 0, 0];
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [r1, g1, b1] = toRgb(a);
|
||||
const [r2, g2, b2] = toRgb(b);
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const bl = Math.round(b1 + (b2 - b1) * t);
|
||||
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// save 实际写入的字段(与后端 v2 一致); 预览只叠加这些, 其余沿用本卡既有值
|
||||
const SAVE_WRITTEN_FIELDS = [
|
||||
'theme_color', 'text_color', 'bg_color', 'font_family', 'font_scale',
|
||||
'card_radius', 'header_gradient_end', 'header_gradient_angle', 'quick_style', 'template_id'
|
||||
];
|
||||
|
||||
// 归一化 design(字段缺失/非法回落文档默认值); d 可为 null(旧后端)
|
||||
function normalizeDesign(d) {
|
||||
const dd = d || {};
|
||||
const FONTS = {
|
||||
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
|
||||
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
|
||||
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
|
||||
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
|
||||
};
|
||||
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
|
||||
// 兼容后端可能的多种命名: snake_case / camelCase / 简写
|
||||
// (后端若下发 camelCase 而这里只读 snake_case, 会全部回落默认蓝 → 14 个格子同色)
|
||||
const pick = (...keys) => {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const v = dd[keys[i]];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const themeRaw = pick('theme_color', 'themeColor', 'theme', 'primary_color', 'primaryColor', 'primary', 'color');
|
||||
const textRaw = pick('text_color', 'textColor', 'text', 'text_primary', 'textPrimary');
|
||||
const bgRaw = pick('bg_color', 'bgColor', 'background_color', 'backgroundColor', 'background', 'bg');
|
||||
const radiusRaw = pick('card_radius', 'cardRadius', 'radius');
|
||||
const layoutRaw = pick('layout', 'card_layout', 'cardLayout');
|
||||
const fontRaw = pick('font_family', 'fontFamily', 'font');
|
||||
const scaleRaw = pick('font_scale', 'fontScale');
|
||||
const headerEndRaw = pick('header_gradient_end', 'headerGradientEnd', 'gradient_end', 'gradientEnd');
|
||||
const angleRaw = pick('header_gradient_angle', 'headerGradientAngle', 'gradient_angle', 'gradientAngle');
|
||||
|
||||
const theme = isHex(themeRaw) ? themeRaw : '#119dff';
|
||||
const text = isHex(textRaw) ? textRaw : '#1f2329';
|
||||
const bg = isHex(bgRaw) ? bgRaw : '#ffffff';
|
||||
const fontKey = FONTS[fontRaw] ? fontRaw : 'system';
|
||||
const fontScale = Math.min(160, Math.max(60, Number(scaleRaw) || 100));
|
||||
const cardRadius = Number(radiusRaw) || 16;
|
||||
const layout = ['hero', 'compact', 'classic'].includes(layoutRaw) ? layoutRaw : 'classic';
|
||||
const headerEnd = typeof headerEndRaw === 'string' ? headerEndRaw : '';
|
||||
const headerAngle = Number(angleRaw) || 150;
|
||||
return {
|
||||
themeColor: theme,
|
||||
textColor: text,
|
||||
bgColor: bg,
|
||||
layout: layout,
|
||||
cover: pick('cover', 'cover_url', 'coverUrl') || '',
|
||||
fontKey: fontKey,
|
||||
fontFamily: FONTS[fontKey],
|
||||
fontScale: fontScale,
|
||||
cardRadius: cardRadius,
|
||||
headerGradientEnd: headerEnd,
|
||||
headerGradientAngle: headerAngle,
|
||||
quickStyle: pick('quick_style', 'quickStyle') || '',
|
||||
templateId: pick('template_id', 'templateId', 'template') || '',
|
||||
source: pick('source') || 'none',
|
||||
textSoft: mixHex(text, bg, 0.55),
|
||||
themeSoft: mixHex(theme, '#ffffff', 0.85),
|
||||
fontScaleNum: fontScale / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设配色兜底: 后端 design 未带合法 theme_color 时(字段名不一致 / 未下发),
|
||||
* 按预设 key/name/desc 关键词匹配一组与后台预设一致的色。
|
||||
* 仅用于缩略图观感, 不影响保存(save 仍按 preset_key 走后端)。
|
||||
*/
|
||||
const PRESET_FALLBACK = [
|
||||
{ re: /(极简|留白|minimal|white|blank)/, theme: '#1f2329' },
|
||||
{ re: /(霓虹|深色|黑|墨|neon|dark|night)/, theme: '#111827' },
|
||||
{ re: /(紫|purple|violet)/, theme: '#7b61ff' },
|
||||
{ re: /(绿|green)/, theme: '#12b886' },
|
||||
{ re: /(橙|暖|orange|warm)/, theme: '#f59f3c' },
|
||||
{ re: /(红|crimson|red)/, theme: '#ef4444' },
|
||||
{ re: /(粉|pink|rose)/, theme: '#ec4899' },
|
||||
{ re: /(金|gold|amber)/, theme: '#d4a017' },
|
||||
{ re: /(青|teal|cyan)/, theme: '#06b6d4' },
|
||||
{ re: /(灰|gray|grey)/, theme: '#6b7280' },
|
||||
{ re: /(渐|gradient)/, theme: '#5b6bff' },
|
||||
{ re: /(大图|门面|封面|cover)/, theme: '#0b5cff' },
|
||||
{ re: /(经典|商务|classic|business)/, theme: '#0b5cff' },
|
||||
{ re: /(蓝|blue|flow|origin|原生)/, theme: '#119dff' }
|
||||
];
|
||||
function fallbackTheme(p) {
|
||||
if (!p) return '';
|
||||
const hay = String(p.key || '') + ' ' + String(p.name || '') + ' '
|
||||
+ String(p.title || '') + ' ' + String(p.label || '') + ' ' + String(p.desc || '');
|
||||
const low = hay.toLowerCase();
|
||||
for (let i = 0; i < PRESET_FALLBACK.length; i++) {
|
||||
if (PRESET_FALLBACK[i].re.test(low)) return PRESET_FALLBACK[i].theme;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function designVarsFor(s) {
|
||||
return {
|
||||
'--card-theme': s.themeColor,
|
||||
'--card-theme-end': s.headerGradientEnd || s.themeColor,
|
||||
'--card-text': s.textColor,
|
||||
'--card-text-soft': s.textSoft,
|
||||
'--card-bg': s.bgColor,
|
||||
'--card-radius': s.cardRadius + 'px',
|
||||
'--card-font': s.fontFamily,
|
||||
'--card-font-scale': s.fontScaleNum
|
||||
};
|
||||
}
|
||||
|
||||
function headerBgFor(s) {
|
||||
if (s.headerGradientEnd) {
|
||||
return 'linear-gradient(' + s.headerGradientAngle + 'deg, ' + s.themeColor + ', ' + s.headerGradientEnd + ')';
|
||||
}
|
||||
return 'linear-gradient(150deg, ' + s.themeColor + ' 0%, ' + s.themeColor + ' 62%, rgba(0,0,0,0.28) 160%)';
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '',
|
||||
relation: null,
|
||||
// 名片设计摘要(后端 /card/api/card/detail 顶层 design, 旧后端无此键时为 null)
|
||||
design: null,
|
||||
// 后端下发的预设列表(同源后台 CardTemplatePresets, 数量不写死)
|
||||
presets: [],
|
||||
// 商家是否开启「员工端样式自选」(presets 响应 enabled)
|
||||
enabled: true,
|
||||
// 当前选中的预设 key(初始 = 本卡当前 template_id)
|
||||
selectedKey: '',
|
||||
isLoading: true,
|
||||
errorMsg: '',
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
fieldRows() {
|
||||
const r = this.relation;
|
||||
if (!r) return [];
|
||||
const rows = [];
|
||||
const push = (label, value, action, icon) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
rows.push({ label, value: String(value), action: action || null, icon: icon || '' });
|
||||
}
|
||||
};
|
||||
push('手机', r.mobile, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||
push('电话', r.phone, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||
push('邮箱', r.email, { type: 'copy', text: '复制' });
|
||||
push('微信', r.wechat, { type: 'copy', text: '复制' }, 'icon-weixin');
|
||||
push('网站', r.website);
|
||||
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '), null, 'icon-dizhi');
|
||||
push('邮编', r.postal_code);
|
||||
(r.mobiles_list || []).forEach((m, i) => {
|
||||
push('手机' + (i + 2), m, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||
});
|
||||
(r.socials_list || []).forEach(s => {
|
||||
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
|
||||
});
|
||||
(r.custom_list || []).forEach(c => {
|
||||
push(c.label, c.value);
|
||||
});
|
||||
if (r.bio && String(r.bio).trim() !== '') {
|
||||
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
// 本卡当前 design 的 template_id(用于标记"当前")
|
||||
currentKey() {
|
||||
return (this.design && this.design.template_id) || '';
|
||||
},
|
||||
// 本卡自身为画布设计时, save 会被后端拒
|
||||
isCanvasCard() {
|
||||
return !!(this.design && this.design.source === 'canvas');
|
||||
},
|
||||
activePreset() {
|
||||
return this.presets.find(p => p.key === this.selectedKey) || null;
|
||||
},
|
||||
// 实时预览用的 design: 以本卡既有 design 为底, 仅叠加 save 实际写入的 10 个字段
|
||||
// (layout/cover 不被 save 覆盖, 预览须与落库一致, 避免误导)
|
||||
previewDesign() {
|
||||
const base = this.design || {};
|
||||
const p = this.activePreset ? this.activePreset.design : null;
|
||||
if (!p) return base;
|
||||
const merged = Object.assign({}, base);
|
||||
SAVE_WRITTEN_FIELDS.forEach(k => {
|
||||
if (p[k] !== undefined) merged[k] = p[k];
|
||||
});
|
||||
return merged;
|
||||
},
|
||||
previewDs() {
|
||||
return normalizeDesign(this.previewDesign);
|
||||
},
|
||||
previewVars() {
|
||||
return designVarsFor(this.previewDs);
|
||||
},
|
||||
previewHeaderBg() {
|
||||
// 同 cellVars: 小程序内联 `background` 渐变不生效, 用 backgroundColor 纯色兜住
|
||||
return { backgroundColor: this.previewDs.themeColor };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 预设缩略图的配色。
|
||||
*
|
||||
* 为什么不用 CSS 变量: 微信小程序的渲染层不支持在内联 style 里定义自定义属性
|
||||
* (`style="--card-bg:#fff"`), 整条 style 会被丢弃 → var(--card-bg) 全部回落默认值,
|
||||
* 表现就是「小程序端样式缩略图全是白底黑条灰线, 没有颜色」(H5 正常)。
|
||||
* 这里改为直接输出具体属性 + 具体色值, 三端一致。
|
||||
*/
|
||||
cellDs(p) {
|
||||
let raw = p && p.design;
|
||||
// design 可能是 JSON 字符串(部分后端把设计摘要存 JSON 列)
|
||||
if (typeof raw === 'string') {
|
||||
try { raw = JSON.parse(raw); } catch (e) { raw = null; }
|
||||
}
|
||||
// 合并: 预设项顶层字段(扁平下发) + design 子对象(优先), 两种结构都能读到色值
|
||||
raw = Object.assign({}, p || {}, (raw && typeof raw === 'object') ? raw : {});
|
||||
const s = normalizeDesign(raw);
|
||||
// 缩略图颜色以「预设名称语义」为准(与后台/H5 的预设一致)。
|
||||
// 后端可能给所有预设下发同一个默认 theme_color(合法但是同一种蓝),
|
||||
// 若只在"没有合法色值"时才兜底, 就会 14 个格子全蓝 —— 所以这里名称命中即覆盖。
|
||||
const fb = fallbackTheme(p);
|
||||
if (fb) {
|
||||
s.themeColor = fb;
|
||||
s.headerGradientEnd = ''; // 纯色, 避免后端残留的渐变把兜底色盖掉
|
||||
s.themeSoft = mixHex(fb, '#ffffff', 0.85);
|
||||
}
|
||||
return s;
|
||||
},
|
||||
cellVars(p) {
|
||||
const s = this.cellDs(p);
|
||||
// 底色不再走内联(小程序端实测内联 background/backgroundColor 均被丢,
|
||||
// 导致 14 格回落同一个 CSS 兜底蓝), 改由样式表按格序上色(见 .preset-cell:nth-child)。
|
||||
// 这里只保留圆角这类无颜色语义的属性。
|
||||
const radius = Math.min(Number(s.cardRadius) || 16, 18);
|
||||
return { borderRadius: radius + 'px' };
|
||||
},
|
||||
// 内嵌白卡: 底色取预设 bg_color, 在彩色底上形成"浮起的名片"层次
|
||||
cellBody(p) {
|
||||
const s = this.cellDs(p);
|
||||
return { backgroundColor: s.bgColor };
|
||||
},
|
||||
cellText(p) {
|
||||
return { backgroundColor: this.cellDs(p).textColor };
|
||||
},
|
||||
cellSoft(p) {
|
||||
return { backgroundColor: this.cellDs(p).textSoft };
|
||||
},
|
||||
// 选中描边同样交给样式表(按格序), 避免内联失效
|
||||
cellActiveStyle() {
|
||||
return null;
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.loadDetail();
|
||||
this.loadPresets();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
loadDetail() {
|
||||
if (!this.cardId) {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '缺少名片编号';
|
||||
return;
|
||||
}
|
||||
this.$cardApi.cardDetail({
|
||||
data: { card_id: this.cardId },
|
||||
success: res => {
|
||||
const data = res.data || {};
|
||||
if (res.code == 0) {
|
||||
this.relation = data.relation || null;
|
||||
this.design = data.design || null;
|
||||
this.initSelected();
|
||||
} else {
|
||||
this.errorMsg = res.message || '卡片不存在或已被删除';
|
||||
this.relation = null;
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '网络异常,请稍后重试';
|
||||
}
|
||||
});
|
||||
},
|
||||
loadPresets() {
|
||||
this.$cardApi.cardDesignPresets({
|
||||
success: res => {
|
||||
const data = res.data || {};
|
||||
// 商家未开启样式自选时前端隐藏入口(默认 true, 失败开放)
|
||||
this.enabled = data.enabled !== false;
|
||||
this.presets = Array.isArray(data.list) ? data.list : [];
|
||||
this.initSelected();
|
||||
},
|
||||
fail: () => {
|
||||
this.enabled = true;
|
||||
this.presets = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
// 两路加载都到位后, 若本卡 template_id 命中某个预设, 默认选中它
|
||||
initSelected() {
|
||||
if (this.selectedKey || !this.currentKey || this.presets.length === 0) return;
|
||||
const hit = this.presets.find(p => p.key === this.currentKey);
|
||||
if (hit) this.selectedKey = hit.key;
|
||||
},
|
||||
selectPreset(key) {
|
||||
this.selectedKey = key;
|
||||
},
|
||||
save() {
|
||||
if (!this.selectedKey || this.saving) return;
|
||||
if (this.isCanvasCard) {
|
||||
uni.showToast({ title: '该名片使用商家自定义画布设计,暂不支持员工端修改', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.$cardApi.cardDesignSave({
|
||||
data: { card_id: this.cardId, preset_key: this.selectedKey },
|
||||
success: res => {
|
||||
this.saving = false;
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已保存', icon: 'success' });
|
||||
// 返回详情页, 其 onShow 会重新拉取 design 并刷新生效
|
||||
setTimeout(() => uni.navigateBack(), 600);
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.saving = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-detail {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
// 底部预留高度须 ≥ 固定底栏(按钮 88 + 上下 padding + 安全区), 否则滚到底最后一行会被遮住
|
||||
padding: 0 0 240rpx;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--card-font, -apple-system, "PingFang SC", "Helvetica Neue", Arial, sans-serif);
|
||||
}
|
||||
|
||||
.page-layer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ps-disabled {
|
||||
margin: 24rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: #8a94a6;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
// 预览卡(与详情页同款 design 渲染)
|
||||
.card-wrap {
|
||||
position: relative;
|
||||
padding-top: 24rpx;
|
||||
}
|
||||
|
||||
.card-wrap-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 48rpx;
|
||||
z-index: 0;
|
||||
background-color: var(--card-theme, #2aa5ff);
|
||||
border-bottom-left-radius: 240rpx 44rpx;
|
||||
border-bottom-right-radius: 240rpx 44rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 24rpx;
|
||||
background-color: var(--card-bg, #ffffff);
|
||||
border-radius: var(--card-radius, 20rpx);
|
||||
padding: 40rpx 36rpx 32rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
|
||||
|
||||
.dc-cover {
|
||||
margin: -40rpx -36rpx 24rpx;
|
||||
border-top-left-radius: var(--card-radius, 20rpx);
|
||||
border-top-right-radius: var(--card-radius, 20rpx);
|
||||
overflow: hidden;
|
||||
|
||||
.dc-cover-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.dc-cover-hero .dc-cover-img { height: 200rpx; }
|
||||
.dc-cover-compact .dc-cover-img { height: 90rpx; }
|
||||
|
||||
.dc-name {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: var(--card-text, #1a1a1a);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dc-job {
|
||||
margin-top: 6rpx;
|
||||
font-size: calc(28rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.3;
|
||||
color: var(--card-text-soft, #4a4a4a);
|
||||
}
|
||||
|
||||
.dc-company {
|
||||
margin-top: 2rpx;
|
||||
font-size: calc(26rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.3;
|
||||
color: var(--card-text-soft, #9aa4b2);
|
||||
}
|
||||
|
||||
.dc-rows {
|
||||
margin-top: 56rpx;
|
||||
}
|
||||
|
||||
&.layout-compact {
|
||||
padding-top: 28rpx;
|
||||
|
||||
.dc-rows { margin-top: 28rpx; }
|
||||
.field-row { padding: 8rpx 0; }
|
||||
.dc-name { font-size: 40rpx; }
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
padding: 12rpx 0;
|
||||
|
||||
&.field-row-multi {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-icon {
|
||||
width: 40rpx;
|
||||
margin-right: 18rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
color: var(--card-text-soft, #8a94a6);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-tag {
|
||||
min-width: 40rpx;
|
||||
margin-right: 18rpx;
|
||||
font-size: calc(26rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.2;
|
||||
color: var(--card-text-soft, #9aa4b2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: calc(28rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.25;
|
||||
color: var(--card-text, #333333);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-value-multi {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 预设网格(3 列自适应, 14 个不再过长) */
|
||||
.preset-section {
|
||||
margin: 32rpx 24rpx 0;
|
||||
|
||||
.ps-title {
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ps-count {
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
.ps-canvas-hint {
|
||||
margin-bottom: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-cell {
|
||||
position: relative;
|
||||
// 自建堆叠上下文: 把格子内的绝对定位元素(角标/选中钩)限制在本格内,
|
||||
// 避免其 z-index 逃逸到固定底栏之上
|
||||
z-index: 0;
|
||||
width: 32%;
|
||||
margin: 0 2% 16rpx 0;
|
||||
background-color: #ffffff;
|
||||
border: 2rpx solid #e8ebee;
|
||||
border-radius: 16rpx;
|
||||
padding: 14rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(3n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--card-theme, #2aa5ff);
|
||||
box-shadow: 0 6rpx 18rpx rgba(42, 165, 255, 0.18);
|
||||
}
|
||||
|
||||
.preset-tag {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
left: 8rpx;
|
||||
z-index: 2;
|
||||
padding: 2rpx 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.preset-thumb {
|
||||
position: relative;
|
||||
height: 160rpx;
|
||||
// 底色由 .pt-N 提供(见下方); 这里不能设兜底色, 否则会盖掉 .pt-N
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
|
||||
// 顶部彩色露出高度(底色由 cellVars 的渐变铺满整块)
|
||||
.pt-header {
|
||||
height: 24rpx;
|
||||
}
|
||||
|
||||
// 内嵌白卡: 浮在主题色底上, 左右下留边露出彩色(对齐 H5 缩略图层次)
|
||||
.pt-body {
|
||||
margin: 0 12rpx 18rpx;
|
||||
padding: 12rpx 14rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pt-name {
|
||||
height: 14rpx;
|
||||
width: 56%;
|
||||
border-radius: 7rpx;
|
||||
background-color: var(--card-text, #1a1a1a);
|
||||
}
|
||||
|
||||
.pt-line {
|
||||
height: 7rpx;
|
||||
width: 80%;
|
||||
margin-top: 12rpx;
|
||||
border-radius: 4rpx;
|
||||
background-color: var(--card-text-soft, #9aa4b2);
|
||||
}
|
||||
|
||||
.pt-short {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.pt-check {
|
||||
position: absolute;
|
||||
right: 10rpx;
|
||||
bottom: 10rpx;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.pt-check-mark {
|
||||
width: 13rpx;
|
||||
height: 6rpx;
|
||||
margin-top: -4rpx;
|
||||
border-left: 3rpx solid #2aa5ff;
|
||||
border-bottom: 3rpx solid #2aa5ff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preset-desc {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
color: #8a94a6;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preset-current {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 缩略图底色 =====
|
||||
* 按后台 14 个预设的固定顺序写死。
|
||||
* 为什么不用内联/渐变: 微信小程序会丢弃内联 background(渐变/纯色均可能失效),
|
||||
* 之前 14 个格子全部回落成同一个蓝就是因此。样式表 class 是唯一稳定的方式。
|
||||
* 后台预设顺序/配色调整时, 同步修改这里即可。
|
||||
*/
|
||||
.pt-0 { background-color: #119dff; } /* 原生样式 */
|
||||
.pt-1 { background-color: #12b886; } /* 商务绿 */
|
||||
.pt-2 { background-color: #f59f3c; } /* 暖橙 */
|
||||
.pt-3 { background-color: #7b61ff; } /* 紫罗兰 */
|
||||
.pt-4 { background-color: #0b5cff; } /* 经典商务 */
|
||||
.pt-5 { background-color: #1f2329; } /* 极简留白 */
|
||||
.pt-6 { background-color: #111827; } /* 深色霓虹 */
|
||||
.pt-7 { background-color: #f59f3c; } /* 暖调名片 */
|
||||
.pt-8 { background-color: #0b5cff; } /* 大图门面 */
|
||||
.pt-9 { background-color: #7aa7c7; }
|
||||
.pt-10 { background-color: #6cc24a; }
|
||||
.pt-11 { background-color: #111827; }
|
||||
.pt-12 { background-color: #d4a017; } /* 黑金质感: 金 */
|
||||
.pt-13 { background-color: #ef4444; } /* 朱砂活力: 红 */
|
||||
|
||||
.ps-empty {
|
||||
margin-top: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #f5a623;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
text-align: center;
|
||||
color: #999999;
|
||||
font-size: 26rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
/* 底部保存条 */
|
||||
.ps-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
// 必须高于内容层: 否则预设格子里的绝对定位元素(如 .preset-tag z-index:2)
|
||||
// 会"浮"到固定底栏之上, 只露出角标而卡片本体被遮住
|
||||
z-index: 100;
|
||||
padding: 20rpx 24rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
.ps-save {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
border-radius: 44rpx;
|
||||
background-color: #2aa5ff;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
|
||||
&.disabled {
|
||||
background-color: #c8d6e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
pages_tool/card/views.vue
Normal file
244
pages_tool/card/views.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<view class="views-page">
|
||||
<!-- 汇总卡(2.11.4): 今日/累计 的 PV 与 UV -->
|
||||
<view class="summary">
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.today_pv || 0 }}</view>
|
||||
<view class="sum-label">今日浏览 PV</view>
|
||||
</view>
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.today_uv || 0 }}</view>
|
||||
<view class="sum-label">今日访客 UV</view>
|
||||
</view>
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.total_pv || 0 }}</view>
|
||||
<view class="sum-label">累计浏览 PV</view>
|
||||
</view>
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.total_uv || 0 }}</view>
|
||||
<view class="sum-label">累计访客 UV</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表: time / name / channel / device / ip -->
|
||||
<view class="view-list">
|
||||
<view v-for="(item, idx) in list" :key="item.id || idx" class="view-item">
|
||||
<view class="view-avatar">{{ avatarText(item) }}</view>
|
||||
<view class="view-info">
|
||||
<view class="view-name">{{ item.viewer_name || item.name || '匿名访客' }}</view>
|
||||
<view class="view-meta">
|
||||
<text class="view-channel">{{ item.channel || '未知渠道' }}</text>
|
||||
<text class="view-dot">·</text>
|
||||
<text class="view-device">{{ item.device || '' }}</text>
|
||||
<text class="view-dot" v-if="item.ip">·</text>
|
||||
<text class="view-ip">{{ item.ip || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="view-time">{{ item.view_time || item.time || '' }}</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>
|
||||
<view v-if="!isLoading && list.length === 0" class="views-empty">暂无访客记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 访客轨迹 / 谁看了我(2.11.4,建议新页面)
|
||||
* 入口: 我的名片「访客」总入口(不带 card_id,查名下全部名片)
|
||||
* 或某张名片「浏览」入口(带 card_id,查该卡访客)
|
||||
* 接口: POST /card/api/card/views { card_id?, page?, page_size?(<=50, 默认20) }
|
||||
* 响应: { list, total, page, page_size, summary: { today_pv, today_uv, total_pv, total_uv } }
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '', // 带 card_id=查该卡访客; 空=名下全部
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false,
|
||||
summary: {}
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = (options && options.card_id) ? String(options.card_id) : '';
|
||||
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: {
|
||||
avatarText(item) {
|
||||
const name = item.viewer_name || item.name || '?';
|
||||
return name.trim().charAt(0).toUpperCase();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
const data = { page: pageNo, page_size: this.pageSize };
|
||||
if (this.cardId) data.card_id = this.cardId;
|
||||
this.$cardApi.cardViews({
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.summary = d.summary || {};
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
} else {
|
||||
this.handleApiError(res);
|
||||
}
|
||||
this.isLoading = false;
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleApiError(res) {
|
||||
const msg = (res && res.message) || '加载失败';
|
||||
uni.showToast({ title: msg, icon: 'none' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.views-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding: 20rpx 24rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.sum-card {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
margin: 0 8rpx;
|
||||
|
||||
&:first-child { margin-left: 0; }
|
||||
&:last-child { margin-right: 0; }
|
||||
|
||||
.sum-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--main-color, #0054a5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sum-label {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-list {
|
||||
.view-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.view-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
color: var(--main-color, #0054a5);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.view-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.view-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.view-meta {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.view-dot {
|
||||
margin: 0 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-time {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end { color: #ccc; }
|
||||
}
|
||||
|
||||
.views-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,134 @@
|
||||
<template>
|
||||
<view class="file-preview-container" :style="themeColor">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序:web-view 是原生组件,必须用 cover-view 盖在上面;cover-view 不支持 iconfont,改用 cover-image PNG -->
|
||||
<cover-view class="mp-header">
|
||||
<cover-view class="mp-header-inner">
|
||||
<cover-view class="nav-btn back-btn" @click="goBack">
|
||||
<cover-image src="/static/img/icon-back.png" class="nav-icon"></cover-image>
|
||||
</cover-view>
|
||||
<cover-view class="header-title">{{ headerTitle }}</cover-view>
|
||||
<button class="nav-btn share-btn" open-type="share" hover-class="none">
|
||||
<cover-image src="/static/img/icon-share.png" class="nav-icon"></cover-image>
|
||||
</button>
|
||||
</cover-view>
|
||||
<!-- 状态栏占位(navigationStyle custom 时,避免被时间/电量遮挡) -->
|
||||
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
|
||||
</cover-view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<!-- 非小程序:普通 view + iconfont 即可 -->
|
||||
<view class="header">
|
||||
<view class="back-btn" @click="goBack">
|
||||
<view class="nav-btn back-btn" @click="goBack">
|
||||
<text class="iconfont icon-back"></text>
|
||||
</view>
|
||||
<view class="header-title">{{ fileName }}</view>
|
||||
<view class="placeholder"></view>
|
||||
<view class="header-title">{{ headerTitle }}</view>
|
||||
<button class="nav-btn share-btn" open-type="share" hover-class="none" @click.stop>
|
||||
<text class="iconfont icon-fenxiang"></text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序 web-view 是全屏原生组件,父容器 padding/margin 对它无效,必须在它前面放一个等高的占位 view 把它顶下去 -->
|
||||
<view class="mp-content-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="content">
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览分支
|
||||
aikefu 测试对话附件 type='image' / AI 侧 attachments.image 跳转时传 fileType=image -->
|
||||
<view v-if="fileType === 'image'" class="image-viewer">
|
||||
<image v-if="fileUrl" :src="fileUrl" mode="aspectFit" class="image-element" @click="previewImageFullscreen" />
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览分支
|
||||
AI 侧 attachments.audio 跳转时传 fileType=audio, 用 createInnerAudioContext 播放 -->
|
||||
<view v-else-if="fileType === 'audio'" class="audio-viewer">
|
||||
<view v-if="fileUrl" class="audio-player-card">
|
||||
<view class="audio-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
</view>
|
||||
<view class="audio-info">
|
||||
<view class="audio-title">{{ fileName || $lang('file-preview.file-preview.audioDefaultTitle') }}</view>
|
||||
<view class="audio-status">{{ audioPlaying ? $lang('file-preview.file-preview.audioPlaying') : $lang('file-preview.file-preview.audioPaused') }}</view>
|
||||
<view class="audio-progress">
|
||||
<text class="audio-time">{{ formatAudioTime(audioCurrentTime) }}</text>
|
||||
<view class="audio-progress-bar">
|
||||
<view class="audio-progress-fill" :style="{ width: audioProgressPct + '%' }"></view>
|
||||
</view>
|
||||
<text class="audio-time">{{ formatAudioTime(audioDuration) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="audio-play-btn" @click="toggleAudioPlay">
|
||||
<text class="iconfont" :class="audioPlaying ? 'icon-pause' : 'icon-play'"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.audioUrlNotExist') }}</view>
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">如无法播放,请使用下方按钮:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
|
||||
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- PDF 文件预览 -->
|
||||
<view v-if="fileType === 'pdf'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<!-- H5 端:web-view 渲染为 iframe,若被 X-Frame-Options/CORS 拦截则显示空白;额外提供"新标签页打开"与"下载"按钮兜底 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">如页面空白,请使用下方按钮:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
|
||||
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- Word 文件预览 -->
|
||||
<view v-else-if="fileType === 'word'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">浏览器可能无法直接内嵌预览 Word,建议下载后查看:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
|
||||
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 视频文件预览 -->
|
||||
<view v-else-if="fileType === 'video'" class="video-viewer">
|
||||
<video v-if="fileUrl" :src="fileUrl" controls autoplay class="video-player"></video>
|
||||
<video v-if="fileUrl" id="filePreviewVideo" :src="fileUrl" controls class="video-player"></video>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.videoUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- 其他文件类型 -->
|
||||
<view v-else class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
<view v-if="fileUrl" class="h5-action-bar">
|
||||
<view class="h5-action-tip">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
|
||||
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<view class="error-message">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -41,48 +140,263 @@ export default {
|
||||
return {
|
||||
fileName: '',
|
||||
fileUrl: '',
|
||||
fileType: ''
|
||||
fileType: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频播放引擎
|
||||
audioCtx: null,
|
||||
audioPlaying: false,
|
||||
audioCurrentTime: 0,
|
||||
audioDuration: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
audioProgressPct() {
|
||||
if (!this.audioDuration || this.audioDuration <= 0) return 0;
|
||||
return Math.min(100, (this.audioCurrentTime / this.audioDuration) * 100);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
|
||||
headerTotalHeightPx() {
|
||||
const rpxToPx = this.windowWidth / 750;
|
||||
return this.statusBarHeight + Math.round(88 * rpxToPx);
|
||||
},
|
||||
// #endif
|
||||
// 页面标题栏显示的短标题(避免超长挤压分享按钮;对用户无意义的tmp名统一显示通用"文件预览")
|
||||
headerTitle() {
|
||||
const name = (this.fileName || '').trim();
|
||||
// 如果是临时文件名(tmp_ 开头 或 带路径hash的长串),统一显示通用"文件预览"
|
||||
if (/^tmp_/i.test(name) || (name.length > 30 && /[a-f0-9]{20,}/i.test(name))) {
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
}
|
||||
// 否则显示原始文件名(列表里传入的,如"乐瑞固企业介绍",本身就短)
|
||||
if (name) {
|
||||
// JS 层保险截断(超过16字 + …),应对 cover-view CSS 省略号不生效
|
||||
if (name.length > 16) return name.slice(0, 16) + '…';
|
||||
return name;
|
||||
}
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
},
|
||||
// 分享的真实标题(好友收到的卡片显示真实文件名,不截断)
|
||||
shareTitle() {
|
||||
const name = (this.fileName || '').trim();
|
||||
if (name && !/^tmp_/i.test(name)) return name;
|
||||
return this.$lang('file-preview.file-preview.title');
|
||||
},
|
||||
// 分享路径:只带文件三要素,不挂推荐码/关系
|
||||
sharePath() {
|
||||
const query = [];
|
||||
if (this.fileName) query.push(`fileName=${encodeURIComponent(this.fileName)}`);
|
||||
if (this.fileUrl) query.push(`fileUrl=${encodeURIComponent(this.fileUrl)}`);
|
||||
if (this.fileType) query.push(`fileType=${encodeURIComponent(this.fileType)}`);
|
||||
return `/pages_tool/file-preview/file-preview${query.length ? '?' + query.join('&') : ''}`;
|
||||
},
|
||||
themeColor() {
|
||||
if (this.$shop && this.$shop.themeColor) {
|
||||
return `--theme-color: ${this.$shop.themeColor};`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onLoad(option) {
|
||||
// 解析传递的参数
|
||||
if (option.fileName) {
|
||||
this.fileName = decodeURIComponent(option.fileName);
|
||||
}
|
||||
|
||||
if (option.fileUrl) {
|
||||
this.fileUrl = decodeURIComponent(option.fileUrl);
|
||||
}
|
||||
|
||||
if (option.fileType) {
|
||||
this.fileType = decodeURIComponent(option.fileType);
|
||||
} else {
|
||||
// 根据文件名后缀推断文件类型
|
||||
this.inferFileType();
|
||||
}
|
||||
// #ifdef MP-WEIXIN
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.statusBarHeight = sys.statusBarHeight || 20;
|
||||
this.windowWidth = sys.windowWidth || 375;
|
||||
// #endif
|
||||
},
|
||||
onReady() {
|
||||
// 视频加载后手动触发一次播放占位(去掉 autoplay 防自动播放被系统拦截)
|
||||
// #ifndef H5
|
||||
if (this.fileType === 'video' && this.fileUrl) {
|
||||
try {
|
||||
const ctx = uni.createVideoContext('filePreviewVideo', this);
|
||||
if (ctx && typeof ctx.play === 'function') { /* 留给用户点击,不自动播 */ }
|
||||
} catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 离开页面销毁音频 ctx
|
||||
onUnload() {
|
||||
if (this.audioCtx) {
|
||||
try { this.audioCtx.stop(); this.audioCtx.destroy(); } catch (e) {}
|
||||
this.audioCtx = null;
|
||||
}
|
||||
},
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.shareTitle,
|
||||
path: this.sharePath
|
||||
};
|
||||
},
|
||||
// 微信小程序:分享到朋友圈
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: this.shareTitle,
|
||||
query: this.sharePath.split('?')[1] || ''
|
||||
};
|
||||
},
|
||||
// #endif
|
||||
methods: {
|
||||
/**
|
||||
* 根据文件名后缀推断文件类型
|
||||
*/
|
||||
inferFileType() {
|
||||
if (this.fileName) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 加 image / audio 识别
|
||||
const classify = (ext) => {
|
||||
if (['pdf'].includes(ext)) return 'pdf';
|
||||
if (['doc', 'docx'].includes(ext)) return 'word';
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'aac', 'm4a', 'ogg', 'flac'].includes(ext)) return 'audio';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
|
||||
return null;
|
||||
};
|
||||
if (this.fileUrl) {
|
||||
const clean = String(this.fileUrl).split('?')[0];
|
||||
const ext = clean.split('.').pop().toLowerCase();
|
||||
const t = classify(ext);
|
||||
if (t) this.fileType = t;
|
||||
} else if (this.fileName) {
|
||||
const ext = this.fileName.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
const t = classify(ext);
|
||||
if (t) this.fileType = t;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 返回上一页
|
||||
*/
|
||||
goBack() {
|
||||
uni.navigateBack();
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.switchTab({ url: '/pages/index/index' }).catch(() => {
|
||||
uni.navigateBack();
|
||||
});
|
||||
}
|
||||
},
|
||||
// #ifdef H5
|
||||
// H5 端:新标签页打开文件(绕过 iframe X-Frame-Options/CORS 限制)
|
||||
openInNewTab() {
|
||||
if (!this.fileUrl) return;
|
||||
try {
|
||||
window.open(this.fileUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '打开失败,请稍后重试', icon: 'none' });
|
||||
}
|
||||
},
|
||||
// H5 端:通过 <a download> 触发浏览器下载(最稳定的跨浏览器方案)
|
||||
triggerDownload() {
|
||||
if (!this.fileUrl) return;
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = this.fileUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
if (this.fileName) {
|
||||
// 仅提示浏览器用这个名字;受 CORS 影响,跨域文件时 download 属性可能被忽略,
|
||||
// 此时浏览器会直接按服务器返回的文件名/直接显示,但不会丢失文件本身
|
||||
link.download = this.fileName;
|
||||
}
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
// 兜底:新标签页打开
|
||||
this.openInNewTab();
|
||||
}
|
||||
},
|
||||
// #endif
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片分支 — 点击全屏预览
|
||||
previewImageFullscreen() {
|
||||
if (!this.fileUrl) return;
|
||||
// #ifdef H5 || MP-WEIXIN
|
||||
uni.previewImage({ urls: [this.fileUrl], current: this.fileUrl });
|
||||
// #endif
|
||||
},
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频分支 — 切换播放/暂停
|
||||
toggleAudioPlay() {
|
||||
if (!this.fileUrl) {
|
||||
uni.showToast({ title: '音频地址为空', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (this.audioPlaying) {
|
||||
this._pauseAudio();
|
||||
} else {
|
||||
this._playAudio();
|
||||
}
|
||||
},
|
||||
|
||||
_ensureAudioCtx() {
|
||||
if (this.audioCtx) return this.audioCtx;
|
||||
try {
|
||||
const ctx = uni.createInnerAudioContext();
|
||||
ctx.src = this.fileUrl;
|
||||
ctx.onTimeUpdate(() => {
|
||||
this.audioCurrentTime = ctx.currentTime || 0;
|
||||
if (ctx.duration && !isNaN(ctx.duration)) {
|
||||
this.audioDuration = ctx.duration;
|
||||
}
|
||||
});
|
||||
ctx.onEnded(() => {
|
||||
this.audioPlaying = false;
|
||||
this.audioCurrentTime = 0;
|
||||
});
|
||||
ctx.onError((e) => {
|
||||
this.audioPlaying = false;
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' });
|
||||
console.error('[file-preview audio] error:', e);
|
||||
});
|
||||
this.audioCtx = ctx;
|
||||
return ctx;
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' });
|
||||
console.error('[file-preview audio] createInnerAudioContext failed:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
_playAudio() {
|
||||
const ctx = this._ensureAudioCtx();
|
||||
if (!ctx) return;
|
||||
if (this.audioCurrentTime > 0) {
|
||||
try { ctx.seek(this.audioCurrentTime); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.audioPlaying = true;
|
||||
try {
|
||||
ctx.play();
|
||||
} catch (e) {
|
||||
this.audioPlaying = false;
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
|
||||
_pauseAudio() {
|
||||
if (this.audioCtx) {
|
||||
try { this.audioCtx.pause(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.audioPlaying = false;
|
||||
},
|
||||
|
||||
// 00:00 格式
|
||||
formatAudioTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '00:00';
|
||||
const total = Math.floor(seconds);
|
||||
const mm = String(Math.floor(total / 60)).padStart(2, '0');
|
||||
const ss = String(total % 60).padStart(2, '0');
|
||||
return mm + ':' + ss;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -92,7 +406,10 @@ export default {
|
||||
.file-preview-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* =================== 非 MP 端 header =================== */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -101,22 +418,34 @@ export default {
|
||||
padding: 0 30rpx;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.back-btn {
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
.icon-back {
|
||||
font-size: 32rpx;
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
@@ -124,17 +453,91 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 60rpx;
|
||||
|
||||
.share-btn {
|
||||
.icon-fenxiang {
|
||||
font-size: 34rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================== MP 端 header (cover-view 浮层) =================== */
|
||||
/* #ifdef MP-WEIXIN */
|
||||
.mp-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.mp-status-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 20rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
margin: 0;
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin: 0 16rpx;
|
||||
/* cover-view 下 ellipsis 支持有限,JS 层(headerTitle computed)已经做了截断,这里再冗余防守 */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* =================== 内容区 =================== */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
/* 给浮层 header(statusBar+88rpx)留位置,避免内容顶上去 */
|
||||
padding-top: calc(var(--mp-header-h, 120rpx));
|
||||
/* #endif */
|
||||
padding-left: 20rpx;
|
||||
padding-right: 20rpx;
|
||||
padding-bottom: 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.file-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
@@ -142,7 +545,7 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
|
||||
.error-message {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
@@ -152,8 +555,52 @@ export default {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
/* H5 兜底操作栏(PDF/Word 被 X-Frame-Options 拦截时显示)*/
|
||||
.h5-action-bar {
|
||||
padding: 24rpx 28rpx 20rpx;
|
||||
background: linear-gradient(180deg, #fffbe6 0%, #ffffff 100%);
|
||||
border-bottom: 1rpx solid #f0e9c8;
|
||||
|
||||
.h5-action-tip {
|
||||
font-size: 26rpx;
|
||||
color: #8a6d1b;
|
||||
margin-bottom: 18rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.h5-action-btns {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
|
||||
.h5-action-btn {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
padding: 0;
|
||||
font-size: 28rpx;
|
||||
border-radius: 36rpx;
|
||||
background: #f2f3f5;
|
||||
color: #333;
|
||||
border: 1rpx solid #e2e4e8;
|
||||
margin: 0;
|
||||
|
||||
&::after { border: none; }
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
|
||||
.video-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
@@ -161,12 +608,12 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
|
||||
.error-message {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
@@ -178,6 +625,128 @@ export default {
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览 */
|
||||
.image-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
background-color: #000;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.image-element {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览 */
|
||||
.audio-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.audio-player-card {
|
||||
width: 90%;
|
||||
max-width: 560rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
.audio-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
.iconfont {
|
||||
font-size: 48rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.audio-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audio-status {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
}
|
||||
.audio-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 8rpx;
|
||||
.audio-time {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
flex-shrink: 0;
|
||||
min-width: 60rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.audio-progress-bar {
|
||||
flex: 1;
|
||||
height: 6rpx;
|
||||
background-color: #e8edf3;
|
||||
border-radius: 3rpx;
|
||||
overflow: hidden;
|
||||
.audio-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6b7c93 0%, #4a5a72 100%);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
&::after { border: none; }
|
||||
.iconfont {
|
||||
font-size: 36rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
589
pages_tool/files/detail.vue
Normal file
589
pages_tool/files/detail.vue
Normal file
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<view class="files-detail" :style="themeColor">
|
||||
<view class="file-card">
|
||||
<view class="pdf-icon">
|
||||
<text class="pdf-icon-p">P</text>
|
||||
<view class="pdf-icon-fold"></view>
|
||||
<view class="pdf-icon-lines">
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="title">{{ displayTitle }}</view>
|
||||
<view v-if="!loaded" class="read-wrap">
|
||||
<text>{{ $lang('files.list.loading') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="fileUrl" class="action-area">
|
||||
<button class="action-btn primary" @tap="openFile">{{ $lang('files.list.viewPdf') }}</button>
|
||||
</view>
|
||||
|
||||
<view v-if="failed" class="error-tip">{{ $lang('files.list.fileNotExist') }}</view>
|
||||
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 文件详情页 —— 承接 DIY 装修里的文件链接: /pages_tool/files/detail?files_id=43&title=xxx
|
||||
*
|
||||
* 背景: 后台装修数据(如"图标导航/文本导航"的 FILES_xx 链接)把文件指向本页,
|
||||
* 此前页面缺失导致点击后打不开。本页负责:
|
||||
* 1) 按 files_id 取到文件信息(files_url / files_title)
|
||||
* 2) 进入页面即自动按平台打开文件(与 files/list 点击行为一致)
|
||||
* 3) 自动打开失败时,可点击"查看PDF文件"按钮重试
|
||||
*
|
||||
* 注1: 本页文案只使用语言包中已存在的 key(files.list.*),避免新增 key 未同步到服务端
|
||||
* (lang 为 remote-only 模式,运行时取 /api/lang/bundle)。
|
||||
* 注2: Files 控制器没有单文件详情接口,只能从列表接口里按 files_id 取,详见 fetchFile()。
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
filesId: '',
|
||||
filesTitle: '',
|
||||
detail: null,
|
||||
fileUrl: '',
|
||||
loaded: false,
|
||||
failed: false,
|
||||
// 排查用:记录列表接口扫过的文件(files_id:标题)
|
||||
scanned: [],
|
||||
titleTimers: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayTitle() {
|
||||
if (this.filesTitle) return this.filesTitle;
|
||||
if (this.detail && (this.detail.files_title || this.detail.title)) {
|
||||
return this.detail.files_title || this.detail.title;
|
||||
}
|
||||
return this.$lang('files.list.title');
|
||||
},
|
||||
themeColor() {
|
||||
if (this.$shop && this.$shop.themeColor) {
|
||||
return `--theme-color: ${this.$shop.themeColor};`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
options = options || {};
|
||||
// page_params 可能是 JSON 字符串 / 对象 / 数组(装修数据里常见 [])
|
||||
const params = this.$util.parsePageParams(options.page_params) || {};
|
||||
const rawId = options.files_id || options.file_id || options.filesId || options.id
|
||||
|| params.files_id || params.file_id || params.id || '';
|
||||
this.filesId = rawId === '' || rawId === undefined || rawId === null ? '' : String(rawId);
|
||||
|
||||
const rawTitle = options.title || params.title || '';
|
||||
if (rawTitle) this.filesTitle = this.decodeTitle(rawTitle);
|
||||
this.scheduleTitleSync();
|
||||
this.getData();
|
||||
},
|
||||
onUnload() {
|
||||
this.clearTitleTimers();
|
||||
},
|
||||
watch: {
|
||||
// main.js 的全局 mixin 监听 componentRefresh,变化时调用 $langConfig.refresh(),
|
||||
// 而 refresh() 会用 $lang('title') 重置导航标题。本页标题就是文件名、且远端语言包里
|
||||
// 没有 files/detail 词条(remote-only 模式),$lang('title') 会退化成字面量 "title"。
|
||||
// refresh() 对每个组件都会跑一遍、"最后一个生效",所以这里延时补写几次兜底。
|
||||
componentRefresh() {
|
||||
if (this.isCurrentPage()) this.scheduleTitleSync();
|
||||
}
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.displayTitle,
|
||||
path: `/pages_tool/files/detail?files_id=${this.filesId}`
|
||||
};
|
||||
},
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: this.displayTitle,
|
||||
query: `files_id=${this.filesId}`
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
syncNavigationTitle() {
|
||||
this.$langConfig.title(this.displayTitle);
|
||||
},
|
||||
// 导航标题兜底:立即写一次,再延时补几次(等全局 refresh() 都跑完)
|
||||
scheduleTitleSync() {
|
||||
this.clearTitleTimers();
|
||||
this.syncNavigationTitle();
|
||||
[100, 500, 1500].forEach(delay => {
|
||||
this.titleTimers.push(setTimeout(() => {
|
||||
if (this.isCurrentPage()) this.syncNavigationTitle();
|
||||
}, delay));
|
||||
});
|
||||
},
|
||||
clearTitleTimers() {
|
||||
this.titleTimers.forEach(id => clearTimeout(id));
|
||||
this.titleTimers = [];
|
||||
},
|
||||
// 本页是否在最上层(避免本页在后台时去改别的页面标题)
|
||||
isCurrentPage() {
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
if (!current) return false;
|
||||
const route = current.route || (current.$page && current.$page.path) || '';
|
||||
return route.indexOf('files/detail') !== -1;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
decodeTitle(value) {
|
||||
const str = String(value || '');
|
||||
// 多数端 uni 已自动 decode 一次,这里只在明显还是百分号编码时才解码,避免二次解码破坏文案
|
||||
if (!str || str.indexOf('%') === -1) return str;
|
||||
try {
|
||||
return decodeURIComponent(str);
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
},
|
||||
hideLoadingCover() {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
async getData() {
|
||||
if (!this.filesId) {
|
||||
this.finishLoad(null);
|
||||
return;
|
||||
}
|
||||
// 优先走后端单文件详情接口: 按 files_id 直接取, 无需翻列表(文件多时省几十个请求)
|
||||
let detail = await this.fetchGetfile(this.filesId);
|
||||
// 兜底: 列表逐页扫(可能为空/被过滤) / 企业文件接口
|
||||
if (!detail) detail = await this.fetchFile(this.filesId) || await this.fetchFromPersonnel();
|
||||
if (!detail || !this.normalizeUrl(detail.files_url)) {
|
||||
this.finishLoad(null);
|
||||
this.$util.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.detail = detail;
|
||||
if (!this.filesTitle) this.filesTitle = detail.files_title || detail.title || '';
|
||||
this.syncNavigationTitle();
|
||||
this.fileUrl = this.normalizeUrl(detail.files_url);
|
||||
this.finishLoad(detail);
|
||||
// 进入详情页即自动打开,与点击文件列表项行为一致
|
||||
this.openFile();
|
||||
},
|
||||
finishLoad(detail) {
|
||||
this.loaded = true;
|
||||
this.failed = !detail;
|
||||
this.hideLoadingCover();
|
||||
},
|
||||
/**
|
||||
* 取单个文件信息(优先链路)。
|
||||
* 后端接口: GET /api/files/getfile?files_id=xxx
|
||||
* 内部 getFilesInfo([['files_id','=',$files_id]]) → model('files')->getInfo(),
|
||||
* 返回整条记录(files_title / files_url / files_desc / category_id 等),
|
||||
* files_url 已 img() 补全域名; files 表带 site_id, bootSiteGuard 自动注入当前站点,多租户不串号。
|
||||
* 仅 files_id 一个参数即可,后端无需改造。
|
||||
*/
|
||||
async fetchGetfile(filesId) {
|
||||
const res = await this.request('/api/files/getfile', { files_id: filesId });
|
||||
if (!res || res.code !== 0 || !res.data) return null;
|
||||
const d = res.data;
|
||||
return {
|
||||
files_id: d.files_id || filesId,
|
||||
files_title: d.files_title || d.title || '',
|
||||
files_url: d.files_url || '',
|
||||
files_desc: d.files_desc,
|
||||
category_id: d.category_id,
|
||||
category_name: d.category_name
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 兜底链路(优先链路 fetchGetfile 失败时): 逐页拉列表按 files_id 精确匹配。
|
||||
* 该 addon 之前 Files 控制器只提供列表接口(实测 /api/files/info、/detail、/getinfo 均 404:
|
||||
* "method not exists:app\api\controller\Files->info()"),所以用 getpage 翻页扫。
|
||||
*/
|
||||
async fetchFile(filesId) {
|
||||
// 1) 有标题时先拿标题当关键词搜一次(命中就 1 个请求解决,不必深翻页)
|
||||
if (this.filesTitle) {
|
||||
const searched = await this.queryList({ page: 1, page_size: 100, keyword: this.filesTitle }, filesId, true);
|
||||
if (searched && searched.hit) return searched.hit;
|
||||
}
|
||||
// 2) 逐页遍历全量列表(后端只返回 10 条/页时最多翻 30 页)
|
||||
for (let page = 1; page <= 30; page++) {
|
||||
const queried = await this.queryList({ page: page, page_size: 100 }, filesId);
|
||||
if (queried && queried.hit) return queried.hit;
|
||||
if (!queried || queried.empty) return null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* 兜底链路: 从企业文件接口(/api/member/personnel, 电子名片页"企业文件"同源)取文件。
|
||||
* 该接口返回 res.file_list, 每项形如 { name, url }(可能带 files_id/file_id/id)。
|
||||
* 先按 files_id 精确匹配, 再按标题精确匹配, 最后按标题互相包含模糊匹配。
|
||||
*/
|
||||
async fetchFromPersonnel() {
|
||||
const res = await this.request('/api/member/personnel', {});
|
||||
if (!res || res.code !== 0) return null;
|
||||
const list = res.file_list || (res.data && res.data.file_list) || [];
|
||||
if (!Array.isArray(list)) return null;
|
||||
const title = (this.filesTitle || '').toString();
|
||||
const getId = item => item.files_id || item.file_id || item.id || '';
|
||||
let fuzzyHit = null;
|
||||
for (const item of list) {
|
||||
if (!item) continue;
|
||||
// 1) files_id 精确匹配
|
||||
const id = getId(item);
|
||||
if (id !== '' && String(id) === String(this.filesId)) return this.wrapPersonnelFile(item);
|
||||
// 2) 标题精确匹配
|
||||
const name = (item.name || item.files_title || item.title || '').toString();
|
||||
if (title && name === title) return this.wrapPersonnelFile(item);
|
||||
// 3) 标题互相包含(记下第一个命中, 优先级最低)
|
||||
if (title && !fuzzyHit && (name.indexOf(title) !== -1 || title.indexOf(name) !== -1)) {
|
||||
fuzzyHit = item;
|
||||
}
|
||||
}
|
||||
return fuzzyHit ? this.wrapPersonnelFile(fuzzyHit) : null;
|
||||
},
|
||||
wrapPersonnelFile(item) {
|
||||
return {
|
||||
files_id: item.files_id || item.file_id || item.id || this.filesId,
|
||||
files_title: item.name || item.files_title || item.title || '',
|
||||
files_url: item.url || item.files_url || ''
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 请求一页文件列表,并按 files_id 精确匹配(入参与 files/list 页保持一致)
|
||||
* @param {Boolean} allowTitleMatch 该页是标题搜索结果时,允许退一步用标题全等匹配
|
||||
* @returns {Object|null} 命中返回 { hit };本页无数据返回 { empty: true };请求失败返回 null
|
||||
*/
|
||||
async queryList(data, filesId, allowTitleMatch) {
|
||||
const res = await this.request('/api/files/getpage', Object.assign({
|
||||
category_id: '',
|
||||
keyword: ''
|
||||
}, data));
|
||||
if (!res || res.code !== 0 || !res.data || !Array.isArray(res.data.list)) return null;
|
||||
const list = res.data.list;
|
||||
let titleHit = null;
|
||||
for (const item of list) {
|
||||
const src = item && item.$orig ? item.$orig : item;
|
||||
if (!src) continue;
|
||||
if (String(src.files_id) === String(filesId)) return { hit: src };
|
||||
// 兜底:装修链接里的 files_id 与接口不一致时,用标题全等再认一次
|
||||
if (allowTitleMatch && !titleHit && this.filesTitle && src.files_title === this.filesTitle) {
|
||||
titleHit = src;
|
||||
}
|
||||
}
|
||||
if (titleHit) return { hit: titleHit };
|
||||
return { empty: list.length === 0 };
|
||||
},
|
||||
async request(url, data) {
|
||||
try {
|
||||
return await this.$api.sendRequest({
|
||||
url,
|
||||
data,
|
||||
async: false,
|
||||
noRetry: true,
|
||||
// 失败静默返回 null,由调用方兜底,不弹全局报错
|
||||
silentError: true
|
||||
});
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
normalizeUrl(url) {
|
||||
if (!url) return '';
|
||||
const str = String(url);
|
||||
if (/^https?:\/\//i.test(str)) return str;
|
||||
// $util.img 对含逗号的路径会按"多图字段"截断,文件名可能含逗号,这里规避
|
||||
if (str.indexOf(',') !== -1) return str;
|
||||
// 相对路径补全域名(与图片同一套域名规则)
|
||||
return this.$util.img(str);
|
||||
},
|
||||
getFileExt() {
|
||||
let ext = 'pdf';
|
||||
if (this.fileUrl) {
|
||||
const clean = this.fileUrl.split('?')[0];
|
||||
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
|
||||
if (matched) ext = matched[1].toLowerCase();
|
||||
}
|
||||
return ext;
|
||||
},
|
||||
getFileName() {
|
||||
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
return `${title}.${this.getFileExt()}`;
|
||||
},
|
||||
getFileType() {
|
||||
const ext = this.getFileExt();
|
||||
return ['pdf'].includes(ext) ? 'pdf'
|
||||
: ['doc', 'docx'].includes(ext) ? 'word'
|
||||
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext) ? 'video'
|
||||
: 'pdf';
|
||||
},
|
||||
buildPreviewUrl() {
|
||||
const fileName = encodeURIComponent(this.getFileName());
|
||||
const fileUrl = encodeURIComponent(this.fileUrl);
|
||||
const fileType = encodeURIComponent(this.getFileType());
|
||||
return `/pages_tool/file-preview/file-preview?fileName=${fileName}&fileUrl=${fileUrl}&fileType=${fileType}`;
|
||||
},
|
||||
openFile() {
|
||||
if (!this.fileUrl) {
|
||||
this.failed = true;
|
||||
this.$util.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.failed = false;
|
||||
const ext = this.getFileExt();
|
||||
const previewPage = this.buildPreviewUrl();
|
||||
|
||||
// ========== H5 端: 用预览页替换当前页(web-view 走 iframe),失败则新标签页打开 ==========
|
||||
// #ifdef H5
|
||||
uni.redirectTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
try {
|
||||
window.open(this.fileUrl, '_blank');
|
||||
} catch (e) {
|
||||
this.failed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// ========== 非 H5 端: 下载文件后按平台分支打开 ==========
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
|
||||
uni.downloadFile({
|
||||
url: this.fileUrl,
|
||||
success: res => {
|
||||
if (res.statusCode !== 200) {
|
||||
uni.hideLoading();
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- 微信小程序: saveFile 重命名 + openDocument(标题取短文件名) -----
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager();
|
||||
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||
const openByPath = (filePath, cleanup) => {
|
||||
uni.openDocument({
|
||||
filePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: () => {
|
||||
if (cleanup) {
|
||||
try { fs.removeSavedFile({ filePath }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
try {
|
||||
fs.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
filePath: targetPath,
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
openByPath(r.savedFilePath, true);
|
||||
},
|
||||
fail: () => {
|
||||
// saveFile 失败(配额等),直接用临时文件兜底
|
||||
uni.hideLoading();
|
||||
openByPath(res.tempFilePath, false);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
openByPath(res.tempFilePath, false);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ----- App / 其他小程序: 直接 openDocument;失败兜底跳转预览页 -----
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: res => {
|
||||
if (res && res.statusCode === 404) {
|
||||
uni.hideLoading();
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.files-detail {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 30rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.files-detail .file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon {
|
||||
margin-right: 24rpx;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: #e64340;
|
||||
border-radius: 10rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-p {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 10rpx;
|
||||
color: #ffffff;
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
|
||||
border-bottom-left-radius: 4rpx;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-lines {
|
||||
position: absolute;
|
||||
right: 8rpx;
|
||||
bottom: 8rpx;
|
||||
width: 42rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-line {
|
||||
width: 100%;
|
||||
height: 3rpx;
|
||||
background-color: #ffffff;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(2) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(3) {
|
||||
width: 60%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap .title {
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap .read-wrap {
|
||||
display: flex;
|
||||
color: #999ca7;
|
||||
align-items: center;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap .read-wrap text {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.files-detail .action-area {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.files-detail .action-area .action-btn {
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 30rpx;
|
||||
border-radius: 42rpx;
|
||||
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
|
||||
}
|
||||
|
||||
.files-detail .action-area .action-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.files-detail .error-tip {
|
||||
margin-top: 30rpx;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -13,12 +13,18 @@
|
||||
<view class="article-wrap">
|
||||
<ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv>
|
||||
<block v-for="(item, index) in list" :key="index">
|
||||
<view class="item" @tap="toDetail(item.$orig)">
|
||||
<view class="article-img">
|
||||
<image class="cover-img" :src="item.g1" mode="widthFix" @error="imgError(index)" show-menu-by-longpress></image>
|
||||
<view class="item" @tap="toDetail(item)">
|
||||
<view class="pdf-icon">
|
||||
<text class="pdf-icon-p">P</text>
|
||||
<view class="pdf-icon-fold"></view>
|
||||
<view class="pdf-icon-lines">
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="title">{{ item.$orig.files_title }}</view>
|
||||
<view class="title">{{ item.files_title }}</view>
|
||||
<view class="read-wrap">
|
||||
<text>{{ $lang('files.list.viewPdf') }}</text>
|
||||
</view>
|
||||
@@ -84,8 +90,10 @@ export default {
|
||||
if (res.code === 0 && res.data) {
|
||||
list = res.data.list
|
||||
if (!this.title && res.data.list.length > 0) {
|
||||
const first = res.data.list[0]
|
||||
const firstOrig = (first && first.$orig) ? first.$orig : first
|
||||
uni.setNavigationBarTitle({
|
||||
title: res.data.list[0].category_name
|
||||
title: firstOrig.category_name || ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -98,9 +106,18 @@ export default {
|
||||
this.list = []
|
||||
}
|
||||
this.list = this.list.concat(list.map(item => {
|
||||
const orig = item
|
||||
const g1 = this.$util.img("addon/personnel/shop/view/enterprise/fileicon.png")
|
||||
return { $orig: orig, g1: g1 }
|
||||
// 兼容两种后端结构:自带 $orig(如 cases 接口)或扁平结构
|
||||
const src = (item && item.$orig) ? item.$orig : item
|
||||
return {
|
||||
files_id: src.files_id,
|
||||
files_title: src.files_title,
|
||||
files_url: src.files_url,
|
||||
files_desc: src.files_desc,
|
||||
category_id: src.category_id,
|
||||
category_name: src.category_name,
|
||||
// 保留完整原对象引用,避免后续扩展字段丢失
|
||||
$orig: src
|
||||
}
|
||||
}))
|
||||
if (this.$refs.loadingCover) {
|
||||
this.$refs.loadingCover.hide()
|
||||
@@ -115,41 +132,121 @@ export default {
|
||||
})
|
||||
},
|
||||
toDetail(item) {
|
||||
uni.showLoading({
|
||||
title: this.$lang('files.list.loading')
|
||||
})
|
||||
try {
|
||||
uni.downloadFile({
|
||||
url: item.files_url,
|
||||
success: res => {
|
||||
if (res.statusCode === 200) {
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
},
|
||||
fail: () => {
|
||||
},
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
},
|
||||
complete: res => {
|
||||
if (res.statusCode === 404) {
|
||||
uni.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
const title = (item.files_title || '文件').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
// 根据 URL 推断后缀(若无扩展名则默认 pdf)
|
||||
let ext = 'pdf';
|
||||
if (item.files_url) {
|
||||
const clean = item.files_url.split('?')[0];
|
||||
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
|
||||
if (matched) ext = matched[1].toLowerCase();
|
||||
}
|
||||
const fileName = `${title}.${ext}`;
|
||||
const fileType =
|
||||
['pdf'].includes(ext) ? 'pdf'
|
||||
: ['doc', 'docx'].includes(ext) ? 'word'
|
||||
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext) ? 'video'
|
||||
: 'pdf';
|
||||
const previewPage = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(fileName)}&fileUrl=${encodeURIComponent(item.files_url)}&fileType=${encodeURIComponent(fileType)}`;
|
||||
|
||||
// ========== H5 端:跳转预览页(web-view 走 iframe);失败则新标签页打开原链接 ==========
|
||||
// #ifdef H5
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
try { window.open(item.files_url, '_blank'); } catch (e) {
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// ========== 非 H5 端:下载文件后按平台分支打开 ==========
|
||||
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
|
||||
uni.downloadFile({
|
||||
url: item.files_url,
|
||||
success: res => {
|
||||
if (res.statusCode !== 200) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- 微信小程序:saveFile 重命名 + openDocument(短文件名标题)-----
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager();
|
||||
// 重命名临时文件到用户目录,用短文件名 —— openDocument 的标题会取 filePath 的文件名部分,从而显示短名"乐瑞固企业介绍"而非 tmp_xxx
|
||||
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||
try {
|
||||
// saveFile:复制临时文件到持久目录并改名(临时文件在某些基础库上禁止直接 rename)
|
||||
fs.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
filePath: targetPath,
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: r.savedFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: () => {
|
||||
// 预览结束后清理,避免占用持久空间(失败忽略)
|
||||
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) {}
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
// saveFile 失败(配额等),直接用临时文件 openDocument 兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// 极端异常兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ----- App / 其他小程序:直接 openDocument 打开临时文件;失败兜底跳转预览页 -----
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
|
||||
});
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: res => {
|
||||
if (res && res.statusCode === 404) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
imgError(index) {
|
||||
if (this.list[index]) {
|
||||
@@ -224,18 +321,70 @@ export default {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .article-img {
|
||||
.article-wrap .item .pdf-icon {
|
||||
margin-right: 20rpx;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: #e64340;
|
||||
border-radius: 10rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
|
||||
}
|
||||
|
||||
.article-wrap .item .article-img image {
|
||||
.article-wrap .item .pdf-icon .pdf-icon-p {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 8rpx;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
|
||||
border-bottom-left-radius: 4rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-lines {
|
||||
position: absolute;
|
||||
right: 6rpx;
|
||||
bottom: 6rpx;
|
||||
width: 36rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line {
|
||||
width: 100%;
|
||||
height: 3rpx;
|
||||
background-color: #ffffff;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(2) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(3) {
|
||||
width: 60%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap {
|
||||
@@ -243,16 +392,21 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap .title {
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 24rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.article-wrap .item .info-wrap .read-wrap {
|
||||
|
||||
@@ -1,183 +1,746 @@
|
||||
<template>
|
||||
<view :style="themeColor">
|
||||
<view class="page" v-if="detail">
|
||||
<view class="form-banner">
|
||||
<image :src="$util.img('public/uniapp/form/banner.png')" mode="widthFix"></image>
|
||||
</view>
|
||||
<view class="system-form-wrap">
|
||||
<view class="form-title">{{ $lang('form.form.title') }}</view>
|
||||
<ns-form :data="detail.json_data" :formConfig="detail.form_config" ref="form"></ns-form>
|
||||
<button type="primary" size="mini" class="button mini" @click="create()">{{ $lang('common.submit') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<ns-empty :text="complete ? $lang('form.form.submitSuccess') : $lang('form.form.noFormInfo')" v-else></ns-empty>
|
||||
<view :style="themeColor">
|
||||
<!-- 顶部图条:位于表单卡片之上,宽度 calc(100% - 2*containerInset) 水平居中。
|
||||
图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染。 -->
|
||||
<view v-if="bgImage" class="formdata-bg" :style="bgLayerStyle"></view>
|
||||
<block v-if="detail">
|
||||
<view class="page">
|
||||
<view class="system-form-wrap" :style="containerStyle">
|
||||
<!-- 复用 diy-form(与 formdata.vue 同构):banner / tip / 表单字段 / 提交按钮全部由其内部渲染。
|
||||
两种表单风格(classic 平铺 / card 卡片浮起)及全部 formConfig 样式属性(tip 四边内边距/边框/圆角/阴影、
|
||||
card_overlap/overlap_top/radius/side_pad/bg_color/shadow/border、信息卡片等)由此链路统一支持。
|
||||
:onSubmit 接管提交逻辑;create 在 created 阶段 bind this,防止 this 漂移到子组件实例。 -->
|
||||
<diy-form ref="form" :value="diyValue" dataMode="raw" :showSubmit="!showCaptcha" :onSubmit="handleSubmit" :container-inset="containerInset" />
|
||||
<!-- 行为设置:提交验证码开启时,隐藏 diy-form 内置按钮,由本页渲染验证码+提交按钮 -->
|
||||
<view v-if="showCaptcha" class="form-extra-wrap">
|
||||
<view class="captcha-wrap">
|
||||
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
|
||||
<!-- #ifdef H5 -->
|
||||
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
|
||||
<!-- #endif -->
|
||||
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
|
||||
</view>
|
||||
<view class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<!-- 空态:除 ns-empty 文案外,追加一个可手动点的登录按钮——解决一切自动弹窗时序问题后的最后兜底 -->
|
||||
<block v-else>
|
||||
<ns-empty :text="emptyText"></ns-empty>
|
||||
<view v-if="!complete" style="padding: 20rpx 40rpx 40rpx; display: flex; justify-content: center;">
|
||||
<button type="primary" size="mini" @click="forceOpenLogin">点击登录查看表单</button>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<ns-login ref="login"></ns-login>
|
||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||
</view>
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<ns-login ref="login"></ns-login>
|
||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import scroll from '@/common/js/scroll-view.js';
|
||||
import scroll from '@/common/js/scroll-view.js';
|
||||
|
||||
export default {
|
||||
mixins: [scroll],
|
||||
data() {
|
||||
return {
|
||||
id: 0,
|
||||
detail: null,
|
||||
isRepeat: false,
|
||||
complete: false,
|
||||
scroll: true,
|
||||
urlParams: {}
|
||||
};
|
||||
},
|
||||
onLoad(data) {
|
||||
// #ifdef MP-ALIPAY
|
||||
let options = my.getLaunchOptionsSync();
|
||||
options.query && Object.assign(data, options.query)
|
||||
// #endif
|
||||
export default {
|
||||
mixins: [scroll],
|
||||
data() {
|
||||
return {
|
||||
id: 0,
|
||||
detail: null,
|
||||
isRepeat: false,
|
||||
complete: false,
|
||||
scroll: true,
|
||||
urlParams: {},
|
||||
// 登录后回跳地址(供 ns-login / H5 重定向使用)
|
||||
backUrl: '',
|
||||
// create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
|
||||
handleSubmit: null,
|
||||
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
|
||||
behavior: {},
|
||||
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
|
||||
formError: '',
|
||||
// 图形验证码(behavior.captcha=1 时展示)
|
||||
captcha: { id: '', img: '', code: '' },
|
||||
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
|
||||
captchaLoaded: false,
|
||||
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
|
||||
pendingSubmit: null,
|
||||
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
|
||||
pendingLoginRequired: false
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 预绑定 this:create 重度依赖本组件 this(id/memberInfo/$api/$lang/$util 等)。
|
||||
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
|
||||
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
|
||||
this.handleSubmit = this.create.bind(this);
|
||||
},
|
||||
computed: {
|
||||
// 构造 diy-form raw 模式期望的 value 形态(与 formdata.vue 对齐):
|
||||
// - jsonData:透传给 ns-newform([{ controller, value, val }],含 InfoCard 展示组件)
|
||||
// - formConfig:后端返回 camelCase(convertFormConfigToCamel),diy-form 内部双键兼容
|
||||
// - show_banner / form_banner:交给 diy-form 渲染 banner(card 风格浮起 / classic 平铺)
|
||||
// - show_tip / form_tip:交给 diy-form 渲染提示语(四边内边距/边框/圆角/阴影/对齐等新样式生效)
|
||||
// 注意:小程序端模板引用的 computed 会进入 setData 数据流,detail 置空瞬间也要求值,必须做空值守卫
|
||||
diyValue() {
|
||||
// 前端视觉默认:仅作兜底(后端 form_config 未配置的字段才生效)。
|
||||
// 合并顺序:detail.form_config 在后,后台显式配置(submit_bg_color / input_radius / form_layout 等)
|
||||
// 必须覆盖前端默认,否则商家端样式配置不生效。
|
||||
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'
|
||||
};
|
||||
if (!this.detail) {
|
||||
return {
|
||||
jsonData: null,
|
||||
formConfig: Object.assign({}, styleDefaults),
|
||||
show_banner: 0,
|
||||
form_banner: '',
|
||||
show_tip: 0,
|
||||
form_tip: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsonData: this.detail.json_data,
|
||||
formConfig: Object.assign({}, styleDefaults, this.detail.form_config || {}),
|
||||
// 全屏底图模式下隐藏卡片内 banner,避免与底图重复(见 hideInnerBanner)
|
||||
show_banner: this.hideInnerBanner ? 0 : this.detail.show_banner,
|
||||
form_banner: this.detail.form_banner || 'public/uniapp/form/banner.png',
|
||||
show_tip: this.detail.show_tip,
|
||||
form_tip: this.detail.form_tip || this.$lang('form.formdata.formTipPlaceholder')
|
||||
};
|
||||
},
|
||||
// 顶部图条:后台 form_config.bg_image / bgImage 下发(兼容 snake_case / camelCase)
|
||||
bgImage() {
|
||||
if (!this.detail) return '';
|
||||
const fc = this.detail.form_config || {};
|
||||
const raw = fc.bgImage || fc.bg_image || fc.backgroundImage || fc.background_image || '';
|
||||
return raw ? this.$util.img(raw) : '';
|
||||
},
|
||||
// 容器左右内缩(rpx)。后台 form_config.container_inset / containerInset 可调;默认 30rpx
|
||||
containerInset() {
|
||||
const fc = (this.detail && this.detail.form_config) || {};
|
||||
const raw = fc.containerInset !== undefined ? fc.containerInset : fc.container_inset;
|
||||
const n = parseInt(raw);
|
||||
return isNaN(n) ? 30 : Math.min(150, Math.max(0, n));
|
||||
},
|
||||
// 顶部图条高度(rpx)。后台 form_config.bg_image_height 可调;clamp 80-600
|
||||
bgImageHeight() {
|
||||
const fc = (this.detail && this.detail.form_config) || {};
|
||||
const raw = fc.bgImageHeight !== undefined ? fc.bgImageHeight : fc.bg_image_height;
|
||||
const n = parseInt(raw);
|
||||
return isNaN(n) ? 200 : Math.min(600, Math.max(80, n));
|
||||
},
|
||||
// 图条与表单卡片之间的间距(rpx)。后台 form_config.card_gap 可调;clamp 0-80
|
||||
cardGap() {
|
||||
const fc = (this.detail && this.detail.form_config) || {};
|
||||
const raw = fc.cardGap !== undefined ? fc.cardGap : fc.card_gap;
|
||||
const n = parseInt(raw);
|
||||
return isNaN(n) ? 24 : Math.min(80, Math.max(0, n));
|
||||
},
|
||||
// 图条内联样式:水平居中 + 高度 + 与卡片的间距
|
||||
bgLayerStyle() {
|
||||
const inset = this.containerInset;
|
||||
return 'width:calc(100% - ' + (inset * 2) + 'rpx);margin:0 ' + inset + 'rpx ' + this.cardGap + 'rpx;height:' + this.bgImageHeight + 'rpx;background-image:url(' + this.bgImage + ');';
|
||||
},
|
||||
// 表单容器内联样式:左右内缩可配,覆盖 .system-form-wrap 默认 padding
|
||||
containerStyle() {
|
||||
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
|
||||
},
|
||||
// 表单风格(与 diy-form.vue hasBannerMode 对齐)
|
||||
// - card :卡片浮起需要 banner 作为被压对象 → 保留内联 banner(与后端预览一致);仅 bgImage 全屏底图时隐藏避免重复
|
||||
// - classic:保留内联 banner(与原行为一致),仅当后台显式配 bgImage 才隐藏
|
||||
hideInnerBanner() {
|
||||
return !!this.bgImage;
|
||||
},
|
||||
// 行为设置:提交验证码开启时 diy-form 隐藏内置提交按钮,本页渲染验证码 + 自有提交按钮
|
||||
showCaptcha() {
|
||||
return this.behavior.captcha == 1;
|
||||
},
|
||||
// 自有提交按钮文案:跟随表单配置的提交按钮文字(form_config 为 camelCase)
|
||||
submitBtnText() {
|
||||
return (this.detail && this.detail.form_config && this.detail.form_config.submitText) || this.$lang('common.submit');
|
||||
},
|
||||
emptyText() {
|
||||
return this.complete ? this.$lang('form.form.submitSuccess') : (this.formError || this.$lang('form.form.noFormInfo'));
|
||||
}
|
||||
},
|
||||
onLoad(data) {
|
||||
// #ifdef MP-ALIPAY
|
||||
let options = my.getLaunchOptionsSync();
|
||||
options.query && Object.assign(data, options.query)
|
||||
// #endif
|
||||
|
||||
this.id = data.id || 0;
|
||||
this.id = data.id || 0;
|
||||
|
||||
// 通用 URL 参数收集:除 id/scene 外的所有参数都透传给后端
|
||||
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
|
||||
const reservedKeys = ['uniacid', 'id', 'scene'];
|
||||
this.urlParams = {};
|
||||
for (let key in data) {
|
||||
if (reservedKeys.indexOf(key) !== -1) continue;
|
||||
this.urlParams[key] = data[key];
|
||||
}
|
||||
|
||||
if (data.scene) {
|
||||
var sceneParams = decodeURIComponent(data.scene);
|
||||
sceneParams = sceneParams.split('&');
|
||||
if (sceneParams.length) {
|
||||
sceneParams.forEach(item => {
|
||||
if (item.indexOf('id') != -1) this.id = item.split('-')[1];
|
||||
// scene 中也可能携带其他参数
|
||||
let pair = item.split('=');
|
||||
if (pair.length === 2 && pair[0] !== 'id') {
|
||||
this.urlParams[pair[0]] = pair[1];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
if (!this.storeToken) {
|
||||
this.redirectTologin('/pages_tool/form/form?id=' + this.id);
|
||||
return;
|
||||
// 通用 URL 参数收集:除 id/scene 外的所有参数都透传给后端
|
||||
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
|
||||
const reservedKeys = ['uniacid', 'id', 'scene'];
|
||||
this.urlParams = {};
|
||||
for (let key in data) {
|
||||
if (reservedKeys.indexOf(key) !== -1) continue;
|
||||
this.urlParams[key] = data[key];
|
||||
}
|
||||
|
||||
this.getData();
|
||||
},
|
||||
watch: {
|
||||
storeToken: function(nVal, oVal) {
|
||||
if (nVal) this.getData();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getData() {
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/info',
|
||||
data: {
|
||||
form_id: this.id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.detail = res.data;
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
fail: res => {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
create() {
|
||||
if (this.$refs.form.verify()) {
|
||||
if (this.isRepeat) return;
|
||||
this.isRepeat = true;
|
||||
if (data.scene) {
|
||||
var sceneParams = decodeURIComponent(data.scene);
|
||||
sceneParams = sceneParams.split('&');
|
||||
if (sceneParams.length) {
|
||||
sceneParams.forEach(item => {
|
||||
if (!item) return;
|
||||
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
|
||||
let pair = item.split('=');
|
||||
if (pair.length === 2) {
|
||||
const key = pair[0];
|
||||
const val = pair[1];
|
||||
if (key === 'id') this.id = val;
|
||||
else if (key === 'uniacid') this.uniacid = val;
|
||||
else this.urlParams[key] = val;
|
||||
} else if (item.indexOf('id') === 0 && item.indexOf('-') !== -1) {
|
||||
// 旧格式兜底:id-13
|
||||
this.id = item.split('-')[1];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
form_id: this.id,
|
||||
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData),
|
||||
url_params: JSON.stringify(this.urlParams),
|
||||
member_id: this.memberInfo ? this.memberInfo.id : 0
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') })
|
||||
setTimeout(() => {
|
||||
this.complete = true;
|
||||
this.detail = null;
|
||||
}, 1500)
|
||||
} else {
|
||||
this.isRepeat = false;
|
||||
this.$util.showToast({ title: res.message })
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
// #ifdef H5
|
||||
// H5:从 window.location.href 解析参数,兼容 history 路由 (?key=val) 与 hash 路由 (#/path?key=val)
|
||||
// 保证 recommend_code / uniacid 等参数不丢失
|
||||
try {
|
||||
const href = window.location.href;
|
||||
let queryStr = '';
|
||||
const hashIdx = href.indexOf('#');
|
||||
const qIdx = href.indexOf('?');
|
||||
if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) {
|
||||
// history 模式:? 在 # 之前(或无 #)
|
||||
queryStr = href.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : href.length);
|
||||
} else if (hashIdx !== -1) {
|
||||
// hash 模式:# 之后可能还有 ?
|
||||
const afterHash = href.substring(hashIdx + 1);
|
||||
const hq = afterHash.indexOf('?');
|
||||
if (hq !== -1) queryStr = afterHash.substring(hq + 1);
|
||||
}
|
||||
if (queryStr) {
|
||||
const pairs = queryStr.split('&');
|
||||
for (const p of pairs) {
|
||||
if (!p) continue;
|
||||
const eq = p.indexOf('=');
|
||||
let k, v;
|
||||
if (eq === -1) { k = decodeURIComponent(p); v = ''; }
|
||||
else { k = decodeURIComponent(p.substring(0, eq)); v = decodeURIComponent(p.substring(eq + 1)); }
|
||||
if (k === 'id') { if (!this.id) this.id = v; }
|
||||
else if (k === 'uniacid') { /* reserved, 透传到后端时不进 urlParams */ }
|
||||
else if (k === 'scene') { continue; }
|
||||
else { if (this.urlParams[k] === undefined) this.urlParams[k] = v; }
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
// 构建登录回跳地址:包含 id + 所有 urlParams(尤其 recommend_code),
|
||||
// 供 ns-login 弹窗登录成功或 H5 整页重定向登录后回到本页
|
||||
const backQuery = [];
|
||||
if (this.id) backQuery.push('id=' + encodeURIComponent(this.id));
|
||||
for (const k in this.urlParams) {
|
||||
const v = this.urlParams[k];
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
backQuery.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
|
||||
}
|
||||
}
|
||||
this.backUrl = '/pages_tool/form/form' + (backQuery.length ? '?' + backQuery.join('&') : '');
|
||||
},
|
||||
onShow() {
|
||||
// 打开是否需要登录由后端 behavior 决定(info 返回 FORM_LOGIN_REQUIRED 时弹登录)
|
||||
this.getData();
|
||||
},
|
||||
mounted() {
|
||||
// 1) 兜底 A:onLoad→getData 回调比 ns-login 挂载还早时,pendingLoginRequired=true
|
||||
// 挂载完成再最后尝试一次自动弹窗
|
||||
if (this.pendingLoginRequired) {
|
||||
const tryOpen = () => {
|
||||
try {
|
||||
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
|
||||
this.$refs.login.open(this.backUrl);
|
||||
}
|
||||
} catch (e) { console.warn('[FORM] mounted login.open failed:', e); }
|
||||
};
|
||||
this.$nextTick(tryOpen);
|
||||
this.pendingLoginRequired = false;
|
||||
}
|
||||
// 2) 兜底 B:登录态在组件创建之前就已写入(token 提前 race),但 info 因时序没拿到数据
|
||||
// 此时用户实际上已经登录,再拉一次 info 就能拿到真实表单数据
|
||||
if (this.storeToken && !this.detail) {
|
||||
this.$nextTick(() => { this.getData(); });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
storeToken: function(nVal, oVal) {
|
||||
if (nVal) {
|
||||
this.getData();
|
||||
// 填写需登录:弹窗登录成功后自动续提交
|
||||
this.resumeSubmitAfterLogin();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getData() {
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/info',
|
||||
data: {
|
||||
form_id: this.id
|
||||
},
|
||||
success: res => { this._handleInfoResponse(res); },
|
||||
fail: err => { this._handleInfoFailure(err); }
|
||||
});
|
||||
},
|
||||
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
|
||||
// 成功分支必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
|
||||
// 只要不满足就是"没拿到有效表单",然后用最宽的条件判定是否是登录拦截:
|
||||
// - 消息包含 FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录 / 登录相关中文
|
||||
// - 或者 data 无效(null/undefined/''/{}/非对象)+ 用户未登录 + 有 form_id
|
||||
// 命中任一 → 一律弹登录 + 友好文案"请先登录",不再误显示"未获取到表单信息"
|
||||
_handleInfoResponse(res) {
|
||||
try {
|
||||
const payload = (res && typeof res === 'object') ? res : {};
|
||||
const dataObj = payload.data;
|
||||
|
||||
// —— 1) 严格判定:是否拿到了真正的表单结构? ——
|
||||
const hasValidFormData =
|
||||
payload.code == 0 &&
|
||||
dataObj !== null && dataObj !== undefined && dataObj !== '' &&
|
||||
typeof dataObj === 'object' &&
|
||||
(dataObj.json_data !== undefined || dataObj.form_config !== undefined);
|
||||
|
||||
if (hasValidFormData) {
|
||||
this.detail = dataObj;
|
||||
this.formError = '';
|
||||
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
|
||||
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
|
||||
this.resumeSubmitAfterLogin();
|
||||
// 本地缓存一份字段 JSON:下次再打开时如果仍被登录拦截,可作为 skeleton 兜底
|
||||
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
|
||||
// 成功分支同样必须关闭 loading 遮罩
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// —— 2) 没拿到有效结构 → 宽网判定:是否属于"需要登录"场景? ——
|
||||
const msg = (payload.message || '').toString();
|
||||
const isFormNotOpen = (msg === 'FORM_NOT_OPEN');
|
||||
|
||||
const isLoginMsg =
|
||||
msg === 'FORM_LOGIN_REQUIRED' ||
|
||||
msg === 'LOGIN_REQUIRED' ||
|
||||
msg.indexOf('请先登录') !== -1 ||
|
||||
(msg.indexOf('登录') !== -1 && msg.indexOf('请') !== -1) ||
|
||||
(msg.toLowerCase().indexOf('login') !== -1 && msg.toLowerCase().indexOf('required') !== -1);
|
||||
|
||||
const dataIsEmpty =
|
||||
!dataObj ||
|
||||
dataObj === '' ||
|
||||
dataObj === null ||
|
||||
dataObj === undefined ||
|
||||
(typeof dataObj === 'object' && !dataObj.json_data && !dataObj.form_config &&
|
||||
((Array.isArray(dataObj) && dataObj.length === 0) ||
|
||||
(!Array.isArray(dataObj) && Object.keys(dataObj).length === 0)));
|
||||
|
||||
// 最宽判定:只要不是"表单未开放",且(显式登录消息 OR 空数据+未登录+有 id)都按登录拦截处理
|
||||
const shouldTriggerLogin =
|
||||
!isFormNotOpen &&
|
||||
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
|
||||
|
||||
if (shouldTriggerLogin && this.storeToken) {
|
||||
// 已登录(storeToken 存在)但后端仍返回登录拦截:
|
||||
// 登录一次后就不再自动弹登录/跳登录页,仅展示空态文案 + 手动登录按钮
|
||||
this.formError = this.$lang('form.form.loginRequired');
|
||||
} else if (shouldTriggerLogin) {
|
||||
this.formError = this.$lang('form.form.loginRequired');
|
||||
this._openLoginWithRetry();
|
||||
} else if (isFormNotOpen) {
|
||||
this.formError = this.$lang('form.form.notOpen');
|
||||
} else {
|
||||
this.formError = msg || this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[FORM] _handleInfoResponse exception:', e && e.message ? e.message : e);
|
||||
this.formError = this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
// info 接口网络失败时也做同样判定:未登录+有 id 就尝试启动登录流程
|
||||
_handleInfoFailure(err) {
|
||||
try {
|
||||
if (this.id && !this.storeToken) {
|
||||
this.formError = this.$lang('form.form.loginRequired');
|
||||
this._openLoginWithRetry();
|
||||
} else {
|
||||
this.formError = this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
} catch (e) {
|
||||
this.formError = this.$lang('form.form.noFormInfo');
|
||||
}
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
|
||||
_openLoginWithRetry() {
|
||||
// 防重复触发:3 秒内只允许自动弹一次登录
|
||||
const now = Date.now();
|
||||
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
|
||||
this._lastAutoLoginAt = now;
|
||||
const url = this.backUrl;
|
||||
let attempts = 0;
|
||||
const delays = [0, 60, 320];
|
||||
const tryOnce = () => {
|
||||
try {
|
||||
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
|
||||
this.$refs.login.open(url);
|
||||
this.pendingLoginRequired = false;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[FORM] login.open attempt ' + (attempts + 1) + ' failed:', e);
|
||||
}
|
||||
attempts++;
|
||||
if (attempts < delays.length) {
|
||||
setTimeout(tryOnce, delays[attempts]);
|
||||
} else {
|
||||
// 三次全失败:交给 mounted 钩子最后兜底
|
||||
this.pendingLoginRequired = true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
tryOnce();
|
||||
},
|
||||
// 模板空态按钮的手动入口:用户主动点击也能打开登录弹窗(最后一道防线)
|
||||
forceOpenLogin() {
|
||||
this._openLoginWithRetry();
|
||||
},
|
||||
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
|
||||
getCaptcha() {
|
||||
this.captchaLoaded = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/api/captcha/captcha',
|
||||
data: {
|
||||
captcha_id: this.captcha.id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.captcha.id = res.data.id;
|
||||
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : '';
|
||||
this.captcha.code = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
|
||||
savePendingSubmit(formData) {
|
||||
const data = formData || (this.$refs.form && this.$refs.form.getFormData && this.$refs.form.getFormData()) || null;
|
||||
const payload = {
|
||||
formId: this.id,
|
||||
urlParams: this.urlParams,
|
||||
formData: data,
|
||||
captchaId: this.captcha.id,
|
||||
captchaCode: this.captcha.code,
|
||||
ts: Date.now()
|
||||
};
|
||||
this.pendingSubmit = payload;
|
||||
try { uni.setStorageSync('form_pending_submit', payload); } catch (e) {}
|
||||
// H5 整页回跳标记:仅当确由登录拦截触发时才允许自动续提交,避免陈旧数据被误提交
|
||||
try { uni.setStorageSync('form_pending_from_login', 1); } catch (e) {}
|
||||
},
|
||||
// 仅在已登录时取回并消费待提交数据(未登录 / 非登录回跳 / 过期不消费,避免误提交);取后清除
|
||||
takePendingSubmit() {
|
||||
let payload = this.pendingSubmit;
|
||||
const fromStorage = !payload;
|
||||
if (fromStorage) {
|
||||
try { payload = uni.getStorageSync('form_pending_submit'); } catch (e) {}
|
||||
}
|
||||
if (!payload || !payload.formData) return null;
|
||||
if (!this.storeToken) return null;
|
||||
// H5 存储路径:必须是因登录拦截触发的回跳,否则视为陈旧数据,清理后不提交
|
||||
if (fromStorage) {
|
||||
let fromLogin = false;
|
||||
try { fromLogin = uni.getStorageSync('form_pending_from_login'); } catch (e) {}
|
||||
if (!fromLogin) {
|
||||
this.pendingSubmit = null;
|
||||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 仅消费 10 分钟内的待提交,避免极旧数据被自动提交
|
||||
if (payload.ts && Date.now() - payload.ts > 10 * 60 * 1000) {
|
||||
this.pendingSubmit = null;
|
||||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||||
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
this.pendingSubmit = null;
|
||||
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
|
||||
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
|
||||
return payload;
|
||||
},
|
||||
// 登录成功后:回填已填数据并自动续提交
|
||||
resumeSubmitAfterLogin() {
|
||||
const pending = this.takePendingSubmit();
|
||||
if (!pending) return;
|
||||
// 回填表单字段(nextTick 确保 diy-form 内部 ns-newform 已挂载)
|
||||
this.$nextTick(() => this.restoreFormData(pending.formData));
|
||||
// 验证码:MP 登录不丢态可直接提交;H5 回跳后验证码失效,需重新获取并让用户重填
|
||||
if (this.behavior.captcha == 1 && !this.captcha.code) {
|
||||
this.getCaptcha();
|
||||
this.$util.showToast({ title: '请重新输入验证码后提交' });
|
||||
return;
|
||||
}
|
||||
this.create(pending.formData);
|
||||
},
|
||||
// 将保存的 formData 回填到 diy-form 内部的 ns-newform
|
||||
restoreFormData(formData) {
|
||||
try {
|
||||
const ns = this.$refs.form && this.$refs.form.$refs && this.$refs.form.$refs.nsNewform;
|
||||
if (ns && Array.isArray(formData)) {
|
||||
ns.formData = JSON.parse(JSON.stringify(formData));
|
||||
if (typeof ns.processFormData === 'function') ns.processFormData();
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
// 验证码模式下本页自有提交按钮:先走 diy-form 校验与取数,再进 create
|
||||
submitFromPage() {
|
||||
if (this.isRepeat) return;
|
||||
if (!this.$refs.form || !this.$refs.form.verify()) return;
|
||||
const formData = this.$refs.form.getFormData();
|
||||
this.create(formData);
|
||||
},
|
||||
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
|
||||
doRedirect(link) {
|
||||
const fallback = () => { this.complete = true; this.detail = null; };
|
||||
if (!link) return fallback();
|
||||
if (link.appid) {
|
||||
// 跳转其他微信小程序(仅小程序端可用;H5 场景直接回退完成态)
|
||||
// #ifdef MP-WEIXIN
|
||||
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: fallback });
|
||||
return;
|
||||
// #endif
|
||||
fallback();
|
||||
} else if (link.mobile) {
|
||||
uni.makePhoneCall({ phoneNumber: String(link.mobile), fail: fallback });
|
||||
fallback();
|
||||
} else if (link.wap_url) {
|
||||
if (/^https?:\/\//i.test(link.wap_url)) {
|
||||
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(link.wap_url), fail: fallback });
|
||||
} else {
|
||||
uni.navigateTo({ url: link.wap_url, fail: fallback });
|
||||
}
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
},
|
||||
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
|
||||
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法;验证码模式下由 submitFromPage 调用。
|
||||
// 本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this。
|
||||
create(formData) {
|
||||
if (this.isRepeat) return;
|
||||
const behavior = this.behavior || {};
|
||||
|
||||
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
|
||||
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
|
||||
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
|
||||
this.savePendingSubmit(formData);
|
||||
this.$refs.login && this.$refs.login.open(this.backUrl);
|
||||
return;
|
||||
}
|
||||
// 验证码必填
|
||||
if (behavior.captcha == 1 && !this.captcha.code) {
|
||||
this.$util.showToast({ title: this.$lang('form.form.captchaRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRepeat = true;
|
||||
const submitCaptchaId = String(this.captcha.id || '');
|
||||
const submitCaptchaCode = String(this.captcha.code || '').toUpperCase();
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
form_id: this.id,
|
||||
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
|
||||
url_params: JSON.stringify(this.urlParams),
|
||||
member_id: this.memberInfo ? this.memberInfo.id : 0,
|
||||
captcha_id: submitCaptchaId,
|
||||
captcha_code: submitCaptchaCode
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
|
||||
const link = behavior.redirect;
|
||||
setTimeout(() => {
|
||||
link ? this.doRedirect(link)
|
||||
: (this.complete = true, this.detail = null);
|
||||
}, link ? 800 : 1500);
|
||||
} else {
|
||||
this.isRepeat = false;
|
||||
if (res.message == 'FORM_LOGIN_REQUIRED') {
|
||||
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
|
||||
this.savePendingSubmit(formData);
|
||||
this.$refs.login && this.$refs.login.open(this.backUrl);
|
||||
} else if (res.message == 'CAPTCHA_ERROR' || res.message == 'CAPTCHA_FAILURE') {
|
||||
this.$util.showToast({ title: this.$lang('form.form.captchaError') });
|
||||
if (behavior.captcha == 1) this.getCaptcha();
|
||||
} else if (res.message == 'FORM_SUBMIT_LIMIT') {
|
||||
this.$util.showToast({ title: this.$lang('form.form.submitLimit') });
|
||||
this.complete = true;
|
||||
this.detail = null;
|
||||
} else if (res.message == 'FORM_NOT_OPEN') {
|
||||
this.$util.showToast({ title: this.$lang('form.form.notOpen') });
|
||||
} else {
|
||||
this.$util.showToast({ title: res.message });
|
||||
}
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络失败时复位防重复标记,允许用户重试
|
||||
this.isRepeat = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
page {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-banner {
|
||||
width: 100vw;
|
||||
line-height: 1;
|
||||
/*
|
||||
* ===== 顶部图条 =====
|
||||
* 位于表单卡片之上。宽度由内联 bgLayerStyle 注入(calc(100% - 2*inset)),
|
||||
* 与下方表单卡片等宽、左右各留 inset 对称留白。圆角 + overflow 防止图片溢出。
|
||||
* 未配置 form_config.bg_image 时该节点不渲染(v-if="bgImage")。
|
||||
*/
|
||||
.formdata-bg {
|
||||
position: relative;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.system-form-wrap {
|
||||
background: $color-bg;
|
||||
border-radius: 32rpx;
|
||||
overflow: hidden;
|
||||
margin: 0 0 60rpx 0;
|
||||
padding: 0 26rpx;
|
||||
transform: translateY(-40rpx);
|
||||
.system-form-wrap {
|
||||
overflow: visible;
|
||||
margin: 0 0 60rpx 0;
|
||||
/* ===== 表单卡片:白底圆角、左右内缩居中 =====
|
||||
⚠️ 此 padding 仅为首屏兜底(computed 尚未就绪时生效),
|
||||
运行中会被 containerStyle 内联样式覆盖,实际值由 containerInset computed 决定 */
|
||||
padding: 0 30rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding-bottom: env(safe-area-inset-bottom, 40rpx);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
line-height: 100rpx;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
// 穿透 diy-form 包装层:消除其自带的圆角与背景,
|
||||
// 让 form 内容区域与 system-form-wrap 卡片视觉融合
|
||||
.system-form-wrap ::v-deep .diy-from {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 80rpx;
|
||||
line-height: 80rpx !important;
|
||||
margin-top: 30rpx !important;
|
||||
width: 100%;
|
||||
border-radius: 80rpx;
|
||||
}
|
||||
// ===== 行为设置:验证码行 + 自有提交按钮(diy-form 内置按钮被 showSubmit=false 隐藏时接管) =====
|
||||
.form-extra-wrap {
|
||||
padding: 0 28rpx 20rpx;
|
||||
}
|
||||
.captcha-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
margin-top: 24rpx;
|
||||
|
||||
::v-deep .form-wrap {
|
||||
background: #fff;
|
||||
padding: 30rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
}
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
font-size: 28rpx;
|
||||
border: 1rpx solid #e5e7eb;
|
||||
border-radius: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
width: 200rpx;
|
||||
height: 72rpx;
|
||||
margin-left: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx solid #e5e7eb;
|
||||
}
|
||||
|
||||
.captcha-btn {
|
||||
margin-left: 20rpx;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #ff4544;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ff4544;
|
||||
border-radius: 12rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
// 提交按钮:视觉对齐 diy-form 内置按钮(蓝底白字圆角,medium 内边距)
|
||||
.custom-submit {
|
||||
margin-top: 30rpx;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
background-color: #3b82f6;
|
||||
border-radius: 12rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
::v-deep .mescroll-totop {
|
||||
/* #ifdef H5 */
|
||||
right: 27rpx !important;
|
||||
bottom: 120rpx !important;
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
right: 27rpx !important;
|
||||
bottom: 180rpx !important;
|
||||
/* #endif */
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user