Compare commits
1 Commits
dev/1.0
...
yuanlaide3
| Author | SHA1 | Date | |
|---|---|---|---|
| a137f3533c |
81
.gitignore
vendored
81
.gitignore
vendored
@@ -5,83 +5,10 @@
|
|||||||
/iconfont-preview.html
|
/iconfont-preview.html
|
||||||
|
|
||||||
.codebuddy
|
.codebuddy
|
||||||
.workbuddy
|
|
||||||
.vscode
|
.vscode
|
||||||
.trae*
|
.trae
|
||||||
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
|
|
||||||
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
|
|
||||||
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
|
|
||||||
# 清理历史 (仅限团队协商后操作)
|
|
||||||
.local.config.js
|
|
||||||
|
|
||||||
# 鸿蒙元服务构建产物 (被 .gitignore 的只是产物, HAR 元数据需要 git 管理)
|
# 鸿蒙元服务构建产物
|
||||||
# 重要: ascf_build 根目录的元数据 (oh-package.json5 / Index.ets / build-profile.json5 / ...)
|
|
||||||
# 必须被 git 管理, 否则无法追踪 "module.name 被清空" 等回归。
|
|
||||||
# 这些元数据通过下方的 ! 取反规则显式纳入 git。
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_src
|
stub-app-projects/mp-harmony/ascf/ascf_src
|
||||||
stub-app-projects/mp-harmony/entry/src/main/resources/rawfile
|
stub-app-projects/mp-harmony/ascf/ascf_build
|
||||||
|
stub-app-projects/mp-harmony/entry/src/main/resources
|
||||||
# ascf_build/<pkg>/ 是 HAR 包定义
|
|
||||||
# 区分两类内容:
|
|
||||||
# 1. 必须入 git (元数据 / 构建配置 / HAR 入口):
|
|
||||||
# - oh-package.json5, build-profile.json5, hvigorfile.ts
|
|
||||||
# - obfuscation-rules.txt, src/main/module.json5
|
|
||||||
# - src/main/resources/base/element/string.json (HAR i18n)
|
|
||||||
# 2. 每次构建覆盖 (不应入 git):
|
|
||||||
# - src/main/resources/rawfile/** : 鸿蒙端从 ascf_src 同步的编译产物
|
|
||||||
# - Index.ets : 空壳文件, 无内容
|
|
||||||
# - build/, oh_modules/, .preview/, .cxx/, .test/, node_modules/
|
|
||||||
|
|
||||||
# rawfile/ 整体 ignore (鸿蒙端每次 Build 重新生成)
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/rawfile/**
|
|
||||||
|
|
||||||
# Index.ets 是空壳 (每个包都是 0 字节), 不入 git
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/Index.ets
|
|
||||||
|
|
||||||
# 其他产物目录
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/build
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/oh_modules
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/.preview
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/.cxx
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/.test
|
|
||||||
stub-app-projects/mp-harmony/ascf/ascf_build/**/node_modules
|
|
||||||
|
|
||||||
# entry 下的产物
|
|
||||||
stub-app-projects/mp-harmony/entry/build
|
|
||||||
stub-app-projects/mp-harmony/entry/oh_modules
|
|
||||||
stub-app-projects/mp-harmony/entry/.preview
|
|
||||||
stub-app-projects/mp-harmony/entry/.cxx
|
|
||||||
stub-app-projects/mp-harmony/entry/.test
|
|
||||||
stub-app-projects/mp-harmony/entry/node_modules
|
|
||||||
# 鸿蒙工程顶层产物 (.hvigor / hvigor / .idea 已在根 .gitignore 范围或更精准的鸿蒙子 .gitignore)
|
|
||||||
stub-app-projects/mp-harmony/oh_modules
|
|
||||||
stub-app-projects/mp-harmony/.hvigor
|
|
||||||
stub-app-projects/mp-harmony/.idea
|
|
||||||
# 鸿蒙 hvigor 自身依赖
|
|
||||||
stub-app-projects/mp-harmony/hvigor
|
|
||||||
# Trae/IDE 临时文件
|
|
||||||
.hvigor/outputs
|
|
||||||
.trae_permission_check_*
|
|
||||||
|
|
||||||
# 本地测试脚本与产物归档(不入 git, 留本地复用)
|
|
||||||
# 内容: Playwright 截图 / 测试脚本 / 对比图 / 临时提交草稿
|
|
||||||
# 复用方式: cd _test_archive && python xxx.py
|
|
||||||
_test_archive/
|
|
||||||
|
|
||||||
# 兜底:防止任何临时测试文件被遗漏在根目录
|
|
||||||
# 名称以 _test_/_compare_/_auth_ 开头, 或 check_button.py 等
|
|
||||||
_test_*.png
|
|
||||||
_test_*.html
|
|
||||||
_compare*.png
|
|
||||||
_compare*.py
|
|
||||||
_auth_*.json
|
|
||||||
_commit_msg.txt
|
|
||||||
/check_button.py
|
|
||||||
# 鸿蒙签名材料 (敏感, 绝不能提交到公共 git)
|
|
||||||
# 仓库有 origin (github.com/Zajd/jsysq-uniapp) 是公开仓库, 签名材料必须 gitignore
|
|
||||||
stub-app-projects/mp-harmony/**/cert-profile
|
|
||||||
stub-app-projects/mp-harmony/**/.p12
|
|
||||||
stub-app-projects/mp-harmony/**/.p7b
|
|
||||||
stub-app-projects/mp-harmony/**/.cer
|
|
||||||
stub-app-projects/mp-harmony/**/.csr
|
|
||||||
stub-app-projects/mp-harmony/**/material
|
|
||||||
181
.local.config.js
181
.local.config.js
@@ -21,7 +21,7 @@ const localDevConfig = ({
|
|||||||
'2881': { // 防爆电器
|
'2881': { // 防爆电器
|
||||||
uniacid: 2881,
|
uniacid: 2881,
|
||||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'2724': { // 生物菌肥
|
'2724': { // 生物菌肥
|
||||||
uniacid: 2724,
|
uniacid: 2724,
|
||||||
domain: 'https://xcx.aigc-quickapp.com/',
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
@@ -45,11 +45,11 @@ const localDevConfig = ({
|
|||||||
'11-test': { // 测试平台
|
'11-test': { // 测试平台
|
||||||
uniacid: 11,
|
uniacid: 11,
|
||||||
domain: 'https://test.aigc-quickapp.com',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
'18-test': { // 测试平台
|
'18-test': { // 测试平台
|
||||||
uniacid: 18,
|
uniacid: 18,
|
||||||
domain: 'https://test.aigc-quickapp.com',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
'local-2': { // 测试平台
|
'local-2': { // 测试平台
|
||||||
uniacid: 2,
|
uniacid: 2,
|
||||||
domain: 'http://localhost:8050/',
|
domain: 'http://localhost:8050/',
|
||||||
@@ -59,16 +59,11 @@ const localDevConfig = ({
|
|||||||
domain: 'http://192.168.197.1:8050/',
|
domain: 'http://192.168.197.1:8050/',
|
||||||
// domain: 'http://localhost:8050/',
|
// domain: 'http://localhost:8050/',
|
||||||
},
|
},
|
||||||
'192.168.8.118-1': { // 测试平台
|
|
||||||
uniacid: 1,
|
|
||||||
domain: 'http://192.168.8.118:8050/',
|
|
||||||
// domain: 'http://localhost:8050/',
|
|
||||||
},
|
|
||||||
'local-11': { // 测试平台
|
'local-11': { // 测试平台
|
||||||
uniacid: 11,
|
uniacid: 11,
|
||||||
domain: 'http://localhost:8050/',
|
domain: 'http://localhost:8050/',
|
||||||
},
|
},
|
||||||
'100': {// 口译教学
|
'100': {
|
||||||
uniacid: 100,
|
uniacid: 100,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
@@ -76,55 +71,47 @@ const localDevConfig = ({
|
|||||||
uniacid: 1,
|
uniacid: 1,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'115': {// 低压断路器
|
'115': {
|
||||||
uniacid: 115,
|
uniacid: 115,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-11': {// 铝箔袋包装
|
'5g-11': {
|
||||||
uniacid: 11,
|
uniacid: 11,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-206': {// 气体探测
|
'5g-206': {
|
||||||
uniacid: 206,
|
uniacid: 206,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
'47': { // 修改弹窗
|
||||||
uniacid: 165,
|
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
|
||||||
},
|
|
||||||
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
|
|
||||||
uniacid: 47,
|
uniacid: 47,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
|
'145': { // 表单链接转义问题
|
||||||
uniacid: 145,
|
uniacid: 145,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
|
'5g-213': { // 单图组点击链接无法跳转
|
||||||
uniacid: 213,
|
uniacid: 213,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
|
'5g-4': { // 公告点击链接无法跳转
|
||||||
uniacid: 4,
|
uniacid: 4,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
|
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||||
uniacid: 97,
|
uniacid: 97,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-50': { // 燃气安全产品机器人,购物车无反应
|
'5g-50': { // 燃气安全产品机器人,购物车无反应
|
||||||
uniacid: 50,
|
uniacid: 50,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
|
'5g-237': { // 商品分类,没有立即咨询文字
|
||||||
uniacid: 237,
|
uniacid: 237,
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
domain: 'https://5g.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'5g-248': { //中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
'2847': { //
|
||||||
uniacid: 248,
|
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
|
||||||
},
|
|
||||||
'2847': { // 家居装饰品
|
|
||||||
uniacid: 2847,
|
uniacid: 2847,
|
||||||
domain: 'https://xcx.aigc-quickapp.com/',
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
@@ -132,128 +119,40 @@ const localDevConfig = ({
|
|||||||
uniacid: 2482,
|
uniacid: 2482,
|
||||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
|
'2485': { // 验证电子表单前后端数据一致性
|
||||||
uniacid: 2485,
|
uniacid: 2485,
|
||||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'2821': { // 离型纸硅油纸
|
'2821': { // 离型纸硅油纸
|
||||||
uniacid: 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',
|
|
||||||
},
|
|
||||||
'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/',
|
domain: 'https://xcx.aigc-quickapp.com/',
|
||||||
},
|
},
|
||||||
'192.168.8.118-325': { // 测试平台
|
'54': { // 测试平台
|
||||||
uniacid: 325,
|
uniacid: 54,
|
||||||
domain: 'http://192.168.8.118:8050/',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
// domain: 'http://localhost:8050/',
|
|
||||||
},
|
},
|
||||||
'2505': { // 煤矿钻机
|
'2': { // 测试平台
|
||||||
uniacid: 2505,
|
uniacid: 2,
|
||||||
domain: 'https://xcx.aigc-quickapp.com/',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
'3007': { // 钎焊材料
|
'54': { // 测试平台
|
||||||
uniacid: 3007,
|
uniacid: 54,
|
||||||
domain: 'https://xcx.aigc-quickapp.com/',
|
domain: 'https://test.aigc-quickapp.com',
|
||||||
},
|
},
|
||||||
'5g-317': { // 电磁兼容,模板显示不全商品
|
|
||||||
uniacid: 317,
|
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
|
||||||
},
|
|
||||||
'5g-312': { // 天然植物功效活性物
|
|
||||||
uniacid: 312,
|
|
||||||
domain: 'https://5g.aigc-quickapp.com/',
|
|
||||||
},
|
|
||||||
'192.168.8.113-1': { // 测试平台
|
|
||||||
uniacid: 1,
|
|
||||||
domain: 'http://192.168.8.113:8050/',
|
|
||||||
// domain: 'http://localhost:8050/',
|
|
||||||
},
|
|
||||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||||
// })['2821']; // 离型纸硅油纸
|
// })['2821']; // 离型纸硅油纸
|
||||||
// })['2482']; // 低空经济
|
})['54']; // 低空经济
|
||||||
// })['48']; // 离型纸硅油纸
|
// })['48']; // 离型纸硅油纸
|
||||||
// })['2485']; // 验证电子表单前后端数据一致性
|
// })['2485']; // 验证电子表单前后端数据一致性
|
||||||
// })['5g-11']; // 数码
|
// })['5g-11']; // 数码
|
||||||
// })['5g-213']; // 选择要使用的环境配置
|
// })['5g-213']; // 选择要使用的环境配置
|
||||||
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
|
||||||
// })['2724']; // 生物菌肥
|
|
||||||
// })['2811']; // 选择要使用的环境配置
|
// })['2811']; // 选择要使用的环境配置
|
||||||
// })['5g-351']; //洗车机
|
|
||||||
// })['5g-204']; //热固性塑料
|
|
||||||
// })['5g-59']; //合成孔径雷达
|
|
||||||
// })['2709']; //中央厨房
|
|
||||||
// })['3007']; //钎焊材料
|
|
||||||
// })['2505']; //煤矿钻机
|
|
||||||
// })['local-11']; // 选择要使用的环境配置
|
// })['local-11']; // 选择要使用的环境配置
|
||||||
// })['2811']; // 本地
|
// })['local-1']; // 本地
|
||||||
// })['5g-317']; // 电磁兼容
|
|
||||||
// })['5g-351']; //
|
|
||||||
})['2811']; // 本地
|
|
||||||
// })['5g-248']; // 中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
|
||||||
// })['2881']; // 本地
|
|
||||||
// })['local-2']; // 本地
|
// })['local-2']; // 本地
|
||||||
// })['local-11']; // 本地
|
// })['local-11']; // 本地
|
||||||
// })['1-test']; // test
|
// })['1-test']; // test
|
||||||
// })['test-12']; // test
|
// })['18-test']; // test
|
||||||
export default localDevConfig;
|
export default localDevConfig;
|
||||||
60
App.vue
60
App.vue
@@ -5,15 +5,6 @@
|
|||||||
import ThemeOverride from 'common/js/theme-override.js';
|
import ThemeOverride from 'common/js/theme-override.js';
|
||||||
import PrivacyManager from 'common/js/privacy.js';
|
import PrivacyManager from 'common/js/privacy.js';
|
||||||
import splashState from 'common/js/splash-state.js';
|
import splashState from 'common/js/splash-state.js';
|
||||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
|
|
||||||
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
|
||||||
import aiService from 'common/js/ai-service.js';
|
|
||||||
import { markNeedStartupAd } from 'common/js/startup-ad.js';
|
|
||||||
|
|
||||||
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
|
|
||||||
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
|
|
||||||
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
|
|
||||||
let _appHasShown = false;
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Weixin
|
Weixin
|
||||||
@@ -280,56 +271,27 @@
|
|||||||
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
||||||
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
||||||
// 只弹一次: 同意后写 storage,后续不再弹
|
// 只弹一次: 同意后写 storage,后续不再弹
|
||||||
// 异步查询平台隐私状态(微信/快应用), H5 同步返回
|
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
|
||||||
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
||||||
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
||||||
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
||||||
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
||||||
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
|
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
|
||||||
//
|
|
||||||
// 鸿蒙元服务(双轨制):
|
|
||||||
// 已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
|
|
||||||
// 应用层不调 PrivacyManager.show() (会和系统弹窗叠加被驳回).
|
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
// 鸿蒙: 走平台隐私托管, markSkipped 写 storage
|
|
||||||
try {
|
|
||||||
await Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig])
|
|
||||||
PrivacyManager.markSkipped()
|
|
||||||
} catch (e) { console.warn('[privacy] MP-HARMONY markSkipped fail', e); }
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP-HARMONY
|
|
||||||
try {
|
try {
|
||||||
await PrivacyManager.show({
|
await PrivacyManager.show({
|
||||||
urlFallback: _buildPrivacyUrlFallback(options),
|
urlFallback: _buildPrivacyUrlFallback(options),
|
||||||
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||||
})
|
})
|
||||||
} catch (e) { console.warn('[privacy] show fail', e); }
|
} catch (e) { console.warn('[privacy] show fail', e); }
|
||||||
// #endif
|
|
||||||
|
|
||||||
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
||||||
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
||||||
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
||||||
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
|
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
|
||||||
//
|
|
||||||
// 关键 (鸿蒙 + 其他平台): 这里只发"软隐藏"信号, splash 实际关闭要等
|
|
||||||
// splashState._softHide() 看到 _hasMounted && _pageReady 才真关。
|
|
||||||
// _pageReady 由首页 diy-group 渲染完时调 notifyPageReady() 触发。
|
|
||||||
// 这样保证 splash **一直显示**, 直到首页 diy 内容真的渲染好,
|
|
||||||
// 避免 splash 关闭后到 diy 内容出现之间出现白屏。
|
|
||||||
//
|
|
||||||
// 鸿蒙端 (mode='strict'):
|
|
||||||
// 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好
|
|
||||||
// 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底
|
|
||||||
// (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏)
|
|
||||||
// strict 模式只受 DEFAULT_TIMEOUT=12s 总超时兜底, 实际由 notifyPageReady() 触发
|
|
||||||
// #ifndef H5
|
// #ifndef H5
|
||||||
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
||||||
if (_needSplash) {
|
if (_needSplash) {
|
||||||
// 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
|
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
|
||||||
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
|
|
||||||
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
|
|
||||||
// 避免 splash 提前关导致白屏
|
|
||||||
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
|
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
@@ -472,14 +434,6 @@
|
|||||||
if (uni.getStorageSync('memberInfo')) {
|
if (uni.getStorageSync('memberInfo')) {
|
||||||
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
|
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
|
||||||
|
|
||||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
|
|
||||||
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
|
|
||||||
try {
|
|
||||||
if (aiService && typeof aiService.onUserChange === 'function') {
|
|
||||||
aiService.onUserChange();
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
// 查询购物车信息
|
// 查询购物车信息
|
||||||
this.$store.dispatch('getCartNumber');
|
this.$store.dispatch('getCartNumber');
|
||||||
}
|
}
|
||||||
@@ -508,16 +462,6 @@
|
|||||||
},
|
},
|
||||||
onShow: function(options) {
|
onShow: function(options) {
|
||||||
// #ifdef MP
|
// #ifdef MP
|
||||||
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
|
|
||||||
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
|
|
||||||
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
|
|
||||||
if (_appHasShown) {
|
|
||||||
try { Lang.onAppReentry(); } catch (e) {}
|
|
||||||
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
|
|
||||||
try { markNeedStartupAd(); } catch (e) {}
|
|
||||||
}
|
|
||||||
_appHasShown = true;
|
|
||||||
|
|
||||||
// 自动授权登录
|
// 自动授权登录
|
||||||
this.getAuthInfo();
|
this.getAuthInfo();
|
||||||
if (this.$store.state.token) {
|
if (this.$store.state.token) {
|
||||||
|
|||||||
@@ -118,8 +118,6 @@
|
|||||||
.goods-video video {
|
.goods-video video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
|
||||||
object-position: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.goods-video .uni-video-cover {
|
.goods-video .uni-video-cover {
|
||||||
@@ -1225,7 +1223,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.popup-layer {
|
.popup-layer {
|
||||||
position: relative;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
.head-wrap {
|
.head-wrap {
|
||||||
font-size: $font-size-toolbar;
|
font-size: $font-size-toolbar;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,567 +0,0 @@
|
|||||||
/**
|
|
||||||
* 名片云(card addon)API 收口层
|
|
||||||
*
|
|
||||||
* 单一事实来源:后端《名片云(card addon)API 对接文档》CARD_API_REFERENCE.md(2026-10-13)
|
|
||||||
* 文档与源码如有出入,以源码为准;URL 路径一个字符都不能变,只做收口不做改名。
|
|
||||||
*
|
|
||||||
* 收口要求(来自后端 checklist):
|
|
||||||
* 1. 所有 /card/api/* URL 必须集中在本层,页面不得再直连字面量;
|
|
||||||
* 2. 统一走项目请求工具(自动带 token / uniacid),任何接口不传 site_id;
|
|
||||||
* 3. 失败分支统一 toast(message);「不存在」类错误按"内容不存在或已删除"提示;
|
|
||||||
* 4. card/update 只传编辑过的键(键存在才写、空串=清空、键缺席=不擦除);
|
|
||||||
* 5. 详情/列表渲染富化字段用 *_list 解码键,不解析原始 JSON 串;
|
|
||||||
* 6. 编辑页对 locked_fields 命中的字段渲染只读;
|
|
||||||
* 7. recognize 成功后检查 deduped,为 true 时提示"已存在同手机号名片";
|
|
||||||
* 8. 分页参数 page_size 不超过 100;
|
|
||||||
* 9. 名片路由参数用 card_id(字符串),人脉/线索用 id(数字),不要混用。
|
|
||||||
*
|
|
||||||
* 用法(页面内 this.$cardApi):
|
|
||||||
* this.$cardApi.cardLists({ data: { page: 1 }, success: res => {}, fail: () => {} })
|
|
||||||
*
|
|
||||||
* 注意:本层只做"URL 收口 + 通道选择",不做业务加工;
|
|
||||||
* 业务字典(状态/来源/字段白名单)随端口导出,供各页面复用。
|
|
||||||
*/
|
|
||||||
import Http from './http.js';
|
|
||||||
|
|
||||||
/** 所有端点路径(与后端文档逐一比对,改动需两边同步) */
|
|
||||||
export const CARD_API_URL = {
|
|
||||||
// === 名片夹 /card/api/card/* ===
|
|
||||||
CARD_LISTS: '/card/api/card/lists',
|
|
||||||
CARD_DETAIL: '/card/api/card/detail',
|
|
||||||
CARD_UPDATE: '/card/api/card/update',
|
|
||||||
CARD_FAVORITE: '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
|
|
||||||
CARD_CREATE: '/card/api/card/create', // 新建数字名片(后端源码已有, 文档未列; 以源码为准)
|
|
||||||
CARD_CLAIM: '/card/api/card/claim',
|
|
||||||
CARD_RECOGNIZE: '/card/api/card/recognize',
|
|
||||||
CARD_RETRY: '/card/api/card/retry',
|
|
||||||
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
|
|
||||||
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
|
|
||||||
|
|
||||||
// === 跟进完成态 2.11.3 /card/api/card/* ===
|
|
||||||
CARD_FOLLOW_DONE: '/card/api/card/followDone',
|
|
||||||
// === 访客轨迹 2.11.4 /card/api/card/* ===
|
|
||||||
CARD_VIEWS: '/card/api/card/views',
|
|
||||||
|
|
||||||
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
|
|
||||||
APPLY_SUBMIT: '/card/api/apply/submit',
|
|
||||||
APPLY_STATUS: '/card/api/apply/status',
|
|
||||||
|
|
||||||
// === 人脉库 /card/api/contact/* ===
|
|
||||||
CONTACT_LISTS: '/card/api/contact/lists',
|
|
||||||
CONTACT_ADD: '/card/api/contact/add',
|
|
||||||
CONTACT_INFO: '/card/api/contact/info',
|
|
||||||
CONTACT_UPDATE: '/card/api/contact/update',
|
|
||||||
CONTACT_DELETE: '/card/api/contact/delete',
|
|
||||||
CONTACT_TO_LEAD: '/card/api/contact/toLead',
|
|
||||||
|
|
||||||
// === 潜在客户 /card/api/lead/* ===
|
|
||||||
LEAD_LISTS: '/card/api/lead/lists',
|
|
||||||
LEAD_SET_STATUS: '/card/api/lead/setStatus',
|
|
||||||
|
|
||||||
// === 交换名片 /card/api/exchange/* ===
|
|
||||||
EXCHANGE_LISTS: '/card/api/exchange/lists',
|
|
||||||
EXCHANGE_AGREE: '/card/api/exchange/agree',
|
|
||||||
EXCHANGE_IGNORE: '/card/api/exchange/ignore',
|
|
||||||
|
|
||||||
// === 名片设计(员工端可选样式, 后端 v2) /card/api/design/* ===
|
|
||||||
// 预设列表必须与商家后台 CardTemplatePresets 同源(避免前端自建漂移)
|
|
||||||
// ⚠ v2 路径无 /card/ 段: 初版 /card/api/card/design/* 在本框架 action 单段路由下 404
|
|
||||||
CARD_DESIGN_PRESETS: '/card/api/design/presets', // 预设列表(同源后台 CardTemplatePresets)
|
|
||||||
CARD_DESIGN_SAVE: '/card/api/design/save', // 应用预设/保存本卡样式
|
|
||||||
|
|
||||||
// === 企业名片 /card/api/enterprise/* (2.13.6) ===
|
|
||||||
// 业务模型: 企业卡片 owner_id=0(店铺级), 同一时刻仅 1 张 is_active=1 生效;
|
|
||||||
// 名片区三模式 header_mode: official(指定/默认官方企业卡) / self(员工自选个人卡) / staff(员工轮播)
|
|
||||||
ENTERPRISE_DETAIL: '/card/api/enterprise/detail', // GET ?card_id= 不传=当前生效卡
|
|
||||||
ENTERPRISE_SAVE: '/card/api/enterprise/save', // POST {card_id?,fields?,design?,header_mode?,official_card_id?}
|
|
||||||
ENTERPRISE_MINE: '/card/api/enterprise/mine', // GET 自己名下企业卡列表
|
|
||||||
ENTERPRISE_ACTIVATE: '/card/api/enterprise/activate', // POST {card_id} 切换生效(互斥)
|
|
||||||
ENTERPRISE_CHOOSE_SELF: '/card/api/enterprise/chooseSelf', // POST {card_id} 员工自选展示个人卡
|
|
||||||
ENTERPRISE_DELETE: '/card/api/enterprise/delete' // POST {card_id} 删候选卡(生效卡禁删)
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 社交媒体类型白名单:必须与后端 CardRichFields::SOCIAL_TYPES 保持一致(增减需两边同步)。
|
|
||||||
* type 非法或 value 为空的条目会被后端静默丢弃。
|
|
||||||
* ⚠️ 2026-10-13:telegram / messenger 由独立标量字段软下线,统一并入本数组;
|
|
||||||
* 旧数据后端读取时自动合并进 socials_list(见 CARD_API_REFERENCE.md 3.3)。
|
|
||||||
*/
|
|
||||||
export const SOCIAL_TYPES = [
|
|
||||||
{ key: 'alipay', label: '支付宝' },
|
|
||||||
{ key: 'douyin', label: '抖音' },
|
|
||||||
{ key: 'wxvideo', label: '视频号' },
|
|
||||||
{ key: 'xiaohongshu', label: '小红书' },
|
|
||||||
{ key: 'weibo', label: '微博' },
|
|
||||||
{ key: 'kuaishou', label: '快手' },
|
|
||||||
{ key: 'bilibili', label: '哔哩哔哩' },
|
|
||||||
{ key: 'qq', label: 'QQ' },
|
|
||||||
{ key: 'linkedin', label: 'LinkedIn' },
|
|
||||||
{ key: 'facebook', label: 'Facebook' },
|
|
||||||
{ key: 'instagram', label: 'Instagram' },
|
|
||||||
{ key: 'x', label: 'X' },
|
|
||||||
{ key: 'telegram', label: 'Telegram' },
|
|
||||||
{ key: 'messenger', label: 'Messenger' },
|
|
||||||
{ key: 'other', label: '其他' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 富化字段上限:与后端 CardRichFields::MAX_* 一致 */
|
|
||||||
export const RICH_FIELD_LIMITS = {
|
|
||||||
MAX_MOBILES: 5,
|
|
||||||
MAX_SOCIALS: 10,
|
|
||||||
MAX_CUSTOM: 10
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 线索跟进状态:与后端 Lead::STATUSES 一致(0-3,其它值报「参数不合法」) */
|
|
||||||
export const LEAD_STATUS = [
|
|
||||||
{ value: 0, label: '新客户' },
|
|
||||||
{ value: 1, label: '已联系' },
|
|
||||||
{ value: 2, label: '已成交' },
|
|
||||||
{ value: 3, label: '无效' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 人脉来源:与后端 Contact::SOURCES 一致(scan/exchange 暂无产生入口) */
|
|
||||||
export const CONTACT_SOURCES = [
|
|
||||||
{ value: 'manual', label: '手工录入' },
|
|
||||||
{ value: 'lead_in', label: '潜在客户转入' },
|
|
||||||
{ value: 'scan', label: '扫描' },
|
|
||||||
{ value: 'exchange', label: '交换' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 名片来源:与后端 relation.source_type 一致 */
|
|
||||||
export const CARD_SOURCE_TYPES = [
|
|
||||||
{ value: 'manual', label: '手动创建' },
|
|
||||||
{ value: 'manual_batch', label: '批量制作' },
|
|
||||||
{ value: 'scan', label: '扫描识别' },
|
|
||||||
{ value: 'import_csv', label: 'CSV 导入' },
|
|
||||||
{ value: 'import_xlsx', label: 'Excel 导入' },
|
|
||||||
{ value: 'clone', label: '克隆' },
|
|
||||||
{ value: 'api', label: '接口创建' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 名片编辑标量字段:与后端 BusinessCardFieldNormalizer 输出对齐。
|
|
||||||
* ⚠️ 2026-10-13:telegram / messenger 已软下线为独立标量(传了也被忽略),
|
|
||||||
* 统一走 fields.socials 数组按类型添加;读取时旧值自动合并进 socials_list。
|
|
||||||
*/
|
|
||||||
export const CARD_FIELDS = [
|
|
||||||
'name', 'company', 'job_title', 'avatar',
|
|
||||||
'mobile', 'phone', 'email', 'website', 'wechat',
|
|
||||||
'country', 'province', 'city', 'address', 'postal_code',
|
|
||||||
'bio'
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
|
|
||||||
export const CONTACT_FIELDS = [
|
|
||||||
'name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status'
|
|
||||||
];
|
|
||||||
|
|
||||||
const CardApi = {
|
|
||||||
url: CARD_API_URL,
|
|
||||||
|
|
||||||
/* ================= 名片夹 ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 我的名片列表
|
|
||||||
* @param {Object} p { page, page_size(<=100), keyword, success, fail }
|
|
||||||
* 返回 data: { list, count, page_count }
|
|
||||||
*/
|
|
||||||
cardLists(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_LISTS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 卡片详情
|
|
||||||
* @param {Object} p { card_id, success, fail }
|
|
||||||
* 返回 data: { relation, source, share_list }
|
|
||||||
*/
|
|
||||||
cardDetail(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DETAIL }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新卡片(normalize 语义:键存在才写;空串=清空;键缺席=不擦除)
|
|
||||||
* 局部更新时只传用户实际编辑过的键。走 JSON body(fields 为嵌套对象)。
|
|
||||||
* @param {Object} p { card_id, fields, success, fail }
|
|
||||||
*/
|
|
||||||
cardUpdate(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_UPDATE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新建数字名片(商家端批量制作/单张新建入口)
|
|
||||||
* 入参:`fields`(与 card/update 同结构,`name` 必填)+ 可选 `owner_id`(指定归属员工,0=未归属待认领)
|
|
||||||
* 返回 `data`:`{ card_id, ...relation }`(前端用 data.card_id 跳详情)
|
|
||||||
* ⚠️ 该端点后端源码已实现但旧文档未列出,按「文档与源码出入以源码为准」收口。
|
|
||||||
*/
|
|
||||||
cardCreate(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_CREATE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 认领名片(商家后台建的卡未归属 owner_id=0,员工认领到自己名下)
|
|
||||||
* 入参(二选一, claim_code 优先): { data: { claim_code } } 或 { data: { card_id } }
|
|
||||||
* 成功 data.claimed: true=新认领, false=早已是自己的(幂等)
|
|
||||||
* 文档: card_claim_api.md(POST /card/api/card/claim,token 自动带)
|
|
||||||
* @param {Object} p { data: { claim_code | card_id }, success, fail }
|
|
||||||
*/
|
|
||||||
cardClaim(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_CLAIM }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换收藏(双向 toggle,幂等可逆:未收藏→置1,已收藏→置0)
|
|
||||||
* 直接用返回值 data.is_favorite 回填 UI,前端不自行判断状态。
|
|
||||||
* @param {Object} p { data: { card_id }, success, fail }
|
|
||||||
* 返回 data: { card_id, is_favorite: 0|1 }
|
|
||||||
* 拒绝(message 直接 toast): 卡不存在 / 非自己名下卡(统一「名片不存在」) / 参数不合法 / 未登录
|
|
||||||
*/
|
|
||||||
cardToggleFavorite(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_FAVORITE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除自己的名片(软删, 2.11.91)
|
|
||||||
* 文档: CARD_DELETE_API.md(POST /card/api/card/delete,token 自动带,不传 site_id)
|
|
||||||
* 通道: 与 cardClaim/cardFollowDone 同款 sendRequest(表单编码);只需 card_id 一个字段
|
|
||||||
* 交互: 调用前页面必须已做二次确认;成功后 toast「已删除」并刷新列表(接口不再返回该卡)
|
|
||||||
* 口径: 「卡片不存在」是故意模糊(他人/已删/不存在同一文案),前端统一按 message toast,不特殊处理
|
|
||||||
* @param {Object} p { data: { card_id }, success, fail }
|
|
||||||
*/
|
|
||||||
cardDelete(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DELETE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 识别建卡
|
|
||||||
* @param {Object} p { media_path, media_type?, source_type?, success, fail }
|
|
||||||
* 返回 data: { source_id, relation_id, card_id, deduped, fields }
|
|
||||||
* deduped=true 表示站点内已有同手机号名片,未新建,前端提示"已存在同名片"
|
|
||||||
*/
|
|
||||||
cardRecognize(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 识别失败重试(仅本站 + status=failed 的来源可重试,有次数上限)
|
|
||||||
* @param {Object} p { source_id, success, fail }
|
|
||||||
*/
|
|
||||||
cardRetry(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RETRY }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量识别(1~10 条,逐项独立成败)
|
|
||||||
* @param {Object} p { items: [{ source_type?, media_type?, media_path, media_key? }], success, fail }
|
|
||||||
* 返回 data: { items: [{ ok, message, data }], ok, failed }
|
|
||||||
*/
|
|
||||||
cardRecognizeBatch(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE_BATCH }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标记跟进完成(2.11.3 跟进闭环)
|
|
||||||
* 行级归属: 只能标记自己名下的名片(与收藏/认领同一套口径)
|
|
||||||
* 成功 data: { card_id, follow_up_done: 1 }
|
|
||||||
* 失败 message 直接 toast: 卡片不存在 / 只能标记自己名下的名片
|
|
||||||
* @param {Object} p { data: { card_id }, success, fail }
|
|
||||||
*/
|
|
||||||
cardFollowDone(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_FOLLOW_DONE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 访客轨迹(2.11.4 谁看了我)
|
|
||||||
* 入参(全部可选): { data: { card_id?, page?, page_size?(<=50, 建议20) } }
|
|
||||||
* 不传 card_id = 当前员工名下全部名片; 成功 data: { list, total, page, page_size,
|
|
||||||
* summary: { today_pv, today_uv, total_pv, total_uv } }
|
|
||||||
* @param {Object} p { data: {...}, success, fail }
|
|
||||||
*/
|
|
||||||
cardViews(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ================= 人脉库 ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 人脉列表
|
|
||||||
* @param {Object} p { page, page_size(<=100), keyword, source?, status?, success, fail }
|
|
||||||
* 返回 data: { list, count, page_count }
|
|
||||||
*/
|
|
||||||
contactLists(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_LISTS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新增人脉
|
|
||||||
* @param {Object} p { fields: { name(必填), company?, ... }, success, fail }
|
|
||||||
*/
|
|
||||||
contactAdd(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_ADD }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 人脉详情(编辑页回填;id 为数字自增主键,注意与名片 card_id 字符串不同)
|
|
||||||
* @param {Object} p { id, success, fail }
|
|
||||||
*/
|
|
||||||
contactInfo(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_INFO }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新人脉(兼容 fields 对象或顶层平铺字段)
|
|
||||||
* @param {Object} p { id, fields?, ...平铺字段, success, fail }
|
|
||||||
*/
|
|
||||||
contactUpdate(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_UPDATE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除人脉(真删不可恢复,前端需二次确认弹窗)
|
|
||||||
* @param {Object} p { id, success, fail }
|
|
||||||
*/
|
|
||||||
contactDelete(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_DELETE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 人脉 → 潜在客户(不删人脉行;同员工同手机号已转过则报错)
|
|
||||||
* @param {Object} p { id, success, fail }
|
|
||||||
*/
|
|
||||||
contactToLead(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_TO_LEAD }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ================= 潜在客户 ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 线索列表(自己名下名片收到的线索 + 自己从人脉转入的线索)
|
|
||||||
* @param {Object} p { page, page_size(<=100), status?(0-3), keyword, success, fail }
|
|
||||||
* 返回 data: { list, count, page_count }
|
|
||||||
*/
|
|
||||||
leadLists(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_LISTS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新跟进状态(status 只能是 0/1/2/3)
|
|
||||||
* @param {Object} p { id, status, success, fail }
|
|
||||||
* 返回 data: { id, status }
|
|
||||||
*/
|
|
||||||
leadSetStatus(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_SET_STATUS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ================= 交换名片 ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 待处理交换请求列表(返回 status=0;已同意/已忽略员工端不可见)
|
|
||||||
* @param {Object} p { page, page_size(<=100), success, fail }
|
|
||||||
* 返回 data: { list:[{ id, from_card_id, name, avatar, job_title, company,
|
|
||||||
* create_time, create_time_txt, status }], count, page_count }
|
|
||||||
* ⚠️ 详情页红点计数技巧: lists?page=1&page_size=1 取 count 即可
|
|
||||||
* ⚠️ 后端契约要求 lists 走 GET(分页在 query); 而 Http.sendRequest 是
|
|
||||||
* 「带 data 就发 POST」的语义, 所以这里把分页拼进 url、不传 data,
|
|
||||||
* 否则会被发成 POST 导致接口不匹配、列表恒为空。
|
|
||||||
*/
|
|
||||||
exchangeLists(p) {
|
|
||||||
const opt = Object.assign({}, p || {});
|
|
||||||
const src = opt.data || opt;
|
|
||||||
const page = src.page || 1;
|
|
||||||
const pageSize = src.page_size || 20;
|
|
||||||
delete opt.data;
|
|
||||||
delete opt.page;
|
|
||||||
delete opt.page_size;
|
|
||||||
opt.url = CARD_API_URL.EXCHANGE_LISTS + '?page=' + page + '&page_size=' + pageSize;
|
|
||||||
Http.sendRequest(opt);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同意交换(后端自动双方建人脉,前端无需再调 contact/add)
|
|
||||||
* 幂等: 同侧已存在同手机号人脉时 receiver_created=0,仍是成功,不当失败
|
|
||||||
* @param {Object} p { data: { id }, success, fail }
|
|
||||||
* 返回 data: { id, status:1, receiver_created, initiator_created }
|
|
||||||
*/
|
|
||||||
exchangeAgree(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_AGREE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 忽略交换(仅本方隐藏 status=2,对方不发通知、可再次发起)
|
|
||||||
* @param {Object} p { data: { id }, success, fail }
|
|
||||||
* 返回 data: { id, status:2 }
|
|
||||||
*/
|
|
||||||
exchangeIgnore(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_IGNORE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同意交换后「校验 + 兜底」建人脉(页面点「同意」后必调;幂等,可安全重复调用)。
|
|
||||||
* 背景: 后端声明 CardExchange::agree 会双向互存人脉(我方人脉收对方名片 / 对方人脉收我方名片),
|
|
||||||
* 但实测出现「同意成功、对方名片却没进我的人脉」, 故前端不再只信 receiver_created,
|
|
||||||
* 统一在同意后按「对方名片编号」校验一次: 我的人脉里没有同手机号记录才补建。
|
|
||||||
* 流程: 取对方名片详情 → 按手机号查我的人脉 → 不存在则以 source=exchange 新建人脉。
|
|
||||||
* 幂等: 同手机号人脉已存在(含后端已建)时 created=0, 与后台已建不会重复。
|
|
||||||
* @param {Object} p { fromCardId, success, fail }
|
|
||||||
* 回调 done(data): { code, created, existed } created=1 新建 / 0 已存在或无需建;
|
|
||||||
* existed = 命中的同手机号人脉行(未命中为 null), 供页面提示「人脉中已有该联系人」。
|
|
||||||
*/
|
|
||||||
ensureExchangeContact(p) {
|
|
||||||
const self = this;
|
|
||||||
const done = p.success, fail = p.fail;
|
|
||||||
const finish = (d) => { typeof done === 'function' && done(d); };
|
|
||||||
const fromCardId = p.fromCardId;
|
|
||||||
if (!fromCardId) { finish({ code: 0, created: 0 }); return; }
|
|
||||||
this.cardDetail({
|
|
||||||
data: { card_id: fromCardId },
|
|
||||||
success: res => {
|
|
||||||
if (res.code != 0) { typeof fail === 'function' && fail(res); return; }
|
|
||||||
const r = (res.data && (res.data.relation || res.data)) || {};
|
|
||||||
const mobile = r.mobile || '';
|
|
||||||
const fields = {
|
|
||||||
name: r.name || '',
|
|
||||||
company: r.company || '',
|
|
||||||
job_title: r.job_title || '',
|
|
||||||
mobile: mobile,
|
|
||||||
source: 'exchange'
|
|
||||||
};
|
|
||||||
// 富化字段仅在对方有名片值时带上(空串=清空语义, 新建人脉时不传空键)
|
|
||||||
if (r.wechat) fields.wechat = r.wechat;
|
|
||||||
if (r.email) fields.email = r.email;
|
|
||||||
if (r.website) fields.website = r.website;
|
|
||||||
if (r.address) fields.address = r.address;
|
|
||||||
const add = () => self.contactAdd({
|
|
||||||
data: { source: 'exchange', fields: fields },
|
|
||||||
success: res2 => {
|
|
||||||
// code!=0(如手机号已存在被拒): 交换已同意成功, 兜底不打扰用户, 按无需新建收口
|
|
||||||
const ok = res2 && res2.code == 0;
|
|
||||||
finish({ code: ok ? 0 : ((res2 && res2.code) || 0), created: ok ? 1 : 0, raw: res2 });
|
|
||||||
},
|
|
||||||
fail: err => { typeof fail === 'function' && fail(err); }
|
|
||||||
});
|
|
||||||
// 按手机号去重: 已存在同手机号人脉则不重复建(兼容后台已建 / 重复同意)
|
|
||||||
if (mobile) {
|
|
||||||
self.contactLists({
|
|
||||||
data: { page: 1, page_size: 20, keyword: mobile },
|
|
||||||
success: res3 => {
|
|
||||||
const d3 = res3.data || {};
|
|
||||||
const list = Array.isArray(d3) ? d3 : (d3.list || []);
|
|
||||||
const hit = list.find(c => (c.mobile || '') === mobile);
|
|
||||||
// 命中: 对方已在我的人脉里(可能显示为旧姓名), 不覆盖用户维护过的数据, 只回传命中行
|
|
||||||
if (hit) { finish({ code: 0, created: 0, existed: hit }); return; }
|
|
||||||
add();
|
|
||||||
},
|
|
||||||
fail: () => add()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
add();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: err => { typeof fail === 'function' && fail(err); }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ================= 员工建卡申请 2.11.15 ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交建卡申请(空态申请 → 商家审批 → 自动建卡归属)
|
|
||||||
* 约束: 同一会员同时只允许一条待审申请(重复提交后端会拒)
|
|
||||||
* 入参: { data: { name, mobile } }
|
|
||||||
* @param {Object} p { data: { name, mobile }, success, fail }
|
|
||||||
*/
|
|
||||||
applySubmit(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_SUBMIT }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询我的建卡申请状态(进入名片夹/空态时调用或轮询)
|
|
||||||
* 成功 data: { status: 0 待审/1 已同意/2 已拒绝, status_text, note, has_card }
|
|
||||||
* @param {Object} p { success, fail }
|
|
||||||
*/
|
|
||||||
applyStatus(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_STATUS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ================= 名片设计(员工端可选样式) ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取名片预设列表(与商家后台 CardTemplatePresets 同一份, 同源)
|
|
||||||
* 成功 data: { list:[{ key, name, desc, thumb,
|
|
||||||
* design:{ theme_color, text_color, bg_color, layout, cover, font_family,
|
|
||||||
* font_scale, card_radius, header_gradient_end, header_gradient_angle, quick_style } }] }
|
|
||||||
* @param {Object} p { success, fail }
|
|
||||||
*/
|
|
||||||
cardDesignPresets(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DESIGN_PRESETS }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用预设 / 保存本卡样式(员工端选择后台预设后落库)
|
|
||||||
* 入参(包在 data 里): { card_id, preset_key } —— 后端按 preset_key 取 CardTemplatePresets
|
|
||||||
* 的 design 合并进本卡 lucky_card_design(非空覆盖), 返回 { code:0 }
|
|
||||||
* @param {Object} p { data: { card_id, preset_key }, success, fail }
|
|
||||||
*/
|
|
||||||
cardDesignSave(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_DESIGN_SAVE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ================= 企业名片 2.13.6 ================= */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 企业名片详情(核心)
|
|
||||||
* GET ?card_id= 不传返回当前生效企业名片;传候选卡 id 可预览(is_active=0)
|
|
||||||
* 成功 data: { card_id, is_active, is_owner, fields:{name,company,job_title,mobile,email,
|
|
||||||
* website,address,wechat,avatar,bio,name_en,company_en,job_title_en,bio_en},
|
|
||||||
* design:{theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id},
|
|
||||||
* header:{mode,official_card_id,official,self,staff} }
|
|
||||||
* 渲染: 名片区按 header.mode 取 official/self/staff 数据, 内容区用 fields+design.canvas
|
|
||||||
* 失败「企业名片不存在」→ 前端空态引导去管理端创建
|
|
||||||
* @param {Object} p { data: { card_id? }, success, fail }
|
|
||||||
*/
|
|
||||||
enterpriseDetail(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_DETAIL }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存企业名片(后台/会员;owner_id=0 的卡会员不可改)
|
|
||||||
* @param {Object} p { data: { card_id?, fields?, design?, header_mode?, official_card_id? }, success, fail }
|
|
||||||
*/
|
|
||||||
enterpriseSave(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_SAVE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 我的企业名片列表(自己名下)
|
|
||||||
* 成功 data: { list: [{ card_id, name, is_active }] }
|
|
||||||
* @param {Object} p { success, fail }
|
|
||||||
*/
|
|
||||||
enterpriseMine(p) {
|
|
||||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_MINE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换生效企业名片(互斥:全店仅 1 张 is_active=1,仅能切自己名下的卡)
|
|
||||||
* @param {Object} p { data: { card_id }, success, fail }
|
|
||||||
*/
|
|
||||||
enterpriseActivate(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_ACTIVATE }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 员工自选展示名片(self 模式):card_id 必须是自己名下的 contact 卡
|
|
||||||
* 选择按会员存储;detail 的 header.self.chosen=1 表示已自选
|
|
||||||
* @param {Object} p { data: { card_id }, success, fail }
|
|
||||||
*/
|
|
||||||
enterpriseChooseSelf(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_CHOOSE_SELF }, p || {}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除企业名片(会员端;生效卡不可删,删除候选卡会清理官方名片引用)
|
|
||||||
* @param {Object} p { data: { card_id }, success, fail }
|
|
||||||
*/
|
|
||||||
enterpriseDelete(p) {
|
|
||||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_DELETE }, p || {}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CardApi;
|
|
||||||
@@ -63,7 +63,7 @@ if (process?.env?.NODE_ENV === 'development') {
|
|||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
/**
|
/**
|
||||||
@@ -80,10 +80,6 @@ export default {
|
|||||||
//api请求地址
|
//api请求地址
|
||||||
baseUrl: domain,
|
baseUrl: domain,
|
||||||
|
|
||||||
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
|
|
||||||
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
|
|
||||||
aikefuConfigUuid: aikefuConfigUuid,
|
|
||||||
|
|
||||||
// 图片域名
|
// 图片域名
|
||||||
imgDomain: domain,
|
imgDomain: domain,
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import IMAGE_CONFIG from './image-config.js';
|
import IMAGE_CONFIG from './image-config.js';
|
||||||
import Platform from './platform.js';
|
import Platform from './platform.js';
|
||||||
import PAGE_KEY_MAP from './util.js';
|
|
||||||
|
|
||||||
export class CustomerService {
|
export class CustomerService {
|
||||||
constructor(vueInstance, externalConfig = null) {
|
constructor(vueInstance, externalConfig = null) {
|
||||||
@@ -119,16 +118,6 @@ export class CustomerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到 AI智能客服页面(AiChat)
|
* 跳转到 AI智能客服页面(AiChat)
|
||||||
*
|
|
||||||
* 注意:不在此处注入 __dyu=FloatBtn。
|
|
||||||
* 该标记原本是 diy-float-btn.vue 用来告诉 ai-chat "我是从浮动按钮来的,请隐藏底部 tabBar"。
|
|
||||||
* 此方法是 customer-service.js 的**通用 AI 客服入口**,被多处复用:
|
|
||||||
* - hover-nav(悬浮客服按钮)
|
|
||||||
* - 商品详情页 / 会员中心 / 个人中心 等的"联系客服"按钮
|
|
||||||
* - DIY 浮动按钮(diy-float-btn 内部已自行注入 __dyu=FloatBtn,这里再注入会重复)
|
|
||||||
* 若在此处统一注入,会让所有 AI 客服入口都隐藏底部 tabBar,语义错误且侵入性过强。
|
|
||||||
* hover-nav 需要隐藏 tabBar 的场景,由 hover-nav.vue#handleUnifiedKefuClick
|
|
||||||
* 自行注入 __dyu=FloatBtn(与 diy-float-btn.vue#onFloatBtnClick 一致)。
|
|
||||||
*/
|
*/
|
||||||
openAiChatService() {
|
openAiChatService() {
|
||||||
try {
|
try {
|
||||||
@@ -138,7 +127,7 @@ export class CustomerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
||||||
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||||
|
|
||||||
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
@@ -562,9 +551,6 @@ export class CustomerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开 AI 智能助手
|
* 打开 AI 智能助手
|
||||||
*
|
|
||||||
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
|
|
||||||
* 由调用方(hover-nav.vue 等)按需注入。
|
|
||||||
*/
|
*/
|
||||||
openAIChat() {
|
openAIChat() {
|
||||||
try {
|
try {
|
||||||
@@ -572,7 +558,7 @@ export class CustomerService {
|
|||||||
this.vm.setAiUnreadCount(0);
|
this.vm.setAiUnreadCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
|
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: aiChatUrl,
|
url: aiChatUrl,
|
||||||
|
|||||||
273
common/js/diy.js
273
common/js/diy.js
@@ -1,8 +1,6 @@
|
|||||||
import WxMap from 'common/js/map-wx-jssdk.js';
|
import WxMap from 'common/js/map-wx-jssdk.js';
|
||||||
import Config from '@/common/js/config.js';
|
import Config from '@/common/js/config.js';
|
||||||
import util from '@/common/js/util.js';
|
import util from '@/common/js/util.js';
|
||||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
|
||||||
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
|
|
||||||
|
|
||||||
let systemInfo = util.getDeviceInfo();
|
let systemInfo = util.getDeviceInfo();
|
||||||
|
|
||||||
@@ -53,15 +51,10 @@ export default {
|
|||||||
lastDiyInfoTime: 0, // 上次成功加载的时间戳
|
lastDiyInfoTime: 0, // 上次成功加载的时间戳
|
||||||
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
|
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
|
||||||
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
|
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
|
||||||
|
|
||||||
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
|
|
||||||
diyLoadError: false,
|
|
||||||
diyLoadErrorMsg: '',
|
|
||||||
diyLoadErrorUrl: '', // 出错的网络请求地址
|
|
||||||
|
|
||||||
//启动广告
|
//启动广告
|
||||||
adv: {},
|
adv:{},
|
||||||
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onLoad(option) {
|
onLoad(option) {
|
||||||
@@ -124,10 +117,10 @@ export default {
|
|||||||
// 关闭定位
|
// 关闭定位
|
||||||
this.enterDefaultStore();
|
this.enterDefaultStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
@@ -212,12 +205,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
play() {
|
play(){
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
|
|
||||||
// 定位信息过期后,重新获取定位
|
// 定位信息过期后,重新获取定位
|
||||||
if (this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
|
if(this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
|
||||||
this.$util.getLocation({
|
this.$util.getLocation({
|
||||||
fail: (res) => {
|
fail: (res) => {
|
||||||
// 拒绝定位,进入默认总店
|
// 拒绝定位,进入默认总店
|
||||||
@@ -233,7 +226,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
|
|
||||||
await this.getDiyInfo();
|
await this.getDiyInfo();
|
||||||
//获取启动广告
|
//获取启动广告
|
||||||
await this.getDiyAdv();
|
await this.getDiyAdv();
|
||||||
@@ -291,159 +283,50 @@ export default {
|
|||||||
}, 50);
|
}, 50);
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
async getDiyAdv() {
|
async getDiyAdv(){
|
||||||
//启动广告
|
//启动广告
|
||||||
let res = await this.$api.sendRequest({
|
let res = await this.$api.sendRequest({
|
||||||
url: '/api/diyview/getstartadv',
|
url: '/api/diyview/getstartadv',
|
||||||
data: {},
|
data: {},
|
||||||
async: false
|
async: false
|
||||||
});
|
});
|
||||||
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
|
this.adv = res.value
|
||||||
// 避免取到 undefined 后模板 v-if 抛错导致 uni-popup 不渲染、真机广告弹不出来
|
// 弹框形式,首次弹出 1,每次弹出 0
|
||||||
let advData = (res && (res.data != null ? res.data : res.value)) || {};
|
if(this.adv.advshow == 1){
|
||||||
this.adv = advData;
|
setTimeout(() => {
|
||||||
// 后台配置: advshow=1 开启启动广告; advtype 决定弹出策略
|
if (res.value.advtype == 1) {
|
||||||
// advtype == 1: 首次弹出 —— 仅第一次进入小程序弹出(持久化标记, 清缓存后可再弹)
|
|
||||||
// advtype == 0: 每次弹出 —— 每次"重新进入小程序"都弹出(不含 Tab 切换回首页)
|
|
||||||
if (this.adv.advshow == 1) {
|
|
||||||
setTimeout(async () => {
|
|
||||||
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
|
|
||||||
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
|
|
||||||
await this._waitPrivacyResolved();
|
|
||||||
if (!this.$refs.uniPopupWindow) return;
|
|
||||||
|
|
||||||
// 先判定本次是否需要弹出, 避免无谓的图片预加载
|
|
||||||
let shouldOpen = false;
|
|
||||||
let isFirstType = false;
|
|
||||||
if (this.adv.advtype == 1) {
|
|
||||||
// 首次弹出: 从未展示过才弹
|
|
||||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||||
if (popwindow_count == '' || popwindow_count == 0) {
|
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
||||||
shouldOpen = true;
|
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
||||||
isFirstType = true;
|
|
||||||
|
this.$refs.uniPopupWindow.open();
|
||||||
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (res.value.advtype == 0) {
|
||||||
// 每次弹出: 冷启动首次 或 真正"重新进入小程序"(App 前后台切换)时弹出,
|
this.$refs.uniPopupWindow.open();
|
||||||
// 由 App.onShow 在重新进入时置位的信号驱动, 切 Tab 不会触发 App.onShow 故不弹
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||||
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);
|
}, 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 模式永远不关)
|
|
||||||
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
|
|
||||||
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
|
|
||||||
// @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business'
|
|
||||||
_showDiyLoadError(err, url, errorType) {
|
|
||||||
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
|
|
||||||
this.diyLoadErrorUrl = url || '';
|
|
||||||
this.diyLoadError = true;
|
|
||||||
// 通知 splash: diy 加载失败, 启动屏应该让位给错误横幅
|
|
||||||
// 用 uni.$emit 而非直接 import splash-state, 避免循环依赖
|
|
||||||
// reason 拼成 'diy-{errorType}-error' 方便业务方区分 (splash:diy-error 事件)
|
|
||||||
const reason = errorType ? `diy-${errorType}-error` : 'diy-load-error'
|
|
||||||
try { uni.$emit && uni.$emit('diy:load-error', { reason, errorType, err, url }); } catch (e) { }
|
|
||||||
},
|
|
||||||
// 根据错误信息组织更详细的提示文案
|
|
||||||
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
|
|
||||||
_buildDiyErrorMsg(err) {
|
|
||||||
const info = classifyHttpError(err);
|
|
||||||
|
|
||||||
// 服务端明确返回的业务消息(字符串):直接透传
|
|
||||||
if (info.type === 'business') return info.raw;
|
|
||||||
|
|
||||||
const key = httpErrorKey(info.type);
|
|
||||||
let reason = this.$lang(key);
|
|
||||||
|
|
||||||
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
|
|
||||||
if (info.statusCode) reason += '(' + info.statusCode + ')';
|
|
||||||
|
|
||||||
// empty / unknown 不走模板,直接返回通用网络异常
|
|
||||||
if (info.type === 'empty' || info.type === 'unknown') return reason;
|
|
||||||
|
|
||||||
const tpl = this.$lang('diy.loadFailTpl', { reason });
|
|
||||||
return tpl;
|
|
||||||
},
|
|
||||||
// 错误横幅「重试」入口
|
|
||||||
retryDiyInfo() {
|
|
||||||
this.diyLoadError = false;
|
|
||||||
this.diyLoadErrorMsg = '';
|
|
||||||
this.diyLoadErrorUrl = '';
|
|
||||||
this.getDiyInfo(true);
|
|
||||||
},
|
},
|
||||||
async getDiyInfo(force = false) {
|
async getDiyInfo(force = false) {
|
||||||
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
|
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentLang = uni.getStorageSync("lang") || '';
|
const currentLang = uni.getStorageSync("lang") || '';
|
||||||
console.log('[DIY][getDiyInfo] start', 'force=', force, 'id=', this.id, 'name=', this.name, 'lang=', currentLang, 'loading=', this.diyInfoLoading, 'lastAt=', this.lastDiyInfoTime, 'lastLang=', this.lastDiyInfoLang);
|
|
||||||
if (!force) {
|
if (!force) {
|
||||||
if (this.diyInfoLoading) {
|
if (this.diyInfoLoading) return;
|
||||||
console.log('[DIY][getDiyInfo] skip: already loading');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
|
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
|
||||||
&& this.lastDiyInfoLang === currentLang) {
|
&& this.lastDiyInfoLang === currentLang) {
|
||||||
console.log('[DIY][getDiyInfo] skip: throttled by ttl');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.diyInfoLoading = true;
|
this.diyInfoLoading = true;
|
||||||
let res;
|
let res;
|
||||||
// 出错时用于展示的完整请求地址(Request URL),与 http.js 拼接/归一化方式保持一致
|
|
||||||
const diyInfoUrl = '/api/diyview/info';
|
|
||||||
const diyInfoFullUrl = ((Config.baseUrl || '') + diyInfoUrl).replace(/(?<!:)\/+/g, '/');
|
|
||||||
try {
|
try {
|
||||||
res = await this.$api.sendRequest({
|
res = await this.$api.sendRequest({
|
||||||
url: diyInfoUrl,
|
url: '/api/diyview/info',
|
||||||
data: {
|
data: {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
@@ -452,30 +335,19 @@ export default {
|
|||||||
async: false
|
async: false
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[DIY][getDiyInfo] request failed', e);
|
console.error('getDiyInfo request failed', e);
|
||||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
|
this.$util.showToast({
|
||||||
|
title: this.$lang('diy.networkError')
|
||||||
|
});
|
||||||
this.diyInfoLoading = false;
|
this.diyInfoLoading = false;
|
||||||
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
|
|
||||||
// errorType='network': 网络异常 (uni.request catch)
|
|
||||||
this._showDiyLoadError(e, diyInfoFullUrl, 'network');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.diyInfoLoading = false;
|
this.diyInfoLoading = false;
|
||||||
console.log('[DIY][getDiyInfo] got response', 'code=', res && res.code, 'hasData=', !!(res && res.data), 'valueType=', res && res.data && typeof res.data.value, 'valueLen=', res && res.data && (typeof res.data.value === 'string' ? res.data.value.length : (res.data.value ? res.data.value.length : null)));
|
|
||||||
|
|
||||||
if (!res || res.code != 0 || !res.data) {
|
if (!res || res.code != 0 || !res.data) {
|
||||||
console.warn('[DIY][getDiyInfo] invalid response', JSON.stringify(res));
|
|
||||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
|
|
||||||
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
|
|
||||||
if (!res) {
|
|
||||||
// errorType='no-response': 完全没有 res 对象
|
|
||||||
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
|
|
||||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
|
||||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res && res.code == -3) {
|
if (res && res.code == -3) {
|
||||||
this.$util.showToast({
|
this.$util.showToast({
|
||||||
title: res.message
|
title: res.message
|
||||||
@@ -485,10 +357,9 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
|
this.$util.showToast({
|
||||||
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
|
title: this.$lang('diy.noDiyData')
|
||||||
// errorType='business': 业务失败 (code!=0 / 消息提示)
|
});
|
||||||
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
|
|
||||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||||
return;
|
return;
|
||||||
@@ -496,20 +367,10 @@ export default {
|
|||||||
|
|
||||||
this.lastDiyInfoTime = now;
|
this.lastDiyInfoTime = now;
|
||||||
this.lastDiyInfoLang = currentLang;
|
this.lastDiyInfoLang = currentLang;
|
||||||
// 成功加载后清除错误提示
|
|
||||||
this.diyLoadError = false;
|
|
||||||
this.diyLoadErrorMsg = '';
|
|
||||||
this.diyLoadErrorUrl = '';
|
|
||||||
|
|
||||||
let diyDataValue = res.data;
|
let diyDataValue = res.data;
|
||||||
if (diyDataValue.value) {
|
if (diyDataValue.value) {
|
||||||
try {
|
this.diyData = JSON.parse(diyDataValue.value);
|
||||||
this.diyData = JSON.parse(diyDataValue.value);
|
|
||||||
} catch (parseErr) {
|
|
||||||
console.error('[DIY][getDiyInfo] JSON.parse(value) failed', parseErr, 'value preview=', String(diyDataValue.value).slice(0, 200));
|
|
||||||
throw parseErr;
|
|
||||||
}
|
|
||||||
console.log('[DIY][getDiyInfo] parsed diyData', 'globalKeys=', this.diyData && this.diyData.global ? Object.keys(this.diyData.global) : null, 'valueCount=', this.diyData && this.diyData.value && this.diyData.value.length, 'topIndexValue before=', this.topIndexValue);
|
|
||||||
|
|
||||||
// 导航栏标题要根据语言环境切换
|
// 导航栏标题要根据语言环境切换
|
||||||
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
|
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
|
||||||
@@ -518,22 +379,22 @@ export default {
|
|||||||
|
|
||||||
this.mpCollect = this.diyData.global.mpCollect;
|
this.mpCollect = this.diyData.global.mpCollect;
|
||||||
this.setPublicShare();
|
this.setPublicShare();
|
||||||
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
|
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
|
||||||
// 弹框形式,首次弹出 1,每次弹出 0
|
// 弹框形式,首次弹出 1,每次弹出 0
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.diyData.global.popWindow.count == 1) {
|
if (this.diyData.global.popWindow.count == 1) {
|
||||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||||
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
||||||
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
||||||
this.$refs.uniPopupWindow.open();
|
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
|
||||||
}
|
|
||||||
} else if (this.diyData.global.popWindow.count == 0) {
|
|
||||||
this.$refs.uniPopupWindow.open();
|
this.$refs.uniPopupWindow.open();
|
||||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||||
}
|
}
|
||||||
}, 500);
|
} else if (this.diyData.global.popWindow.count == 0) {
|
||||||
}*/
|
this.$refs.uniPopupWindow.open();
|
||||||
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}*/
|
||||||
|
|
||||||
// 修改diy数据结构排序
|
// 修改diy数据结构排序
|
||||||
let searchIndex = -1;
|
let searchIndex = -1;
|
||||||
@@ -597,51 +458,11 @@ export default {
|
|||||||
this.openBottomNav = this.diyData.global.openBottomNav;
|
this.openBottomNav = this.diyData.global.openBottomNav;
|
||||||
}
|
}
|
||||||
this.isShowCopyRight = true;
|
this.isShowCopyRight = true;
|
||||||
console.log('[DIY][getDiyInfo] done', 'topIndexValue after=', this.topIndexValue && this.topIndexValue.componentName, 'valueCount=', this.diyData.value.length, 'openBottomNav=', this.openBottomNav, 'isShowCopyRight=', this.isShowCopyRight);
|
|
||||||
} else {
|
|
||||||
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* 预加载启动广告图片
|
|
||||||
* 广告弹窗由 uni-popup 在 open() 时才用 v-if 渲染内容, 图片是网络图,
|
|
||||||
* 若等到弹窗打开后才开始加载, 真机网络慢时会出现"一截一截"逐步显示。
|
|
||||||
* 这里提前把图片下载进本地缓存, 等全部加载完成(或超时)后再弹出,
|
|
||||||
* 使弹窗一次性完整呈现。
|
|
||||||
* @param {Array} list - adv.list, 元素形如 { imageUrl, link }
|
|
||||||
* @param {number} [timeout=4000] - 单张兜底超时, 避免个别图片卡住导致广告永不弹出
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
_preloadAdvImages(list, timeout = 4000) {
|
|
||||||
if (!list || !list.length) return Promise.resolve();
|
|
||||||
const tasks = list.map((item) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const url = (item && item.imageUrl) ? this.$util.img(item.imageUrl) : '';
|
|
||||||
if (!url) return resolve();
|
|
||||||
let done = false;
|
|
||||||
const finish = () => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
const timer = setTimeout(finish, timeout);
|
|
||||||
try {
|
|
||||||
// getImageInfo 会下载图片并写入本地缓存, 之后 image 组件可直接命中缓存
|
|
||||||
uni.getImageInfo({
|
|
||||||
src: url,
|
|
||||||
success: finish,
|
|
||||||
fail: finish
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return Promise.all(tasks).then(() => {});
|
|
||||||
},
|
|
||||||
closePopupWindow() {
|
closePopupWindow() {
|
||||||
this.$refs.uniPopupWindow.close();
|
this.$refs.uniPopupWindow.close();
|
||||||
|
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
|
||||||
},
|
},
|
||||||
closeCollectPopupWindow() {
|
closeCollectPopupWindow() {
|
||||||
this.$refs.collectPopupWindow.close();
|
this.$refs.collectPopupWindow.close();
|
||||||
|
|||||||
@@ -68,10 +68,6 @@ export default {
|
|||||||
|
|
||||||
hover_nav_bg_color: '#FFFC',
|
hover_nav_bg_color: '#FFFC',
|
||||||
hover_nav_text_color: '#000',
|
hover_nav_text_color: '#000',
|
||||||
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
|
||||||
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
|
||||||
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
|
||||||
hover_nav_phone_font_size: '36rpx', // 电话按钮内图标字号基准
|
|
||||||
|
|
||||||
// ===== DIY 分类页字号 token =====
|
// ===== DIY 分类页字号 token =====
|
||||||
diy_category_tree_select_size: '28rpx',
|
diy_category_tree_select_size: '28rpx',
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class EventSafety {
|
|||||||
// 安全的方法
|
// 安全的方法
|
||||||
matches: (selector) => EventSafety.safeMatches(target, selector),
|
matches: (selector) => EventSafety.safeMatches(target, selector),
|
||||||
// 数据集
|
// 数据集
|
||||||
dataset: target?.dataset || {}
|
dataset: target.dataset || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return safeTarget
|
return safeTarget
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* 浮动侧边栏配置消费 (字段来源: /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); // 老行为兜底
|
|
||||||
}
|
|
||||||
@@ -80,126 +80,113 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
// 处理商品详情数据
|
// 处理商品详情数据
|
||||||
async handleGoodsSkuData() {
|
async handleGoodsSkuData() {
|
||||||
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
|
// this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
|
||||||
try {
|
//设置标题
|
||||||
if (this.goodsSkuDetail.config) {
|
// this.$langConfig.title(this.navbarData.title);
|
||||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
if (this.goodsSkuDetail.config) {
|
||||||
|
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||||
|
|
||||||
|
this.modifyGoodsInfo();
|
||||||
|
|
||||||
|
// 初始化商品详情视图数据
|
||||||
|
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
|
||||||
|
sku_id: this.skuId,
|
||||||
|
goods_id: this.goodsSkuDetail.goods_id,
|
||||||
|
preview: this.preview,
|
||||||
|
source_member: this.source_member,
|
||||||
|
posterParams: this.posterParams,
|
||||||
|
posterApi: this.posterApi,
|
||||||
|
shareUrl: this.shareUrl,
|
||||||
|
goodsRoute: this.goodsRoute,
|
||||||
|
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||||
|
deliveryType: this.goodsSkuDetail.express_type,
|
||||||
|
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||||
|
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||||
|
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||||
|
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||||
|
});
|
||||||
|
|
||||||
|
//媒体
|
||||||
|
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||||
|
|
||||||
|
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||||
|
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||||
|
else this.goodsSkuDetail.sku_images = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多规格时合并主图
|
||||||
|
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||||
|
|
||||||
|
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||||
|
|
||||||
|
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxHeight = '';
|
||||||
|
let systemInfo = uni.getSystemInfoSync();
|
||||||
|
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||||
|
if (typeof item.pic_spec == "string")
|
||||||
|
item.pic_spec = item.pic_spec.split('*');
|
||||||
|
|
||||||
|
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||||
|
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||||
|
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||||
|
|
||||||
|
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||||
|
maxHeight = item.pic_spec[1];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||||
|
|
||||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||||
|
|
||||||
this.modifyGoodsInfo();
|
// 当前商品SKU规格
|
||||||
|
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||||
|
|
||||||
// 初始化商品详情视图数据
|
// 商品属性
|
||||||
try {
|
if (this.goodsSkuDetail.goods_attr_format) {
|
||||||
if (this.$refs && this.$refs.goodsDetailView) {
|
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||||
this.$refs.goodsDetailView.init({
|
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||||
sku_id: this.skuId,
|
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||||
goods_id: this.goodsSkuDetail.goods_id,
|
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||||
preview: this.preview,
|
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||||
source_member: this.source_member,
|
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||||
posterParams: this.posterParams,
|
|
||||||
posterApi: this.posterApi,
|
|
||||||
shareUrl: this.shareUrl,
|
|
||||||
goodsRoute: this.goodsRoute,
|
|
||||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
|
||||||
deliveryType: this.goodsSkuDetail.express_type,
|
|
||||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
|
||||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
|
||||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
|
||||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('goodsDetailView.init failed:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
//媒体
|
|
||||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
|
||||||
|
|
||||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
|
||||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
|
||||||
else this.goodsSkuDetail.sku_images = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多规格时合并主图
|
|
||||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
|
||||||
|
|
||||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
|
||||||
|
|
||||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxHeight = '';
|
|
||||||
let systemInfo = uni.getSystemInfoSync();
|
|
||||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
|
||||||
if (typeof item.pic_spec == "string")
|
|
||||||
item.pic_spec = item.pic_spec.split('*');
|
|
||||||
|
|
||||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
|
||||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
|
||||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
|
||||||
|
|
||||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
|
||||||
maxHeight = item.pic_spec[1];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
|
||||||
|
|
||||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
|
||||||
|
|
||||||
// 当前商品SKU规格
|
|
||||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
|
||||||
|
|
||||||
// 商品属性
|
|
||||||
if (this.goodsSkuDetail.goods_attr_format) {
|
|
||||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
|
||||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
|
||||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
|
||||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
|
||||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
|
||||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 商品SKU格式
|
// 商品SKU格式
|
||||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||||
|
|
||||||
// 商品详情
|
// 商品详情
|
||||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||||
|
|
||||||
//商品服务
|
//商品服务
|
||||||
if (this.goodsSkuDetail.goods_service) {
|
if (this.goodsSkuDetail.goods_service) {
|
||||||
for (let i in this.goodsSkuDetail.goods_service) {
|
for (let i in this.goodsSkuDetail.goods_service) {
|
||||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.contactData = {
|
this.contactData = {
|
||||||
title: this.goodsSkuDetail.sku_name,
|
title: this.goodsSkuDetail.sku_name,
|
||||||
path: this.shareUrl,
|
path: this.shareUrl,
|
||||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||||
size: 'big'
|
size: 'big'
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
try {
|
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||||
if (this.$refs && this.$refs.goodsPromotion) {
|
|
||||||
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
|
|
||||||
|
|
||||||
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
|
if (this.goodsRoute != '/pages_goods/detail') this.setPublicShare();
|
||||||
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
|
|
||||||
try {
|
// this.getBarrageData();
|
||||||
this._goodsFormPromise = this.getGoodsForm();
|
if (this.addonIsExist.form) {
|
||||||
} catch (e) {
|
await this.getGoodsForm();
|
||||||
console.error('getGoodsForm kicked off failed:', e);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('handleGoodsSkuData failed:', e);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -340,35 +327,13 @@ export default {
|
|||||||
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
|
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
|
||||||
*/
|
*/
|
||||||
async getGoodsForm() {
|
async getGoodsForm() {
|
||||||
// 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
|
const res = await this.$api.sendRequest({
|
||||||
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
|
url: "/form/api/form/goodsform",
|
||||||
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
|
data: {
|
||||||
const emptyFn = () => {
|
goods_id: this.goodsSkuDetail.goods_id
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
},
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
async: false
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
});
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
|
||||||
};
|
|
||||||
emptyFn();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = await this.$api.sendRequest({
|
|
||||||
url: "/form/api/form/goodsform",
|
|
||||||
data: {
|
|
||||||
goods_id: this.goodsSkuDetail.goods_id,
|
|
||||||
format: 'object'
|
|
||||||
},
|
|
||||||
async: false
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('getGoodsForm request failed', e);
|
|
||||||
res = null;
|
|
||||||
}
|
|
||||||
const empty = () => {
|
const empty = () => {
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* 通用网络/接口错误分类器
|
|
||||||
*
|
|
||||||
* 适用场景:
|
|
||||||
* - uniapp 各端统一错误分类(diy.js / 商品 / 订单 / 自定义页面等)
|
|
||||||
* - 任意传入 {statusCode, errMsg, message} 形式错误对象的位置
|
|
||||||
*
|
|
||||||
* 设计要点:
|
|
||||||
* 1) 与 Vue / $lang 解耦:不调用任何 i18n 函数,只返回分类结果 + 原始错误字段
|
|
||||||
* 调用方拿到 type 后,自行决定使用 $lang('http.timeout') / $lang('http.serverError')
|
|
||||||
* 或其它命名空间的文案,完全保持各模块原有的 key 命名习惯
|
|
||||||
* 2) 通用网络/接口错误统一映射到 common.http.* 命名空间:
|
|
||||||
* - http.networkError: 通用网络异常(兜底)
|
|
||||||
* - http.timeout: 请求超时
|
|
||||||
* - http.serverError: 服务器错误(5xx)
|
|
||||||
* - http.notFound: 接口不存在(404)
|
|
||||||
* - http.auth: 无访问权限(401/403)
|
|
||||||
* - http.unknown: 其他非 2xx
|
|
||||||
* 3) 兼容多种错误对象形态:
|
|
||||||
* - uniapp request: { statusCode, errMsg, data }
|
|
||||||
* - axios: { response: { status }, message }
|
|
||||||
* - fetch / 原生: { message, name }
|
|
||||||
*
|
|
||||||
* @param {Object|string} err 错误对象或后端业务消息字符串
|
|
||||||
* @returns {{ type: string, statusCode?: number, errMsg?: string, raw?: any }}
|
|
||||||
* - type 取值:
|
|
||||||
* 'business' - 后端返回的业务消息字符串(已透传到 raw)
|
|
||||||
* 'empty' - 错误对象为空
|
|
||||||
* 'notFound' - HTTP 404
|
|
||||||
* 'server' - HTTP 5xx
|
|
||||||
* 'auth' - HTTP 401 / 403
|
|
||||||
* 'request' - 其它非 2xx 状态码
|
|
||||||
* 'timeout' - errMsg 含 timeout
|
|
||||||
* 'network' - errMsg 含 connect / network / fail
|
|
||||||
* 'unknown' - 无法分类
|
|
||||||
* - statusCode: 若有则原样返回
|
|
||||||
* - errMsg: 归一后的原始消息
|
|
||||||
* - raw: 原始输入,方便调用方进一步处理
|
|
||||||
*/
|
|
||||||
export function classifyHttpError(err) {
|
|
||||||
// 1) 无错误对象
|
|
||||||
if (!err) {
|
|
||||||
return { type: 'empty', raw: err };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 后端直接返回的业务消息(字符串)
|
|
||||||
if (typeof err === 'string') {
|
|
||||||
return { type: 'business', raw: err };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 字段归一(兼容 uni / axios / 原生 Error)
|
|
||||||
const statusCode = err.statusCode
|
|
||||||
?? err.response?.status
|
|
||||||
?? err.status;
|
|
||||||
const errMsg = err.errMsg || err.message || '';
|
|
||||||
|
|
||||||
if (statusCode) {
|
|
||||||
if (statusCode === 404) return { type: 'notFound', statusCode, errMsg, raw: err };
|
|
||||||
if (statusCode >= 500) return { type: 'server', statusCode, errMsg, raw: err };
|
|
||||||
if (statusCode === 401 || statusCode === 403) return { type: 'auth', statusCode, errMsg, raw: err };
|
|
||||||
return { type: 'request', statusCode, errMsg, raw: err };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/timeout/i.test(errMsg)) {
|
|
||||||
return { type: 'timeout', errMsg, raw: err };
|
|
||||||
}
|
|
||||||
if (/(connect|network|fail)/i.test(errMsg)) {
|
|
||||||
return { type: 'network', errMsg, raw: err };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: 'unknown', errMsg, raw: err };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 classifyHttpError 的结果,映射到 common.http.* 命名空间的 i18n key
|
|
||||||
*
|
|
||||||
* 通用网络/接口错误统一走 http.*,调用方通过 $lang(key) 解析文案。
|
|
||||||
* 原 diy.errTimeout 这类 key 已合并到 http.*,保持项目国际化更专业。
|
|
||||||
*
|
|
||||||
* @param {string} type classifyHttpError 返回的 type
|
|
||||||
* @returns {string|null} 对应的 http.* key;type='business' 时返回 null(透传 raw)
|
|
||||||
*/
|
|
||||||
export function httpErrorKey(type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'empty': return 'http.networkError';
|
|
||||||
case 'business': return null; // 透传,不用 key
|
|
||||||
case 'notFound': return 'http.notFound';
|
|
||||||
case 'server': return 'http.serverError';
|
|
||||||
case 'auth': return 'http.auth';
|
|
||||||
case 'request': return 'http.unknown';
|
|
||||||
case 'timeout': return 'http.timeout';
|
|
||||||
case 'network': return 'http.networkError';
|
|
||||||
case 'unknown':
|
|
||||||
default: return 'http.networkError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
classifyHttpError,
|
|
||||||
httpErrorKey,
|
|
||||||
};
|
|
||||||
@@ -44,15 +44,6 @@ function _sleep(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
|
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
|
||||||
const reqId = 'req_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
|
|
||||||
const reqStart = Date.now();
|
|
||||||
console.log('[HTTP][REQUEST]', reqId, method, url, JSON.stringify({
|
|
||||||
data: data,
|
|
||||||
header: header,
|
|
||||||
dataType: dataType,
|
|
||||||
responseType: responseType,
|
|
||||||
timeout: timeout
|
|
||||||
}));
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: url,
|
url: url,
|
||||||
@@ -63,7 +54,6 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
|
|||||||
responseType: responseType,
|
responseType: responseType,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
console.log('[HTTP][RESPONSE]', reqId, method, url, 'statusCode=', res.statusCode, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res.data));
|
|
||||||
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
|
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
|
||||||
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
|
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
|
||||||
return;
|
return;
|
||||||
@@ -83,7 +73,6 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
|
|||||||
resolve(res.data);
|
resolve(res.data);
|
||||||
},
|
},
|
||||||
fail: (res) => {
|
fail: (res) => {
|
||||||
console.error('[HTTP][FAIL]', reqId, method, url, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res));
|
|
||||||
reject(res);
|
reject(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,20 +87,15 @@ async function _requestWithRetry(params, url, method, data, header, dataType, re
|
|||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
if (attempt > 0) {
|
|
||||||
console.log('[HTTP][RETRY]', method, url, 'attempt=', attempt, 'maxRetries=', maxRetries);
|
|
||||||
}
|
|
||||||
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
|
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
lastStatusCode = err && err.statusCode;
|
lastStatusCode = err && err.statusCode;
|
||||||
console.warn('[HTTP][RETRY-FAIL]', method, url, 'attempt=', attempt, 'errMsg=', err && err.errMsg, 'statusCode=', lastStatusCode);
|
|
||||||
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
|
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
|
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
|
||||||
console.log('[HTTP][RETRY-WAIT]', method, url, 'delay=', delay + 'ms');
|
|
||||||
await _sleep(delay);
|
await _sleep(delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +207,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
uploadBase64(params) {
|
uploadBase64(params) {
|
||||||
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
|
|
||||||
uni.request({
|
uni.request({
|
||||||
url: Config.baseUrl + '/api/upload/headimgBase64',
|
url: Config.baseUrl + '/api/upload/headimgBase64',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -238,17 +221,14 @@ export default {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
success: res => {
|
success: res => {
|
||||||
console.log('[HTTP][RESPONSE] uploadBase64', 'statusCode=', res.statusCode, JSON.stringify(res.data));
|
|
||||||
typeof params.success == 'function' && params.success(res.data);
|
typeof params.success == 'function' && params.success(res.data);
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
console.error('[HTTP][FAIL] uploadBase64', JSON.stringify(res));
|
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
pullImage(params) {
|
pullImage(params) {
|
||||||
console.log('[HTTP][REQUEST] pullImage', Config.baseUrl + '/api/upload/headimgPull', 'path=', params.path);
|
|
||||||
uni.request({
|
uni.request({
|
||||||
url: Config.baseUrl + '/api/upload/headimgPull',
|
url: Config.baseUrl + '/api/upload/headimgPull',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -263,70 +243,14 @@ export default {
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
success: res => {
|
success: res => {
|
||||||
console.log('[HTTP][RESPONSE] pullImage', 'statusCode=', res.statusCode, JSON.stringify(res.data));
|
|
||||||
typeof params.success == 'function' && params.success(res.data);
|
typeof params.success == 'function' && params.success(res.data);
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
console.error('[HTTP][FAIL] pullImage', JSON.stringify(res));
|
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* JSON body 请求通道
|
|
||||||
*
|
|
||||||
* 为什么需要: sendRequest 走表单编码, 嵌套数组(如 fields.mobiles)的序列化
|
|
||||||
* 格式在各端不一致(uni 序列化器的平台差异, 小程序/H5 产出不同),
|
|
||||||
* 后端 input('fields/a') 依赖 PHP 方括号语法, 踩坑风险高。
|
|
||||||
* 本方法统一 JSON.stringify + application/json —— TP6 Request
|
|
||||||
* 原生解析 JSON body(getInputData), 且字符串 body 各端原样发送, 零歧义。
|
|
||||||
* token/uniacid/app_type 与 sendRequest 一样自动补齐。
|
|
||||||
*/
|
|
||||||
sendJson(params) {
|
|
||||||
if (!Config.baseUrl) {
|
|
||||||
uni.showToast({
|
|
||||||
title: getLang().lang('http.baseUrlNotConfigured'),
|
|
||||||
'icon': 'none',
|
|
||||||
'duration': 10000
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/');
|
|
||||||
const data = {
|
|
||||||
app_type: appType,
|
|
||||||
app_type_name: appTypeName,
|
|
||||||
token: store.state.token || '',
|
|
||||||
uniacid: uni.getStorageSync('uniacid') || Config.uniacid
|
|
||||||
};
|
|
||||||
if (params.data != undefined) Object.assign(data, params.data);
|
|
||||||
|
|
||||||
const store_info = store.state.globalStoreInfo;
|
|
||||||
if (store_info) data.store_id = store_info.store_id;
|
|
||||||
|
|
||||||
console.log('[HTTP][REQUEST] sendJson', url);
|
|
||||||
uni.request({
|
|
||||||
url: url,
|
|
||||||
method: params.method || 'POST',
|
|
||||||
header: Object.assign({ 'content-type': 'application/json' }, params.header || {}),
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
dataType: params.dataType || 'json',
|
|
||||||
responseType: params.responseType || 'text',
|
|
||||||
success: (res) => {
|
|
||||||
console.log('[HTTP][RESPONSE] sendJson', url, 'statusCode=', res.statusCode);
|
|
||||||
let r = res.data;
|
|
||||||
if (typeof r === 'string') {
|
|
||||||
try { r = JSON.parse(r); } catch (e) { /* 保持原样 */ }
|
|
||||||
}
|
|
||||||
typeof params.success == 'function' && params.success(r);
|
|
||||||
},
|
|
||||||
fail: (res) => {
|
|
||||||
console.error('[HTTP][FAIL] sendJson', url, JSON.stringify(res));
|
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
upload(params) {
|
upload(params) {
|
||||||
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
|
|
||||||
uni.uploadFile({
|
uni.uploadFile({
|
||||||
url: Config.baseUrl + params.url,
|
url: Config.baseUrl + params.url,
|
||||||
filePath: params.filePath,
|
filePath: params.filePath,
|
||||||
@@ -340,11 +264,9 @@ export default {
|
|||||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||||
},
|
},
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
console.log('[HTTP][RESPONSE] upload', Config.baseUrl + params.url, 'statusCode=', res.statusCode, res.data);
|
|
||||||
typeof params.success == 'function' && params.success(JSON.parse(res.data));
|
typeof params.success == 'function' && params.success(JSON.parse(res.data));
|
||||||
},
|
},
|
||||||
fail: (res) => {
|
fail: (res) => {
|
||||||
console.error('[HTTP][FAIL] upload', Config.baseUrl + params.url, JSON.stringify(res));
|
|
||||||
typeof params.fail == 'function' && params.fail(res);
|
typeof params.fail == 'function' && params.fail(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,21 +284,6 @@ export async function loadBundleTree(options = {}) {
|
|||||||
const version = String(payload.version || '');
|
const version = String(payload.version || '');
|
||||||
const sign = payload.sign || '';
|
const sign = payload.sign || '';
|
||||||
|
|
||||||
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
|
|
||||||
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
|
|
||||||
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
|
|
||||||
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
|
|
||||||
console.warn(
|
|
||||||
'[lang-loader] bundle tree INCOMPLETE. ' +
|
|
||||||
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
|
|
||||||
' lang=' + lang +
|
|
||||||
' appType=' + appType +
|
|
||||||
' uniacid=' + uniacid +
|
|
||||||
' version=' + version +
|
|
||||||
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) 签名校验(可选)
|
// 4) 签名校验(可选)
|
||||||
const secret = langConfig.remote.signSecret || '';
|
const secret = langConfig.remote.signSecret || '';
|
||||||
const signValid = validateSign(tree, version, sign, secret);
|
const signValid = validateSign(tree, version, sign, secret);
|
||||||
|
|||||||
@@ -628,26 +628,6 @@ function loadLangPackSync(lang, path) {
|
|||||||
* 获得当前本地语言
|
* 获得当前本地语言
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* 语言切换开关钩子(由后台 shop_info.ischina 决定,经 store/index.js 的 setShopInfo 写入)。
|
|
||||||
*
|
|
||||||
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischina
|
|
||||||
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
|
|
||||||
*
|
|
||||||
* 行为:
|
|
||||||
* - 后台关闭双语/小程序(ischina 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
|
|
||||||
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
|
|
||||||
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
|
|
||||||
* - 后台开启双语(ischina 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
|
|
||||||
*/
|
|
||||||
function setLangSwitchEnabled(enabled) {
|
|
||||||
if (!enabled) {
|
|
||||||
// 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文
|
|
||||||
invalidateStorageCache('lang');
|
|
||||||
try { uni.removeStorageSync('lang'); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentLocale() {
|
function getCurrentLocale() {
|
||||||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||||||
const val = cachedStorageGet('lang', null);
|
const val = cachedStorageGet('lang', null);
|
||||||
@@ -993,13 +973,6 @@ export default {
|
|||||||
*/
|
*/
|
||||||
getCurrentLocale,
|
getCurrentLocale,
|
||||||
|
|
||||||
/**
|
|
||||||
* 语言切换功能开关(后台 shop_info.ischina 决定)。
|
|
||||||
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
|
|
||||||
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
|
|
||||||
*/
|
|
||||||
setLangSwitchEnabled,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从后端数据中提取当前语言的文案(向后兼容)
|
* 从后端数据中提取当前语言的文案(向后兼容)
|
||||||
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
||||||
@@ -1081,13 +1054,6 @@ export default {
|
|||||||
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
||||||
let isComplete1 = isBundleComplete(result.tree);
|
let isComplete1 = isBundleComplete(result.tree);
|
||||||
if (!isComplete1) {
|
if (!isComplete1) {
|
||||||
// [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components
|
|
||||||
try {
|
|
||||||
console.warn('[i18n:boot] bundle incomplete (1st fetch). ' +
|
|
||||||
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
|
||||||
', source=' + (result && result.source) +
|
|
||||||
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
|
||||||
} catch (e) {}
|
|
||||||
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
||||||
delete remoteTreeCache[_preloadMk];
|
delete remoteTreeCache[_preloadMk];
|
||||||
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
||||||
@@ -1098,13 +1064,6 @@ export default {
|
|||||||
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
||||||
let isComplete2 = isBundleComplete(result.tree);
|
let isComplete2 = isBundleComplete(result.tree);
|
||||||
if (!isComplete2) {
|
if (!isComplete2) {
|
||||||
// [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree
|
|
||||||
try {
|
|
||||||
console.warn('[i18n:boot] bundle incomplete (after force retry). ' +
|
|
||||||
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
|
||||||
', source=' + (result && result.source) +
|
|
||||||
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
|
||||||
} catch (e) {}
|
|
||||||
throw new Error('lang bundle unusable after force retry');
|
throw new Error('lang bundle unusable after force retry');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,14 +1103,10 @@ export default {
|
|||||||
setRemoteStatus('fail', { reason: _reason, lang });
|
setRemoteStatus('fail', { reason: _reason, lang });
|
||||||
bootCompleted = true;
|
bootCompleted = true;
|
||||||
emitUpdated(lang);
|
emitUpdated(lang);
|
||||||
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
|
|
||||||
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
|
|
||||||
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: 'cache',
|
source: 'cache',
|
||||||
status: 'ok',
|
status: 'fail',
|
||||||
degraded: true,
|
|
||||||
reason: _errMsg || 'fallback to cache',
|
reason: _errMsg || 'fallback to cache',
|
||||||
lang,
|
lang,
|
||||||
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
||||||
@@ -1221,42 +1176,6 @@ export default {
|
|||||||
loadOverrides(lang);
|
loadOverrides(lang);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用:
|
|
||||||
* 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启),
|
|
||||||
* 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在:
|
|
||||||
* - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid
|
|
||||||
* - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果
|
|
||||||
* - remote-only 模式下远端 bundle 不会被重新拉取
|
|
||||||
* 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前
|
|
||||||
* storage 的 locale 重新读取并合并,不再沿用旧值。
|
|
||||||
* 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。
|
|
||||||
*/
|
|
||||||
onAppReentry() {
|
|
||||||
// [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry
|
|
||||||
try {
|
|
||||||
console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY +
|
|
||||||
', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) +
|
|
||||||
', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null)));
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
// 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid)
|
|
||||||
invalidateStorageCache();
|
|
||||||
// 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并
|
|
||||||
invalidateLangCaches();
|
|
||||||
// [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存)
|
|
||||||
try {
|
|
||||||
console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() +
|
|
||||||
', freshUniacid=' + getCurrentUniacid());
|
|
||||||
} catch (e) {}
|
|
||||||
// 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染
|
|
||||||
if (IS_REMOTE_ONLY) {
|
|
||||||
this.boot();
|
|
||||||
} else {
|
|
||||||
this.refresh(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
||||||
* @param {(status:string, detail:object) => void} fn
|
* @param {(status:string, detail:object) => void} fn
|
||||||
@@ -1328,7 +1247,7 @@ export default {
|
|||||||
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
|
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
|
||||||
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
|
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
|
||||||
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
|
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
|
||||||
|
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
|
||||||
if (cachedFieldResult !== undefined) {
|
if (cachedFieldResult !== undefined) {
|
||||||
let value = cachedFieldResult;
|
let value = cachedFieldResult;
|
||||||
// 没找到翻译
|
// 没找到翻译
|
||||||
@@ -1337,6 +1256,10 @@ export default {
|
|||||||
if (hasDefault) {
|
if (hasDefault) {
|
||||||
return arguments[1];
|
return arguments[1];
|
||||||
}
|
}
|
||||||
|
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||||||
|
if (!__DEV__) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
// 开发环境 → 返回 raw key(便于发现问题)
|
// 开发环境 → 返回 raw key(便于发现问题)
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -1427,6 +1350,9 @@ export default {
|
|||||||
// 有默认值 → 使用默认值
|
// 有默认值 → 使用默认值
|
||||||
if (hasDefault) {
|
if (hasDefault) {
|
||||||
value = arguments[1];
|
value = arguments[1];
|
||||||
|
} else if (!__DEV__) {
|
||||||
|
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||||||
|
value = '';
|
||||||
}
|
}
|
||||||
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
||||||
} else if (arguments.length > 1) {
|
} else if (arguments.length > 1) {
|
||||||
@@ -1441,7 +1367,7 @@ export default {
|
|||||||
if (value == undefined) value = '';
|
if (value == undefined) value = '';
|
||||||
|
|
||||||
// 多语言调试
|
// 多语言调试
|
||||||
if (field == value) {
|
if (__DEV__ && field == value) {
|
||||||
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,768 +0,0 @@
|
|||||||
// common/js/media-upload.js
|
|
||||||
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
|
|
||||||
//
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
|
|
||||||
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
|
|
||||||
// "Cannot find module '@/common/js/media-upload.js'". 此次按
|
|
||||||
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
|
|
||||||
//
|
|
||||||
// 调用方:
|
|
||||||
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
|
|
||||||
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
|
|
||||||
//
|
|
||||||
// 上传策略:
|
|
||||||
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
|
|
||||||
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
|
|
||||||
//
|
|
||||||
// 端点:
|
|
||||||
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
|
|
||||||
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
|
|
||||||
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
|
|
||||||
|
|
||||||
import aiService from './ai-service.js'
|
|
||||||
import Config from './config.js'
|
|
||||||
import store from '@/store/index.js'
|
|
||||||
|
|
||||||
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
|
|
||||||
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
|
|
||||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
|
|
||||||
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
|
|
||||||
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
|
|
||||||
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
|
|
||||||
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
|
|
||||||
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
|
|
||||||
const EXT_MIME_MAP = {
|
|
||||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
|
||||||
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
|
|
||||||
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
|
||||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
|
||||||
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
|
||||||
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
|
||||||
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
|
||||||
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
|
|
||||||
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
|
|
||||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
|
||||||
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
|
||||||
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
|
||||||
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
|
||||||
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
|
|
||||||
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
|
|
||||||
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
|
|
||||||
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
|
|
||||||
doc: 'application/msword',
|
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
ppt: 'application/vnd.ms-powerpoint',
|
|
||||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
||||||
zip: 'application/zip', rar: 'application/x-rar-compressed',
|
|
||||||
'7z': 'application/x-7z-compressed',
|
|
||||||
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
|
|
||||||
pdf: 'application/pdf', txt: 'text/plain',
|
|
||||||
xls: 'application/vnd.ms-excel',
|
|
||||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传图片 (type=image)
|
|
||||||
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
|
|
||||||
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
|
|
||||||
*/
|
|
||||||
export async function uploadImage(filePath, opts = {}) {
|
|
||||||
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
|
|
||||||
const config = await aiService.getServiceConfig()
|
|
||||||
const strategy = config?.upload_strategy || 'backend'
|
|
||||||
|
|
||||||
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
|
|
||||||
let compressedPath = filePath
|
|
||||||
let detectedSize = 0
|
|
||||||
try {
|
|
||||||
detectedSize = await getFileSize(filePath)
|
|
||||||
} catch (e) {
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
|
|
||||||
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
|
|
||||||
console.warn('[media-upload] getFileSize failed:', e)
|
|
||||||
}
|
|
||||||
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
|
|
||||||
// #ifdef H5 || MP-WEIXIN
|
|
||||||
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
|
|
||||||
try {
|
|
||||||
const compressed = await uni.compressImage({
|
|
||||||
src: filePath,
|
|
||||||
quality: 80,
|
|
||||||
compressedWidth: 2048,
|
|
||||||
compressedHeight: 2048,
|
|
||||||
})
|
|
||||||
compressedPath = compressed.tempFilePath || filePath
|
|
||||||
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[media-upload] compressImage failed, use original:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
|
|
||||||
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
|
|
||||||
// fileSizeFinal = 探测失败的兜底:
|
|
||||||
// - 第一次探测失败(原始大小未知) → 保守走 STS
|
|
||||||
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
|
|
||||||
let fileSizeFinal = detectedSize
|
|
||||||
if (compressedPath !== filePath) {
|
|
||||||
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
|
|
||||||
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
|
|
||||||
fileSizeFinal = detectedSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const useSts = strategy === 'oss_sts'
|
|
||||||
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
|
|
||||||
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
|
|
||||||
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
|
|
||||||
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
|
|
||||||
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
|
|
||||||
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
|
|
||||||
|
|
||||||
if (finalUseSts) {
|
|
||||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
|
|
||||||
} catch (e) {
|
|
||||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
|
|
||||||
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
|
|
||||||
if (e && e.code === 'OSS_STS_REQUIRED') {
|
|
||||||
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
|
||||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传 audio / video / file
|
|
||||||
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
|
|
||||||
* 未配云存储 (backend) → 直接 PHP 中转
|
|
||||||
* @param {string} filePath
|
|
||||||
* @param {'audio'|'video'|'file'} kind
|
|
||||||
* @param {{name?:string, mime?:string, size?:number}} [opts]
|
|
||||||
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
|
|
||||||
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
|
|
||||||
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
|
|
||||||
*/
|
|
||||||
export async function uploadMedia(filePath, kind, opts = {}) {
|
|
||||||
if (!['audio', 'video', 'file'].includes(kind)) {
|
|
||||||
throw new Error(`invalid kind: ${kind}`)
|
|
||||||
}
|
|
||||||
const addon = aiService.getAddonName()
|
|
||||||
const config = await aiService.getServiceConfig()
|
|
||||||
const agentId = aiService.getAgentId()
|
|
||||||
const hints = { name: opts.name || '', mime: opts.mime || '' }
|
|
||||||
const size = opts.size || 0
|
|
||||||
const strategy = (config && config.upload_strategy) || 'backend'
|
|
||||||
|
|
||||||
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
|
|
||||||
const doPhpUpload = async (fallbackErr) => {
|
|
||||||
let phpRes = null
|
|
||||||
try {
|
|
||||||
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
|
|
||||||
}
|
|
||||||
if (!phpRes || !phpRes.url) {
|
|
||||||
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
|
|
||||||
}
|
|
||||||
return Object.assign({}, phpRes, { kind })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
|
||||||
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
|
||||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
|
|
||||||
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
|
|
||||||
if (strategy === 'backend') {
|
|
||||||
try {
|
|
||||||
return await doPhpUpload()
|
|
||||||
} catch (e) {
|
|
||||||
if (e && e.code === 'OSS_STS_REQUIRED') {
|
|
||||||
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
|
||||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
|
||||||
} catch (e) {
|
|
||||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
|
|
||||||
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
|
|
||||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
|
|
||||||
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
|
|
||||||
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
|
|
||||||
try {
|
|
||||||
return await doPhpUpload(e)
|
|
||||||
} catch (e2) {
|
|
||||||
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
|
|
||||||
throw e2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PHP 中转 (multipart/form-data)
|
|
||||||
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
|
|
||||||
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
|
|
||||||
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
|
|
||||||
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
|
|
||||||
*/
|
|
||||||
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
|
||||||
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
|
|
||||||
+ (kind === 'image' ? 'image' : 'file')
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
uni.uploadFile({
|
|
||||||
url: Config.baseUrl + url,
|
|
||||||
filePath,
|
|
||||||
name: 'file',
|
|
||||||
formData: {
|
|
||||||
kind,
|
|
||||||
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
|
||||||
agent_id: agentId || undefined,
|
|
||||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
||||||
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
||||||
uniacid: aiService.getUniacid(),
|
|
||||||
user_id: aiService.getUserId?.() || '',
|
|
||||||
},
|
|
||||||
success: (res) => {
|
|
||||||
try {
|
|
||||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
|
||||||
if (data.code === 0 || data.success) {
|
|
||||||
resolve({
|
|
||||||
url: data.data.url,
|
|
||||||
mime: data.data.mime,
|
|
||||||
size: data.data.size || knownSize || 0,
|
|
||||||
name: data.data.name,
|
|
||||||
kind: data.data.kind || kind,
|
|
||||||
used_strategy: data.data.used_strategy || 'backend',
|
|
||||||
oss_sts_required: data.data.oss_sts_required || false,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
|
|
||||||
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
|
|
||||||
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
|
||||||
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reject(new Error('解析响应失败: ' + e.message))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
const form = new FormData()
|
|
||||||
const file = await pathToFile(filePath, kind)
|
|
||||||
form.append('file', file)
|
|
||||||
form.append('kind', kind)
|
|
||||||
if (addon === 'aiagent') form.append('agent_id', agentId)
|
|
||||||
else {
|
|
||||||
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
|
||||||
const uuid = aiService.getCurrentConfigUuid?.() || ''
|
|
||||||
form.append('config_uuid', uuid)
|
|
||||||
form.append('config_uid', uuid)
|
|
||||||
form.append('agent_id', agentId)
|
|
||||||
}
|
|
||||||
form.append('uniacid', aiService.getUniacid())
|
|
||||||
form.append('user_id', aiService.getUserId?.() || '')
|
|
||||||
const res = await fetch(Config.baseUrl + url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'token': store.state.token || '' },
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.code === 0 || data.success) {
|
|
||||||
return {
|
|
||||||
url: data.data.url,
|
|
||||||
mime: data.data.mime,
|
|
||||||
size: data.data.size || knownSize || 0,
|
|
||||||
name: data.data.name,
|
|
||||||
kind: data.data.kind || kind,
|
|
||||||
used_strategy: data.data.used_strategy || 'backend',
|
|
||||||
oss_sts_required: data.data.oss_sts_required || false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
|
|
||||||
if (data.oss_sts_required === true) {
|
|
||||||
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
|
||||||
err.code = 'OSS_STS_REQUIRED'
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
throw new Error(data.msg || data.message || '上传失败')
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* STS 直传 (拿临时凭证 → 直传云存储)
|
|
||||||
*
|
|
||||||
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
|
|
||||||
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
|
||||||
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
|
|
||||||
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
|
|
||||||
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
|
|
||||||
* 标记, 不带 Authorization
|
|
||||||
*
|
|
||||||
* 后端 stsResp.data.data 可能字段:
|
|
||||||
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
|
|
||||||
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
|
|
||||||
* 1) filePath 最后一段正则匹配 .xxx
|
|
||||||
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
|
|
||||||
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
|
|
||||||
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
|
|
||||||
*
|
|
||||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
|
||||||
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
|
||||||
*/
|
|
||||||
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
|
||||||
let ext = 'bin'
|
|
||||||
if (typeof filePath === 'string') {
|
|
||||||
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
|
||||||
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
|
|
||||||
if (m) ext = m[1].toLowerCase()
|
|
||||||
}
|
|
||||||
if (ext === 'bin' && filePath && filePath.name) {
|
|
||||||
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
|
||||||
if (m2) ext = m2[1].toLowerCase()
|
|
||||||
}
|
|
||||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
|
|
||||||
if (ext === 'bin' && nameHint) {
|
|
||||||
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
|
|
||||||
if (m3) ext = m3[1].toLowerCase()
|
|
||||||
}
|
|
||||||
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
|
|
||||||
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
|
|
||||||
if (ext === 'bin' && mimeHint) {
|
|
||||||
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
|
|
||||||
if (m === mimeHint) { ext = e; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
|
|
||||||
return ext
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
|
|
||||||
*
|
|
||||||
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
|
|
||||||
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
|
|
||||||
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
|
|
||||||
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
|
|
||||||
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
|
|
||||||
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
|
|
||||||
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
|
|
||||||
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
|
|
||||||
*
|
|
||||||
* 端点: POST /<addon>/api/upload/ossPresign
|
|
||||||
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
|
|
||||||
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
|
|
||||||
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
|
|
||||||
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
|
|
||||||
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
|
|
||||||
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
|
|
||||||
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
|
|
||||||
*/
|
|
||||||
function uniRequest(opts) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
uni.request(Object.assign({}, opts, {
|
|
||||||
success: resolve,
|
|
||||||
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
|
|
||||||
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
|
|
||||||
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
|
|
||||||
let mimeHint = hints.mime || ''
|
|
||||||
let nameHint = hints.name || ''
|
|
||||||
let probeFile = null
|
|
||||||
// #ifdef H5
|
|
||||||
try {
|
|
||||||
probeFile = await pathToFile(filePath, kind)
|
|
||||||
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
|
|
||||||
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
|
|
||||||
} catch (e) {
|
|
||||||
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
|
|
||||||
console.warn('[media-upload] presign probe failed:', e && e.message)
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
const ext = extractExt(filePath, mimeHint, nameHint)
|
|
||||||
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
|
|
||||||
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
|
|
||||||
const contentType = mimeHint || guessMime(finalExt)
|
|
||||||
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
|
|
||||||
? nameHint
|
|
||||||
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
|
|
||||||
|
|
||||||
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
|
|
||||||
const preResp = await uniRequest({
|
|
||||||
url: Config.baseUrl + preUrl,
|
|
||||||
method: 'POST',
|
|
||||||
header: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'token': store.state.token || ''
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
filename,
|
|
||||||
content_type: contentType,
|
|
||||||
uniacid: aiService.getUniacid(),
|
|
||||||
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
|
|
||||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (preResp.statusCode === 404) {
|
|
||||||
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
|
|
||||||
err.code = 'PRESIGN_UNSUPPORTED'
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
|
|
||||||
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
|
|
||||||
}
|
|
||||||
const pre = preResp.data.data
|
|
||||||
const uploadUrl = pre.upload_url
|
|
||||||
const finalContentType = pre.content_type || contentType
|
|
||||||
|
|
||||||
let uploadedSize = knownSize || 0
|
|
||||||
// #ifdef H5
|
|
||||||
if (probeFile && probeFile.size) uploadedSize = probeFile.size
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
|
|
||||||
// #ifdef H5
|
|
||||||
const file = probeFile || await pathToFile(filePath, kind)
|
|
||||||
const ossRes = await fetch(uploadUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': finalContentType },
|
|
||||||
body: file,
|
|
||||||
})
|
|
||||||
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
|
|
||||||
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
uni.getFileSystemManager().readFile({
|
|
||||||
filePath,
|
|
||||||
success: (res) => {
|
|
||||||
uni.request({
|
|
||||||
url: uploadUrl,
|
|
||||||
method: 'PUT',
|
|
||||||
header: { 'Content-Type': finalContentType },
|
|
||||||
data: res.data,
|
|
||||||
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
|
|
||||||
? resolve()
|
|
||||||
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
|
|
||||||
fail: reject,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: (pre.public_url || uploadUrl.split('?')[0]),
|
|
||||||
mime: finalContentType,
|
|
||||||
size: uploadedSize,
|
|
||||||
name: (pre.object_key || filename).split('/').pop(),
|
|
||||||
kind,
|
|
||||||
used_strategy: 'oss_sts',
|
|
||||||
provider: pre.provider || 'alioss',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
|
|
||||||
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
|
|
||||||
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
|
|
||||||
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
|
|
||||||
const providerHint = (config && config.oss_provider) || ''
|
|
||||||
if (!providerHint || providerHint === 'alioss') {
|
|
||||||
try {
|
|
||||||
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
|
|
||||||
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
|
|
||||||
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
|
|
||||||
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
|
|
||||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
|
|
||||||
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
|
|
||||||
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
|
|
||||||
|
|
||||||
// 1. 拿 STS 凭证 (addon 模式 URL)
|
|
||||||
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
|
||||||
const stsResp = await uniRequest({
|
|
||||||
url: Config.baseUrl + stsUrl,
|
|
||||||
method: 'POST',
|
|
||||||
header: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'token': store.state.token || ''
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
agent_id: agentId || undefined,
|
|
||||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
||||||
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
||||||
uniacid: aiService.getUniacid(),
|
|
||||||
user_id: aiService.getUserId?.() || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
|
|
||||||
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
|
|
||||||
}
|
|
||||||
const sts = stsResp.data.data
|
|
||||||
const provider = sts.provider || 'alioss' // 老后端默认 alioss
|
|
||||||
|
|
||||||
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
|
|
||||||
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
||||||
const r = Math.random() * 16 | 0
|
|
||||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
|
||||||
})
|
|
||||||
const now = new Date()
|
|
||||||
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
|
|
||||||
.replace('{site_id}', sts.site_id || '0')
|
|
||||||
.replace('{agent_id}', agentId || '0')
|
|
||||||
.replace('{yyyy}', now.getFullYear().toString())
|
|
||||||
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
|
|
||||||
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
|
|
||||||
.replace('{rand}', uuid)
|
|
||||||
+ '.' + ext
|
|
||||||
|
|
||||||
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
|
|
||||||
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
|
|
||||||
const isSingleUrlMode = typeof sts.securityToken === 'string'
|
|
||||||
&& /-(SIGNED)$/.test(sts.securityToken)
|
|
||||||
|
|
||||||
let ossUrl, headers
|
|
||||||
if (isSingleUrlMode) {
|
|
||||||
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
|
|
||||||
ossUrl = sts.accessKeyId
|
|
||||||
headers = { 'Content-Type': guessMime(ext) }
|
|
||||||
} else if (provider === 'qiniu') {
|
|
||||||
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
|
|
||||||
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
|
|
||||||
headers = {
|
|
||||||
'Authorization': 'UpToken ' + sts.accessKeyId,
|
|
||||||
'Content-Type': guessMime(ext),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
|
||||||
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
|
|
||||||
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
|
|
||||||
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
|
|
||||||
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
|
|
||||||
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
|
|
||||||
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
|
|
||||||
const aliossTpl = sts.oss_url
|
|
||||||
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
|
|
||||||
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
|
|
||||||
const tpl = (provider === 'alioss') ? aliossTpl
|
|
||||||
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
|
|
||||||
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
|
|
||||||
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
|
|
||||||
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
|
|
||||||
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
|
|
||||||
ossUrl = `${tpl}/${key}`
|
|
||||||
headers = {
|
|
||||||
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
|
|
||||||
'x-oss-security-token': sts.securityToken,
|
|
||||||
'Content-Type': guessMime(ext),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
|
|
||||||
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
|
|
||||||
let uploadedSize = knownSize || 0
|
|
||||||
// #ifdef H5
|
|
||||||
try {
|
|
||||||
const probe = await pathToFile(filePath, kind)
|
|
||||||
if (probe && probe.size) uploadedSize = probe.size
|
|
||||||
} catch (e) {}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 3. 直传云存储
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
uni.uploadFile({
|
|
||||||
url: ossUrl,
|
|
||||||
filePath,
|
|
||||||
name: 'file',
|
|
||||||
formData: {},
|
|
||||||
header: headers,
|
|
||||||
success: (res) => {
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
|
|
||||||
else reject(new Error(`${provider} PUT ${res.statusCode}`))
|
|
||||||
},
|
|
||||||
fail: reject,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// #endif
|
|
||||||
// #ifdef H5
|
|
||||||
const file = await pathToFile(filePath, kind)
|
|
||||||
const ossRes = await fetch(ossUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers,
|
|
||||||
body: file,
|
|
||||||
})
|
|
||||||
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 4. 返回公网 URL
|
|
||||||
return {
|
|
||||||
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
|
|
||||||
mime: guessMime(ext),
|
|
||||||
size: uploadedSize,
|
|
||||||
name: key.split('/').pop(),
|
|
||||||
kind,
|
|
||||||
used_strategy: 'oss_sts',
|
|
||||||
provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFileSize(filePath) {
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
|
|
||||||
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
|
|
||||||
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
uni.getFileInfo({
|
|
||||||
filePath,
|
|
||||||
success: (res) => resolve(res.size || 0),
|
|
||||||
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// #endif
|
|
||||||
// #ifdef H5
|
|
||||||
try {
|
|
||||||
const res = await fetch(filePath)
|
|
||||||
const blob = await res.blob()
|
|
||||||
return blob.size
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('getFileSize fetch failed: ' + e.message)
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathToFile(filePath, kind) {
|
|
||||||
// #ifdef H5
|
|
||||||
const res = await fetch(filePath)
|
|
||||||
const blob = await res.blob()
|
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
|
|
||||||
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
|
|
||||||
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
|
|
||||||
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
|
|
||||||
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
|
|
||||||
const rawName = filePath.split('/').pop() || `${kind}.bin`
|
|
||||||
const ext = extractExt(filePath, blob.type)
|
|
||||||
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
|
|
||||||
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
|
|
||||||
const mime = blob.type || guessMime(ext)
|
|
||||||
return new File([blob], finalName, { type: mime })
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
|
|
||||||
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
|
|
||||||
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
|
|
||||||
* 而不是路径 ②/③ (URL fetch / resource_link).
|
|
||||||
*
|
|
||||||
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
|
|
||||||
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
|
|
||||||
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
|
|
||||||
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
|
|
||||||
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
|
|
||||||
*
|
|
||||||
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
|
|
||||||
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
|
|
||||||
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
|
|
||||||
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
|
|
||||||
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
|
|
||||||
*
|
|
||||||
* 返回: { data: '<bare-base64>', size, mime } | null
|
|
||||||
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
|
|
||||||
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
|
|
||||||
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
|
|
||||||
*
|
|
||||||
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
|
|
||||||
* @param {object} [opts]
|
|
||||||
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
|
|
||||||
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
|
|
||||||
*/
|
|
||||||
export async function fileToBase64(filePath, opts = {}) {
|
|
||||||
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
|
|
||||||
if (!filePath) return null
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
try {
|
|
||||||
const res = await fetch(filePath)
|
|
||||||
if (!res.ok) throw new Error('fetch failed: ' + res.status)
|
|
||||||
const blob = await res.blob()
|
|
||||||
if (blob.size > maxBytes) {
|
|
||||||
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const dataUri = await new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => resolve(reader.result)
|
|
||||||
reader.onerror = () => reject(new Error('FileReader failed'))
|
|
||||||
reader.readAsDataURL(blob)
|
|
||||||
})
|
|
||||||
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
|
|
||||||
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
|
|
||||||
return { data: m[2], size: blob.size, mime: m[1] }
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[media-upload] fileToBase64 H5 失败:', e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN || APP-PLUS
|
|
||||||
try {
|
|
||||||
const fsm = uni.getFileSystemManager()
|
|
||||||
// 1. 探测大小, 超限直接返回 null
|
|
||||||
let size = 0
|
|
||||||
try {
|
|
||||||
const stat = fsm.statSync(filePath)
|
|
||||||
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
|
|
||||||
size = (stat && (stat.size || stat.fileSize)) || 0
|
|
||||||
} catch (e) {
|
|
||||||
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
|
|
||||||
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
|
|
||||||
}
|
|
||||||
if (size > maxBytes) {
|
|
||||||
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
|
|
||||||
const data = fsm.readFileSync(filePath, 'base64')
|
|
||||||
return { data, size: size || 0, mime: opts.mimeHint || '' }
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
function guessMime(ext) {
|
|
||||||
return EXT_MIME_MAP[ext] || 'application/octet-stream'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { uploadImage, uploadMedia }
|
|
||||||
@@ -147,13 +147,20 @@ function getQuickAppPrivacySetting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
|
* 鸿蒙元服务: 查询平台级隐私设置
|
||||||
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
|
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
|
||||||
* @returns {Promise<{needAuthorization: boolean}>}
|
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||||
*/
|
*/
|
||||||
function getHarmonyPrivacySetting() {
|
function getHarmonyPrivacySetting() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
|
||||||
|
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
|
||||||
resolve({ needAuthorization: !getStorageAgreed() });
|
resolve({ needAuthorization: !getStorageAgreed() });
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-HARMONY
|
||||||
|
resolve({ needAuthorization: !getStorageAgreed() });
|
||||||
|
// #endif
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +188,7 @@ async function checkPrivacy() {
|
|||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
|
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
||||||
// H5 / 其他平台: 仅查 storage
|
// H5 / 其他平台: 仅查 storage
|
||||||
return checkPrivacySync();
|
return checkPrivacySync();
|
||||||
}
|
}
|
||||||
@@ -723,9 +730,8 @@ const PrivacyManager = {
|
|||||||
|
|
||||||
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
||||||
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
||||||
// 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
|
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
|
||||||
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
||||||
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
|
|
||||||
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
||||||
|
|
||||||
_needShow = result.needAuthorization;
|
_needShow = result.needAuthorization;
|
||||||
@@ -735,18 +741,7 @@ const PrivacyManager = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// 设置待弹窗标志
|
||||||
// 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗
|
|
||||||
// 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue
|
|
||||||
// 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗
|
|
||||||
_needShow = false;
|
|
||||||
_pendingShow = false;
|
|
||||||
_urlRuleDecided = true;
|
|
||||||
console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗');
|
|
||||||
return;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 其他平台: 设置待弹窗标志 + emit 触发自建弹窗
|
|
||||||
// 已 mounted 的页面组件通过事件接收
|
// 已 mounted 的页面组件通过事件接收
|
||||||
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
|
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
|
||||||
_pendingShow = true;
|
_pendingShow = true;
|
||||||
@@ -762,28 +757,18 @@ const PrivacyManager = {
|
|||||||
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
||||||
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
||||||
*
|
*
|
||||||
* 历史兼容:
|
* 鸿蒙元服务兼容:
|
||||||
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
||||||
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
||||||
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
||||||
*
|
|
||||||
* 鸿蒙元服务(双轨制):
|
|
||||||
* 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
|
||||||
* 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
|
|
||||||
* 鸿蒙系统级弹窗叠加出现"多次弹出"。
|
|
||||||
* @returns {boolean} true=需要弹窗,false=不需要
|
* @returns {boolean} true=需要弹窗,false=不需要
|
||||||
*/
|
*/
|
||||||
consumePendingShow() {
|
consumePendingShow() {
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
// 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
|
|
||||||
return false;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
if (_pendingShow && _needShow) {
|
if (_pendingShow && _needShow) {
|
||||||
_pendingShow = false;
|
_pendingShow = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||||||
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
||||||
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
||||||
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
||||||
|
|||||||
@@ -33,16 +33,7 @@ function useNativeLoading() {
|
|||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// 总兜底超时档位 (show 时可覆盖):
|
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
|
||||||
// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松)
|
|
||||||
// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲)
|
|
||||||
// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲)
|
|
||||||
// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用
|
|
||||||
// 业务方覆盖: splashState.show({ timeout: 17000 })
|
|
||||||
// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低
|
|
||||||
// 严防因为 timeout 太短导致用户看到白屏.
|
|
||||||
// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳
|
|
||||||
const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s)
|
|
||||||
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
|
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
|
||||||
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
|
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
|
||||||
|
|
||||||
@@ -80,111 +71,6 @@ class SplashState {
|
|||||||
// 避免多通道并发时, splash 组件 re-render 风暴
|
// 避免多通道并发时, splash 组件 re-render 风暴
|
||||||
this._pendingProgress = null; // 待 emit 的最新进度
|
this._pendingProgress = null; // 待 emit 的最新进度
|
||||||
this._flushTimer = null; // 节流定时器
|
this._flushTimer = null; // 节流定时器
|
||||||
// 网络环境 (用于 show() 自动选档)
|
|
||||||
// _networkType: 'wifi' | '4g' | '5g' | '3g' | '2g' | 'none' | 'unknown'
|
|
||||||
// _networkReady: false 表示还没检测完 (splash 显示中第一次构造时常见)
|
|
||||||
// _networkReady 后, 网络变化时通过 onNetworkStatusChange 实时更新
|
|
||||||
this._networkType = 'unknown';
|
|
||||||
this._networkReady = false;
|
|
||||||
|
|
||||||
// 启动网络检测 (异步, 不阻塞 splash 显示)
|
|
||||||
this._initNetworkDetect();
|
|
||||||
|
|
||||||
// 监听 diy 加载失败事件 (diy.js 通过 uni.$emit 触发)
|
|
||||||
// 收到后调 notifyDiyLoadError (严格模式下必须能关闭 splash)
|
|
||||||
// 解耦: splash-state 不直接 import diy.js
|
|
||||||
// 透传 reason / errorType / err / url 给业务方监听 splash:diy-error 使用
|
|
||||||
try {
|
|
||||||
if (typeof uni !== 'undefined' && uni.$on) {
|
|
||||||
uni.$on('diy:load-error', (payload) => {
|
|
||||||
try { this.notifyDiyLoadError(payload || {}) } catch (e) {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化网络检测
|
|
||||||
* - 调 uni.getNetworkType 异步获取当前网络类型
|
|
||||||
* - 监听 uni.onNetworkStatusChange 实时更新
|
|
||||||
* - H5 平台降级: 调 navigator.onLine, 不支持细粒度网络类型
|
|
||||||
* 失败保护: try-catch 包, 任意失败都保持 _networkType='unknown' (不会破坏 splash)
|
|
||||||
*/
|
|
||||||
_initNetworkDetect() {
|
|
||||||
try {
|
|
||||||
// #ifdef H5
|
|
||||||
// H5 平台: navigator.onLine 是粗粒度, 只能判断在线/离线
|
|
||||||
// 仅在 navigator.onLine 是明确 boolean (true/false) 时才是 H5
|
|
||||||
// Node 24+ 有 navigator 但 onLine 是 undefined, 不算 H5 (走下面 uni.getNetworkType)
|
|
||||||
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
|
|
||||||
// 初始检测: 用 _updateNetworkType (内部会触发事件)
|
|
||||||
this._updateNetworkType(navigator.onLine ? 'wifi' : 'none', { source: 'h5-initial' })
|
|
||||||
// 监听 online/offline 事件
|
|
||||||
if (typeof window !== 'undefined' && window.addEventListener) {
|
|
||||||
window.addEventListener('online', () => {
|
|
||||||
this._updateNetworkType('wifi', { source: 'h5-online', isConnected: true })
|
|
||||||
})
|
|
||||||
window.addEventListener('offline', () => {
|
|
||||||
this._updateNetworkType('none', { source: 'h5-offline', isConnected: false })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// navigator.onLine 不是 boolean (Node/SSR) - 走 uni.getNetworkType
|
|
||||||
// #endif
|
|
||||||
// #ifndef H5
|
|
||||||
if (typeof uni === 'undefined' || !uni.getNetworkType) {
|
|
||||||
// 不可用: 用 _updateNetworkType 设 unknown (会触发事件)
|
|
||||||
this._updateNetworkType('unknown', { source: 'no-uni-api' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 异步获取当前网络类型
|
|
||||||
uni.getNetworkType({
|
|
||||||
success: (res) => {
|
|
||||||
this._updateNetworkType((res && res.networkType) || 'unknown', { source: 'uni-initial' })
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
this._updateNetworkType('unknown', { source: 'uni-initial-fail' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// 监听网络变化
|
|
||||||
if (uni.onNetworkStatusChange) {
|
|
||||||
uni.onNetworkStatusChange((res) => {
|
|
||||||
const newType = (res && res.networkType)
|
|
||||||
|| (res && res.isConnected === false ? 'none' : 'unknown')
|
|
||||||
this._updateNetworkType(newType, {
|
|
||||||
source: 'uni-change',
|
|
||||||
isConnected: res && typeof res.isConnected === 'boolean' ? res.isConnected : undefined,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
} catch (e) {
|
|
||||||
// 任意失败都用 unknown, 不影响 splash
|
|
||||||
this._updateNetworkType('unknown', { source: 'init-error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据当前网络环境选档
|
|
||||||
* - 离线 (none): 4000 (4s, 让 diy 错误信号有时间触发)
|
|
||||||
* - 弱网 (2g/3g): 22000 (22s 慢网档, 多 2s 缓冲)
|
|
||||||
* - 正常 (4g/5g/wifi): 12000 (12s 默认档, 多 2s 缓冲)
|
|
||||||
* - 未知 (unknown): 17000 (17s 中档, 保守)
|
|
||||||
* - 网络未就绪: 12000 (DEFAULT_TIMEOUT, 保守)
|
|
||||||
*/
|
|
||||||
_pickTimeoutByNetwork() {
|
|
||||||
if (!this._networkReady) return DEFAULT_TIMEOUT // 网络检测未就绪, 用默认
|
|
||||||
switch (this._networkType) {
|
|
||||||
case 'none': return 4000 // 离线: 4s 兜底
|
|
||||||
case '2g':
|
|
||||||
case '3g': return 22000 // 弱网: 22s 慢网档
|
|
||||||
case '4g':
|
|
||||||
case '5g':
|
|
||||||
case 'wifi': return 12000 // 正常: 12s 默认档
|
|
||||||
case 'unknown':
|
|
||||||
default: return 17000 // 未知: 17s 中档 (保守)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
show(opts = {}) {
|
show(opts = {}) {
|
||||||
@@ -225,34 +111,7 @@ class SplashState {
|
|||||||
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
|
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效)
|
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
||||||
// 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s
|
|
||||||
// timeout 低于 5s 会经常触发兜底强关, 用户看到白屏
|
|
||||||
// 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn
|
|
||||||
// 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT
|
|
||||||
const MIN_RECOMMENDED_TIMEOUT = 5000
|
|
||||||
let timeout
|
|
||||||
if (opts.timeout) {
|
|
||||||
timeout = opts.timeout
|
|
||||||
if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) {
|
|
||||||
console.warn(
|
|
||||||
`[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` +
|
|
||||||
`可能导致启动期白屏. 生产环境建议 10000-20000ms.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 自动选档: 根据当前网络环境选 timeout
|
|
||||||
timeout = this._pickTimeoutByNetwork()
|
|
||||||
}
|
|
||||||
this._armTimeout(timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置/重设 splash 总超时兜底
|
|
||||||
* - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用
|
|
||||||
* - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏
|
|
||||||
*/
|
|
||||||
_armTimeout(timeout) {
|
|
||||||
if (this._timeoutId) {
|
if (this._timeoutId) {
|
||||||
clearTimeout(this._timeoutId);
|
clearTimeout(this._timeoutId);
|
||||||
this._timeoutId = null;
|
this._timeoutId = null;
|
||||||
@@ -266,55 +125,6 @@ class SplashState {
|
|||||||
}, timeout);
|
}, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 网络变化时更新 _networkType, 并按需重启 splash 兜底
|
|
||||||
* @param {string} newType 新的网络类型
|
|
||||||
* @param {Object} [meta] 附加信息 { isConnected, source: 'h5-online' | 'uni-change' | ... }
|
|
||||||
* @returns {boolean} 是否真的更新了 (新旧不同时为 true)
|
|
||||||
*/
|
|
||||||
_updateNetworkType(newType, meta) {
|
|
||||||
newType = newType || 'unknown'
|
|
||||||
const oldType = this._networkType
|
|
||||||
const changed = oldType !== newType
|
|
||||||
// 关键: 即使 type 没变, _networkReady 也要设 true
|
|
||||||
// 原因: 首次调用 _updateNetworkType 是从 _initNetworkDetect 进来
|
|
||||||
// 此时 _networkType='unknown' (初始), 可能 newType 也是 'unknown' (uni 不可用)
|
|
||||||
// 但 _networkReady 仍要从 false → true (让 _pickTimeoutByNetwork 工作)
|
|
||||||
this._networkReady = true
|
|
||||||
if (!changed) return false
|
|
||||||
this._networkType = newType
|
|
||||||
|
|
||||||
// 网络变化时: 如果 splash 还 visible, 重启兜底 (用新档)
|
|
||||||
// 业务场景:
|
|
||||||
// - wifi → 2g: 启动期远端慢, 兜底延长, 避免过早强关
|
|
||||||
// - 2g → wifi: 兜底缩短, 避免过晚强关
|
|
||||||
// - any → none: 兜底缩短到 4s, 让 diy 错误信号更快触发
|
|
||||||
// - none → any: 兜底恢复正常档, 给足时间
|
|
||||||
const shouldReboot = this._visible && this._pendingHide === null
|
|
||||||
// 关键: 仅当 splash 还没进入"软隐藏"阶段时重启兜底
|
|
||||||
// _pendingHide 不为 null 表示业务方已调 hide(), 等 notifyPageReady/DiyLoadError 即可
|
|
||||||
// 此时网络变化不影响兜底 (业务方已接管决策)
|
|
||||||
if (shouldReboot) {
|
|
||||||
const newTimeout = this._pickTimeoutByNetwork()
|
|
||||||
this._armTimeout(newTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发 splash:network-change 事件 (业务方监听)
|
|
||||||
// payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout }
|
|
||||||
// retryRecommended: 从 none 恢复时建议重试 (业务方重调 diy 接口)
|
|
||||||
// rebootSplashTimeout: 是否重启了 splash 兜底 (true/false)
|
|
||||||
const eventPayload = {
|
|
||||||
from: oldType,
|
|
||||||
to: newType,
|
|
||||||
isConnected: meta && typeof meta.isConnected === 'boolean' ? meta.isConnected : newType !== 'none',
|
|
||||||
retryRecommended: oldType === 'none' && newType !== 'none', // 离线→在线, 建议重试
|
|
||||||
rebootSplashTimeout: shouldReboot,
|
|
||||||
source: (meta && meta.source) || 'unknown',
|
|
||||||
}
|
|
||||||
this._emit('splash:network-change', eventPayload)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 隐藏 splash
|
* 隐藏 splash
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
@@ -326,22 +136,15 @@ class SplashState {
|
|||||||
* @param {string} [opts.mode] - 离开动画模式
|
* @param {string} [opts.mode] - 离开动画模式
|
||||||
* - 'default': 默认 480ms 动画
|
* - 'default': 默认 480ms 动画
|
||||||
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
||||||
* - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
|
|
||||||
* 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
|
|
||||||
* 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
|
|
||||||
* splash 必须真等到首页 diy 内容渲染好才能关
|
|
||||||
*/
|
*/
|
||||||
hide(opts = {}) {
|
hide(opts = {}) {
|
||||||
const reason = opts.reason || 'manual';
|
const reason = opts.reason || 'manual';
|
||||||
const useStrict = opts.mode === 'strict' || opts._strict === true;
|
|
||||||
// boot-complete 默认走软隐藏, 避免时序问题
|
// boot-complete 默认走软隐藏, 避免时序问题
|
||||||
const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||||
// strict 模式复用 fast 动画
|
const mode = opts.mode === 'fast' ? 'fast' : 'default';
|
||||||
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
|
|
||||||
|
|
||||||
if (useSoft) {
|
if (useSoft) {
|
||||||
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
|
this._softHide({ reason, mode });
|
||||||
this._softHide(opts);
|
|
||||||
} else {
|
} else {
|
||||||
this._doHide({ reason, mode });
|
this._doHide({ reason, mode });
|
||||||
}
|
}
|
||||||
@@ -351,17 +154,10 @@ class SplashState {
|
|||||||
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
|
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
|
||||||
* 如果组件 1s 内没 mount, 兜底自动 hide
|
* 如果组件 1s 内没 mount, 兜底自动 hide
|
||||||
* 如果页面 2s 内没就绪, 兜底自动 hide
|
* 如果页面 2s 内没就绪, 兜底自动 hide
|
||||||
*
|
|
||||||
* strict 模式 (鸿蒙已接入平台托管):
|
|
||||||
* 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
|
|
||||||
* 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
|
|
||||||
* 保证 splash 一定等到首页 diy 内容真的渲染好
|
|
||||||
*/
|
*/
|
||||||
_softHide(opts) {
|
_softHide(opts) {
|
||||||
if (!this._visible) return; // 已经隐藏了
|
if (!this._visible) return; // 已经隐藏了
|
||||||
this._pendingHide = opts;
|
this._pendingHide = opts;
|
||||||
// strict 模式识别: mode='strict' 或 _strict=true
|
|
||||||
const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
|
|
||||||
|
|
||||||
// 如果组件已经 mount 且页面已就绪, 立即隐藏
|
// 如果组件已经 mount 且页面已就绪, 立即隐藏
|
||||||
if (this._hasMounted && this._pageReady) {
|
if (this._hasMounted && this._pageReady) {
|
||||||
@@ -369,12 +165,6 @@ class SplashState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
|
|
||||||
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
|
|
||||||
if (useStrict) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兜底: 1s 内没 mount, 强制隐藏
|
// 兜底: 1s 内没 mount, 强制隐藏
|
||||||
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
||||||
this._mountGraceId = setTimeout(() => {
|
this._mountGraceId = setTimeout(() => {
|
||||||
@@ -387,27 +177,15 @@ class SplashState {
|
|||||||
/**
|
/**
|
||||||
* 真正触发软隐藏
|
* 真正触发软隐藏
|
||||||
* 如果页面还未就绪, 等待页面就绪后再 hide
|
* 如果页面还未就绪, 等待页面就绪后再 hide
|
||||||
* strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
|
|
||||||
*/
|
*/
|
||||||
_flushPendingHide() {
|
_flushPendingHide() {
|
||||||
if (this._mountGraceId) {
|
if (this._mountGraceId) {
|
||||||
clearTimeout(this._mountGraceId);
|
clearTimeout(this._mountGraceId);
|
||||||
this._mountGraceId = null;
|
this._mountGraceId = null;
|
||||||
}
|
}
|
||||||
// 已被 notifyDiyLoadError 拿走 _pendingHide 并自己 _doHide: noop
|
|
||||||
// 原因: notifyMounted 50ms 后会调 _flushPendingHide, 但 _pendingHide 已被 notifyDiyLoadError 消费
|
|
||||||
// 不应再调 _doHide (避免重复发 splash:hide 或时序错乱)
|
|
||||||
if (!this._pendingHide) return;
|
|
||||||
const _opts = this._pendingHide
|
|
||||||
const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true)
|
|
||||||
|
|
||||||
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
|
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
|
||||||
if (!this._pageReady) {
|
if (!this._pageReady) {
|
||||||
// strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
|
|
||||||
// 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
|
|
||||||
if (useStrict) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 兜底: 2s 内没就绪, 强制隐藏
|
// 兜底: 2s 内没就绪, 强制隐藏
|
||||||
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
|
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
|
||||||
this._pageReadyGraceId = setTimeout(() => {
|
this._pageReadyGraceId = setTimeout(() => {
|
||||||
@@ -426,11 +204,6 @@ class SplashState {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 实际隐藏 (硬路径)
|
* 实际隐藏 (硬路径)
|
||||||
* @param {Object} opts
|
|
||||||
* @param {string} [opts.reason='manual'] 隐藏原因
|
|
||||||
* @param {string} [opts.mode='default'] 动画模式
|
|
||||||
* @param {boolean} [opts._silent=false] true=不发 splash:hide 事件
|
|
||||||
* 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide
|
|
||||||
*/
|
*/
|
||||||
_doHide(opts) {
|
_doHide(opts) {
|
||||||
if (!this._visible) return; // 防重入
|
if (!this._visible) return; // 防重入
|
||||||
@@ -452,8 +225,6 @@ class SplashState {
|
|||||||
if (useNativeLoading()) {
|
if (useNativeLoading()) {
|
||||||
try { uni.hideLoading(); } catch (e) {}
|
try { uni.hideLoading(); } catch (e) {}
|
||||||
}
|
}
|
||||||
// _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide
|
|
||||||
if (opts._silent) return;
|
|
||||||
this._emit('splash:hide', {
|
this._emit('splash:hide', {
|
||||||
reason: opts.reason || 'manual',
|
reason: opts.reason || 'manual',
|
||||||
mode: opts.mode || 'default',
|
mode: opts.mode || 'default',
|
||||||
@@ -493,60 +264,6 @@ class SplashState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* diy 加载失败时通知 (网络异常/业务失败/无响应)
|
|
||||||
* - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上)
|
|
||||||
* - 触发独立事件 'splash:diy-error' (不是 'splash:hide')
|
|
||||||
* 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型
|
|
||||||
* 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError)
|
|
||||||
*
|
|
||||||
* @param {Object} [opts]
|
|
||||||
* @param {string} [opts.reason='diy-load-error'] 隐藏原因
|
|
||||||
* 用于 'splash:diy-error' 事件的 payload, 业务方可区分:
|
|
||||||
* - 'diy-network-error': 网络异常 (uni.request catch)
|
|
||||||
* - 'diy-no-response': 无响应 (res 为空)
|
|
||||||
* - 'diy-business-error': 业务失败 (code!=0)
|
|
||||||
* @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response'
|
|
||||||
* @param {Object|string} [opts.err] 原始错误对象
|
|
||||||
* @param {string} [opts.url] 出错的请求地址
|
|
||||||
*/
|
|
||||||
notifyDiyLoadError(opts = {}) {
|
|
||||||
// 已 _pageReady=true (notifyPageReady 之后): noop
|
|
||||||
// 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱)
|
|
||||||
// 不应再触发 splash:diy-error (已经发过 splash:hide)
|
|
||||||
if (this._pageReady) return;
|
|
||||||
// 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪")
|
|
||||||
// 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉
|
|
||||||
this._pageReady = true
|
|
||||||
// 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError)
|
|
||||||
if (this._pageReadyGraceId) {
|
|
||||||
clearTimeout(this._pageReadyGraceId)
|
|
||||||
this._pageReadyGraceId = null
|
|
||||||
}
|
|
||||||
if (this._mountGraceId) {
|
|
||||||
clearTimeout(this._mountGraceId)
|
|
||||||
this._mountGraceId = null
|
|
||||||
}
|
|
||||||
// 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误)
|
|
||||||
this._emit('splash:diy-error', {
|
|
||||||
reason: opts.reason || 'diy-load-error',
|
|
||||||
errorType: opts.errorType,
|
|
||||||
err: opts.err,
|
|
||||||
url: opts.url,
|
|
||||||
})
|
|
||||||
// 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide
|
|
||||||
// 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件
|
|
||||||
// notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide
|
|
||||||
// (业务方根据事件类型区分, 不能混淆)
|
|
||||||
if (this._pendingHide) {
|
|
||||||
// 把 _pendingHide 取出, 直接 _doHide 关闭
|
|
||||||
// _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error)
|
|
||||||
const _pendingOpts = this._pendingHide
|
|
||||||
this._pendingHide = null
|
|
||||||
setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
|
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
|
||||||
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
|
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// 启动广告弹出信号
|
|
||||||
// 用于判定"当前是否需要弹出启动广告":
|
|
||||||
// - 小程序冷启动(首次进入): 由模块级 _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,13 +62,7 @@ export default {
|
|||||||
},
|
},
|
||||||
...{
|
...{
|
||||||
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
||||||
hover_nav_text_color: '#000', // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
hover_nav_text_color: '#000' // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
||||||
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
|
||||||
// ===== 逐按钮字号(与 hover_nav_lang/kefu/phone 的 bg/text/size 同约定, 基准 36rpx) =====
|
|
||||||
// 未单独配置时回退到上面的 hover_nav_font_size(全局旋钮)
|
|
||||||
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
|
||||||
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
|
||||||
hover_nav_phone_font_size: '36rpx' // 电话按钮内图标字号基准
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
||||||
@@ -77,10 +71,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -274,10 +264,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -470,10 +456,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -666,10 +648,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -862,10 +840,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -1058,10 +1032,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -1254,10 +1224,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
@@ -1450,10 +1416,6 @@ export default {
|
|||||||
...{
|
...{
|
||||||
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
|
||||||
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
|
||||||
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
|
|
||||||
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
|
|
||||||
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
|
||||||
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
|
||||||
diy_category_item_name_size: '24rpx',// 分类项名称
|
diy_category_item_name_size: '24rpx',// 分类项名称
|
||||||
|
|||||||
@@ -702,10 +702,18 @@ export function _safeEncodeForPlatform(v) {
|
|||||||
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
|
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
|
||||||
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
|
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||||
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
|
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
|
||||||
if (['mp-weixin','mp-harmony'].includes(platform)) {
|
if (platform === 'mp-weixin') {
|
||||||
return _encodeUrlDelimiters(once);
|
return _encodeUrlDelimiters(once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 鸿蒙元服务: uni.navigateTo 内部整体 encode url(把中文、& 等都 encode)。
|
||||||
|
// JS 侧完全不 encode,直接返回 decode roundtrip 后的原文,让鸿蒙原生做唯一一次 encode。
|
||||||
|
// (再 encode 会双重编码 → onLoad 拿到 %E6%B2%99%E5%8F%91 乱码)
|
||||||
|
// webview URL 含 & 时, JS 不 encode,鸿蒙原生 encode 后 & 变 %26,webview 能正常打开。
|
||||||
|
if (platform === 'mp-harmony') {
|
||||||
|
return once;
|
||||||
|
}
|
||||||
|
|
||||||
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
|
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
|
||||||
return encodeURIComponent(once);
|
return encodeURIComponent(once);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,36 +102,6 @@ export function parsePageParams(raw, defaultValue = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
|
|
||||||
*
|
|
||||||
* 设计动机:URL query 中布尔值的合法写法不止一种——
|
|
||||||
* ?show_home=1 -> '1' -> true
|
|
||||||
* ?show_home=true -> 'true' -> true
|
|
||||||
* ?show_home=true -> true -> true
|
|
||||||
* ?show_home=0 -> '0' -> false
|
|
||||||
* ?show_home=false -> 'false' -> false
|
|
||||||
* ?show_home= -> '' -> false
|
|
||||||
* 未传 -> undefined -> false
|
|
||||||
*
|
|
||||||
* 与裸 `Boolean(x)` 的差异:
|
|
||||||
* Boolean('false') === true (任何非空字符串都是 true)
|
|
||||||
* Boolean('0') === true (同上)
|
|
||||||
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
|
|
||||||
*
|
|
||||||
* @param {*} val 任意值
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function coerceBool(val) {
|
|
||||||
if (val === true || val === 1) return true;
|
|
||||||
if (typeof val === 'string') {
|
|
||||||
const s = val.trim().toLowerCase();
|
|
||||||
return s === '1' || s === 'true';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 关于页面路径,使用常量导出
|
// 关于页面路径,使用常量导出
|
||||||
export const INDEX_PAGE_URL = '/pages/index/index';
|
export const INDEX_PAGE_URL = '/pages/index/index';
|
||||||
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
||||||
@@ -139,7 +109,6 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
|
|||||||
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
|
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
|
||||||
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
|
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
|
||||||
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
|
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
|
||||||
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
|
|
||||||
|
|
||||||
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
||||||
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
||||||
@@ -200,16 +169,6 @@ export const PAGE_KEY_MAP = {
|
|||||||
// === 电子名片 ===
|
// === 电子名片 ===
|
||||||
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
||||||
|
|
||||||
// === 名片云(card 插件) ===
|
|
||||||
PAGE_CARD_LIST: '/pages_tool/card/list',
|
|
||||||
PAGE_CARD_LEADS: '/pages_tool/card/leads',
|
|
||||||
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
|
|
||||||
|
|
||||||
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
|
|
||||||
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
|
||||||
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
|
|
||||||
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads',
|
|
||||||
|
|
||||||
// === VR ===
|
// === VR ===
|
||||||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||||||
|
|
||||||
@@ -498,9 +457,6 @@ export default {
|
|||||||
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
||||||
parsePageParams,
|
parsePageParams,
|
||||||
|
|
||||||
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
|
|
||||||
coerceBool,
|
|
||||||
|
|
||||||
/** HTML 实体解码 */
|
/** HTML 实体解码 */
|
||||||
decodeEntity,
|
decodeEntity,
|
||||||
|
|
||||||
@@ -555,27 +511,6 @@ export default {
|
|||||||
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
||||||
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
||||||
//
|
//
|
||||||
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
|
|
||||||
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
|
|
||||||
// 修复场景:
|
|
||||||
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
|
|
||||||
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
|
|
||||||
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
|
|
||||||
// onShow 死循环跳登录页.
|
|
||||||
// 排除场景 (重要, 避免影响):
|
|
||||||
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
|
|
||||||
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
|
|
||||||
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
|
|
||||||
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
|
|
||||||
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
|
|
||||||
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
|
|
||||||
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
|
|
||||||
if (cleanPath === LOGIN_PAGE_URL) {
|
|
||||||
mode = 'reLaunch';
|
|
||||||
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
||||||
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
||||||
|
|
||||||
@@ -603,18 +538,18 @@ export default {
|
|||||||
merged.page_params = page_params;
|
merged.page_params = page_params;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
|
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
|
||||||
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
||||||
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
|
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
|
||||||
// 是已编码字符串(典型表现:商品分类 → 商品列表,
|
// 是已编码字符串(典型表现:商品分类 → 商品列表,
|
||||||
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
|
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
|
||||||
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
|
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
|
||||||
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
|
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
|
||||||
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
|
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
|
||||||
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
|
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
|
||||||
// 成 top-level 分隔符丢参数)
|
// 成 top-level 分隔符丢参数)
|
||||||
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
|
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
|
||||||
// 做 decode roundtrip + encode 一次)
|
// 做 decode roundtrip + encode 一次)
|
||||||
const queryStr = Object.keys(merged)
|
const queryStr = Object.keys(merged)
|
||||||
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
|
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
|
||||||
.join('&');
|
.join('&');
|
||||||
@@ -680,33 +615,30 @@ export default {
|
|||||||
const diyTabBar = store.state.tabBarList;
|
const diyTabBar = store.state.tabBarList;
|
||||||
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
|
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
|
||||||
const cleanUrl = url.split('?')[0];
|
const cleanUrl = url.split('?')[0];
|
||||||
const isExcludedPage = NO_TAB_PAGES.has(cleanUrl);
|
const isDiyTabRoot = diyTabBar.list.some(item => {
|
||||||
if (!isExcludedPage) {
|
if (!item || !item.link || !item.link.wap_url) return false;
|
||||||
const isDiyTabRoot = diyTabBar.list.some(item => {
|
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
|
||||||
if (!item || !item.link || !item.link.wap_url) return false;
|
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
|
||||||
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
|
return normalizedTab === normalizedUrl;
|
||||||
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
|
});
|
||||||
return normalizedTab === normalizedUrl;
|
if (isDiyTabRoot) {
|
||||||
|
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
|
||||||
|
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
|
||||||
|
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
|
||||||
|
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
|
||||||
|
|
||||||
|
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
navApi = uni.reLaunch;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
navApi({
|
||||||
|
url,
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (isDiyTabRoot) {
|
return;
|
||||||
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
|
|
||||||
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
|
|
||||||
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
|
|
||||||
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
|
|
||||||
|
|
||||||
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
|
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
navApi = uni.reLaunch;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
navApi({
|
|
||||||
url,
|
|
||||||
fail: (err) => {
|
|
||||||
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,41 +669,6 @@ export default {
|
|||||||
break;
|
break;
|
||||||
case 'reLaunch':
|
case 'reLaunch':
|
||||||
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
||||||
// H5 平台修复 (2026-07-23):
|
|
||||||
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
|
|
||||||
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
|
|
||||||
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
|
|
||||||
// 注销/退出登录后用户能看到旧会员信息。
|
|
||||||
// location.replace 不产生新历史条目, 直接替换当前条目,
|
|
||||||
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
|
|
||||||
// #ifdef H5
|
|
||||||
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
|
|
||||||
&& typeof window.location.replace === 'function') {
|
|
||||||
window.location.replace(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
// 小程序平台修复 (2026-07-23):
|
|
||||||
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
|
|
||||||
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
|
|
||||||
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
|
|
||||||
// 解决方案:
|
|
||||||
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
|
|
||||||
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
|
|
||||||
// #ifdef MP
|
|
||||||
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
|
|
||||||
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
globalThis.__reLaunchLock = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.reLaunch({ url });
|
|
||||||
// 300ms 后释放锁
|
|
||||||
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
|
|
||||||
}, 0);
|
|
||||||
return;
|
|
||||||
// #endif
|
|
||||||
// APP / 其他平台: 原生 reLaunch
|
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url
|
url
|
||||||
});
|
});
|
||||||
@@ -1214,23 +1111,6 @@ export default {
|
|||||||
* @param {Object} link
|
* @param {Object} link
|
||||||
*/
|
*/
|
||||||
diyRedirectTo(link) {
|
diyRedirectTo(link) {
|
||||||
|
|
||||||
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
|
|
||||||
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
|
|
||||||
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
|
|
||||||
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
|
|
||||||
const title = link.title || 'AI 智能体';
|
|
||||||
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
|
|
||||||
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
|
|
||||||
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
|
|
||||||
return this.redirectTo(AI_AGENT_PAGE_URL, {
|
|
||||||
agent_id: agentId,
|
|
||||||
title: encodeURIComponent(title),
|
|
||||||
enable_set_title: link.enable_set_title ? 1 : 0,
|
|
||||||
...(dyuEntry ? { __dyu: dyuEntry } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
|
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
|
||||||
//if (link == null || Object.keys(link).length == 1) return;
|
//if (link == null || Object.keys(link).length == 1) return;
|
||||||
|
|
||||||
@@ -1264,21 +1144,6 @@ export default {
|
|||||||
link.wap_url = resolvedUrl;
|
link.wap_url = resolvedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 仅微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页 =====
|
|
||||||
// 命中: page_key=PAGE_FILES_DETAIL / name=FILES_xx / wap_url 指向 files/detail
|
|
||||||
// 且能提取到 files_id。拉取或打开失败时自动降级回原详情页(openDiyFileDirect 内部处理)。
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
if (link && (link.page_key === 'PAGE_FILES_DETAIL'
|
|
||||||
|| (link.name && /^FILES_\d+$/i.test(String(link.name)))
|
|
||||||
|| String(link.wap_url || '').indexOf('files/detail') !== -1)) {
|
|
||||||
const filesId = this._extractFilesIdFromLink(link);
|
|
||||||
if (filesId) {
|
|
||||||
this.openDiyFileDirect(link, filesId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
||||||
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
||||||
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
||||||
@@ -1452,123 +1317,6 @@ 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()
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
/**
|
||||||
* 获取默认图
|
* 获取默认图
|
||||||
*/
|
*/
|
||||||
@@ -1786,118 +1534,59 @@ export default {
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取小程序分享内容数据
|
* 获取小程序分享内容数据
|
||||||
*
|
|
||||||
* 重要:本函数保证永远 resolve(绝不 reject)。
|
|
||||||
* 原因:外部 8 处调用(category.vue / diy.js / promote_code.vue / contact.vue 等)
|
|
||||||
* 全部只写了 .then(res => {}) 没有 .catch(),一旦 reject 就会变成 Unhandled Promise Rejection,
|
|
||||||
* 微信小程序框架会把它包成 Error: MiniProgramError 并以红框形式打印到控制台,污染日志且易被
|
|
||||||
* 误判为业务崩溃。后台未配置某路径的分享(data:null)、接口返回 code<0、甚至 HTTP 网络失败,
|
|
||||||
* 全部退化为"从传入 path 构造最小分享对象",保证调用方始终能拿到可消费的结构。
|
|
||||||
*/
|
*/
|
||||||
getMpShare(path) {
|
getMpShare(path) {
|
||||||
// 如果没有特别指定 则获取当前页面的路由
|
//如果没有特别指定 则获取当前页面的路由
|
||||||
if (!path) {
|
if (!path) {
|
||||||
let route = this.getCurrentRoute();
|
let route = this.getCurrentRoute();
|
||||||
path = route.path;
|
path = route.path;
|
||||||
if (path == '/pages_tool/member/index') {
|
if (path == '/pages_tool/member/index') {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
resolve({})
|
resolve({})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 从传入 path 生成的兜底分享(后台未配置 / 接口异常时使用)
|
return new Promise((resolve, reject) => {
|
||||||
const buildFallback = (p) => {
|
|
||||||
const fallbackPath = p || '/pages/index/index';
|
|
||||||
const fallback = {
|
|
||||||
title: '小程序分享',
|
|
||||||
path: fallbackPath,
|
|
||||||
imageUrl: ''
|
|
||||||
};
|
|
||||||
const appMessage = {
|
|
||||||
title: fallback.title,
|
|
||||||
path: fallback.path,
|
|
||||||
imageUrl: fallback.imageUrl,
|
|
||||||
success: () => {},
|
|
||||||
fail: () => {}
|
|
||||||
};
|
|
||||||
const query = (fallback.path.indexOf('?') > 0) ? fallback.path.split('?')[1] : '';
|
|
||||||
const timeLine = {
|
|
||||||
title: fallback.title,
|
|
||||||
query: fallback.path,
|
|
||||||
imageUrl: fallback.imageUrl
|
|
||||||
};
|
|
||||||
return { appMessage, timeLine, _fallback: true, query };
|
|
||||||
};
|
|
||||||
// 通用构建:从后端返回的 {title,path,imageUrl} 拼成标准结构
|
|
||||||
const buildFromConfig = (shareConfig) => {
|
|
||||||
const appMessage = {
|
|
||||||
title: shareConfig.title,
|
|
||||||
path: shareConfig.path,
|
|
||||||
imageUrl: shareConfig.imageUrl || '',
|
|
||||||
success: () => {},
|
|
||||||
fail: () => {}
|
|
||||||
};
|
|
||||||
const query = (shareConfig.path && shareConfig.path.indexOf('?') > 0)
|
|
||||||
? shareConfig.path.split('?')[1]
|
|
||||||
: '';
|
|
||||||
const timeLine = {
|
|
||||||
title: shareConfig.title,
|
|
||||||
query: shareConfig.path,
|
|
||||||
imageUrl: shareConfig.imageUrl || ''
|
|
||||||
};
|
|
||||||
return { appMessage, timeLine, query };
|
|
||||||
};
|
|
||||||
const fallback = buildFallback(path);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
Http.sendRequest({
|
Http.sendRequest({
|
||||||
url: "/weapp/api/weapp/share",
|
url: "/weapp/api/weapp/share",
|
||||||
data: {
|
data: {
|
||||||
path: path
|
path: path
|
||||||
},
|
},
|
||||||
success: res => {
|
success: res => {
|
||||||
try {
|
if (res.code >= 0) {
|
||||||
if (!res || typeof res !== 'object') {
|
let shareConfig = res.data.data;
|
||||||
resolve(fallback);
|
if (shareConfig) {
|
||||||
return;
|
//分享给好友
|
||||||
}
|
let appMessageData = {
|
||||||
if (res.code >= 0) {
|
title: shareConfig.title,
|
||||||
// 响应可能是两种形态:
|
path: shareConfig.path,
|
||||||
// A) { code, message, data: { title, path, imageUrl } }
|
imageUrl: shareConfig.imageUrl,
|
||||||
// B) { code, message, permission:{...}, data: null }
|
success: res => {
|
||||||
// 统一安全解包:外层 payload 可能在 res.data 里,也可能直接在 res 里
|
},
|
||||||
const outer = res.data !== undefined ? res.data : res;
|
fail: res => {
|
||||||
let shareConfig = null;
|
|
||||||
if (outer && typeof outer === 'object') {
|
|
||||||
// 后端在 outer 内嵌真正的配置对象(A 形态):outer.data = {title, path, ...}
|
|
||||||
if (outer.data && typeof outer.data === 'object' && (outer.data.title || outer.data.path)) {
|
|
||||||
shareConfig = outer.data;
|
|
||||||
} else if (outer.title || outer.path) {
|
|
||||||
// 极少数后端版本把配置平铺在 outer 上(扁平形态)
|
|
||||||
shareConfig = outer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shareConfig) {
|
//分享到朋友圈
|
||||||
resolve(buildFromConfig(shareConfig));
|
let query = '';
|
||||||
} else {
|
if (shareConfig.path.indexOf('?') > 0) {
|
||||||
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
|
query = shareConfig.path.split('?')[1];
|
||||||
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
|
|
||||||
resolve(fallback);
|
|
||||||
}
|
}
|
||||||
|
let timeLineData = {
|
||||||
|
title: shareConfig.title,
|
||||||
|
query: shareConfig.path,
|
||||||
|
imageUrl: shareConfig.imageUrl,
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
appMessage: appMessageData,
|
||||||
|
timeLine: timeLineData,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
|
reject(res.data);
|
||||||
resolve(fallback);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else {
|
||||||
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
|
reject(res.data)
|
||||||
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
|
|
||||||
resolve(fallback);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
fail: () => {
|
|
||||||
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
|
|
||||||
resolve(fallback);
|
|
||||||
},
|
|
||||||
complete: () => {}
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,92 +0,0 @@
|
|||||||
// iOS 微信小程序 <video> 兼容工具
|
|
||||||
// 问题背景:
|
|
||||||
// 1) 弹窗内的 <video> 用 autoplay 不可靠——popup 初始隐藏,autoplay 在隐藏态不触发,
|
|
||||||
// 等 popup 显示后也不会重新 autoplay,导致"安卓有时能播、iOS 不能";
|
|
||||||
// 2) iOS 对同一个 <video> 实例在 src 重绑 / 弹窗重开 / 播放结束后,不会自动重新加载,
|
|
||||||
// 需要靠 :key 重建实例或 seek(0) 才能重播;
|
|
||||||
// 3) iOS 微信不支持 webm / vp9 / hevc / ogv 等封装或编码,会直接黑屏;
|
|
||||||
// 4) H5 端(含微信内置浏览器)$nextTick 调 play() 太快,弹窗动画未完时浏览器内部
|
|
||||||
// 会自动 pause(),导致 "The play() request was interrupted by a call to pause()" 错误。
|
|
||||||
// 这里统一提供"格式检测"与"在视频可见后触发播放"的能力,供各视频组件复用。
|
|
||||||
//
|
|
||||||
// 重要:微信小程序的 <video>.play() 不要求用户手势(与 H5 不同),只要在视频元素
|
|
||||||
// 已经渲染、popup 已经显示后调用即可。因此请务必在 uni-popup 的 @change(show=true)
|
|
||||||
// 回调里调用本方法,而不是在 open() 里紧跟着 $nextTick 调用(那时元素尚未可见)。
|
|
||||||
|
|
||||||
// iOS 微信 <video> 已知不支持的封装/编码(仅按扩展名做保守判断,用于给出友好提示)
|
|
||||||
const UNSUPPORTED_EXT_ON_IOS = ['webm', 'ogv', 'ogg', 'mov', 'm4v', 'hevc', 'av1', '3gp'];
|
|
||||||
|
|
||||||
// 取 URL 中的小写扩展名(忽略查询参数与 hash)
|
|
||||||
export function getVideoExt(url) {
|
|
||||||
if (!url || typeof url !== 'string') return '';
|
|
||||||
const clean = url.split('?')[0].split('#')[0];
|
|
||||||
const idx = clean.lastIndexOf('.');
|
|
||||||
if (idx < 0) return '';
|
|
||||||
return clean.slice(idx + 1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 该地址在 iOS 上是否很可能无法播放(基于扩展名的保守判断)
|
|
||||||
export function isLikelyUnsupportedOnIOS(url) {
|
|
||||||
const ext = getVideoExt(url);
|
|
||||||
if (!ext) return false;
|
|
||||||
return UNSUPPORTED_EXT_ON_IOS.indexOf(ext) !== -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在"视频已经渲染、popup 已经显示"的前提下触发播放。
|
|
||||||
// 必须在 uni-popup 的 @change(show=true) 回调里调用,此时 <video> 元素已可见,
|
|
||||||
// createVideoContext 才能拿到有效实例。
|
|
||||||
// videoId: <video> 的 id;instance: 当前组件/页面实例(组件内必传)。
|
|
||||||
export function playVideoInContext(videoId, instance) {
|
|
||||||
if (!videoId || !instance) return;
|
|
||||||
|
|
||||||
// H5 端需要更长延迟:等 uni-popup 动画播完(~350ms),否则浏览器内部会自动
|
|
||||||
// pause() 导致 "play() interrupted by pause()" 错误;小程序端 $nextTick 即可。
|
|
||||||
// #ifdef H5
|
|
||||||
const delay = 400;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifndef H5
|
|
||||||
const delay = 0;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
const doPlay = () => {
|
|
||||||
let ctx = null;
|
|
||||||
try {
|
|
||||||
ctx = uni.createVideoContext(videoId, instance);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[video-helper] createVideoContext failed:', e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
ctx.play();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[video-helper] play() failed, will retry:', e);
|
|
||||||
// H5 端首次 play() 常被浏览器自动 pause() 打断,延迟重试一次
|
|
||||||
// #ifdef H5
|
|
||||||
setTimeout(() => {
|
|
||||||
try { ctx.play(); } catch (retryErr) {
|
|
||||||
console.error('[video-helper] retry play() also failed:', retryErr);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
// H5:先等 nextTick(DOM 更新),再等 delay(弹窗动画完成)
|
|
||||||
if (typeof instance.$nextTick === 'function') {
|
|
||||||
instance.$nextTick(() => setTimeout(doPlay, delay));
|
|
||||||
} else {
|
|
||||||
setTimeout(doPlay, delay);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 小程序端:nextTick 即可
|
|
||||||
if (typeof instance.$nextTick === 'function') {
|
|
||||||
instance.$nextTick(doPlay);
|
|
||||||
} else {
|
|
||||||
doPlay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@@ -1,35 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 修复底部 TabBar 频闪:
|
<view data-component-name="diy-bottom-nav" v-if="shouldShowTabBar">
|
||||||
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
|
<view class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
||||||
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
|
<view class="tabbar-border"></view>
|
||||||
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
|
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
||||||
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
|
<view class="bd">
|
||||||
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
|
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
||||||
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
:animation="isCartItem(item.link) ? cartAnimation : null"
|
||||||
<view class="tabbar-border"></view>
|
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
||||||
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
<block v-if="verify(item.link)">
|
||||||
<view class="bd">
|
<image v-if="item.selected_icon_type == 'img'"
|
||||||
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
:src="$util.img(item.selectedIconPath)" />
|
||||||
:animation="isCartItem(item.link) ? cartAnimation : null"
|
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
||||||
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
||||||
<block v-if="verify(item.link)">
|
</block>
|
||||||
<image v-if="item.selected_icon_type == 'img'"
|
<block v-else>
|
||||||
:src="$util.img(item.selectedIconPath)" />
|
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
||||||
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
||||||
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
:value="item.style ? item.style : null"></diy-icon>
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<view class="cart-count-mark font-size-activity-tag"
|
||||||
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
:class="{ max: cartNumber > 99 }"
|
||||||
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
:style="{ background: 'var(--price-color)' }"
|
||||||
:value="item.style ? item.style : null"></diy-icon>
|
v-if="isCartItem(item.link) && cartNumber > 0">
|
||||||
</block>
|
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
||||||
<view class="cart-count-mark font-size-activity-tag"
|
|
||||||
:class="{ max: cartNumber > 99 }"
|
|
||||||
:style="{ background: 'var(--price-color)' }"
|
|
||||||
v-if="isCartItem(item.link) && cartNumber > 0">
|
|
||||||
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<view class="label"
|
<view class="label"
|
||||||
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
|
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
|
||||||
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
|
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
|
||||||
@@ -43,7 +38,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
|
<!-- 解决fixed定位后底部导航栏塌陷问题 -->
|
||||||
<view class="tab-bar-placeholder"></view>
|
<view class="tab-bar-placeholder"></view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -265,7 +265,7 @@ export default {
|
|||||||
// #ifndef MP-WEIXIN
|
// #ifndef MP-WEIXIN
|
||||||
if (item?.channelType === 'wechat') {
|
if (item?.channelType === 'wechat') {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: this.$lang('wechatChannel.wechatMpOnly'),
|
title: '视频号仅支持在微信小程序环境中播放',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<view :style="componentStyle" data-component-name="diy-digit">
|
<view :style="componentStyle" data-component-name="diy-digit">
|
||||||
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||||
:scroll-x="value.showStyle == 'singleSlide'">
|
:scroll-x="value.showStyle == 'singleSlide'">
|
||||||
<view class="uni-scroll-view-content">
|
<view class="uni-scroll-view-content">
|
||||||
<view v-for="(item, index) in value.list" :key="index"
|
<view v-for="(item, index) in value.list" :key="index"
|
||||||
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
|
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
|
||||||
:style="itemStyle">
|
:style="{ width: (100 / value.rowCount + '%') + ';' }">
|
||||||
<view :style="innerWrapStyle">
|
<view style="display:flex;">
|
||||||
<view :style="innerRowStyle">
|
<view :style="{
|
||||||
|
'line-height': '1.2;',
|
||||||
|
'font-size': (value.font.titlesize * 2 + 'rpx') + ';',
|
||||||
|
'font-weight': '600;',
|
||||||
|
'color': value.font.titlecolor + ';'
|
||||||
|
}">
|
||||||
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
|
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
|
||||||
:decimals="getvalue(item.title)" decimal="."
|
:decimals="getvalue(item.title)" decimal="."></uv-count-to>
|
||||||
:color="value.font.titlecolor"
|
<text :style="{
|
||||||
:fontSize="titleFontSize"
|
'margin-left': '4rpx;',
|
||||||
:bold="isBold(value.font.weight)"></uv-count-to>
|
'font-size': (value.font.unitsize * 2 + 'rpx') + ';',
|
||||||
<text :style="unitStyle">{{ item.unit }}</text>
|
'font-weight': value.font.weight + ';',
|
||||||
|
'color': value.font.unitcolor + ';'
|
||||||
|
}">{{ item.unit }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="graphic-text">
|
<view class="graphic-text">
|
||||||
<text :style="descStyle">{{ item.desc }}</text>
|
<text :style="{
|
||||||
</view>
|
'font-size': (value.font.descsize * 2 + 'rpx') + ';',
|
||||||
|
'font-weight': value.font.weight + ';',
|
||||||
|
'color': value.font.desccolor + ';'
|
||||||
|
}">{{ item.desc }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
<ns-login ref="login"></ns-login>
|
<ns-login ref="login"></ns-login>
|
||||||
@@ -43,20 +54,10 @@ export default {
|
|||||||
pageWidth: '',
|
pageWidth: '',
|
||||||
indicatorDots: false,
|
indicatorDots: false,
|
||||||
swiperCurrent: 0
|
swiperCurrent: 0
|
||||||
// #ifndef MP
|
|
||||||
,windowWidth: 375
|
|
||||||
// #endif
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
// 组件创建时的逻辑
|
// 组件创建时的逻辑
|
||||||
// #ifndef MP
|
|
||||||
try {
|
|
||||||
let info = uni.getSystemInfoSync();
|
|
||||||
this.windowWidth = info.windowWidth || info.screenWidth || 375;
|
|
||||||
if (this.windowWidth > 960) this.windowWidth = 375;
|
|
||||||
} catch (e) { }
|
|
||||||
// #endif
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
componentRefresh(newValue) {
|
componentRefresh(newValue) {
|
||||||
@@ -64,42 +65,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
// 全部返回字符串:WeChat 小程序的 style="{{obj}}" 对象绑定不会被运行时正确解析为内联 CSS
|
|
||||||
//(参考 diy-graphic-nav.vue:那里同样用字符串拼接 style="{{'width:...%'}}",能正常生效)。
|
|
||||||
// 字符串既能被 MP 直接当 cssText,也能被 H5 的 CSSOM 正确解析。
|
|
||||||
itemStyle() {
|
|
||||||
return 'width:' + (100 / this.value.rowCount) + '%;';
|
|
||||||
},
|
|
||||||
innerWrapStyle() {
|
|
||||||
return 'display:flex;justify-content:center;align-items:center;width:100%;';
|
|
||||||
},
|
|
||||||
innerRowStyle() {
|
|
||||||
return 'display:flex;align-items:baseline;justify-content:center;width:100%;line-height:1.2;';
|
|
||||||
},
|
|
||||||
unitStyle() {
|
|
||||||
// #ifdef MP
|
|
||||||
return 'margin-left:4rpx;font-size:' + (this.value.font.unitsize * 2) + 'rpx;font-weight:' + this.value.font.weight + ';color:' + this.value.font.unitcolor + ';';
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP
|
|
||||||
return 'margin-left:' + this.rpxToPx(4) + 'px;font-size:' + this.rpxToPx((Number(this.value.font.unitsize) || 12) * 2) + 'px;font-weight:' + this.value.font.weight + ';color:' + this.value.font.unitcolor + ';';
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
descStyle() {
|
|
||||||
// #ifdef MP
|
|
||||||
return 'font-size:' + (this.value.font.descsize * 2) + 'rpx;font-weight:' + this.value.font.weight + ';color:' + this.value.font.desccolor + ';';
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP
|
|
||||||
return 'font-size:' + this.rpxToPx((Number(this.value.font.descsize) || 12) * 2) + 'px;font-weight:' + this.value.font.weight + ';color:' + this.value.font.desccolor + ';';
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
titleFontSize() {
|
|
||||||
// #ifdef MP
|
|
||||||
return this.value.font.titlesize || 22;
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP
|
|
||||||
return this.rpxToPx((Number(this.value.font.titlesize) || 22) * 2);
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
componentStyle() {
|
componentStyle() {
|
||||||
let style = '';
|
let style = '';
|
||||||
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
|
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
|
||||||
@@ -118,25 +83,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// #ifndef MP
|
|
||||||
// rpx -> px:H5 行内样式里的 rpx 不生效,统一换算成 px
|
|
||||||
rpxToPx(rpx) {
|
|
||||||
let num = Number(rpx) || 0;
|
|
||||||
if (!num) return 0;
|
|
||||||
return Math.round((num * (this.windowWidth || 375) / 750) * 100) / 100;
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 获取小数位数
|
// 获取小数位数
|
||||||
getvalue(value) {
|
getvalue(value) {
|
||||||
return value % 1 !== 0 ? 2 : 0;
|
return value % 1 !== 0 ? 2 : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 是否加粗
|
|
||||||
isBold(weight) {
|
|
||||||
return weight === 'bold' || (weight && Number(weight) >= 600);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 页面跳转
|
// 页面跳转
|
||||||
redirectTo(item) {
|
redirectTo(item) {
|
||||||
if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||||
@@ -306,5 +257,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
</style>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
|
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
|
||||||
:style="style">
|
:style="style">
|
||||||
<block v-for="(item, index) in value.list" :key="index">
|
<block v-for="(item, index) in value.list" :key="index">
|
||||||
<view class="button-box" @tap.stop="onFloatBtnClick(item)"
|
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)"
|
||||||
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
|
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
|
||||||
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
|
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
|
||||||
show-menu-by-longpress />
|
show-menu-by-longpress />
|
||||||
@@ -44,44 +44,7 @@ export default {
|
|||||||
componentRefresh: function (nval) { }
|
componentRefresh: function (nval) { }
|
||||||
},
|
},
|
||||||
components: {},
|
components: {},
|
||||||
methods: {
|
methods: {},
|
||||||
/**
|
|
||||||
* 浮动按钮点击处理:
|
|
||||||
* 在跳转前给 link 注入 __dyu=FloatBtn 来源标记,
|
|
||||||
* 让目标页面(尤其是 ai-chat)能识别"来自浮动按钮"从而隐藏底部 tabBar,
|
|
||||||
* 与"从底部导航栏点击进入时显示并高亮 tabBar"的场景并存。
|
|
||||||
*
|
|
||||||
* 为什么用 __dyu 这种 3 字符命名:
|
|
||||||
* - 双下划线 __ 前缀 + 3 位字母(dy-ui 框架) 是前端约定俗成的"系统内部保留字段"标志
|
|
||||||
* (如 __proto__, __esModule),业务方配置 link 时几乎不可能使用这种短命名 → 与业务参数零冲突
|
|
||||||
* - URL 中占用空间极小,不污染业务 query 字符串
|
|
||||||
* - 未来如需扩展其他组件的入口来源,可继续使用 __dyu 前缀(如 __dyu=Banner / __dyu=Nav)
|
|
||||||
*
|
|
||||||
* 实现要点:
|
|
||||||
* - 浅拷贝 item.link 避免污染原数据(可能被 v-for 复用)
|
|
||||||
* - 把标记写入 page_params,diyRedirectTo → redirectTo 会自动拼到 URL query,
|
|
||||||
* 目标页 onLoad(options) 可直接读取
|
|
||||||
* - 同时在 wap_url 末尾追加/合并 __dyu=FloatBtn 兜底:某些路径不走 page_params 拼接
|
|
||||||
*/
|
|
||||||
onFloatBtnClick(item) {
|
|
||||||
if (!item || !item.link) return;
|
|
||||||
const link = { ...item.link };
|
|
||||||
link.page_params = Object.assign({}, link.page_params || {}, {
|
|
||||||
__dyu: 'FloatBtn'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 兜底:直接把标记拼到 wap_url query,防止 page_params 在某些分支未被透传
|
|
||||||
if (link.wap_url && typeof link.wap_url === 'string' && link.wap_url.indexOf('http') === -1) {
|
|
||||||
const sep = link.wap_url.indexOf('?') === -1 ? '?' : '&';
|
|
||||||
// 避免重复追加(精确匹配 __dyu= 而不是 __dyu,避免误伤可能的 __dyu_other)
|
|
||||||
if (link.wap_url.indexOf('__dyu=') === -1) {
|
|
||||||
link.wap_url = link.wap_url + sep + '__dyu=FloatBtn';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$util.diyRedirectTo(link);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
style() {
|
style() {
|
||||||
let style = {},
|
let style = {},
|
||||||
|
|||||||
@@ -1,84 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
|
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
|
||||||
<!-- Banner -->
|
<!-- Banner -->
|
||||||
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
|
<view v-if="value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||||
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
|
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
|
||||||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
</view>
|
||||||
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
<!-- 提示语 -->
|
||||||
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
<view v-if="value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value.form_tip }}</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 对齐):
|
<ns-newform
|
||||||
默认 mode="widthFix":宽度撑满、高度按图片原始比例自适应,图片完整显示不裁剪;
|
:data="formComponents"
|
||||||
显式配置 bannerHeight/banner_height 或 bannerCropRatio → 切 aspectFill 居中裁剪;
|
:customAttr="value"
|
||||||
显式配置 bannerCropBox → 切 widthFix + image 内联 transform translate/缩放(手动裁栏框);
|
:mode="mergedFormConfig.formLayout || 'horizontal'"
|
||||||
裁剪位置(bannerCropPosition):uni-app image 不支持 object-position,
|
:formConfig="mergedFormConfig"
|
||||||
通过 .banner-img-crop 容器的 transform translateY 控制(top/居中/底部)。 -->
|
ref="nsNewform"
|
||||||
<view class="banner-img-wrap" :class="bannerImgWrapClass" :style="bannerImgWrapStyle">
|
></ns-newform>
|
||||||
<view class="banner-img-crop" :class="'crop-' + bannerCropPosition">
|
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
|
||||||
<image :src="$util.img(value.form_banner)" :mode="bannerImgMode" class="banner-img" :style="bannerImgStyle" />
|
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
|
||||||
</view>
|
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||||
</view>
|
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 自定义表单
|
// 自定义表单
|
||||||
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
|
|
||||||
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
|
|
||||||
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
|
|
||||||
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
|
|
||||||
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
|
|
||||||
import DiyMinx from './minx.js'
|
import DiyMinx from './minx.js'
|
||||||
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
||||||
|
|
||||||
// 横幅裁剪比例白名单(与后端 form.js syncFormConfigToCSS 保持一致)
|
|
||||||
const BANNER_CROP_RATIOS = { '16:9': 16/9, '4:3': 4/3, '1:1': 1, '3:4': 3/4 }
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'diy-from',
|
name: 'diy-from',
|
||||||
components: { NsNewform },
|
components: { NsNewform },
|
||||||
@@ -99,20 +47,6 @@ export default {
|
|||||||
showSubmit: {
|
showSubmit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
|
||||||
// 父级容器左右内边距(rpx)。卡片模式下让 banner 图片突破这个内边距左右铺满(如 system-form-wrap 的 padding-x)。
|
|
||||||
// 仅在 hasBannerMode=true 时生效;默认 0(不铺满)。
|
|
||||||
// 引入原因:formdata.vue 的 .system-form-wrap 默认 padding: 0 50rpx,banner 图片被左右各 50rpx 收缩,
|
|
||||||
// 后台配 form_banner 后图片应跨越屏幕宽度,而不是被容器内缩。
|
|
||||||
containerInset: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
|
|
||||||
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
|
|
||||||
onSubmit: {
|
|
||||||
type: Function,
|
|
||||||
default: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [DiyMinx],
|
mixins: [DiyMinx],
|
||||||
@@ -136,15 +70,13 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
style() {
|
style() {
|
||||||
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
|
|
||||||
var css = '';
|
var css = '';
|
||||||
var v = this.value || {};
|
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||||
css += 'background-color:' + (v.contentBgColor || '') + ';';
|
if (this.value.elementAngle === 'round') {
|
||||||
if (v.elementAngle === 'round') {
|
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||||
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||||
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||||
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||||
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
|
||||||
}
|
}
|
||||||
return css;
|
return css;
|
||||||
},
|
},
|
||||||
@@ -158,292 +90,24 @@ export default {
|
|||||||
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// Banner 图片高度模式(2026-09-03 对齐商家端预览):
|
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
|
||||||
// 商家端预览 .preview-banner 是 max-width:100%; height:auto —— 按图片原始比例完整显示。
|
|
||||||
// 此前 H5 固定 180rpx + aspectFill 居中裁剪,导致横幅高度不一/图片内容被裁缺失。
|
|
||||||
// 现在:无显式配置 → widthFix 自适应(与后端 height:auto 等价);
|
|
||||||
// 显式配置 bannerHeight / banner_height → 固定高度 + aspectFill 裁剪(用户主动选择);
|
|
||||||
// 显式配置 bannerCropRatio → 按比例锁高度,比例+高度都设时高度优先(保持数字 rpx 的语义);
|
|
||||||
// 显式配置 bannerCropBox → 手动裁栏框(widthFix + image transform,最高优先级,独享遮罩)。
|
|
||||||
hasBannerHeight() {
|
|
||||||
const cfg = this.mergedFormConfig || {};
|
|
||||||
const raw = cfg.bannerHeight !== undefined ? cfg.bannerHeight : cfg.banner_height;
|
|
||||||
const n = parseInt(raw);
|
|
||||||
return !isNaN(n) && n > 0;
|
|
||||||
},
|
|
||||||
bannerImgMode() {
|
|
||||||
// 手动裁栏框优先(widthFix + transform);
|
|
||||||
// 显式选择比例(16:9/4:3/...)时用 aspectFill 填满比例容器;
|
|
||||||
// 仅配置高度 + 原比例时与商家端预览一致:widthFix 自适应,容器限制最大高度,图片完整显示不裁剪。
|
|
||||||
if (this.hasManualCrop) return 'widthFix';
|
|
||||||
return this.bannerCropRatio ? 'aspectFill' : 'widthFix';
|
|
||||||
},
|
|
||||||
// Banner 图片包裹层高度:
|
|
||||||
// - 比例模式 / 手动框:输出固定 height,配合 aspectFill 裁剪;
|
|
||||||
// - 仅高度 + 原比例:输出 max-height,让图片按原始比例完整显示,高度不超过设定值。
|
|
||||||
bannerImgWrapStyle() {
|
|
||||||
if (!this.hasBannerHeight) return '';
|
|
||||||
let h = parseInt(this.mergedFormConfig.bannerHeight !== undefined ? this.mergedFormConfig.bannerHeight : this.mergedFormConfig.banner_height);
|
|
||||||
if (h < 40) h = 40;
|
|
||||||
if (h > 1200) h = 1200;
|
|
||||||
if (this.bannerCropRatio || this.hasManualCrop) {
|
|
||||||
return this.$util.objToStyle({ height: h + 'rpx' });
|
|
||||||
}
|
|
||||||
return this.$util.objToStyle({ 'max-height': h + 'rpx' });
|
|
||||||
},
|
|
||||||
// 裁剪比例:读双键(camel + snake);合法值 16:9/4:3/1:1/3:4;空 = 不启用
|
|
||||||
bannerCropRatio() {
|
|
||||||
const cfg = this.mergedFormConfig || {};
|
|
||||||
const raw = cfg.bannerCropRatio !== undefined ? cfg.bannerCropRatio : cfg.banner_crop_ratio;
|
|
||||||
return (raw && BANNER_CROP_RATIOS[raw]) ? raw : '';
|
|
||||||
},
|
|
||||||
// 裁剪位置:top/center/bottom(默认 center);通过 CSS object-position 控制(uni-app H5/MP 容器配合 transform 实现)
|
|
||||||
bannerCropPosition() {
|
|
||||||
const cfg = this.mergedFormConfig || {};
|
|
||||||
const raw = cfg.bannerCropPosition !== undefined ? cfg.bannerCropPosition : cfg.banner_crop_position;
|
|
||||||
return (raw === 'top' || raw === 'bottom') ? raw : 'center';
|
|
||||||
},
|
|
||||||
// 手动裁栏框:读双键;返回 {x,y,w,h} 0..1 比例或 null
|
|
||||||
// 三优先级:手动框 > 比例/位置 > 高度/位置(与后端 syncFormConfigToCSS 对齐)
|
|
||||||
bannerCropBox() {
|
|
||||||
const cfg = this.mergedFormConfig || {};
|
|
||||||
const raw = cfg.bannerCropBox !== undefined ? cfg.bannerCropBox : cfg.banner_crop_box;
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
||||||
if (obj && typeof obj.x === 'number' && typeof obj.y === 'number' && typeof obj.w === 'number' && typeof obj.h === 'number' && obj.w > 0 && obj.h > 0) {
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
} catch (e) { return null; }
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
// 手动裁栏框激活时:渲染模式切 widthFix(保留全图)+ 内层 img 用 transform 移动/缩放显示裁栏区域
|
|
||||||
hasManualCrop() { return !!this.bannerCropBox; },
|
|
||||||
// 手动裁栏框下 image 内联样式:放大到原图尺寸,按 box 坐标 translate 显示
|
|
||||||
// 用 rpx 转换(MP 原生 rpx / H5 换算 px)
|
|
||||||
bannerImgStyle() {
|
|
||||||
if (!this.hasManualCrop) return '';
|
|
||||||
const b = this.bannerCropBox;
|
|
||||||
const scaleX = 1 / b.w;
|
|
||||||
const scaleY = 1 / b.h;
|
|
||||||
const tx = -b.x * 100;
|
|
||||||
const ty = -b.y * 100;
|
|
||||||
return this.$util.objToStyle({
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: (scaleX * 100) + '%',
|
|
||||||
height: (scaleY * 100) + '%',
|
|
||||||
maxWidth: 'none',
|
|
||||||
transform: 'translate(' + tx + '%, ' + ty + '%)',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 手动裁栏框下容器需要绝对定位 + 隐藏溢出
|
|
||||||
bannerImgWrapClass() {
|
|
||||||
const isAspectFill = this.bannerImgMode === 'aspectFill';
|
|
||||||
return {
|
|
||||||
'is-fixed-height': isAspectFill,
|
|
||||||
'has-crop': isAspectFill,
|
|
||||||
'has-manual-crop': this.hasManualCrop,
|
|
||||||
'has-height-limit': this.hasBannerHeight && !isAspectFill && !this.hasManualCrop
|
|
||||||
};
|
|
||||||
},
|
|
||||||
// 表单风格:优先读显式 form_style(classic / card),未设置时回退到隐式判定(看 show_banner && form_banner)
|
|
||||||
// - classic:经典风格,平铺无 banner 卡片
|
|
||||||
// - card :卡片浮起风格,banner + 卡片浮起 + 容器内缩
|
|
||||||
// 注意:必须读 this.formCfg(原始 formConfig 对象)而不是 mergedFormConfig(白名单组装对象,不含 form_style 键)
|
|
||||||
// 双键兼容:formdata 链路 camel(formStyle,api splitFormConfig 转换);DIY 链路 snake(form_style,design.js 直存)
|
|
||||||
// 老表单无 form_style 字段时 → undefined → 走隐式判定,与原行为完全一致,零迁移风险
|
|
||||||
hasBannerMode() {
|
|
||||||
const v = this.value || {};
|
|
||||||
const fc = this.formCfg || {};
|
|
||||||
const style = fc.formStyle !== undefined ? fc.formStyle : fc.form_style;
|
|
||||||
if (style === 'card') return true;
|
|
||||||
if (style === 'classic') return false;
|
|
||||||
return !!(v.show_banner && v.form_banner);
|
|
||||||
},
|
|
||||||
// tip 提示语位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
|
|
||||||
// outside=独立白卡浮起压 banner(默认);inside=内容卡内(无独立卡视觉)
|
|
||||||
tipPosition() {
|
|
||||||
if (!this.hasBannerMode) return 'outside';
|
|
||||||
return this.mergedFormConfig.tipPosition === 'inside' ? 'inside' : 'outside';
|
|
||||||
},
|
|
||||||
// 提交按钮位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
|
|
||||||
// inside=内容卡内(默认);outside=卡片下方独立区域
|
|
||||||
submitPosition() {
|
|
||||||
if (!this.hasBannerMode) return 'outside';
|
|
||||||
return this.mergedFormConfig.submitPosition === 'outside' ? 'outside' : 'inside';
|
|
||||||
},
|
|
||||||
// 卡片容器内联样式:margin-top(负偏移+下移补偿)+ 左右外缩 + border-radius + box-shadow + background + border。
|
|
||||||
// clamp 与默认值对齐后端 form.js syncFormConfigToCSS:overlap 0-200(默认48) / radius 0-40(默认24) / sidePad 0-60(默认30);
|
|
||||||
// margin-top = -overlap + overlap_top(后端 --card-overlap 公式 (-overlap+overlapTop)/2 为 px,rpx 不除 2)
|
|
||||||
// tip 独立卡(outside + showTip)带队浮起时,本卡只留正常间距(后端预览 draggable margin 10px=20rpx)
|
|
||||||
// sidePad 语义(2026-09-03 与后端统一):卡片距屏幕左右距离(对齐后端 --card-side-pad,默认 30rpx=15px)。
|
|
||||||
// formdata 链路(containerInset>0)容器已有 padding,卡片负 margin 破壳后再缩 sidePad(与 banner bleed 同思路);
|
|
||||||
// DIY 链路(无 containerInset)直接 margin = sidePad。
|
|
||||||
// 卡内内容缩进由 ns-newform .form-wrap 的 formPaddingX 承担(与后端 .draggable-wrap padding: form-padding-x 同构)。
|
|
||||||
cardWrapStyle() {
|
|
||||||
const cfg = this.mergedFormConfig || {};
|
|
||||||
const parentInset = this._num(this.containerInset, 0);
|
|
||||||
let sidePad = this._num(cfg.cardSidePad, 30);
|
|
||||||
if (sidePad < 0) sidePad = 0;
|
|
||||||
if (sidePad > 60) sidePad = 60;
|
|
||||||
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
|
|
||||||
// overlap:负值向上压住 banner 底部;overlap_top 为下移补偿(可正可负,叠加生效)
|
|
||||||
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
|
|
||||||
if (overlap > 200) overlap = 200;
|
|
||||||
const overlapTop = this._num(cfg.cardOverlapTop, 0);
|
|
||||||
// banner 实际渲染时才用负 margin 压住它;banner 被隐藏(如 hideInnerBanner / 配置缺失)时
|
|
||||||
// 负 margin 会把卡片(含 tip)整体推出屏幕顶部,必须收回 0
|
|
||||||
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
|
|
||||||
// tip 独立卡存在时由 tip 卡带队压 banner,内容卡只留正常间距(20rpx ≈ 后端预览 10px)
|
|
||||||
const tipLeads = this.tipPosition === 'outside' && !!(this.value && this.value.show_tip && this.value.form_tip);
|
|
||||||
// 卡片圆角:缺省 24rpx(浮起卡片的圆角,比外层卡片的 16rpx 更明显,突出浮起感)
|
|
||||||
let radius = this._num(cfg.cardRadius, 24);
|
|
||||||
if (radius < 0) radius = 0;
|
|
||||||
if (radius > 40) radius = 40;
|
|
||||||
// 阴影:缺省中等阴影(与设计方案一致)—— 由本容器承担浮起感
|
|
||||||
const shadow = cfg.cardShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)';
|
|
||||||
// 卡片底色:纯白,与设计方案一致;可由后台 formConfig.cardBgColor 覆盖
|
|
||||||
const bgColor = cfg.cardBgColor || '#ffffff';
|
|
||||||
// 边框:宽度 rpx(0-20),留空/0 = 无边框
|
|
||||||
let borderWidth = this._num(cfg.cardBorderWidth, 0);
|
|
||||||
if (borderWidth < 0) borderWidth = 0;
|
|
||||||
if (borderWidth > 20) borderWidth = 20;
|
|
||||||
const style = {
|
|
||||||
marginTop: (bannerVisible ? (tipLeads ? 20 : (-overlap + overlapTop)) : 0) + 'rpx',
|
|
||||||
marginLeft: sideMargin + 'rpx',
|
|
||||||
marginRight: sideMargin + 'rpx',
|
|
||||||
borderRadius: radius + 'rpx',
|
|
||||||
boxShadow: shadow,
|
|
||||||
backgroundColor: bgColor
|
|
||||||
};
|
|
||||||
if (borderWidth > 0) {
|
|
||||||
style['border'] = borderWidth + 'rpx ' + (cfg.cardBorderStyle || 'solid') + ' ' + (cfg.cardBorderColor || 'transparent');
|
|
||||||
}
|
|
||||||
return this.$util.objToStyle(style);
|
|
||||||
},
|
|
||||||
// tip 独立卡样式(card + outside):负 margin 压 banner + 左右外缩 + 白底 + 圆角 + 阴影 + 细边框。
|
|
||||||
// 视觉参照后端预览 .form-style-card .preview-tip(margin: var(--card-overlap) var(--card-side-pad) 0;白底 12px 圆角 柔和阴影 左对齐)
|
|
||||||
// 用户配置的 tip 四态属性(bg/border/radius/shadow/padding/align)在此全部生效,仅默认值不同(独立卡默认白底卡视觉)
|
|
||||||
// sidePad 语义与 cardWrapStyle 一致:卡片距屏幕左右距离(默认 30rpx;formdata 链路破壳 sidePad-parentInset)
|
|
||||||
tipCardStyle() {
|
|
||||||
const cfg = this.mergedFormConfig || {};
|
|
||||||
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
|
|
||||||
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
|
|
||||||
if (overlap > 200) overlap = 200;
|
|
||||||
const overlapTop = this._num(cfg.cardOverlapTop, 0);
|
|
||||||
const parentInset = this._num(this.containerInset, 0);
|
|
||||||
let sidePad = this._num(cfg.cardSidePad, 30);
|
|
||||||
if (sidePad < 0) sidePad = 0;
|
|
||||||
if (sidePad > 60) sidePad = 60;
|
|
||||||
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
|
|
||||||
const padT = this._num(cfg.tipPaddingTop, 32);
|
|
||||||
const padR = this._num(cfg.tipPaddingRight, 32);
|
|
||||||
const padB = this._num(cfg.tipPaddingBottom, 32);
|
|
||||||
const padL = this._num(cfg.tipPaddingLeft, 32);
|
|
||||||
const style = {
|
|
||||||
marginTop: (bannerVisible ? (-overlap + overlapTop) : 0) + 'rpx',
|
|
||||||
marginLeft: sideMargin + 'rpx',
|
|
||||||
marginRight: sideMargin + 'rpx',
|
|
||||||
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
|
|
||||||
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
|
||||||
'color': cfg.tipColor || '#1f2937',
|
|
||||||
'background-color': cfg.tipBgColor || '#ffffff',
|
|
||||||
'border-radius': `${this._num(cfg.tipRadius, 24)}rpx`,
|
|
||||||
'box-shadow': cfg.tipShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)',
|
|
||||||
'text-align': cfg.tipAlign || 'left'
|
|
||||||
};
|
|
||||||
// 边框:独立卡默认细边框(2rpx ≈ 后端预览 1px);配置 0 = 无边框
|
|
||||||
const bw = this._num(cfg.tipBorderWidth, 2);
|
|
||||||
if (bw > 0) {
|
|
||||||
style['border'] = `${bw}rpx ${cfg.tipBorderStyle || 'solid'} ${cfg.tipBorderColor || 'rgba(0,0,0,0.06)'}`;
|
|
||||||
}
|
|
||||||
return this.$util.objToStyle(style);
|
|
||||||
},
|
|
||||||
// Banner 容器破壳样式:仅在卡片模式下 + 父级传了 containerInset>0 时启用,让 banner 突破父容器 padding 跨越屏幕宽度。
|
|
||||||
// 计算逻辑:负 margin-left/right + 等量扩宽 width,使 banner 元素左/右边界对齐到屏幕边缘。
|
|
||||||
// 父级未传 containerInset 或传 0 时返回空样式,banner 保持原尺寸(零外溢)。
|
|
||||||
bannerBleedStyle() {
|
|
||||||
const inset = this._num(this.containerInset, 0);
|
|
||||||
if (!this.hasBannerMode || inset <= 0) return '';
|
|
||||||
const expand = inset * 2;
|
|
||||||
return this.$util.objToStyle({
|
|
||||||
marginLeft: -inset + 'rpx',
|
|
||||||
marginRight: -inset + 'rpx',
|
|
||||||
width: 'calc(100% + ' + expand + 'rpx)'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 提示语样式(classic 平铺 / card+inside 卡内共用):与后端 edit.html 内联 .preview-tip 对齐
|
|
||||||
// - 四边内边距:未配置时经典态 上/下=rowPaddingY(4) 左右=tipPaddingX(14);卡片态统一 32rpx
|
|
||||||
// - 边框:classic 用 border-bottom(宽度默认 0;旧数据仅配 color → 2rpx 底边);
|
|
||||||
// card 用全边框(配置宽度才输出)
|
|
||||||
// - 圆角 / 阴影(rpx 字符串) / 对齐(card 态默认左对齐,经典态居中)
|
|
||||||
// - 背景色:''=未配置 → 透明(独立卡白底兜底已挪到 tipCardStyle,卡内 tip 无独立卡视觉)
|
|
||||||
tipStyle() {
|
tipStyle() {
|
||||||
const cfg = this.mergedFormConfig || {};
|
const cfg = this.mergedFormConfig || {};
|
||||||
const card = this.hasBannerMode;
|
|
||||||
const padT = this._num(cfg.tipPaddingTop, card ? 32 : this._num(cfg.rowPaddingY, 4));
|
|
||||||
const padR = this._num(cfg.tipPaddingRight, card ? 32 : this._num(cfg.tipPaddingX, 14));
|
|
||||||
const padB = this._num(cfg.tipPaddingBottom, card ? 32 : this._num(cfg.rowPaddingY, 4));
|
|
||||||
const padL = this._num(cfg.tipPaddingLeft, card ? 32 : this._num(cfg.tipPaddingX, 14));
|
|
||||||
const style = {
|
const style = {
|
||||||
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
|
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.tipPaddingX, 14)}rpx`,
|
||||||
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
||||||
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
|
'color': cfg.tipColor || '#000000',
|
||||||
'background-color': cfg.tipBgColor || 'transparent',
|
'background-color': cfg.tipBgColor || 'transparent'
|
||||||
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
|
|
||||||
};
|
};
|
||||||
// 边框:配置了宽度(>0)输出;未配宽度但配了颜色 → 旧数据兼容,2rpx 底边(仅经典态)
|
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
|
||||||
const bw = this._num(cfg.tipBorderWidth, 0);
|
if (cfg.tipBorderColor) {
|
||||||
if (bw > 0) {
|
|
||||||
const bs = cfg.tipBorderStyle || 'solid';
|
|
||||||
const bc = cfg.tipBorderColor || 'rgba(0,0,0,0.06)';
|
|
||||||
if (card) {
|
|
||||||
style['border'] = `${bw}rpx ${bs} ${bc}`;
|
|
||||||
} else {
|
|
||||||
style['border-bottom'] = `${bw}rpx ${bs} ${bc}`;
|
|
||||||
}
|
|
||||||
} else if (!card && cfg.tipBorderColor) {
|
|
||||||
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
|
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
|
||||||
}
|
}
|
||||||
// 圆角(rpx)
|
|
||||||
if (cfg.tipRadius !== undefined && cfg.tipRadius !== null && cfg.tipRadius !== '') {
|
|
||||||
style['border-radius'] = `${this._num(cfg.tipRadius, 0)}rpx`;
|
|
||||||
}
|
|
||||||
// 阴影:后端存 rpx 单位字符串(如 '0 4rpx 12rpx rgba(...)'),uniapp H5/MP 运行时支持 rpx
|
|
||||||
if (cfg.tipShadow) {
|
|
||||||
style['box-shadow'] = cfg.tipShadow;
|
|
||||||
}
|
|
||||||
return this.$util.objToStyle(style);
|
return this.$util.objToStyle(style);
|
||||||
},
|
},
|
||||||
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
|
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
|
||||||
// card + inside(默认)时按钮已在内容卡内:padding 收窄为卡内边距(≈后端预览 14px 12px 20px),不设独立背景(继承卡白底)
|
|
||||||
submitWrapStyle() {
|
submitWrapStyle() {
|
||||||
const cfg = this.mergedFormConfig || {};
|
const cfg = this.mergedFormConfig || {};
|
||||||
if (this.hasBannerMode && this.submitPosition !== 'outside') {
|
|
||||||
return this.$util.objToStyle({
|
|
||||||
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
|
||||||
padding: `24rpx 24rpx 40rpx`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.hasBannerMode) {
|
|
||||||
// card + outside:卡片下方独立区域 —— 与后端 .preview-submit-wrap(card 模式)同构:
|
|
||||||
// 左右破壳对齐卡片缩进(sidePad - parentInset),水平内缩 formPaddingX,
|
|
||||||
// 上下 padding 36rpx/48rpx(后端 18px/24px),背景白底(后端 .preview-submit-wrap 亦为白底)
|
|
||||||
const parentInset = this._num(this.containerInset, 0);
|
|
||||||
let sidePad = this._num(cfg.cardSidePad, 30);
|
|
||||||
if (sidePad < 0) sidePad = 0;
|
|
||||||
if (sidePad > 60) sidePad = 60;
|
|
||||||
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : 0;
|
|
||||||
return this.$util.objToStyle({
|
|
||||||
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
|
||||||
marginLeft: sideMargin + 'rpx',
|
|
||||||
marginRight: sideMargin + 'rpx',
|
|
||||||
padding: `36rpx ${this._num(cfg.formPaddingX, 14)}rpx 48rpx`,
|
|
||||||
'background-color': cfg.formBgColor || '#ffffff'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.$util.objToStyle({
|
return this.$util.objToStyle({
|
||||||
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
||||||
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
|
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
|
||||||
@@ -467,7 +131,7 @@ export default {
|
|||||||
width: widthPct + '%',
|
width: widthPct + '%',
|
||||||
padding: `${submitPaddingY}rpx 0`,
|
padding: `${submitPaddingY}rpx 0`,
|
||||||
color: cfg.submitColor || '#ffffff',
|
color: cfg.submitColor || '#ffffff',
|
||||||
'background-color': cfg.submitBgColor || '#3b82f6',
|
'background-color': cfg.submitBgColor || '#ff4544',
|
||||||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||||||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||||||
});
|
});
|
||||||
@@ -477,10 +141,6 @@ export default {
|
|||||||
submitText() {
|
submitText() {
|
||||||
return this.mergedFormConfig.submitText || '提交';
|
return this.mergedFormConfig.submitText || '提交';
|
||||||
},
|
},
|
||||||
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
|
|
||||||
hasCustomSubmit() {
|
|
||||||
return typeof this.onSubmit === 'function';
|
|
||||||
},
|
|
||||||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||||||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||||||
formCfg() {
|
formCfg() {
|
||||||
@@ -502,33 +162,12 @@ export default {
|
|||||||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||||||
return fc[snake];
|
return fc[snake];
|
||||||
};
|
};
|
||||||
// 多别名取值:后台不同版本 / 不同序列化路径对同一配置项可能用不同键名,
|
|
||||||
// 这里按优先级取第一个有效值,避免白名单裁剪时把后台配置丢掉(丢了下层永远读不到)
|
|
||||||
const pickAny = function(keys) {
|
|
||||||
for (var i = 0; i < keys.length; i++) {
|
|
||||||
var kv = fc[keys[i]];
|
|
||||||
if (kv !== undefined && kv !== null && kv !== '') return kv;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
|
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
|
||||||
const base = {
|
const base = {
|
||||||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||||||
// Banner 高度(rpx)。后台可配,默认 180rpx ≈ 页面 1/8 高度(适配主流手机屏高)
|
|
||||||
// 配合 mode="aspectFill" 居中裁剪,保证横幅只占页面八分之一左右
|
|
||||||
bannerHeight: pick('bannerHeight', 'banner_height'),
|
|
||||||
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
|
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
|
||||||
// 横幅裁剪位置(top/center/bottom)+ 比例(16:9/4:3/1:1/3:4)+ 手动裁栏框 JSON(2026-09-04 方案 C 抽屉配套)
|
|
||||||
bannerCropPosition: pick('bannerCropPosition', 'banner_crop_position'),
|
|
||||||
bannerCropRatio: pick('bannerCropRatio', 'banner_crop_ratio'),
|
|
||||||
bannerCropBox: pick('bannerCropBox', 'banner_crop_box'),
|
|
||||||
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
|
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
|
||||||
// 行内上下边距:兼容 row_padding_y / rowPaddingY / row_padding / rowPadding 等键名
|
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
|
||||||
rowPaddingY: (function() {
|
|
||||||
var v = pick('rowPaddingY', 'row_padding_y');
|
|
||||||
if (v !== undefined && v !== null && v !== '') return v;
|
|
||||||
return pickAny(['rowPadding', 'row_padding', 'rowPaddingVertical', 'row_padding_vertical', 'row_padding_top', 'rowPaddingTop']);
|
|
||||||
})(),
|
|
||||||
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
||||||
labelGap: pick('labelGap', 'label_gap'),
|
labelGap: pick('labelGap', 'label_gap'),
|
||||||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||||||
@@ -541,24 +180,11 @@ export default {
|
|||||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||||
labelColor: pick('labelColor', 'label_color'),
|
labelColor: pick('labelColor', 'label_color'),
|
||||||
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
|
|
||||||
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
|
|
||||||
requiredColor: pick('requiredColor', 'required_color'),
|
requiredColor: pick('requiredColor', 'required_color'),
|
||||||
// 必填星号位置:before=标签前(默认)/ after=标签后(终端无关,存 base 层)
|
|
||||||
requiredPosition: pick('requiredPosition', 'required_position'),
|
|
||||||
checkedColor: pick('checkedColor', 'checked_color'),
|
checkedColor: pick('checkedColor', 'checked_color'),
|
||||||
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
||||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||||
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
|
|
||||||
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
|
|
||||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
|
||||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
|
||||||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
|
||||||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
|
||||||
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
|
|
||||||
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
|
|
||||||
submitText: pick('submitText', 'submit_text'),
|
submitText: pick('submitText', 'submit_text'),
|
||||||
submitColor: pick('submitColor', 'submit_color'),
|
submitColor: pick('submitColor', 'submit_color'),
|
||||||
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
||||||
@@ -566,71 +192,21 @@ export default {
|
|||||||
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
||||||
submitSize: pick('submitSize', 'submit_size'),
|
submitSize: pick('submitSize', 'submit_size'),
|
||||||
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
||||||
// 提示语(与后端 form.js syncFormConfigToCSS 的 --tip-* 变量一一对应)
|
// 提示语
|
||||||
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
||||||
tipColor: pick('tipColor', 'tip_color'),
|
tipColor: pick('tipColor', 'tip_color'),
|
||||||
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
||||||
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
|
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
|
||||||
// 边框:宽度(rpx)/线型;未配置宽度时保留旧逻辑(仅 color 配置 → 底边 2rpx)
|
};
|
||||||
tipBorderWidth: pick('tipBorderWidth', 'tip_border_width'),
|
|
||||||
tipBorderStyle: pick('tipBorderStyle', 'tip_border_style'),
|
|
||||||
// 独立样式:圆角 / 四边内边距 / 阴影 / 对齐('' 与 null 均走 delete → 用各自态的兜底默认)
|
|
||||||
tipRadius: pick('tipRadius', 'tip_radius'),
|
|
||||||
tipPaddingTop: pick('tipPaddingTop', 'tip_padding_top'),
|
|
||||||
tipPaddingRight: pick('tipPaddingRight', 'tip_padding_right'),
|
|
||||||
tipPaddingBottom: pick('tipPaddingBottom', 'tip_padding_bottom'),
|
|
||||||
tipPaddingLeft: pick('tipPaddingLeft', 'tip_padding_left'),
|
|
||||||
tipShadow: pick('tipShadow', 'tip_shadow'),
|
|
||||||
tipAlign: pick('tipAlign', 'tip_align'),
|
|
||||||
// 卡片浮起(仅 show_banner && form_banner 启用)
|
|
||||||
cardOverlap: pick('cardOverlap', 'card_overlap'),
|
|
||||||
// 额外下移补偿(rpx,可正可负,与 overlap 叠加:margin-top = -overlap + overlap_top)
|
|
||||||
cardOverlapTop: pick('cardOverlapTop', 'card_overlap_top'),
|
|
||||||
cardRadius: pick('cardRadius', 'card_radius'),
|
|
||||||
cardShadow: pick('cardShadow', 'card_shadow'),
|
|
||||||
cardSidePad: pick('cardSidePad', 'card_side_pad'),
|
|
||||||
cardBgColor: pick('cardBgColor', 'card_bg_color'),
|
|
||||||
// 卡片边框(宽度 rpx / CSS 线型 / 颜色;留空 = 无边框)
|
|
||||||
cardBorderWidth: pick('cardBorderWidth', 'card_border_width'),
|
|
||||||
cardBorderStyle: pick('cardBorderStyle', 'card_border_style'),
|
|
||||||
cardBorderColor: pick('cardBorderColor', 'card_border_color'),
|
|
||||||
// 位置控制(仅 card 风格生效;outside/inside 见 tipPosition/submitPosition computed)
|
|
||||||
tipPosition: pick('tipPosition', 'tip_position'),
|
|
||||||
submitPosition: pick('submitPosition', 'submit_position')
|
|
||||||
};
|
|
||||||
// 移除 undefined / 空字符串(保留 0/false)
|
// 移除 undefined / 空字符串(保留 0/false)
|
||||||
Object.keys(base).forEach(function(k) {
|
Object.keys(base).forEach(function(k) {
|
||||||
if (base[k] === undefined || base[k] === null || base[k] === '') {
|
if (base[k] === undefined || base[k] === null || base[k] === '') {
|
||||||
delete base[k];
|
delete base[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 前端视觉默认:仅作兜底(后端未配置的字段才生效)。
|
|
||||||
// 注意合并顺序:base 在后 —— 后台 formConfig 显式配置的值(如 submit_bg_color / input_radius)
|
|
||||||
// 必须覆盖前端默认,否则商家端样式配置完全不生效(曾因此导致提交按钮恒为蓝色)。
|
|
||||||
const styleDefaults = {
|
|
||||||
formLayout: 'vertical',
|
|
||||||
formPaddingX: 28,
|
|
||||||
rowPaddingY: 24,
|
|
||||||
labelGap: 20,
|
|
||||||
labelFontSize: 32,
|
|
||||||
labelFontWeight: 700,
|
|
||||||
labelColor: '#1f2937',
|
|
||||||
inputHeight: 84,
|
|
||||||
inputRadius: 16,
|
|
||||||
inputBgColor: '#ffffff',
|
|
||||||
inputBorderColor: '#e5e7eb',
|
|
||||||
inputFocusBorderColor: '#3b82f6',
|
|
||||||
inputFocusBgColor: '#ffffff',
|
|
||||||
placeholderColor: '#9ca3af',
|
|
||||||
placeholderFontSize: 28,
|
|
||||||
checkedColor: '#3b82f6',
|
|
||||||
showRowDivider: false,
|
|
||||||
submitColor: '#ffffff',
|
|
||||||
submitBgColor: '#3b82f6'
|
|
||||||
};
|
|
||||||
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
||||||
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
||||||
const result = Object.assign({}, styleDefaults, base);
|
const result = Object.assign({}, base);
|
||||||
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
||||||
if (termBlock && typeof termBlock === 'object') {
|
if (termBlock && typeof termBlock === 'object') {
|
||||||
Object.keys(termBlock).forEach(function(platform) {
|
Object.keys(termBlock).forEach(function(platform) {
|
||||||
@@ -696,26 +272,24 @@ export default {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
|
// 提交表单
|
||||||
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
|
submitForm() {
|
||||||
handleSubmit() {
|
var self = this;
|
||||||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||||||
if (!this.verify()) {
|
if (!this.verify()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const formData = this.getFormData();
|
|
||||||
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
|
|
||||||
if (this.hasCustomSubmit) {
|
|
||||||
return this.onSubmit(formData);
|
|
||||||
}
|
|
||||||
// 兜底:内置 sendRequest 流程
|
|
||||||
this.defaultSubmit(formData);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 公共兜底实现:默认的 /form/api/form/create 流程;
|
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
|
||||||
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
|
if (this.$listeners && this.$listeners.submit) {
|
||||||
defaultSubmit(submitFormData) {
|
this.$emit('submit', this.getFormData());
|
||||||
var self = this;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获得保单数据
|
||||||
|
const submitFormData = this.getFormData();
|
||||||
|
|
||||||
|
// 提交到后端
|
||||||
this.$api.sendRequest({
|
this.$api.sendRequest({
|
||||||
url: '/form/api/form/create',
|
url: '/form/api/form/create',
|
||||||
data: {
|
data: {
|
||||||
@@ -727,7 +301,7 @@ export default {
|
|||||||
success: function(res) {
|
success: function(res) {
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: self.$lang('submitSuccess'),
|
title: '提交成功',
|
||||||
icon: 'success'
|
icon: 'success'
|
||||||
});
|
});
|
||||||
// 重置表单
|
// 重置表单
|
||||||
@@ -736,7 +310,7 @@ export default {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: res.message || self.$lang('submitFail'),
|
title: res.message || '提交失败',
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -759,139 +333,12 @@ export default {
|
|||||||
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
|
|
||||||
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
|
|
||||||
&.is-card-overlap {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 横幅图片容器:默认高度由 image widthFix 按图片比例自适应(与商家端预览 height:auto 对齐);
|
|
||||||
// .is-fixed-height(显式配置 bannerHeight)时高度由 bannerImgWrapStyle 内联写入,
|
|
||||||
// 配合 mode="aspectFill" 居中裁剪填充。
|
|
||||||
.banner-img-wrap {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
// 默认作为 flex 容器,支持 widthFix + max-height 模式下的顶部/居中/底部对齐
|
|
||||||
.banner-img-crop {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.crop-center {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.crop-bottom {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-fixed-height {
|
|
||||||
.banner-img {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启用 aspectFill 裁剪时,包装一层 .banner-img-crop 承接裁剪位置。
|
|
||||||
// 重要:uni-app image 在 H5 端固定转译为 <div style="background-image:url; background-size:cover; background-position:center center;"></div>,
|
|
||||||
// 内层 div 的 background-position 是运行时生成的 inline 样式,普通 CSS 覆盖不了,必须 !important;
|
|
||||||
// (不能用 .banner-img 的 translateY 实现位置:image 高度被钉死为容器 100% 后 transform 一去一回相互抵消,是零位移)
|
|
||||||
// - crop-top → background-position: center top(显示图片顶部区域)
|
|
||||||
// - crop-center → 默认 center center(不覆盖)
|
|
||||||
// - crop-bottom → background-position: center bottom(显示图片底部区域)
|
|
||||||
&.has-crop {
|
|
||||||
.banner-img-crop {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.banner-img {
|
|
||||||
// 撑满 wrap,让内层 background-image div 拿到确定高度(uni-image H5 端转译依赖)
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
// uni-app H5 编译器会给样式追加 [data-v-xxx](即使未写 scoped),
|
|
||||||
// 而 uni-image 运行时 JS 生成的内层 div 没有该属性 → 必须 ::v-deep 把属性选择器挪到 .banner-img 上
|
|
||||||
.banner-img-crop.crop-top .banner-img ::v-deep div {
|
|
||||||
background-position: center top !important;
|
|
||||||
}
|
|
||||||
.banner-img-crop.crop-bottom .banner-img ::v-deep div {
|
|
||||||
background-position: center bottom !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启用手动裁栏框(bannerCropBox 非空)时:image 用 transform 显示裁栏部分,原图按 box 缩放 + 平移
|
|
||||||
&.has-manual-crop {
|
|
||||||
.banner-img-crop {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.banner-img {
|
|
||||||
// 手动框下由 bannerImgStyle 内联 width/height/transform 驱动,不固定 100%
|
|
||||||
height: auto;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-img {
|
.banner-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 卡片容器:浮在 banner 图片上方(参考设计左图)。
|
|
||||||
// 仅在 hasBannerMode=true 时渲染(见 template v-if);
|
|
||||||
// 阴影 + margin-top(负偏移)+ 圆角 + 白底 全部由 cardWrapStyle 内联写入(读取后台 formConfig.card*)。
|
|
||||||
// 关键:卡片必须自带白底并 z-index 高于 banner,才能真正"盖住"banner 图片底部形成浮起;
|
|
||||||
// 父级容器(formdata.vue 的 .system-form-wrap)虽也是白底,但它是 banner 的祖先、背景会被 banner 覆盖,
|
|
||||||
// 因此本容器的白底不可省略(与 .system-form-wrap 同色,视觉上从卡片向下自然延续到页面底部)。
|
|
||||||
.form-card {
|
|
||||||
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
|
||||||
// 关键:裁剪卡内子元素(ns-newform 白底 / submit 区)到圆角内。
|
|
||||||
// tip 关闭时本卡带队负 margin 压 banner,若无 overflow:hidden,
|
|
||||||
// 卡内第一个子元素的方形背景会盖住卡片上圆角(banner 图片在圆角外的部分被遮)。
|
|
||||||
// 商家端预览 .draggable-wrap 同样有 overflow:hidden(form.css 两处同步)。
|
|
||||||
overflow: hidden;
|
|
||||||
// 兜底 padding-bottom:让卡片与后面内容(如推荐码、其它 diy 组件)有间距
|
|
||||||
padding-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tip 独立卡(card + tip_position=outside):浮在 banner 上方的独立白卡。
|
|
||||||
// margin/padding/白底/圆角/阴影/边框 全部由 tipCardStyle 内联写入;
|
|
||||||
// 必须自带 z-index 高于 banner(z-index:1)才能真正"盖住"banner 底部形成浮起。
|
|
||||||
.form-tip-card {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
box-sizing: border-box;
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip {
|
.form-tip {
|
||||||
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -64,13 +64,6 @@
|
|||||||
<diy-rich-text :value="item"></diy-rich-text>
|
<diy-rich-text :value="item"></diy-rich-text>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- #ifdef H5 -->
|
|
||||||
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
|
|
||||||
<!-- 网页容器 -->
|
|
||||||
<diy-webview :value="item"></diy-webview>
|
|
||||||
</template>
|
|
||||||
<!-- #endif -->
|
|
||||||
|
|
||||||
<template v-if="item.componentName == 'HorzLine'">
|
<template v-if="item.componentName == 'HorzLine'">
|
||||||
<!-- 辅助线 -->
|
<!-- 辅助线 -->
|
||||||
<diy-horz-line :value="item"></diy-horz-line>
|
<diy-horz-line :value="item"></diy-horz-line>
|
||||||
@@ -166,10 +159,7 @@
|
|||||||
<!-- 商品推荐 -->
|
<!-- 商品推荐 -->
|
||||||
<diy-goods-recommend :value="item"></diy-goods-recommend>
|
<diy-goods-recommend :value="item"></diy-goods-recommend>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="item.componentName == 'DiyRecommendCode'">
|
|
||||||
<!-- 自定义推荐码组件 -->
|
|
||||||
<diy-recommend-code :value="item"></diy-recommend-code>
|
|
||||||
</template>
|
|
||||||
<template v-if="item.componentName == 'GoodsBrand'">
|
<template v-if="item.componentName == 'GoodsBrand'">
|
||||||
<!-- 商品品牌 -->
|
<!-- 商品品牌 -->
|
||||||
<diy-goods-brand :value="item"></diy-goods-brand>
|
<diy-goods-brand :value="item"></diy-goods-brand>
|
||||||
@@ -316,11 +306,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
console.log('[DIY-GROUP][created] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
|
|
||||||
this.processData();
|
this.processData();
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
console.log('[DIY-GROUP][mounted] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
|
|
||||||
if (this.diyData && this.diyData.value) {
|
if (this.diyData && this.diyData.value) {
|
||||||
this._notifyLoaded();
|
this._notifyLoaded();
|
||||||
}
|
}
|
||||||
@@ -361,13 +349,11 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
_notifyLoaded() {
|
_notifyLoaded() {
|
||||||
console.log('[DIY-GROUP][_notifyLoaded] enter, will emit loaded in 300ms');
|
if (this._loadedTimer) clearTimeout(this._loadedTimer);
|
||||||
if (this._loadedTimer) clearTimeout(this._loadedTimer);
|
this._loadedTimer = setTimeout(() => {
|
||||||
this._loadedTimer = setTimeout(() => {
|
this.$emit('loaded');
|
||||||
console.log('[DIY-GROUP][_notifyLoaded] emit loaded');
|
}, 300);
|
||||||
this.$emit('loaded');
|
},
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
processData() {
|
processData() {
|
||||||
if (!this.diyGlobalData || !this.diyGlobalData.value) {
|
if (!this.diyGlobalData || !this.diyGlobalData.value) {
|
||||||
this.processedData = [];
|
this.processedData = [];
|
||||||
@@ -384,41 +370,14 @@ export default {
|
|||||||
: index + 1;
|
: index + 1;
|
||||||
|
|
||||||
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
|
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
|
||||||
// PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色
|
newItem.pageStyle = `background-color:${newItem.pageBgColor};`;
|
||||||
// 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体;
|
|
||||||
// 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。
|
|
||||||
const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor)
|
|
||||||
? newItem.componentBgColor
|
|
||||||
: newItem.pageBgColor;
|
|
||||||
newItem.pageStyle = `background-color:${wrapBgColor};`;
|
|
||||||
|
|
||||||
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
|
if (newItem.margin) {
|
||||||
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
|
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
|
||||||
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
|
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
|
||||||
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
|
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
|
||||||
// both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致)
|
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
|
||||||
const rawMargin = newItem.margin;
|
}
|
||||||
const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null;
|
|
||||||
const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
|
|
||||||
const pickMargin = (objKey, flatKey) => {
|
|
||||||
if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') {
|
|
||||||
return toMarginNum(objMargin[objKey]);
|
|
||||||
}
|
|
||||||
return toMarginNum(newItem[flatKey]);
|
|
||||||
};
|
|
||||||
const marginTopNum = pickMargin('top', 'marginTop');
|
|
||||||
const marginBottomNum = pickMargin('bottom', 'marginBottom');
|
|
||||||
const marginBothNum = pickMargin('both', 'marginBoth');
|
|
||||||
// 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致)
|
|
||||||
const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left)
|
|
||||||
: (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum);
|
|
||||||
const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right)
|
|
||||||
: (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum);
|
|
||||||
|
|
||||||
newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`;
|
|
||||||
newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`;
|
|
||||||
newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`;
|
|
||||||
newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
|
|||||||
@@ -1,52 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||||||
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
|
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||||
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
|
:scroll-x="value.showStyle == 'singleSlide'">
|
||||||
注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效),
|
<view class="uni-scroll-view-content">
|
||||||
无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar),
|
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||||
H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。
|
style="margin-right: 28rpx;">
|
||||||
fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 -->
|
<!-- 图片部分 -->
|
||||||
<view class="image-nav-wrap">
|
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
||||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
|
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
||||||
:style="imageSpacingStyle"
|
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||||
:scroll-x="value.showStyle == 'singleSlide'"
|
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||||
:show-scrollbar="showScrollbarAttr"
|
}">
|
||||||
:enhanced="enhancedScrollbar"
|
<image v-if="item.link.wap_url" :style="{
|
||||||
@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') + ';',
|
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||||
'font-weight': value.font.weight + ';',
|
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||||
'color': value.font.color + ';'
|
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||||||
}">{{ item.title }}</text>
|
<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>
|
</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>
|
||||||
</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>
|
</view>
|
||||||
<!-- #endif -->
|
</scroll-view>
|
||||||
</view>
|
|
||||||
<ns-login ref="login"></ns-login>
|
<ns-login ref="login"></ns-login>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,12 +60,7 @@ export default {
|
|||||||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||||||
slideStartX: 0,
|
slideStartX: 0,
|
||||||
slideStartScrollLeft: 0,
|
slideStartScrollLeft: 0,
|
||||||
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
|
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||||
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
|
|
||||||
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
|
|
||||||
slideScrolling: false,
|
|
||||||
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
|
|
||||||
mpScroll: { viewW: 0, contentW: 0, left: 0 }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -94,94 +75,23 @@ export default {
|
|||||||
var self = this;
|
var self = this;
|
||||||
this.$nextTick(function() {
|
this.$nextTick(function() {
|
||||||
self.bindSlideMouseEvents();
|
self.bindSlideMouseEvents();
|
||||||
self.bindSlideScrollbar();
|
|
||||||
});
|
|
||||||
// #endif
|
|
||||||
// 小程序端:测量滚动条所需的视口/内容宽度(图片尺寸由内联样式决定, 渲染后稳定)
|
|
||||||
// 多次(0/300/1000ms)重测: 首次渲染时节点可能尚未布局完成, 单次测量容易拿不到宽度
|
|
||||||
// → 滚动条不显示; 之后的测量只覆盖有效值, 不会把已测得的宽度清零。
|
|
||||||
// #ifdef MP
|
|
||||||
var selfMp = this;
|
|
||||||
this.$nextTick(function() {
|
|
||||||
selfMp.measureScrollbar();
|
|
||||||
});
|
|
||||||
this._mpMeasureTimers = [300, 1000].map(function(delay) {
|
|
||||||
return setTimeout(function() {
|
|
||||||
selfMp.measureScrollbar();
|
|
||||||
}, delay);
|
|
||||||
});
|
});
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
this.unbindSlideMouseEvents();
|
this.unbindSlideMouseEvents();
|
||||||
this.unbindSlideScrollbar();
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP
|
|
||||||
if (this._mpMeasureTimers) {
|
|
||||||
this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); });
|
|
||||||
this._mpMeasureTimers = null;
|
|
||||||
}
|
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
componentRefresh(newValue) {
|
componentRefresh(newValue) {
|
||||||
// 监听组件刷新
|
// 监听组件刷新
|
||||||
},
|
|
||||||
// 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条
|
|
||||||
'value.list'(newVal, oldVal) {
|
|
||||||
// #ifdef MP
|
|
||||||
if (newVal !== oldVal) {
|
|
||||||
var self = this;
|
|
||||||
this.$nextTick(function() {
|
|
||||||
self.measureScrollbar();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
|
|
||||||
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
|
|
||||||
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
|
|
||||||
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
|
|
||||||
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
|
|
||||||
componentBgColor() {
|
|
||||||
const v = this.value || {};
|
|
||||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
|
||||||
const asColor = val => {
|
|
||||||
if (Array.isArray(val)) val = val[0];
|
|
||||||
if (typeof val !== 'string') return '';
|
|
||||||
const s = val.trim();
|
|
||||||
if (!s) return '';
|
|
||||||
// 排除图片/URL 等非颜色值
|
|
||||||
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
const sources = [v];
|
|
||||||
['style', 'config', 'setting', 'data'].forEach(k => {
|
|
||||||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
|
||||||
});
|
|
||||||
const candidates = [
|
|
||||||
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
|
|
||||||
'bgcolor', 'backgroundcolor', 'background',
|
|
||||||
'pagebgcolor'
|
|
||||||
];
|
|
||||||
for (let c = 0; c < candidates.length; c++) {
|
|
||||||
for (let s = 0; s < sources.length; s++) {
|
|
||||||
for (const key in sources[s]) {
|
|
||||||
if (norm(key) === candidates[c]) {
|
|
||||||
const color = asColor(sources[s][key]);
|
|
||||||
if (color) return color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
componentStyle() {
|
componentStyle() {
|
||||||
let style = '';
|
let style = '';
|
||||||
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
|
style += 'background-color:' + this.value.componentBgColor + ';';
|
||||||
|
|
||||||
if (this.value.componentAngle == 'round') {
|
if (this.value.componentAngle == 'round') {
|
||||||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||||
@@ -213,446 +123,9 @@ export default {
|
|||||||
isIndicatorDots() {
|
isIndicatorDots() {
|
||||||
return this.value.carousel.type != 'hide' &&
|
return this.value.carousel.type != 'hide' &&
|
||||||
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
||||||
},
|
|
||||||
|
|
||||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
|
||||||
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
|
|
||||||
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
|
|
||||||
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
|
|
||||||
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
|
|
||||||
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
|
|
||||||
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
|
|
||||||
scrollBarMode() {
|
|
||||||
const v = this.value || {};
|
|
||||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
|
||||||
// 从配置对象中取"滚动条"字段的原始值
|
|
||||||
const pickRaw = obj => {
|
|
||||||
if (!obj || typeof obj != 'object') return undefined;
|
|
||||||
for (const key in obj) {
|
|
||||||
const k = norm(key);
|
|
||||||
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
|
|
||||||
const val = obj[key];
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
// 子配置对象形态: { scrollBar: { type: 'always' } }
|
|
||||||
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
|
|
||||||
}
|
|
||||||
if (val !== undefined && val !== null && val !== '') return val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
let raw = pickRaw(v);
|
|
||||||
// 嵌套容器兜底
|
|
||||||
if (raw === undefined) {
|
|
||||||
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
|
|
||||||
for (let i = 0; i < containers.length; i++) {
|
|
||||||
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
|
|
||||||
raw = pickRaw(v[containers[i]]);
|
|
||||||
if (raw !== undefined) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw === undefined || raw === null || raw === '') return 'slide';
|
|
||||||
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
|
|
||||||
if (raw === 0 || raw === '0') return 'slide';
|
|
||||||
if (raw === 1 || raw === '1') return 'always';
|
|
||||||
if (raw === 2 || raw === '2') return 'never';
|
|
||||||
|
|
||||||
const map = {
|
|
||||||
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
|
|
||||||
always: 'always', alwaysdisplay: 'always', show: 'always',
|
|
||||||
never: 'never', none: 'never', hidden: 'never', hide: 'never',
|
|
||||||
// 后台直出中文枚举时兜底
|
|
||||||
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
|
|
||||||
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
|
|
||||||
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
|
|
||||||
};
|
|
||||||
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
|
|
||||||
return hit || 'slide';
|
|
||||||
},
|
|
||||||
|
|
||||||
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
|
|
||||||
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
|
|
||||||
scrollBarClass() {
|
|
||||||
const cls = ['scrollbar-' + this.scrollBarMode];
|
|
||||||
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
|
|
||||||
return cls;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 原生 show-scrollbar / enhanced 仅在 App 端生效, 小程序端被忽略且会干扰原生行为,
|
|
||||||
// 故小程序端一律关闭, 改用自定义滚动条; H5 端这两个属性为 no-op, 不影响。
|
|
||||||
enhancedScrollbar() {
|
|
||||||
// #ifndef MP
|
|
||||||
return this.scrollBarMode != 'never';
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP
|
|
||||||
return false;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
showScrollbarAttr() {
|
|
||||||
// #ifndef MP
|
|
||||||
return this.scrollBarMode != 'never';
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP
|
|
||||||
return false;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// 小程序端自定义滚动条的"是否显示"
|
|
||||||
showCustomScrollbar() {
|
|
||||||
// #ifdef MP
|
|
||||||
if (this.scrollBarMode === 'never') return false;
|
|
||||||
// 只有 singleSlide(横向可滚动)才有滚动条, 与 H5 的 .singleSlide 样式作用域保持一致
|
|
||||||
if (this.value.showStyle != 'singleSlide') return false;
|
|
||||||
// 内容没有溢出(无需横向滚动)则不显示
|
|
||||||
if (!(this.mpScroll.contentW > this.mpScroll.viewW)) return false;
|
|
||||||
// 滑动时显示: 仅在滚动中显示
|
|
||||||
if (this.scrollBarMode === 'slide') return this.slideScrolling;
|
|
||||||
// 始终显示: 一直显示
|
|
||||||
return true;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// 小程序端自定义滚动条 thumb 的内联样式(width + 位移)
|
|
||||||
scrollbarThumbStyle() {
|
|
||||||
// #ifdef MP
|
|
||||||
const viewW = this.mpScroll.viewW;
|
|
||||||
const contentW = this.mpScroll.contentW;
|
|
||||||
const left = this.mpScroll.left;
|
|
||||||
if (!viewW || !contentW || contentW <= viewW) return 'display:none;';
|
|
||||||
const thumbW = Math.max(20, Math.floor(viewW * viewW / contentW));
|
|
||||||
const maxLeft = contentW - viewW;
|
|
||||||
const ratio = maxLeft > 0 ? Math.min(1, Math.max(0, left / maxLeft)) : 0;
|
|
||||||
const thumbLeft = ratio * (viewW - thumbW);
|
|
||||||
return 'width:' + thumbW + 'px;transform:translateX(' + thumbLeft + 'px);';
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
|
|
||||||
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
|
|
||||||
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
|
|
||||||
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
|
|
||||||
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
|
|
||||||
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
|
|
||||||
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
|
|
||||||
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
|
|
||||||
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
|
|
||||||
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
|
|
||||||
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
|
|
||||||
imageSpacingConfig() {
|
|
||||||
const v = this.value || {};
|
|
||||||
const RATIO = 2; // 后台数值 → rpx 换算比例
|
|
||||||
|
|
||||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
|
||||||
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
|
|
||||||
const toRpx = val => {
|
|
||||||
if (val === undefined || val === null || val === '') return null;
|
|
||||||
const n = Number(val);
|
|
||||||
if (isNaN(n)) return null;
|
|
||||||
return Math.max(0, n) * RATIO;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
|
|
||||||
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
|
|
||||||
const sources = [v];
|
|
||||||
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
|
|
||||||
.forEach(k => {
|
|
||||||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
|
|
||||||
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
|
|
||||||
const pick = (exact, fuzzy, nested) => {
|
|
||||||
for (let s = 0; s < sources.length; s++) {
|
|
||||||
const src = sources[s];
|
|
||||||
for (const key in src) {
|
|
||||||
if (exact.indexOf(norm(key)) != -1) {
|
|
||||||
const rpx = toRpx(src[key]);
|
|
||||||
if (rpx !== null) return rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let s = 0; s < sources.length; s++) {
|
|
||||||
const src = sources[s];
|
|
||||||
for (const key in src) {
|
|
||||||
const k = norm(key);
|
|
||||||
if (fuzzy.some(f => k.indexOf(f) != -1)) {
|
|
||||||
const rpx = toRpx(src[key]);
|
|
||||||
if (rpx !== null) return rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
|
|
||||||
for (let s = 1; s < sources.length; s++) {
|
|
||||||
const src = sources[s];
|
|
||||||
for (const key in src) {
|
|
||||||
if (nested.indexOf(norm(key)) != -1) {
|
|
||||||
const rpx = toRpx(src[key]);
|
|
||||||
if (rpx !== null) return rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
|
|
||||||
horizontal: pick(
|
|
||||||
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
|
|
||||||
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
|
|
||||||
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
|
|
||||||
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
|
|
||||||
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
|
|
||||||
),
|
|
||||||
// 图片上间距 —— 后台现用命名 imageSpacingTop
|
|
||||||
paddingTop: pick(
|
|
||||||
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
|
|
||||||
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
|
|
||||||
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
|
|
||||||
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
|
|
||||||
),
|
|
||||||
// 图片下间距 —— 后台现用命名 imageSpacingBottom
|
|
||||||
paddingBottom: pick(
|
|
||||||
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
|
|
||||||
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
|
|
||||||
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
|
|
||||||
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
|
|
||||||
)
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
|
|
||||||
imageSpacingStyle() {
|
|
||||||
const cfg = this.imageSpacingConfig;
|
|
||||||
let style = '';
|
|
||||||
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
|
|
||||||
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
|
|
||||||
return style;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
|
|
||||||
imageItemStyle() {
|
|
||||||
const horizontal = this.imageSpacingConfig.horizontal;
|
|
||||||
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ==========
|
|
||||||
// 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。
|
|
||||||
// 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸,
|
|
||||||
// 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略,
|
|
||||||
// <image> 于是回退到 uni-app 默认尺寸 320px x 240px, 视觉上就是"被撑到最大"。
|
|
||||||
// 取值优先级(后台数值单位按 rpx 约定, 经 $util.rpx() 转成当前端可用单位):
|
|
||||||
// ① 每一项下发的 imgWidth / imgHeight —— 沿用历史约定: 数值 ÷ 2
|
|
||||||
// ② 组件级 imgWidth / imgHeight —— 兜底, 同样 ÷ 2
|
|
||||||
// ③ 组件级"图片尺寸"imageSize —— 与 diy-graphic-nav / diy-merch-list 一致, × 2
|
|
||||||
// ④ 都没有 → 120 正方形, 只做最小安全兜底(远小于 320x240 默认值)
|
|
||||||
//
|
|
||||||
// 落地结构: 外层 .image-img(view) 与 <image> 都按解析出的尺寸渲染, 两端一致;
|
|
||||||
// 样式统一由下面的方法输出"字符串"(见 imageBoxStyle 的说明, 对象在小程序端会失效)。
|
|
||||||
rpxVal(val) {
|
|
||||||
// 项目约定: 小程序/App 输出 'Nrpx', H5 输出 'Npx'(uni.upx2px)
|
|
||||||
if (this.$util && typeof this.$util.rpx === 'function') return this.$util.rpx(val);
|
|
||||||
return val + 'rpx';
|
|
||||||
},
|
|
||||||
resolveItemImgSize(item) {
|
|
||||||
const valid = v => {
|
|
||||||
if (v === undefined || v === null || v === '') return null;
|
|
||||||
const n = Number(v);
|
|
||||||
return (!isNaN(n) && n > 0) ? n : null;
|
|
||||||
};
|
|
||||||
// ① 每一项图片自身尺寸
|
|
||||||
const iw = valid(item && item.imgWidth);
|
|
||||||
const ih = valid(item && item.imgHeight);
|
|
||||||
if (iw !== null && ih !== null) return { width: iw / 2, height: ih / 2 };
|
|
||||||
if (iw !== null) return { width: iw / 2, height: iw / 2 };
|
|
||||||
if (ih !== null) return { width: ih / 2, height: ih / 2 };
|
|
||||||
// ② 组件级 imgWidth / imgHeight
|
|
||||||
const v = this.value || {};
|
|
||||||
const ciw = valid(v.imgWidth);
|
|
||||||
const cih = valid(v.imgHeight);
|
|
||||||
if (ciw !== null && cih !== null) return { width: ciw / 2, height: cih / 2 };
|
|
||||||
if (ciw !== null) return { width: ciw / 2, height: ciw / 2 };
|
|
||||||
if (cih !== null) return { width: cih / 2, height: cih / 2 };
|
|
||||||
// ③ 组件级"图片尺寸"
|
|
||||||
const is = valid(v.imageSize);
|
|
||||||
if (is !== null) return { width: is * 2, height: is * 2 };
|
|
||||||
// ④ 安全兜底
|
|
||||||
return { width: 120, height: 120 };
|
|
||||||
},
|
|
||||||
// 图片外层占位盒样式(决定图片最终尺寸)
|
|
||||||
//
|
|
||||||
// ⚠️ 必须返回"样式字符串", 不能返回对象!
|
|
||||||
// 返回对象时 mp-weixin 的编译产物是 `style="{{(item.m0)}}"`(m0 是对象),
|
|
||||||
// 小程序 WXML 不能把对象转成 style 字符串 → 整条样式直接失效:
|
|
||||||
// · 只有 <image> 时 → 图片回退到默认 320x240(看起来"被撑到最大")
|
|
||||||
// · 外层盒没尺寸 + 图片 width:100% 时 → 高度塌成 0(图片完全不显示)
|
|
||||||
// diy-graphic-nav.vue 能在小程序正常显示, 正是因为它的 style 编译出来是字符串。
|
|
||||||
imageBoxStyle(item) {
|
|
||||||
const size = this.resolveItemImgSize(item);
|
|
||||||
const imageSize = Number(this.value && this.value.imageSize);
|
|
||||||
const font = (!isNaN(imageSize) && imageSize > 0) ? imageSize * 2 : size.height;
|
|
||||||
return 'font-size:' + this.rpxVal(font) + ';'
|
|
||||||
+ 'width:' + this.rpxVal(size.width) + ';'
|
|
||||||
+ 'height:' + this.rpxVal(size.height) + ';';
|
|
||||||
},
|
|
||||||
// 图片自身样式(与占位盒同尺寸, 配合 mode="aspectFit" 等比完整显示)
|
|
||||||
// max-width / max-height 与 diy-graphic-nav 的写法保持一致, 用于兜底夹住尺寸,
|
|
||||||
// 保证图片不会超出后台配置的大小。
|
|
||||||
imageInnerStyle(item) {
|
|
||||||
const size = this.resolveItemImgSize(item);
|
|
||||||
const w = this.rpxVal(size.width);
|
|
||||||
const h = this.rpxVal(size.height);
|
|
||||||
return 'width:' + w + ';height:' + h + ';max-width:' + w + ';max-height:' + h + ';';
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
|
|
||||||
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
|
|
||||||
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
|
|
||||||
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
|
|
||||||
bindSlideScrollbar() {
|
|
||||||
// #ifdef H5
|
|
||||||
if (this._slideScrollBound) return;
|
|
||||||
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
|
|
||||||
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
|
|
||||||
var el = this.getSlideContainer();
|
|
||||||
if (!el) return;
|
|
||||||
this._slideScrollEl = el;
|
|
||||||
this._slideScrollBound = true;
|
|
||||||
var self = this;
|
|
||||||
this._onSlideScroll = function() {
|
|
||||||
if (self.scrollBarMode != 'slide') return;
|
|
||||||
if (!self.slideScrolling) self.slideScrolling = true;
|
|
||||||
clearTimeout(self._slideScrollTimer);
|
|
||||||
self._slideScrollTimer = setTimeout(function() {
|
|
||||||
self.slideScrolling = false;
|
|
||||||
}, 800);
|
|
||||||
};
|
|
||||||
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
unbindSlideScrollbar() {
|
|
||||||
// #ifdef H5
|
|
||||||
if (this._slideScrollEl && this._onSlideScroll) {
|
|
||||||
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
|
|
||||||
}
|
|
||||||
clearTimeout(this._slideScrollTimer);
|
|
||||||
this._slideScrollEl = null;
|
|
||||||
this._onSlideScroll = null;
|
|
||||||
this._slideScrollBound = false;
|
|
||||||
this.slideScrolling = false;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// 统一的 scroll 事件处理(模板 @scroll 绑定, 小程序/H5 共用)
|
|
||||||
// · 两端:"滑动时显示"模式 → 维护 slideScrolling(滚动中标记, H5 用于 is-scrolling 类)
|
|
||||||
// · 小程序端: 驱动自定义滚动条 thumb 位置(原生 show-scrollbar 被忽略, 改用自绘)
|
|
||||||
onSlideScroll(e) {
|
|
||||||
if (this.scrollBarMode === 'slide') {
|
|
||||||
if (!this.slideScrolling) this.slideScrolling = true;
|
|
||||||
clearTimeout(this._slideScrollTimer);
|
|
||||||
this._slideScrollTimer = setTimeout(() => {
|
|
||||||
this.slideScrolling = false;
|
|
||||||
}, 800);
|
|
||||||
}
|
|
||||||
// #ifdef MP
|
|
||||||
// 首次滚动时若还没拿到尺寸, 先测量一次(图片尺寸由内联样式决定, 渲染后即稳定)
|
|
||||||
if (!this.mpScroll.viewW || !this.mpScroll.contentW) this.measureScrollbar();
|
|
||||||
// scroll-view 的 scroll 事件 detail 自带真实 scrollWidth(小程序原生返回),
|
|
||||||
// 只要它比当前记录的更大(内容更长), 就更新 —— 保证滚动条长度比例准确
|
|
||||||
if (e && e.detail && typeof e.detail.scrollWidth === 'number'
|
|
||||||
&& e.detail.scrollWidth > this.mpScroll.contentW) {
|
|
||||||
this.mpScroll.contentW = e.detail.scrollWidth;
|
|
||||||
}
|
|
||||||
let left = 0;
|
|
||||||
if (e && e.detail && typeof e.detail.scrollLeft === 'number') left = e.detail.scrollLeft;
|
|
||||||
this.mpScroll.left = left;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// 小程序端: 测量 scroll-view 视口宽度与内容宽度(供自定义滚动条计算 thumb)
|
|
||||||
//
|
|
||||||
// ⚠️ fix (2026-09-22) 「始终显示水平滚动条」在小程序端不生效的根因:
|
|
||||||
// 原实现只取 .uni-scroll-view-content 的 width 当"内容宽度"。但在 mp-weixin 中,
|
|
||||||
// 该 view 是块级/flex 容器, 宽度恒等于 scroll-view 宽度(子项只是溢出它而不是撑开它),
|
|
||||||
// 于是 contentW 永远 == viewW → showCustomScrollbar / scrollbarThumbStyle 判定
|
|
||||||
// "内容没有溢出" → 滚动条永不渲染(后台选"始终显示"也无效)。
|
|
||||||
// 现改为三级取样, 取到即可用, 且已被测得的有效值不会被后续空测量覆盖:
|
|
||||||
// ① scroll-view 的 scrollOffset.scrollWidth —— WeChat 原生返回的真实滚动区宽度(首选, 含内边距)
|
|
||||||
// ② 全部 .image-nav-item 的 rect 跨度 max(right) - min(left) + 左右内边距 —— 最可靠的 DOM 兜底
|
|
||||||
// ③ 按后台下发的图片/文本尺寸累加推算(rpx → px) —— 极端情况(查询全部失败)下兜底
|
|
||||||
measureScrollbar() {
|
|
||||||
// #ifdef MP
|
|
||||||
if (!uni || !uni.createSelectorQuery) return;
|
|
||||||
var self = this;
|
|
||||||
var query = uni.createSelectorQuery().in(this);
|
|
||||||
query.select('.image-nav').fields({ size: true, scrollOffset: true });
|
|
||||||
query.selectAll('.image-nav-item').boundingClientRect();
|
|
||||||
query.exec(function(res) {
|
|
||||||
var view = res && res[0];
|
|
||||||
if (!view) return;
|
|
||||||
var viewW = view.width || 0;
|
|
||||||
var items = res && res[1];
|
|
||||||
|
|
||||||
// ① 原生 scrollWidth
|
|
||||||
var contentW = view.scrollWidth || 0;
|
|
||||||
|
|
||||||
// ② 首尾 item 的 rect 跨度(与当前滚动位置无关: 两端同步平移, 差值稳定)
|
|
||||||
if (contentW <= viewW && items && items.length) {
|
|
||||||
var minLeft = Infinity;
|
|
||||||
var maxRight = -Infinity;
|
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
var r = items[i];
|
|
||||||
if (!r) continue;
|
|
||||||
if (r.left < minLeft) minLeft = r.left;
|
|
||||||
if (r.right > maxRight) maxRight = r.right;
|
|
||||||
}
|
|
||||||
if (maxRight > minLeft) {
|
|
||||||
var padPx = (uni.upx2px ? uni.upx2px(16) : 8) * 2; // .image-nav 左右各 16rpx
|
|
||||||
var spanW = (maxRight - minLeft) + padPx;
|
|
||||||
if (spanW > contentW) contentW = spanW;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ③ 数据推算兜底
|
|
||||||
if (!contentW) contentW = self.estimateContentWidthPx();
|
|
||||||
|
|
||||||
self.mpScroll.viewW = viewW;
|
|
||||||
// 有效值只增不减: 避免后一次空测量把已测得的宽度清零(会导致滚动条突然消失)
|
|
||||||
if (contentW > 0) self.mpScroll.contentW = contentW;
|
|
||||||
else if (!self.mpScroll.contentW) self.mpScroll.contentW = 0;
|
|
||||||
self.mpScroll.left = view.scrollLeft || 0;
|
|
||||||
});
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// 小程序端: 按后台下发的尺寸推算内容总宽度(rpx → px), 仅在 DOM 测量失败时兜底
|
|
||||||
estimateContentWidthPx() {
|
|
||||||
// #ifdef MP
|
|
||||||
const list = (this.value && this.value.list) || [];
|
|
||||||
if (!list.length) return 0;
|
|
||||||
const cfg = this.imageSpacingConfig;
|
|
||||||
const gap = cfg.horizontal === null ? 28 : cfg.horizontal;
|
|
||||||
let totalRpx = 32; // 容器左右 padding(16rpx × 2)
|
|
||||||
for (let i = 0; i < list.length; i++) {
|
|
||||||
if (this.value.mode == 'text') {
|
|
||||||
const w = Number(list[i] && list[i].imgWidth);
|
|
||||||
totalRpx += (!isNaN(w) && w > 0) ? w / 2 : 0;
|
|
||||||
} else {
|
|
||||||
totalRpx += this.resolveItemImgSize(list[i]).width;
|
|
||||||
}
|
|
||||||
totalRpx += gap;
|
|
||||||
}
|
|
||||||
let winW = 375;
|
|
||||||
try {
|
|
||||||
if (uni && uni.getSystemInfoSync) winW = uni.getSystemInfoSync().windowWidth || 375;
|
|
||||||
} catch (e) {}
|
|
||||||
return totalRpx * winW / 750;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
|
|
||||||
// 页面跳转
|
// 页面跳转
|
||||||
redirectTo(link) {
|
redirectTo(link) {
|
||||||
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||||
@@ -680,10 +153,14 @@ export default {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// 防止重复绑定
|
// 防止重复绑定
|
||||||
if (this._slideEventsBound) return;
|
if (this._slideEventsBound) return;
|
||||||
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
|
var outerEl = this.getSlideOuterEl();
|
||||||
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
|
var innerEl = this.getSlideContainer();
|
||||||
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
|
if (!outerEl || !innerEl) return;
|
||||||
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
|
// 关键发现: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;
|
||||||
this._slideEventsBound = true;
|
this._slideEventsBound = true;
|
||||||
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
||||||
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
||||||
@@ -706,79 +183,50 @@ export default {
|
|||||||
this._slideEventsBound = false;
|
this._slideEventsBound = false;
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
// 本组件实例的根元素(<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
|
|
||||||
var el = this.$el;
|
|
||||||
if (el && el.nodeType === 1) return el;
|
|
||||||
return null;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
|
|
||||||
getSlideOuterEl() {
|
|
||||||
// #ifdef H5
|
|
||||||
return this.getSlideRootEl();
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
getSlideContainer() {
|
getSlideContainer() {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> 自定义元素,
|
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
|
||||||
// 真正的滚动 div 是内部的 .uni-scroll-view。这里在**本实例范围内**查找:
|
// 嵌套结构:
|
||||||
// ① 优先"内容宽度 > 可视宽度"判定真滚动容器(与 overflow 写法无关, 最可靠)
|
// <uni-scroll-view> ← 自定义元素
|
||||||
// ② 其次 overflow-x: auto/scroll
|
// <div class="uni-scroll-view"> ← 第1层,visible
|
||||||
// ③ 再退化为最后一个 .uni-scroll-view
|
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
|
||||||
// ④ 最后退化到 .uni-scroll-view-content 的父元素
|
// 真正能响应 scrollLeft 的是第 2 层 div。
|
||||||
var root = this.getSlideRootEl();
|
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
|
||||||
if (!root) return null;
|
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||||
var candidates = root.querySelectorAll('.uni-scroll-view');
|
if (!componentRoot) return null;
|
||||||
var i;
|
// 找 overflowX:auto 的 div.uni-scroll-view
|
||||||
for (i = 0; i < candidates.length; i++) {
|
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
|
||||||
if (candidates[i].scrollWidth > candidates[i].clientWidth + 1) return candidates[i];
|
for (var i = 0; i < candidates.length; i++) {
|
||||||
}
|
|
||||||
for (i = 0; i < candidates.length; i++) {
|
|
||||||
var s = getComputedStyle(candidates[i]).overflowX;
|
var s = getComputedStyle(candidates[i]).overflowX;
|
||||||
if (s === 'auto' || s === 'scroll') return candidates[i];
|
if (s === 'auto' || s === 'scroll') {
|
||||||
|
return candidates[i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (candidates.length > 0) return candidates[candidates.length - 1];
|
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
|
||||||
var content = root.querySelector('.uni-scroll-view-content');
|
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
||||||
if (content && content.parentElement) return content.parentElement;
|
|
||||||
return root.querySelector('uni-scroll-view');
|
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
// 按需解析本实例的"根元素 + 真滚动容器"(挂载时机不确定, 统一走这里)
|
getSlideOuterEl() {
|
||||||
ensureSlideEls() {
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
var root = this.getSlideRootEl();
|
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
|
||||||
if (!root) return false;
|
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||||
this._slideOuterEl = root;
|
if (!componentRoot) return null;
|
||||||
var el = this.getSlideContainer();
|
var sc = componentRoot.querySelector('uni-scroll-view');
|
||||||
if (!el) return false;
|
return sc || null;
|
||||||
this._slideEl = el;
|
|
||||||
// 滚动条显隐依赖真滚动容器的 scroll 事件:mounted 时若还没拿到容器,
|
|
||||||
// 这里补绑一次(否则"滑动时显示滚动条"也会失效)。
|
|
||||||
if (!this._slideScrollBound) this.bindSlideScrollbar();
|
|
||||||
return true;
|
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
onSlideMouseDown(e) {
|
onSlideMouseDown(e) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (this.value.showStyle != 'singleSlide') return;
|
if (this.value.showStyle != 'singleSlide') return;
|
||||||
// document 级别监听,只处理落在"本实例"容器内的事件
|
// document 级别监听,只处理落在容器内的事件
|
||||||
// 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致),
|
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
|
||||||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||||||
if (!this.ensureSlideEls()) return;
|
if (!this._slideOuterEl) return;
|
||||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
var x = e.clientX, y = e.clientY;
|
var x = e.clientX, y = e.clientY;
|
||||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||||
var el = this._slideEl;
|
var el = this.getSlideContainer();
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
this.slideIsDragging = true;
|
this.slideIsDragging = true;
|
||||||
this.slideStartX = x;
|
this.slideStartX = x;
|
||||||
@@ -792,7 +240,7 @@ export default {
|
|||||||
onSlideMouseMove(e) {
|
onSlideMouseMove(e) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (!this.slideIsDragging) return;
|
if (!this.slideIsDragging) return;
|
||||||
var el = this._slideEl;
|
var el = this.getSlideContainer();
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
var x = (e.clientX != null) ? e.clientX : 0;
|
var x = (e.clientX != null) ? e.clientX : 0;
|
||||||
var deltaX = x - this.slideStartX;
|
var deltaX = x - this.slideStartX;
|
||||||
@@ -819,7 +267,7 @@ export default {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (!this._suppressNextClick) return;
|
if (!this._suppressNextClick) return;
|
||||||
if (!e.target) return;
|
if (!e.target) return;
|
||||||
var outer = this._slideOuterEl || this.getSlideOuterEl();
|
var outer = this._slideOuterEl;
|
||||||
if (!outer || !outer.contains(e.target)) return;
|
if (!outer || !outer.contains(e.target)) return;
|
||||||
this._suppressNextClick = false;
|
this._suppressNextClick = false;
|
||||||
if (e.preventDefault) e.preventDefault();
|
if (e.preventDefault) e.preventDefault();
|
||||||
@@ -830,13 +278,13 @@ export default {
|
|||||||
onSlideWheel(e) {
|
onSlideWheel(e) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (this.value.showStyle != 'singleSlide') return;
|
if (this.value.showStyle != 'singleSlide') return;
|
||||||
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在"本实例"容器内
|
if (!this._slideOuterEl) return;
|
||||||
if (!this.ensureSlideEls()) return;
|
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
|
||||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
var x = e.clientX, y = e.clientY;
|
var x = e.clientX, y = e.clientY;
|
||||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||||
var el = this._slideEl;
|
var el = this.getSlideContainer();
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
||||||
var dx = e.deltaX || 0;
|
var dx = e.deltaX || 0;
|
||||||
@@ -853,36 +301,6 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
// 包裹 scroll-view 的容器(相对定位, 自定义滚动条绝对定位于其底部)
|
|
||||||
.image-nav-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 小程序端自定义水平滚动条(MP 原生 show-scrollbar 被忽略, 改用自绘,
|
|
||||||
// 与 H5 的 CSS 伪元素滚动条视觉一致; 元素仅在 #ifdef MP 下渲染)
|
|
||||||
.img-nav-scrollbar {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 2rpx;
|
|
||||||
height: 4rpx;
|
|
||||||
background-color: rgba(0, 0, 0, 0.12);
|
|
||||||
border-radius: 2rpx;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.img-nav-scrollbar-thumb {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 4rpx;
|
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
border-radius: 2rpx;
|
|
||||||
transition: transform 0.08s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-nav {
|
.image-nav {
|
||||||
padding: 16rpx;
|
padding: 16rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -909,72 +327,17 @@ export default {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
|
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
|
||||||
cursor: grab;
|
scrollbar-width: none; // Firefox
|
||||||
&:active {
|
-ms-overflow-style: none; // IE/Edge legacy
|
||||||
cursor: grabbing;
|
&::-webkit-scrollbar {
|
||||||
}
|
display: none;
|
||||||
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
|
|
||||||
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
|
|
||||||
::v-deep .uni-scroll-view {
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
}
|
||||||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
|
||||||
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
|
|
||||||
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
|
|
||||||
scrollbar-width: none; // Firefox
|
|
||||||
-ms-overflow-style: none; // IE/Edge legacy
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
|
|
||||||
::v-deep .uni-scroll-view {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
|
|
||||||
&.scrollbar-always,
|
|
||||||
&.scrollbar-slide.is-scrolling {
|
|
||||||
scrollbar-width: thin; // Firefox
|
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: block;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
::v-deep .uni-scroll-view {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
|
||||||
}
|
|
||||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
|
||||||
display: block;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,15 +402,6 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 90rpx;
|
font-size: 90rpx;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
|
||||||
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
|
|
||||||
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
|
|
||||||
.image-content {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -6,18 +6,13 @@
|
|||||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="fui-list-inner">
|
<view class="fui-list-inner">
|
||||||
<view class="title">
|
<view class="title" style="height: 75rpx;">
|
||||||
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
|
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
||||||
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
|
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
||||||
<!-- #ifndef H5 -->
|
</view>
|
||||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
|
|
||||||
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
|
|
||||||
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
|
|
||||||
<!-- #endif -->
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
|
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||||
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
|
||||||
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
||||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||||
</view>
|
</view>
|
||||||
@@ -40,31 +35,15 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
|
|
||||||
mpTitleSize: 0,
|
|
||||||
mpDescSize: 0
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
// this.getDataList();
|
// this.getDataList();
|
||||||
},
|
},
|
||||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
|
|
||||||
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
|
|
||||||
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
|
|
||||||
mounted() {
|
|
||||||
this.$nextTick(this.fitOneLineText)
|
|
||||||
},
|
|
||||||
watch: {
|
watch: {
|
||||||
// 组件刷新监听
|
// 组件刷新监听
|
||||||
componentRefresh: function (nval) {
|
componentRefresh: function (nval) {
|
||||||
// this.getDataList();
|
// this.getDataList();
|
||||||
this.$nextTick(this.fitOneLineText)
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
deep: true,
|
|
||||||
handler() {
|
|
||||||
this.$nextTick(this.fitOneLineText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -87,130 +66,9 @@ export default {
|
|||||||
current: img,
|
current: img,
|
||||||
urls: [img]
|
urls: [img]
|
||||||
})
|
})
|
||||||
},
|
|
||||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
|
|
||||||
// #ifdef H5
|
|
||||||
fitOneLineText() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
|
|
||||||
if (!root) return
|
|
||||||
const nodes = root.querySelectorAll('.title-text, .subtitle')
|
|
||||||
nodes.forEach((el) => {
|
|
||||||
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
|
|
||||||
el.style.fontSize = ''
|
|
||||||
let size = parseFloat(window.getComputedStyle(el).fontSize)
|
|
||||||
const MIN_PX = 10 // 下限 10px, 再小可读性太差
|
|
||||||
const target = el.clientWidth - 3
|
|
||||||
let guard = 0
|
|
||||||
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
|
|
||||||
size -= 0.5
|
|
||||||
el.style.fontSize = size + 'px'
|
|
||||||
guard++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
// #ifndef H5
|
|
||||||
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
|
|
||||||
fitOneLineText() {
|
|
||||||
uni.createSelectorQuery().in(this)
|
|
||||||
.select('.fui-list-inner').boundingClientRect()
|
|
||||||
.select('.measure-title').boundingClientRect()
|
|
||||||
.select('.measure-desc').boundingClientRect()
|
|
||||||
.exec((rects) => {
|
|
||||||
if (!rects || !rects[0]) return
|
|
||||||
const sys = uni.getSystemInfoSync()
|
|
||||||
const pxPerRpx = (sys.windowWidth || 375) / 750
|
|
||||||
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
|
|
||||||
const avail = rects[0].width - 16 * pxPerRpx
|
|
||||||
if (avail <= 0) return
|
|
||||||
// 标题: 默认 28rpx, 最小 16rpx
|
|
||||||
if (rects[1] && rects[1].width > avail) {
|
|
||||||
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
|
|
||||||
} else {
|
|
||||||
this.mpTitleSize = 0
|
|
||||||
}
|
|
||||||
// 描述: 默认 24rpx, 最小 16rpx
|
|
||||||
if (rects[2] && rects[2].width > avail) {
|
|
||||||
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
|
|
||||||
} else {
|
|
||||||
this.mpDescSize = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
titleStyle() {
|
|
||||||
// #ifndef H5
|
|
||||||
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
|
|
||||||
// #endif
|
|
||||||
// #ifdef H5
|
|
||||||
return ''
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
descStyle() {
|
|
||||||
// #ifndef H5
|
|
||||||
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
|
|
||||||
// #endif
|
|
||||||
// #ifdef H5
|
|
||||||
return ''
|
|
||||||
// #endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss"></style>
|
||||||
// 客服展示:标题/描述需要完整展示(中英文均不截断)
|
|
||||||
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
|
|
||||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
|
|
||||||
.diy-kefu {
|
|
||||||
.fui-list-group {
|
|
||||||
.fui-list {
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.fui-list-inner {
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
|
|
||||||
padding-right: 8rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
height: auto;
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
.title-text {
|
|
||||||
display: block;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 6rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
|
|
||||||
.measure-text {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999rpx;
|
|
||||||
top: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
<!-- #ifdef MP -->
|
<!-- #ifdef MP -->
|
||||||
<block
|
<block
|
||||||
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
||||||
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||||
</block>
|
</block>
|
||||||
<view class="nickname" v-else>
|
<view class="nickname" v-else>
|
||||||
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||||
<view class="member-level"
|
<view class="member-level"
|
||||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||||
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
||||||
</block>
|
</block>
|
||||||
<view class="nickname" v-else>
|
<view class="nickname" v-else>
|
||||||
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||||
}}</text>
|
}}</text>
|
||||||
<view class="member-level"
|
<view class="member-level"
|
||||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="info">
|
<view class="info">
|
||||||
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
|
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
|
||||||
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -367,26 +367,6 @@ export default {
|
|||||||
isDisabled() {
|
isDisabled() {
|
||||||
if (this.nickName.length > 0) return false;
|
if (this.nickName.length > 0) return false;
|
||||||
return true;
|
return true;
|
||||||
},
|
|
||||||
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
|
|
||||||
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
|
|
||||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
|
|
||||||
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
|
|
||||||
nameStyle() {
|
|
||||||
let name = '';
|
|
||||||
if (this.memberInfo && this.memberInfo.nickname) {
|
|
||||||
name = this.memberInfo.nickname;
|
|
||||||
} else {
|
|
||||||
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
|
|
||||||
}
|
|
||||||
if (!name) return '';
|
|
||||||
let units = 0;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
|
|
||||||
}
|
|
||||||
if (units <= 10) return ''; // 短标题走默认 38rpx
|
|
||||||
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
|
|
||||||
return 'font-size:' + size + 'rpx;';
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
warpCss() {
|
warpCss() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||||
if (this.value.componentAngle == 'round') {
|
if (this.value.componentAngle == 'round') {
|
||||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||||
obj += 'border-top-right-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 />
|
@error="imgError(index)" show-menu-by-longpress />
|
||||||
</view>
|
</view>
|
||||||
<view class="info-wrap">
|
<view class="info-wrap">
|
||||||
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||||
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
|
<text class="desc">{{ item.desc }}</text>
|
||||||
<view class="read-wrap"></view>
|
<view class="read-wrap"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -179,25 +179,6 @@ export default {
|
|||||||
this.toDetail(item);
|
this.toDetail(item);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
|
|
||||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
|
|
||||||
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
|
|
||||||
oneLineFontStyle(text, defaultSize, minSize) {
|
|
||||||
if (!text) return '';
|
|
||||||
let units = 0;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
|
|
||||||
}
|
|
||||||
if (units <= 12) return '';
|
|
||||||
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
|
|
||||||
return 'font-size:' + size + 'rpx;';
|
|
||||||
},
|
|
||||||
titleStyle(item) {
|
|
||||||
return this.oneLineFontStyle(item.merch_name, 30, 20);
|
|
||||||
},
|
|
||||||
descStyle(item) {
|
|
||||||
return this.oneLineFontStyle(item.desc, 24, 20);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -315,19 +296,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-wrap {
|
.info-wrap {
|
||||||
flex: 1;
|
|
||||||
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
|
|
||||||
|
|
||||||
|
// flex: 1;
|
||||||
// display: flex;
|
// display: flex;
|
||||||
// flex-direction: column;
|
// flex-direction: column;
|
||||||
// justify-content: space-between;
|
// justify-content: space-between;
|
||||||
.desc {
|
.desc {
|
||||||
color: #888;
|
color: #888
|
||||||
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -336,9 +311,8 @@ export default {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view data-component-name="diy-search" class="diy-search">
|
<view data-component-name="diy-search" class="diy-search">
|
||||||
<view class="diy-search-wrap" :class="value.positionWay" :style="fixedCss">
|
<view class="diy-search-wrap" :class="value.positionWay" :style="fixedCss">
|
||||||
<!-- 卡片底板: 搜索框背后一圈底板, 与整体区域背景相互独立; 后台清空则透明(无底板) -->
|
|
||||||
<view class="search-board" :style="boardLayerStyle">
|
|
||||||
<view :class="['search-box', 'search-box-' + value.searchStyle]" :style="searchWrapCss"
|
<view :class="['search-box', 'search-box-' + value.searchStyle]" :style="searchWrapCss"
|
||||||
@tap.stop="handlerSearchClick">
|
@tap.stop="handlerSearchClick">
|
||||||
<block v-if="[1, 2].includes(value.searchStyle)">
|
<block v-if="[1, 2].includes(value.searchStyle)">
|
||||||
@@ -15,20 +13,20 @@
|
|||||||
<view class="search-content" :style="inputStyle">
|
<view class="search-content" :style="inputStyle">
|
||||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||||
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
:placeholderStyle="placeholderStyle" />
|
||||||
<text class="iconfont icon-sousuo3" @tap.stop="handlerSearchClick"
|
<text class="iconfont icon-sousuo3" @tap.stop="handlerSearchClick"
|
||||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
|
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<block v-if="value.searchStyle == 3">
|
<block v-if="value.searchStyle == 3">
|
||||||
<view class="search-content" :style="inputStyle"
|
<view class="search-content" :style="inputStyle"
|
||||||
@tap.stop="handlerSearchClick">
|
@tap.stop="handlerSearchClick">
|
||||||
<text class="iconfont icon-sousuo3"
|
<text class="iconfont icon-sousuo3"
|
||||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
|
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
|
||||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||||
@tap.stop="handlerSearchClick"
|
@tap.stop="handlerSearchClick"
|
||||||
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
:placeholderStyle="placeholderStyle" />
|
||||||
<text class="search-content-btn" @tap.stop="handlerSearchClick"
|
<text class="search-content-btn" @tap.stop="handlerSearchClick"
|
||||||
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
|
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
|
||||||
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
|
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
|
||||||
@@ -43,7 +41,6 @@
|
|||||||
@tap.stop="handlerRedirectToClick(value.searchLink)"></diy-icon>
|
@tap.stop="handlerRedirectToClick(value.searchLink)"></diy-icon>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<!-- 解决fixed定位后导航栏塌陷的问题 -->
|
<!-- 解决fixed定位后导航栏塌陷的问题 -->
|
||||||
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
|
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
|
||||||
@@ -117,90 +114,20 @@ export default {
|
|||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
// 搜索行框(紧包搜索输入框的一行, 含左侧 logo/图标): 背景色优先取后台"行框背景色",
|
|
||||||
// 未配置时回退到组件背景色(componentBgColor), 保持旧装修视觉; 兼容嵌套字段命名
|
|
||||||
searchWrapCss() {
|
searchWrapCss() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
var rowBg = this._probe([
|
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||||
'formBgColor', 'rowBgColor', 'lineBgColor', 'searchBoxBgColor', 'searchRowBgColor',
|
|
||||||
'boxBgColor', 'rowBackground', 'lineBackground', 'searchLineBgColor'
|
|
||||||
]) || this.value.componentBgColor || '';
|
|
||||||
if (rowBg) obj += 'background-color:' + rowBg + ';';
|
|
||||||
obj += 'text-align:' + this.value.textAlign + ';';
|
obj += 'text-align:' + this.value.textAlign + ';';
|
||||||
obj += this._radiusStyle(['formBorderRadius', 'rowRadius', 'rowAngle', 'lineRadius', 'searchBoxRadius', 'boxRadius', 'rowRound']);
|
|
||||||
obj += this._boxSpacingStyle('rowPadding', 'rowPaddingTop', 'rowPaddingBottom', 'rowPaddingLeft', 'rowPaddingRight');
|
|
||||||
obj += this._boxSpacingStyle('rowMargin', 'rowMarginTop', 'rowMarginBottom', 'rowMarginLeft', 'rowMarginRight', 'margin');
|
|
||||||
// 后台"搜索行框-内边距/外边距"真实字段 formPadding/formMargin 为带单位 shorthand(如 "0px")
|
|
||||||
obj += this._cssShorthand(['formPadding', 'rowPaddingStr'], 'padding');
|
|
||||||
obj += this._cssShorthand(['formMargin', 'rowMarginStr'], 'margin');
|
|
||||||
// 后台"搜索行框-四边样式"真实字段 formBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
|
||||||
obj += this._borderStyle('formBorder');
|
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
// 搜索输入框: 背景色优先取后台"输入框背景色"(含旧字段 elementBgColor), 文本色同步 textColor;
|
|
||||||
// 高度/内边距/圆角(框体样式)取后台字段, 兼容嵌套命名与缺省
|
|
||||||
inputStyle() {
|
inputStyle() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
var inputBg = this._probe([
|
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||||
'elementBgColor', 'inputBgColor', 'searchInputBgColor', 'inputBackground'
|
if (this.value.borderType == 2) {
|
||||||
]) || this.value.elementBgColor || '';
|
obj += 'border-radius:' + '40rpx;';
|
||||||
if (inputBg) obj += 'background-color:' + inputBg + ';';
|
|
||||||
if (this.value.textColor) obj += 'color:' + this.value.textColor + ';';
|
|
||||||
obj += this._boxSpacingStyle('inputPadding', 'inputPaddingTop', 'inputPaddingBottom', 'inputPaddingLeft', 'inputPaddingRight');
|
|
||||||
// 后台"输入框-内边距"真实字段 searchPadding 为带单位 shorthand(如 "0px")
|
|
||||||
obj += this._cssShorthand(['searchPadding', 'inputPaddingStr'], 'padding');
|
|
||||||
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
|
|
||||||
if (h !== null) obj += 'height:' + (h * 2) + 'rpx;';
|
|
||||||
// 框体样式(圆形): 新字段 round/circle/圆形 → 全圆角; 旧字段 borderType==2 → 40rpx
|
|
||||||
var shape = this._probe(['boxStyle', 'inputShape', 'frameStyle', 'borderShape', 'searchShape']);
|
|
||||||
if (shape && ['round', 'circle', '圆形', '1'].indexOf(String(shape).toLowerCase()) !== -1) {
|
|
||||||
obj += 'border-radius:999rpx;';
|
|
||||||
} else if (this.value.borderType == 2) {
|
|
||||||
obj += 'border-radius:40rpx;';
|
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
// 卡片底板: 搜索框背后一圈底板; 后台"底板背景色/卡片底版"真实字段名为 containerBgColor(历史命名亦兼容)
|
|
||||||
boardLayerStyle() {
|
|
||||||
var obj = '';
|
|
||||||
// 探测候选名尽量覆盖后台各种命名(驼峰/snake_case/下划线都会被 _norm 归一化匹配)
|
|
||||||
var bg = this._probe([
|
|
||||||
'containerBgColor', 'containerBackground', 'containerColor',
|
|
||||||
'cardBgColor', 'boardBgColor', 'cardBackground', 'boardBackground',
|
|
||||||
'baseBgColor', 'panelBgColor', 'plateBgColor', 'bottomBoardBgColor', 'boardColor',
|
|
||||||
'backgroundColor', 'bgColor', 'background_color', 'bg_color', 'plateColor',
|
|
||||||
'bottomBgColor', 'basePlateBgColor', 'basePlateColor', 'cardPlateBgColor', 'cardBottomBgColor'
|
|
||||||
]);
|
|
||||||
if (bg) {
|
|
||||||
obj += 'background-color:' + bg + ';';
|
|
||||||
// 设了底板背景色却没单独配置内边距时, 搜索框会完全盖住底板 → 改了"没反应";
|
|
||||||
// 因此未配置 boardPadding 时给默认内边距, 让底板颜色作为卡片显出来
|
|
||||||
var hasBoardPad = this._probeNum(['boardPadding']) !== null
|
|
||||||
|| this._probeNum(['boardPaddingTop']) !== null
|
|
||||||
|| this._probeNum(['boardPaddingBottom']) !== null
|
|
||||||
|| this._probeNum(['boardPaddingLeft']) !== null
|
|
||||||
|| this._probeNum(['boardPaddingRight']) !== null
|
|
||||||
|| this._probe(['containerPadding']) !== '';
|
|
||||||
if (!hasBoardPad) obj += 'padding:16rpx 12rpx;';
|
|
||||||
}
|
|
||||||
obj += this._radiusStyle(['containerBorderRadius', 'boardRadius', 'boardAngle', 'cardRadius', 'baseRadius', 'boardRound']);
|
|
||||||
obj += this._boxSpacingStyle('boardPadding', 'boardPaddingTop', 'boardPaddingBottom', 'boardPaddingLeft', 'boardPaddingRight');
|
|
||||||
// 后台"底板-内边距"真实字段 containerPadding 为带单位 shorthand(如 "27px 10px 31px")
|
|
||||||
obj += this._cssShorthand(['containerPadding', 'boardPaddingStr'], 'padding');
|
|
||||||
obj += this._boxSpacingStyle('boardMargin', 'boardMarginTop', 'boardMarginBottom', 'boardMarginLeft', 'boardMarginRight', 'margin');
|
|
||||||
// 后台"底板-外边距"真实字段 containerMargin 为带单位 shorthand(如 "0px")
|
|
||||||
obj += this._cssShorthand(['containerMargin', 'boardMarginStr'], 'margin');
|
|
||||||
// 后台"底板-四边样式"真实字段 containerBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
|
||||||
obj += this._borderStyle('containerBorder');
|
|
||||||
return obj;
|
|
||||||
},
|
|
||||||
magnifierBg() {
|
|
||||||
return this._probe(['magnifierBgColor', 'searchIconBgColor', 'iconBgColor', 'magnifierBackground']) || '';
|
|
||||||
},
|
|
||||||
inputElStyle() {
|
|
||||||
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
|
|
||||||
return h !== null ? { height: (h * 2) + 'rpx' } : {};
|
|
||||||
},
|
|
||||||
placeholderStyle() {
|
placeholderStyle() {
|
||||||
var obj = '';
|
var obj = '';
|
||||||
if (this.value.textColor) {
|
if (this.value.textColor) {
|
||||||
@@ -265,93 +192,6 @@ export default {
|
|||||||
this.setModuleLocationFn();
|
this.setModuleLocationFn();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// —— 后台字段名探测(兼容多种命名 / 嵌套容器) ——
|
|
||||||
_norm(k) {
|
|
||||||
return String(k == null ? '' : k).toLowerCase().replace(/[_\-]/g, '');
|
|
||||||
},
|
|
||||||
// 在 value 自身及常见嵌套容器(style/config/setting/board/card/row/input/...)中按候选字段名探测,
|
|
||||||
// 命中即返回原值(含 transparent); 未命中返回 ''
|
|
||||||
_probe(candidates) {
|
|
||||||
var self = this;
|
|
||||||
var cands = candidates.map(function (c) { return self._norm(c); });
|
|
||||||
var v = this.value || {};
|
|
||||||
var sources = [v];
|
|
||||||
['style', 'config', 'setting', 'styleSetting', 'board', 'card', 'row', 'line', 'input', 'element', 'search', 'box', 'margin', 'padding']
|
|
||||||
.forEach(function (k) {
|
|
||||||
var o = v[k];
|
|
||||||
if (o && typeof o === 'object' && !Array.isArray(o)) sources.push(o);
|
|
||||||
});
|
|
||||||
for (var i = 0; i < sources.length; i++) {
|
|
||||||
var src = sources[i];
|
|
||||||
if (!src || typeof src !== 'object') continue;
|
|
||||||
var keys = Object.keys(src);
|
|
||||||
for (var j = 0; j < keys.length; j++) {
|
|
||||||
if (cands.indexOf(self._norm(keys[j])) !== -1) {
|
|
||||||
var val = src[keys[j]];
|
|
||||||
if (val !== undefined && val !== null && val !== '') return String(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
_probeNum(candidates) {
|
|
||||||
var s = this._probe(candidates);
|
|
||||||
var n = parseFloat(s);
|
|
||||||
return isNaN(n) ? null : n;
|
|
||||||
},
|
|
||||||
// 后台带单位 shorthand 字段(如 "27px 10px 31px" / "0px"): 换算为 rpx(数值×2, 与组件内数值字段口径一致)后输出 prop:value;
|
|
||||||
// 未命中候选名返回 ''。无 px 单位的纯数字/其他单位原样透传
|
|
||||||
_cssShorthand(candidates, prop) {
|
|
||||||
var raw = this._probe(candidates);
|
|
||||||
if (raw === '') return '';
|
|
||||||
var s = String(raw).replace(/(-?\d+(?:\.\d+)?)px/g, function (m, n) {
|
|
||||||
return (parseFloat(n) * 2) + 'rpx';
|
|
||||||
});
|
|
||||||
return prop + ':' + s + ';';
|
|
||||||
},
|
|
||||||
// 后台四边独立边框字段: {prefix}{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
|
||||||
// Show 为 true/1 才输出该边; 输出格式 border-xxx: 宽(rpx×2) 线型 颜色
|
|
||||||
_borderStyle(prefix) {
|
|
||||||
var sides = ['top', 'right', 'bottom', 'left'];
|
|
||||||
var obj = '';
|
|
||||||
for (var i = 0; i < sides.length; i++) {
|
|
||||||
var s = sides[i];
|
|
||||||
var show = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Show']);
|
|
||||||
if (show === '' || show === 'false' || show === '0') continue;
|
|
||||||
var color = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Color']);
|
|
||||||
var width = this._probeNum([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Width']);
|
|
||||||
var style = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Style']);
|
|
||||||
if (width === null && !color) continue;
|
|
||||||
obj += 'border-' + s + ':' + ((width === null ? 0 : width) * 2) + 'rpx ' + (style || 'solid') + ' ' + (color || 'transparent') + ';';
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
},
|
|
||||||
// 圆角: round/circle/圆形 → 全圆角; 数值 → 半径(rpx×2)
|
|
||||||
_radiusStyle(cands) {
|
|
||||||
var raw = this._probe(cands);
|
|
||||||
if (raw === '') return '';
|
|
||||||
var lower = String(raw).toLowerCase();
|
|
||||||
if (['round', 'circle', '圆形'].indexOf(lower) !== -1) return 'border-radius:999rpx;';
|
|
||||||
var n = parseFloat(raw);
|
|
||||||
if (!isNaN(n)) return 'border-radius:' + (n * 2) + 'rpx;';
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
// 内/外边距: 单值字段 → 四边; 否则上下左右独立字段。kind 默认 padding
|
|
||||||
_boxSpacingStyle(single, top, bottom, left, right, kind) {
|
|
||||||
kind = kind || 'padding';
|
|
||||||
var singleV = this._probeNum([single]);
|
|
||||||
if (singleV !== null) return kind + ':' + (singleV * 2) + 'rpx;';
|
|
||||||
var s = '';
|
|
||||||
var t = this._probeNum([top]);
|
|
||||||
var b = this._probeNum([bottom]);
|
|
||||||
var l = this._probeNum([left]);
|
|
||||||
var r = this._probeNum([right]);
|
|
||||||
if (t !== null) s += kind + '-top:' + (t * 2) + 'rpx;';
|
|
||||||
if (b !== null) s += kind + '-bottom:' + (b * 2) + 'rpx;';
|
|
||||||
if (l !== null) s += kind + '-left:' + (l * 2) + 'rpx;';
|
|
||||||
if (r !== null) s += kind + '-right:' + (r * 2) + 'rpx;';
|
|
||||||
return s;
|
|
||||||
},
|
|
||||||
search() {
|
search() {
|
||||||
this.$util.redirectTo('/pages_tool/goods/search');
|
this.$util.redirectTo('/pages_tool/goods/search');
|
||||||
},
|
},
|
||||||
@@ -424,11 +264,6 @@ export default {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-board {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed {
|
.fixed {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -5,18 +5,15 @@
|
|||||||
<!-- 顶部标签位置:标签导航 + 指示器 + 内容区域 -->
|
<!-- 顶部标签位置:标签导航 + 指示器 + 内容区域 -->
|
||||||
<template v-if="mergedValue.tabPosition === 'top'">
|
<template v-if="mergedValue.tabPosition === 'top'">
|
||||||
<!-- 标签导航 -->
|
<!-- 标签导航 -->
|
||||||
<scroll-view class="tab-nav" :style="[tabNavStyle]" scroll-x
|
<view class="tab-nav" :style="[tabNavStyle]">
|
||||||
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
||||||
<view class="tab-nav-inner" :style="[tabNavInnerStyle]">
|
:class="{ active: activeTab === idx }"
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
|
||||||
:class="{ active: activeTab === idx }" :id="getNavItemId(idx)"
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
|
{{ getTabTitle(tab.title) }}
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
|
</text>
|
||||||
{{ getTabTitle(tab.title) }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</view>
|
||||||
|
|
||||||
<!-- 指示器 -->
|
<!-- 指示器 -->
|
||||||
<view v-if="mergedValue.showIndicator" class="tab-indicator" :style="[
|
<view v-if="mergedValue.showIndicator" class="tab-indicator" :style="[
|
||||||
@@ -77,36 +74,30 @@
|
|||||||
]"></view>
|
]"></view>
|
||||||
|
|
||||||
<!-- 标签导航 -->
|
<!-- 标签导航 -->
|
||||||
<scroll-view class="tab-nav" :style="[tabNavStyle]" scroll-x
|
<view class="tab-nav" :style="[tabNavStyle]">
|
||||||
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
||||||
<view class="tab-nav-inner" :style="[tabNavInnerStyle]">
|
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||||
:id="getNavItemId(idx)"
|
{{ getTabTitle(tab.title) }}
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
</text>
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
|
||||||
{{ getTabTitle(tab.title) }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
|
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
|
||||||
<template v-else-if="mergedValue.tabPosition === 'left'">
|
<template v-else-if="mergedValue.tabPosition === 'left'">
|
||||||
<view class="tab-layout-horizontal">
|
<view class="tab-layout-horizontal">
|
||||||
<!-- 左侧标签导航 -->
|
<!-- 左侧标签导航 -->
|
||||||
<scroll-view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]" scroll-y
|
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
|
||||||
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
||||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||||
:id="getNavItemId(idx)"
|
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||||
{{ getTabTitle(tab.title) }}
|
{{ getTabTitle(tab.title) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</view>
|
||||||
|
|
||||||
<!-- 左侧指示器 -->
|
<!-- 左侧指示器 -->
|
||||||
<view v-if="mergedValue.showIndicator" class="tab-indicator tab-indicator-vertical" :style="[
|
<view v-if="mergedValue.showIndicator" class="tab-indicator tab-indicator-vertical" :style="[
|
||||||
@@ -170,17 +161,15 @@
|
|||||||
]"></view>
|
]"></view>
|
||||||
|
|
||||||
<!-- 右侧标签导航 -->
|
<!-- 右侧标签导航 -->
|
||||||
<scroll-view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]" scroll-y
|
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
|
||||||
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
|
|
||||||
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
|
||||||
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
|
||||||
:id="getNavItemId(idx)"
|
|
||||||
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
|
||||||
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
|
||||||
{{ getTabTitle(tab.title) }}
|
{{ getTabTitle(tab.title) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
@@ -219,7 +208,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
activeTab: this.value?.activeTabIndex ?? 0, // 设置当前激活的标签索引
|
activeTab: this.value?.activeTabIndex ?? 0, // 设置当前激活的标签索引
|
||||||
scrollIntoId: '', // scroll-view 滚动定位的导航项 id
|
|
||||||
indicatorVars: { // 指示器CSS变量
|
indicatorVars: { // 指示器CSS变量
|
||||||
top: '',
|
top: '',
|
||||||
left: '',
|
left: '',
|
||||||
@@ -241,8 +229,6 @@ export default {
|
|||||||
// 初始化指示器位置
|
// 初始化指示器位置
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateIndicatorPosition();
|
this.updateIndicatorPosition();
|
||||||
// 初始化时将激活标签滚动到可视区域
|
|
||||||
this.syncScrollIntoId();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -431,6 +417,14 @@ export default {
|
|||||||
// 对于水平布局(顶部和底部),设置固定高度
|
// 对于水平布局(顶部和底部),设置固定高度
|
||||||
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
||||||
style.height = this.formatStyleValue(this.mergedValue.tabHeight, 24);
|
style.height = this.formatStyleValue(this.mergedValue.tabHeight, 24);
|
||||||
|
// 应用对齐方式
|
||||||
|
if (this.mergedValue.navAlign === 'start') {
|
||||||
|
style.justifyContent = 'flex-start';
|
||||||
|
} else if (this.mergedValue.navAlign === 'center') {
|
||||||
|
style.justifyContent = 'center';
|
||||||
|
} else if (this.mergedValue.navAlign === 'end') {
|
||||||
|
style.justifyContent = 'flex-end';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 对于垂直布局(左侧和右侧),不设置固定高度,让它自适应内容
|
// 对于垂直布局(左侧和右侧),不设置固定高度,让它自适应内容
|
||||||
else {
|
else {
|
||||||
@@ -457,24 +451,6 @@ export default {
|
|||||||
return style;
|
return style;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 导航栏内层容器样式(与后端版本一致)
|
|
||||||
tabNavInnerStyle() {
|
|
||||||
let style = {};
|
|
||||||
|
|
||||||
// 仅水平布局(顶部和底部)应用对齐方式
|
|
||||||
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
|
|
||||||
if (this.mergedValue.navAlign === 'start') {
|
|
||||||
style.justifyContent = 'flex-start';
|
|
||||||
} else if (this.mergedValue.navAlign === 'center') {
|
|
||||||
style.justifyContent = 'center';
|
|
||||||
} else if (this.mergedValue.navAlign === 'end') {
|
|
||||||
style.justifyContent = 'flex-end';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 标签项样式(与后端版本一致)
|
// 标签项样式(与后端版本一致)
|
||||||
tabItemStyle() {
|
tabItemStyle() {
|
||||||
let style = {
|
let style = {
|
||||||
@@ -625,10 +601,9 @@ export default {
|
|||||||
|
|
||||||
// 监听器
|
// 监听器
|
||||||
watch: {
|
watch: {
|
||||||
// 监听激活标签变化,更新指示器并滚动导航
|
// 监听激活标签变化,更新指示器
|
||||||
activeTab: {
|
activeTab: {
|
||||||
handler() {
|
handler() {
|
||||||
this.syncScrollIntoId();
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateIndicatorPosition();
|
this.updateIndicatorPosition();
|
||||||
});
|
});
|
||||||
@@ -749,16 +724,6 @@ export default {
|
|||||||
getTabKey(tab, idx) {
|
getTabKey(tab, idx) {
|
||||||
return `tab-${tab?.id ?? idx}`;
|
return `tab-${tab?.id ?? idx}`;
|
||||||
},
|
},
|
||||||
// 获取导航项的 DOM id(用于 scroll-view 的 scroll-into-view 定位)
|
|
||||||
getNavItemId(idx) {
|
|
||||||
return `tab-nav-item-${idx}`;
|
|
||||||
},
|
|
||||||
// 将当前激活标签滚动到导航栏可视区域
|
|
||||||
syncScrollIntoId() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.scrollIntoId = this.getNavItemId(this.activeTab);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 更新指示器位置(与后端版本一致)
|
// 更新指示器位置(与后端版本一致)
|
||||||
updateIndicatorPosition() {
|
updateIndicatorPosition() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -1069,6 +1034,8 @@ export default {
|
|||||||
|
|
||||||
// ===== 标签导航栏样式 =====
|
// ===== 标签导航栏样式 =====
|
||||||
.tab-nav {
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -1082,16 +1049,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导航栏内层容器:使用 inline-flex 让内容自然撑开宽度,保证小程序 scroll-view 可横向滚动
|
|
||||||
.tab-nav-inner {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
width: max-content;
|
|
||||||
min-width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 垂直导航栏
|
// 垂直导航栏
|
||||||
.tab-nav-vertical {
|
.tab-nav-vertical {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1111,7 +1068,6 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0; // 防止标签在 scroll-view 内被压缩,保证可完整展示并横向滑动
|
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--active-color, #339af0);
|
color: var(--active-color, #339af0);
|
||||||
|
|||||||
@@ -54,21 +54,17 @@
|
|||||||
<!-- 视频播放弹窗 -->
|
<!-- 视频播放弹窗 -->
|
||||||
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
||||||
<view class="video-container" :style="popupVideoStyle">
|
<view class="video-container" :style="popupVideoStyle">
|
||||||
<video
|
<video
|
||||||
:key="video_url"
|
|
||||||
ref="popupVideoRef"
|
ref="popupVideoRef"
|
||||||
class="adaptive-video"
|
class="adaptive-video"
|
||||||
:autoPauseIfNavigate="true"
|
:autoPauseIfNavigate="true"
|
||||||
:autoPauseIfOpenNative="true"
|
:autoPauseIfOpenNative="true"
|
||||||
:autoplay="false"
|
:autoplay="false"
|
||||||
:enableAutoRotation="true"
|
:enableAutoRotation="true"
|
||||||
id="myVideo"
|
id="myVideo"
|
||||||
:src="video_url"
|
:src="video_url"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
object-fit="contain"
|
object-fit="contain"
|
||||||
playsinline
|
|
||||||
webkit-playsinline
|
|
||||||
x5-playsinline
|
|
||||||
@loadedmetadata="onPopupVideoLoaded"
|
@loadedmetadata="onPopupVideoLoaded"
|
||||||
@error="onPopupVideoError"
|
@error="onPopupVideoError"
|
||||||
></video>
|
></video>
|
||||||
@@ -148,21 +144,41 @@ export default {
|
|||||||
|
|
||||||
// 弹窗视频容器样式
|
// 弹窗视频容器样式
|
||||||
popupVideoStyle() {
|
popupVideoStyle() {
|
||||||
// 容器横向铺满,黑色背景填满左右边界
|
let style = 'position:fixed;width:100%;left:0;';
|
||||||
// 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
|
const defaultRatio = 9 / 16;
|
||||||
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
|
|
||||||
|
let ratio = defaultRatio;
|
||||||
|
if (this.popupVideoWidth && this.popupVideoHeight) {
|
||||||
|
ratio = this.popupVideoHeight / this.popupVideoWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPortrait = ratio > 1;
|
||||||
|
|
||||||
|
if (isPortrait) {
|
||||||
|
style += 'top:50%;transform:translateY(-50%);';
|
||||||
|
} else {
|
||||||
|
style += 'top:20%;';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-HARMONY
|
||||||
|
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
|
||||||
|
// #endif
|
||||||
|
|
||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 显示视频播放弹窗
|
// 显示视频播放弹窗
|
||||||
showVideo(item) {
|
showVideo(item) {
|
||||||
let url = item.videoUrl || '';
|
this.video_url = item.videoUrl;
|
||||||
// 处理视频URL:完整链接直接使用,否则通过 $util.img() 转换
|
|
||||||
if (url && url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
|
||||||
url = this.$util.img(url);
|
|
||||||
}
|
|
||||||
this.video_url = url;
|
|
||||||
this.popupVideoWidth = 0;
|
this.popupVideoWidth = 0;
|
||||||
this.popupVideoHeight = 0;
|
this.popupVideoHeight = 0;
|
||||||
this.$refs.videoPopup.open();
|
this.$refs.videoPopup.open();
|
||||||
@@ -609,6 +625,22 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #ifdef MP-WEIXIN */
|
||||||
|
.adaptive-video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
|
/* #ifdef MP-HARMONY */
|
||||||
|
.adaptive-video {
|
||||||
|
position: static;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
.video-nav {
|
.video-nav {
|
||||||
padding: 16rpx;
|
padding: 16rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -11,9 +11,6 @@
|
|||||||
:style="videoWarpCss"
|
:style="videoWarpCss"
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
playsinline
|
|
||||||
webkit-playsinline
|
|
||||||
x5-playsinline
|
|
||||||
@loadedmetadata="onVideoLoaded"
|
@loadedmetadata="onVideoLoaded"
|
||||||
@click="handlerClick(videoUrl)"
|
@click="handlerClick(videoUrl)"
|
||||||
></video>
|
></video>
|
||||||
@@ -109,11 +106,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
|
|||||||
@@ -1,499 +0,0 @@
|
|||||||
<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,56 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
/**
|
|
||||||
* 读取后台装修下发的布尔开关
|
|
||||||
* 依次取 names 中的字段, 兼容 1/0、"1"/"0"、true/false、"true"/"false" 等值类型
|
|
||||||
* 命中返回 true/false; 所有字段都未下发时返回 undefined (由调用方决定兜底)
|
|
||||||
*
|
|
||||||
* 匹配顺序:
|
|
||||||
* 1. 精确命中 (showAddCart / show_add_cart ...)
|
|
||||||
* 2. 忽略大小写、下划线、中划线、空格的差异 (showaddcart === showAddCart === show_add_cart)
|
|
||||||
* —— 后台实际字段名与前端约定写法不一致时, 这一步兜底
|
|
||||||
* 3. 常见嵌套容器 (value.style / value.config / value.setting / value.data)
|
|
||||||
*/
|
|
||||||
getDiyToggle(names) {
|
|
||||||
const v = this.value || {};
|
|
||||||
const parse = val => {
|
|
||||||
if (val === undefined || val === null || val === '') return undefined;
|
|
||||||
if (val === true || val === 1 || val === '1' || val === 'true') return true;
|
|
||||||
if (val === false || val === 0 || val === '0' || val === 'false') return false;
|
|
||||||
return !!val;
|
|
||||||
};
|
|
||||||
const pick = obj => {
|
|
||||||
if (!obj || typeof obj != 'object') return undefined;
|
|
||||||
// 1. 精确命中
|
|
||||||
let i, res;
|
|
||||||
for (i = 0; i < names.length; i++) {
|
|
||||||
res = parse(obj[names[i]]);
|
|
||||||
if (res !== undefined) return res;
|
|
||||||
}
|
|
||||||
// 2. 忽略大小写/下划线等差异
|
|
||||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
|
||||||
const wanted = names.map(norm);
|
|
||||||
for (const key in obj) {
|
|
||||||
if (wanted.indexOf(norm(key)) != -1) {
|
|
||||||
res = parse(obj[key]);
|
|
||||||
if (res !== undefined) return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const root = pick(v);
|
|
||||||
if (root !== undefined) return root;
|
|
||||||
|
|
||||||
// 3. 嵌套容器兜底
|
|
||||||
const containers = ['style', 'config', 'setting', 'data'];
|
|
||||||
for (let i = 0; i < containers.length; i++) {
|
|
||||||
const res = pick(v[containers[i]]);
|
|
||||||
if (res !== undefined) return res;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 异步触发事件
|
// 异步触发事件
|
||||||
async __$emitEvent(payload = {eventName: '__unnamedEvent', data: {}, promiseCallback: null}) {
|
async __$emitEvent(payload = {eventName: '__unnamedEvent', data: {}, promiseCallback: null}) {
|
||||||
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)
|
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)
|
||||||
@@ -69,4 +18,4 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||||
<view>
|
<view>
|
||||||
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
<view v-if="pageCount === 1 || need" class="fixed-box"
|
||||||
:style="[customContainerStyle, {
|
:style="[customContainerStyle, {
|
||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
width: containerWidth,
|
|
||||||
bottom: containerBottom,
|
bottom: containerBottom,
|
||||||
right: containerRight,
|
right: containerRight,
|
||||||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||||||
@@ -12,18 +11,18 @@
|
|||||||
}]">
|
}]">
|
||||||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||||||
<view
|
<view
|
||||||
v-if="isLanguageSwitchEnabled && hasLangButton"
|
v-if="isLanguageSwitchEnabled && fixBtnShow"
|
||||||
class="btn-item common-bg btn-lang"
|
class="btn-item common-bg"
|
||||||
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
|
:style="btnItemStyle"
|
||||||
@click="handleLangButtonClick"
|
@click="handleLangButtonClick"
|
||||||
>
|
>
|
||||||
<view class="lang-btn-text">{{ langButtonText }}</view>
|
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 微信官方客服按钮-->
|
<!-- 微信官方客服按钮-->
|
||||||
<button
|
<button
|
||||||
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
|
||||||
class="btn-item common-bg btn-kefu"
|
class="btn-item common-bg"
|
||||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
:style="btnItemStyle"
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
>
|
>
|
||||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||||
@@ -31,9 +30,9 @@
|
|||||||
|
|
||||||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||||||
<view
|
<view
|
||||||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||||
class="btn-item common-bg btn-kefu"
|
class="btn-item common-bg"
|
||||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
:style="btnItemStyle"
|
||||||
@click="handleUnifiedKefuClick"
|
@click="handleUnifiedKefuClick"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
@@ -49,9 +48,9 @@
|
|||||||
|
|
||||||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||||||
<view
|
<view
|
||||||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||||
class="btn-item common-bg btn-kefu"
|
class="btn-item common-bg"
|
||||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
:style="btnItemStyle"
|
||||||
@click="handleUnifiedKefuClick"
|
@click="handleUnifiedKefuClick"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
@@ -67,9 +66,9 @@
|
|||||||
|
|
||||||
<!-- 小程序系统客服按钮 -->
|
<!-- 小程序系统客服按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
|
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
|
||||||
class="btn-item common-bg btn-kefu"
|
class="btn-item common-bg"
|
||||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
:style="btnItemStyle"
|
||||||
open-type="contact"
|
open-type="contact"
|
||||||
>
|
>
|
||||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||||
@@ -77,9 +76,9 @@
|
|||||||
|
|
||||||
<!-- 电话按钮 -->
|
<!-- 电话按钮 -->
|
||||||
<view
|
<view
|
||||||
v-if="hasPhoneButton"
|
v-if="fixBtnShow"
|
||||||
class="btn-item common-bg btn-phone"
|
class="btn-item common-bg"
|
||||||
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||||
@click="call"
|
@click="call"
|
||||||
>
|
>
|
||||||
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
||||||
@@ -110,10 +109,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createCustomerService } from '@/common/js/customer-service.js';
|
import { createCustomerService } from '@/common/js/customer-service.js';
|
||||||
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
||||||
import {
|
|
||||||
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
|
|
||||||
resolveLangButtonText, isLangButtonVisible,
|
|
||||||
} from '@/common/js/floating-button.js';
|
|
||||||
|
|
||||||
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
||||||
// key: 语言 value,value: 显示短码(按钮上展示)
|
// key: 语言 value,value: 显示短码(按钮上展示)
|
||||||
@@ -166,20 +161,6 @@ function getLangShortCode(langValue, customMap) {
|
|||||||
return prefix.toUpperCase();
|
return prefix.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨页面/重挂载复用的悬浮按钮避让值(设计稿 rpx)。
|
|
||||||
// H5 切换页面时 hover-nav 会重新挂载并重新测量公告栏: 无公告栏的页面(如分类页)
|
|
||||||
// noticeRect 为 null, 若直接回落基础位置, 会与首页"公告下方"位置不一致,
|
|
||||||
// 切回首页时按钮突然上/下跳。缓存上次算出的避让值, 让所有页面位置恒定。
|
|
||||||
let cachedAvoidBottom = null;
|
|
||||||
|
|
||||||
// 需要隐藏悬浮按钮的页面路由(全平台生效: H5 + 小程序)
|
|
||||||
// /pages_goods/category 商品分类
|
|
||||||
// /pages_tool/contact/contact 电子名片(名片)
|
|
||||||
const H5_HIDDEN_ROUTES = [
|
|
||||||
'/pages_goods/category',
|
|
||||||
'/pages_tool/contact/contact',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'hover-nav',
|
name: 'hover-nav',
|
||||||
mixins: [i18nLazyMixin],
|
mixins: [i18nLazyMixin],
|
||||||
@@ -189,7 +170,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
|
fixBtnShow: true,
|
||||||
shopInfo: null,
|
shopInfo: null,
|
||||||
currentLangIndex: 0,
|
currentLangIndex: 0,
|
||||||
langIndexMap: {},
|
langIndexMap: {},
|
||||||
@@ -209,12 +190,6 @@ export default {
|
|||||||
layoutTopBottom: 272, // --top-btn-bottom
|
layoutTopBottom: 272, // --top-btn-bottom
|
||||||
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
||||||
layoutFloatingWidth: 100, // --floating-btn-width
|
layoutFloatingWidth: 100, // --floating-btn-width
|
||||||
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
|
|
||||||
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
|
|
||||||
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
|
|
||||||
noticeRect: null, // {top,bottom,height} 视口坐标(px)
|
|
||||||
winW: 0, // 视口宽(px)
|
|
||||||
winH: 0, // 视口高(px)
|
|
||||||
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
||||||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||||||
i18n: {
|
i18n: {
|
||||||
@@ -262,75 +237,32 @@ export default {
|
|||||||
customButtonStyle() {
|
customButtonStyle() {
|
||||||
return this.shopInfo?.floatingButton?.button || {};
|
return this.shopInfo?.floatingButton?.button || {};
|
||||||
},
|
},
|
||||||
// ============================================================
|
|
||||||
// 浮动侧边栏配置消费 (来源: /api/config/init → data.floating_button,
|
|
||||||
// store/index.js 的 floatingButtonConfig, 初始 state 已声明 → 响应式)
|
|
||||||
// fbConfig 为 null (老缓存响应缺字段) 时, 各判定全部走老行为兜底。
|
|
||||||
// ============================================================
|
|
||||||
// 接口下发的浮动侧边栏配置 (老缓存响应 → null)
|
|
||||||
fbConfig() {
|
|
||||||
return (this.$store && this.$store.state && this.$store.state.floatingButtonConfig) || null;
|
|
||||||
},
|
|
||||||
// 指定页面隐藏悬浮按钮(商品分类 / 电子名片), 全平台生效
|
|
||||||
hiddenOnRoute() {
|
|
||||||
const r = this.currentRoute;
|
|
||||||
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
|
|
||||||
},
|
|
||||||
// 总开关 + 强制隐藏 + 页面范围 判定结果
|
|
||||||
fbVisible() {
|
|
||||||
if (this.hiddenOnRoute) return false; // 指定页面直接隐藏
|
|
||||||
return shouldShowOnPage(this.fbConfig, this.currentRoute, true);
|
|
||||||
},
|
|
||||||
// 最终要渲染的按钮 (已按 button_order 排序 + 显隐过滤); null = 用老行为
|
|
||||||
visibleButtons() {
|
|
||||||
return resolveVisibleButtons(this.fbConfig);
|
|
||||||
},
|
|
||||||
hasAnyButton() {
|
|
||||||
const b = this.visibleButtons;
|
|
||||||
return b ? b.length > 0 : true; // 老行为: 电话按钮恒显
|
|
||||||
},
|
|
||||||
hasLangButton() {
|
|
||||||
const b = this.visibleButtons;
|
|
||||||
return b ? b.indexOf('lang') !== -1
|
|
||||||
: isLangButtonVisible(this.fbConfig, this.shopInfo, this.langList.length);
|
|
||||||
},
|
|
||||||
hasKefuButton() {
|
|
||||||
const b = this.visibleButtons;
|
|
||||||
return b ? b.indexOf('kefu') !== -1 : true;
|
|
||||||
},
|
|
||||||
hasPhoneButton() {
|
|
||||||
const b = this.visibleButtons;
|
|
||||||
return b ? b.indexOf('phone') !== -1 : true;
|
|
||||||
},
|
|
||||||
// 语言按钮文案: 商家可配「目标语言 / 当前语言 / 固定文案」。
|
|
||||||
// 配置缺失时回退老行为 currentLangDisplayName(展示当前语言)。
|
|
||||||
langButtonText() {
|
|
||||||
if (!this.fbConfig) return this.currentLangDisplayName;
|
|
||||||
return resolveLangButtonText(this.fbConfig, this.langList, this.currentLangIndex);
|
|
||||||
},
|
|
||||||
// 按钮渲染顺序 (button_order 自上而下); .fixed-box 是 flex-direction: column,
|
|
||||||
// 用 CSS order 控制三段(语言/客服/电话)的先后, 配置缺失时退回老顺序。
|
|
||||||
orderIndex() {
|
|
||||||
const order = (this.fbConfig && Array.isArray(this.fbConfig.button_order))
|
|
||||||
? this.fbConfig.button_order
|
|
||||||
: ['lang', 'kefu', 'phone'];
|
|
||||||
const map = {};
|
|
||||||
for (let i = 0; i < order.length; i++) map[order[i]] = i + 1;
|
|
||||||
map.lang = map.lang || 1; map.kefu = map.kefu || 2; map.phone = map.phone || 3;
|
|
||||||
return map;
|
|
||||||
},
|
|
||||||
// 所有 .btn-item 的 inline-style 兜底:
|
// 所有 .btn-item 的 inline-style 兜底:
|
||||||
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
||||||
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
||||||
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
||||||
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
|
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
|
||||||
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
|
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||||
// hover_nav_lang_btn_size / hover_nav_lang_size
|
btnItemStyle() {
|
||||||
// hover_nav_kefu_btn_size / hover_nav_kefu_size
|
return {
|
||||||
// hover_nav_phone_btn_size / hover_nav_phone_size
|
width: '72rpx',
|
||||||
// 未配置时回退 72rpx(与原行为一致)。
|
height: '72rpx',
|
||||||
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
'min-width': '72rpx',
|
||||||
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
|
'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',
|
||||||
|
};
|
||||||
|
},
|
||||||
unreadCount() {
|
unreadCount() {
|
||||||
const customerService = createCustomerService(this);
|
const customerService = createCustomerService(this);
|
||||||
return customerService.getUnreadCount();
|
return customerService.getUnreadCount();
|
||||||
@@ -364,101 +296,53 @@ export default {
|
|||||||
return customerService.hasValidKefuConfig();
|
return customerService.hasValidKefuConfig();
|
||||||
},
|
},
|
||||||
|
|
||||||
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
|
//根据显示的按钮数量动态计算容器高度
|
||||||
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
|
// 计算明细(buttonCount 按钮场景):
|
||||||
renderedBtnSizes() {
|
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
|
||||||
const b = this.visibleButtons;
|
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
|
||||||
if (b && b.length === 0) return [];
|
// 总高度 (rpx):buttonCount × 100 + 40
|
||||||
|
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
|
||||||
const sizes = [];
|
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
|
||||||
if (this.isLanguageSwitchEnabled && this.hasLangButton) {
|
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
|
||||||
sizes.push(this.btnSizeRpx('lang'));
|
// 关联:按钮尺寸与 to-top 同步(72rpx),所以槽位高度也是 100rpx;
|
||||||
}
|
// 之前按钮 80rpx 时槽位 108rpx,改为 72rpx 后槽位缩小 8rpx,容器整体更紧凑
|
||||||
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() {
|
containerHeight() {
|
||||||
return this.rpxStr(this.containerHeightNum);
|
if (!this.fixBtnShow) return '320rpx';
|
||||||
},
|
|
||||||
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
|
let buttonCount = 1;
|
||||||
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
|
if (this.isLanguageSwitchEnabled) buttonCount++;
|
||||||
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
|
if (this.showWeappSystemKefu) buttonCount++;
|
||||||
containerWidthNum() {
|
buttonCount++;
|
||||||
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
|
const totalRpx = buttonCount * 100 + 40;
|
||||||
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
|
|
||||||
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
|
return `${totalRpx}rpx`;
|
||||||
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
|
|
||||||
},
|
|
||||||
containerWidth() {
|
|
||||||
return this.rpxStr(this.containerWidthNum);
|
|
||||||
},
|
},
|
||||||
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
||||||
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
||||||
// containerBottom = Top 顶部边界 + space
|
// containerBottom = Top 顶部边界 + space
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// space 两端统一为 18rpx:
|
// space 是经验调优值(非数学推导),手调结果:
|
||||||
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
|
// H5 : 18 * 5 = 90rpx
|
||||||
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
|
// MP-WEIXIN: 18 * 1 = 18rpx
|
||||||
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
|
// 为什么 H5 反而大、微信反而小:
|
||||||
|
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
|
||||||
|
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
|
||||||
|
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
|
||||||
|
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
|
||||||
|
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
|
||||||
|
// 18rpx 就能有清晰的呼吸感。
|
||||||
|
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||||||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||||||
// 两者会被安全区同步上推,space 保持不变
|
// 两者会被安全区同步上推,space 保持不变
|
||||||
containerBottom() {
|
containerBottom() {
|
||||||
const space = 18;
|
if (!this.fixBtnShow) return '240rpx';
|
||||||
const base = this.layoutTopBottom + this.layoutTopSize + space;
|
let space = 18 * 5;
|
||||||
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
|
// #ifdef MP-WEIXIN
|
||||||
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
|
space = 18 * 1;
|
||||||
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
|
// #endif
|
||||||
// 若直接 (winH - rect.bottom) 算, 滚动时按钮会跟着公告栏上下移动。
|
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
|
||||||
// 这里把 rect 加上当前滚动距离得到"公告栏在文档中的底边",
|
|
||||||
// 再算 (winH - 文档底边): 该值与滚动无关, 按钮固定在"页面顶部时公告栏正下方",
|
|
||||||
// 滚动时按钮静止(公告栏自行滚走), 不再来回滑动。
|
|
||||||
const rect = this.noticeRect;
|
|
||||||
let target = null;
|
|
||||||
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
|
|
||||||
// 当前滚动距离(H5: window.pageYOffset; 小程序无 window 时回退 0)
|
|
||||||
let scrollY = 0;
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined' && 'pageYOffset' in window) {
|
|
||||||
scrollY = window.pageYOffset || 0;
|
|
||||||
} else if (typeof document !== 'undefined' && document.documentElement) {
|
|
||||||
scrollY = document.documentElement.scrollTop || 0;
|
|
||||||
}
|
|
||||||
} catch (e) { scrollY = 0; }
|
|
||||||
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
|
|
||||||
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
|
|
||||||
// 公告栏底边在文档中的坐标 = 视口坐标 + 滚动距离(滚动无关, 恒定)
|
|
||||||
const docBottom = rect.bottom + scrollY;
|
|
||||||
const distFromBottom = (this.winH - docBottom) * scaleInv;
|
|
||||||
// 按钮 bottom = 文档底边距视口底部 + 间距 - 按钮自身高度
|
|
||||||
const below = distFromBottom - gap - this.containerHeightNum;
|
|
||||||
if (Number.isFinite(below)) {
|
|
||||||
target = Math.max(0, below);
|
|
||||||
cachedAvoidBottom = target; // 缓存: 跨页面/重挂载复用, 位置恒定
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 无公告栏的页面(如分类页)复用上一次算出的避让值, 使悬浮按钮在所有页面位置一致
|
|
||||||
if (target === null && cachedAvoidBottom !== null) target = cachedAvoidBottom;
|
|
||||||
return this.rpxStr(target === null ? base : target);
|
|
||||||
},
|
},
|
||||||
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
||||||
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
||||||
@@ -473,10 +357,9 @@ export default {
|
|||||||
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
||||||
// center 对齐不仅中心精准,突出量也更小
|
// center 对齐不仅中心精准,突出量也更小
|
||||||
containerRight() {
|
containerRight() {
|
||||||
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
|
if (!this.fixBtnShow) return '0rpx';
|
||||||
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
|
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
|
||||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
|
return `${offset}rpx`;
|
||||||
return this.rpxStr(offset);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -537,14 +420,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
|
|
||||||
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
|
|
||||||
onShow() {
|
|
||||||
this.refreshRoute();
|
|
||||||
},
|
|
||||||
created() {
|
created() {
|
||||||
this.initLanguage();
|
this.initLanguage();
|
||||||
this.refreshRoute();
|
this.pageCount = getCurrentPages().length;
|
||||||
|
|
||||||
// 三源兜底(store / 同步 storage / 异步 storage),
|
// 三源兜底(store / 同步 storage / 异步 storage),
|
||||||
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
||||||
@@ -634,23 +512,6 @@ export default {
|
|||||||
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
||||||
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
||||||
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
||||||
|
|
||||||
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
|
|
||||||
this.updateViewport();
|
|
||||||
this.measureNotice();
|
|
||||||
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
|
|
||||||
this._noticeTimers = [
|
|
||||||
setTimeout(() => this.measureNotice(), 300),
|
|
||||||
setTimeout(() => this.measureNotice(), 800),
|
|
||||||
];
|
|
||||||
// #ifdef H5
|
|
||||||
// 不再监听 scroll: containerBottom 已改用「文档坐标」(视口坐标 + 滚动距离)计算,
|
|
||||||
// 得到与滚动无关的恒定 bottom —— 按钮固定在"页面顶部时公告栏正下方",
|
|
||||||
// 滚动时按钮静止、公告栏自行滚走, 不再来回滑动。
|
|
||||||
// 仅监听 resize(旋转/尺寸变化), 让按钮在视口尺寸变化时重新避让一次。
|
|
||||||
this._scrollHandler = this._onScroll.bind(this);
|
|
||||||
window.addEventListener('resize', this._scrollHandler);
|
|
||||||
// #endif
|
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||||||
@@ -659,163 +520,9 @@ export default {
|
|||||||
this._origParent.appendChild(this.$el);
|
this._origParent.appendChild(this.$el);
|
||||||
this._origParent = null;
|
this._origParent = null;
|
||||||
}
|
}
|
||||||
// 清理公告栏避让的滚动/缩放监听与定时器
|
|
||||||
if (this._scrollHandler) {
|
|
||||||
window.removeEventListener('scroll', this._scrollHandler);
|
|
||||||
window.removeEventListener('resize', this._scrollHandler);
|
|
||||||
this._scrollHandler = null;
|
|
||||||
}
|
|
||||||
if (this._noticeTimers) {
|
|
||||||
this._noticeTimers.forEach((t) => clearTimeout(t));
|
|
||||||
this._noticeTimers = null;
|
|
||||||
}
|
|
||||||
if (this._scrollRAF) {
|
|
||||||
cancelAnimationFrame(this._scrollRAF);
|
|
||||||
this._scrollRAF = null;
|
|
||||||
}
|
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// ============================================================
|
|
||||||
// rpx 换算工具(内联样式单位跨端兼容的关键)
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
|
|
||||||
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
|
|
||||||
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
|
|
||||||
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
|
|
||||||
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
|
|
||||||
// 小程序端保持 rpx 原样输出。
|
|
||||||
// ============================================================
|
|
||||||
// #ifdef H5
|
|
||||||
rpxNum(n) {
|
|
||||||
const n2 = Number(n) || 0;
|
|
||||||
try {
|
|
||||||
const w = (typeof window !== 'undefined' && window.innerWidth) || 375;
|
|
||||||
return (n2 * w) / 750;
|
|
||||||
} catch (e) {
|
|
||||||
return n2 / 2; // window 异常时按 375px 视口兜底
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rpxStr(n) {
|
|
||||||
return this.rpxNum(n) + 'px';
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
// #ifndef H5
|
|
||||||
rpxNum(n) {
|
|
||||||
return Number(n) || 0;
|
|
||||||
},
|
|
||||||
rpxStr(n) {
|
|
||||||
return (Number(n) || 0) + 'rpx';
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
|
|
||||||
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
|
|
||||||
// containerBottom 据此把按钮放到公告栏底边之下。
|
|
||||||
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
|
|
||||||
// ============================================================
|
|
||||||
// 更新视口尺寸(换算 rpx 用)
|
|
||||||
updateViewport() {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined' && window.innerWidth) {
|
|
||||||
this.winW = window.innerWidth;
|
|
||||||
this.winH = window.innerHeight;
|
|
||||||
} else {
|
|
||||||
const info = uni.getSystemInfoSync();
|
|
||||||
this.winW = info.windowWidth || 375;
|
|
||||||
this.winH = info.windowHeight || 667;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.winW = this.winW || 375;
|
|
||||||
this.winH = this.winH || 667;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
|
|
||||||
measureNotice() {
|
|
||||||
try {
|
|
||||||
const q = uni.createSelectorQuery();
|
|
||||||
q.select('.diy-notice').boundingClientRect((rect) => {
|
|
||||||
if (rect && rect.height > 0) {
|
|
||||||
this.noticeRect = {
|
|
||||||
top: rect.top,
|
|
||||||
bottom: rect.bottom,
|
|
||||||
height: rect.height,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.noticeRect = null; // 无公告栏 → 基础定位
|
|
||||||
}
|
|
||||||
}).exec();
|
|
||||||
} catch (e) {
|
|
||||||
this.noticeRect = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// #ifdef H5
|
|
||||||
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
|
|
||||||
_onScroll() {
|
|
||||||
if (this._scrollRAF) return;
|
|
||||||
this._scrollRAF = requestAnimationFrame(() => {
|
|
||||||
this._scrollRAF = null;
|
|
||||||
this.updateViewport();
|
|
||||||
this.measureNotice();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 读取逐按钮尺寸 token(rpx 数值):
|
|
||||||
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
|
|
||||||
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
|
|
||||||
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
|
|
||||||
btnSizeRpx(type) {
|
|
||||||
let theme = null;
|
|
||||||
try {
|
|
||||||
theme = (this.$store && this.$store.state) ? this.$store.state.themeStyle : null;
|
|
||||||
} catch (e) { theme = null; }
|
|
||||||
const keys = ['hover_nav_' + type + '_btn_size', 'hover_nav_' + type + '_size'];
|
|
||||||
if (theme && typeof theme === 'object') {
|
|
||||||
for (let i = 0; i < keys.length; i++) {
|
|
||||||
const raw = theme[keys[i]];
|
|
||||||
if (raw === undefined || raw === null || raw === '') continue;
|
|
||||||
const n = parseFloat(raw);
|
|
||||||
if (Number.isFinite(n) && n > 0) return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 72;
|
|
||||||
},
|
|
||||||
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
|
|
||||||
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
|
|
||||||
btnItemStyle(type) {
|
|
||||||
const s = this.rpxStr(this.btnSizeRpx(type));
|
|
||||||
return {
|
|
||||||
width: s,
|
|
||||||
height: s,
|
|
||||||
'min-width': s,
|
|
||||||
'min-height': s,
|
|
||||||
'max-width': s,
|
|
||||||
'max-height': s,
|
|
||||||
// 内部图标(.iconfont/.icox)通过该变量跟随按钮尺寸:
|
|
||||||
// 图标盒子里原本锁死 72rpx, 按钮调大后图标仍 72rpx,
|
|
||||||
// 又因列 flex 横轴默认贴左 → 图标偏在按钮左上角
|
|
||||||
'--btn-size': s,
|
|
||||||
'aspect-ratio': '1',
|
|
||||||
// border-radius 保留作 clip-path 不支持时的 fallback
|
|
||||||
'border-radius': '50%',
|
|
||||||
'flex-shrink': '0',
|
|
||||||
'flex-grow': '0',
|
|
||||||
'align-self': 'center',
|
|
||||||
'box-sizing': 'border-box',
|
|
||||||
padding: '0',
|
|
||||||
margin: this.rpxStr(14) + ' 0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
// 刷新当前路由与页面栈深度(供 fbVisible 的页面范围判定 / pageCount 老判定)
|
|
||||||
refreshRoute() {
|
|
||||||
this.currentRoute = getCurrentRoute();
|
|
||||||
this.pageCount = getCurrentPages().length;
|
|
||||||
},
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
/**
|
/**
|
||||||
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
||||||
@@ -827,13 +534,9 @@ export default {
|
|||||||
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const computed = getComputedStyle(root);
|
const computed = getComputedStyle(root);
|
||||||
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
|
|
||||||
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
|
|
||||||
const scale = ((typeof window !== 'undefined' && window.innerWidth) || 375) / 750;
|
|
||||||
const getNum = (name, fallback) => {
|
const getNum = (name, fallback) => {
|
||||||
const v = parseFloat(computed.getPropertyValue(name));
|
const v = parseFloat(computed.getPropertyValue(name));
|
||||||
if (!Number.isFinite(v) || scale <= 0) return fallback;
|
return Number.isFinite(v) ? v : fallback;
|
||||||
return v / scale; // px → 设计稿 rpx 值
|
|
||||||
};
|
};
|
||||||
this.layoutTopRight = getNum('--top-btn-right', 46);
|
this.layoutTopRight = getNum('--top-btn-right', 46);
|
||||||
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
||||||
@@ -1040,53 +743,7 @@ export default {
|
|||||||
this.$langConfig.change(targetLang, url);
|
this.$langConfig.change(targetLang, url);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 客服按钮点击的统一入口。
|
|
||||||
*
|
|
||||||
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
|
|
||||||
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
|
|
||||||
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
|
|
||||||
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
|
|
||||||
*
|
|
||||||
* 为什么不在 customer-service.js#openAiChatService 统一注入:
|
|
||||||
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
|
|
||||||
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
|
|
||||||
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
|
|
||||||
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
|
|
||||||
* 其他入口保持原样。
|
|
||||||
*
|
|
||||||
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
|
|
||||||
*/
|
|
||||||
handleUnifiedKefuClick() {
|
handleUnifiedKefuClick() {
|
||||||
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
|
|
||||||
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
|
|
||||||
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
|
|
||||||
// 与底部 tabBar 无关,继续走原统一入口。
|
|
||||||
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
|
|
||||||
// 清除未读数(与 openAiChatService 行为保持一致)
|
|
||||||
if (typeof this.setAiUnreadCount === 'function') {
|
|
||||||
this.setAiUnreadCount(0);
|
|
||||||
} else if (this.$store && typeof this.$store.commit === 'function') {
|
|
||||||
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
|
|
||||||
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
|
|
||||||
uni.navigateTo({
|
|
||||||
url: aiChatUrl,
|
|
||||||
fail: (err) => {
|
|
||||||
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
|
|
||||||
// H5 兜底
|
|
||||||
// #ifdef H5
|
|
||||||
window.location.href = aiChatUrl;
|
|
||||||
// #endif
|
|
||||||
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
|
|
||||||
this.customerService.handleUnifiedKefuClick({
|
this.customerService.handleUnifiedKefuClick({
|
||||||
sendMessageTitle: '来自悬浮按钮的咨询',
|
sendMessageTitle: '来自悬浮按钮的咨询',
|
||||||
sendMessagePath: '/pages/index/index'
|
sendMessagePath: '/pages/index/index'
|
||||||
@@ -1118,13 +775,8 @@ export default {
|
|||||||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||||||
width: var(--floating-btn-width, 100rpx);
|
width: var(--floating-btn-width, 100rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
|
transition: 0.3s;
|
||||||
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
|
overflow: hidden;
|
||||||
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
|
|
||||||
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
|
|
||||||
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
|
|
||||||
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
|
|
||||||
overflow: visible;
|
|
||||||
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
||||||
margin-bottom: var(--window-bottom);
|
margin-bottom: var(--window-bottom);
|
||||||
}
|
}
|
||||||
@@ -1132,9 +784,6 @@ export default {
|
|||||||
.btn-item {
|
.btn-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
|
|
||||||
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -1169,42 +818,22 @@ export default {
|
|||||||
background-color: var(--hover-nav-bg-color);
|
background-color: var(--hover-nav-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 逐按钮背景/文字颜色覆写(与后台 9 个 key 对应, 未配置时回退全局 --hover-nav-*):
|
|
||||||
* hover_nav_lang_bg_color / hover_nav_lang_text_color → --hover-nav-lang-bg-color / --hover-nav-lang-text-color
|
|
||||||
* hover_nav_kefu_bg_color / hover_nav_kefu_text_color → --hover-nav-kefu-bg-color / --hover-nav-kefu-text-color
|
|
||||||
* hover_nav_phone_bg_color / hover_nav_phone_text_color → --hover-nav-phone-bg-color / --hover-nav-phone-text-color
|
|
||||||
* 文字色经 .btn-item 的 color 继承给内部 text/图标(.lang-btn-text 为 color: inherit) */
|
|
||||||
.btn-lang {
|
|
||||||
background-color: var(--hover-nav-lang-bg-color, var(--hover-nav-bg-color));
|
|
||||||
color: var(--hover-nav-lang-text-color, var(--hover-nav-text-color));
|
|
||||||
}
|
|
||||||
.btn-kefu {
|
|
||||||
background-color: var(--hover-nav-kefu-bg-color, var(--hover-nav-bg-color));
|
|
||||||
color: var(--hover-nav-kefu-text-color, var(--hover-nav-text-color));
|
|
||||||
}
|
|
||||||
.btn-phone {
|
|
||||||
background-color: var(--hover-nav-phone-bg-color, var(--hover-nav-bg-color));
|
|
||||||
color: var(--hover-nav-phone-text-color, var(--hover-nav-text-color));
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-item text {
|
.btn-item text {
|
||||||
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
|
font-size: 28rpx;
|
||||||
font-size: var(--hover-nav-font-size, 36rpx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont,
|
.iconfont,
|
||||||
.icox {
|
.icox {
|
||||||
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
|
font-size: 36rpx;
|
||||||
font-size: var(--hover-nav-font-size, 36rpx);
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
|
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
|
||||||
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
|
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
|
||||||
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
|
* 直接固定 72x72 更稳。 */
|
||||||
width: var(--btn-size, 72rpx);
|
width: 72rpx;
|
||||||
height: var(--btn-size, 72rpx);
|
height: 72rpx;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1222,17 +851,9 @@ export default {
|
|||||||
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 逐按钮字号覆写: 后台 hover_nav_phone/kefu_font_size 可调, 未设时回退全局 --hover-nav-font-size */
|
|
||||||
.iconfont.icon-dianhua {
|
|
||||||
font-size: var(--hover-nav-phone-font-size, var(--hover-nav-font-size, 36rpx));
|
|
||||||
}
|
|
||||||
.icox.icox-kefu {
|
|
||||||
font-size: var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx));
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconfont text,
|
.iconfont text,
|
||||||
.icox text {
|
.icox text {
|
||||||
font-size: var(--hover-nav-font-size, 36rpx);
|
font-size: 36rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,8 +865,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-icon {
|
.ai-icon {
|
||||||
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 ×1.11(原 40/36) */
|
font-size: 40rpx;
|
||||||
font-size: calc(var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx)) * 1.11);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1264,8 +884,7 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* 语言短码文字: 基准字号用 hover_nav_lang_font_size(回退全局 hover_nav_font_size), 文字实际按 0.667 缩放 */
|
font-size: 24rpx;
|
||||||
font-size: calc(var(--hover-nav-lang-font-size, var(--hover-nav-font-size, 36rpx)) * 0.667);
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|||||||
@@ -42,92 +42,82 @@ export default {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.loading-layer {
|
.loading-layer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 997;
|
z-index: 997;
|
||||||
background: #f8f8f8;
|
background: #f8f8f8;
|
||||||
// 修复: 强制合成层, 让 spin 动画只在自身合成层跑, 不触发兄弟节点 (尤其是底部 TabBar) 重绘
|
}
|
||||||
// 兼容环境: 微信小程序 / 鸿蒙元服务 / H5 都受益
|
|
||||||
// - 微信小程序: spin 动画每帧导致兄弟 fixed 元素 TabBar 重绘
|
.loading-anim {
|
||||||
// - 鸿蒙元服务 JSVM: 同上, JSVM 重绘更明显
|
position: absolute;
|
||||||
// - H5: will-change 提示浏览器持续合成
|
left: 50%;
|
||||||
transform: translateZ(0);
|
top: 40%;
|
||||||
-webkit-transform: translateZ(0);
|
transform: translate(-50%, -50%);
|
||||||
will-change: transform;
|
}
|
||||||
}
|
|
||||||
|
.loading-anim > .item {
|
||||||
.loading-anim {
|
position: relative;
|
||||||
position: absolute;
|
width: 70rpx;
|
||||||
left: 50%;
|
height: 70rpx;
|
||||||
top: 40%;
|
perspective: 1600rpx;
|
||||||
transform: translate(-50%, -50%);
|
transform-style: preserve-3d;
|
||||||
}
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
.loading-anim > .item {
|
|
||||||
position: relative;
|
.loading-anim .border {
|
||||||
width: 70rpx;
|
position: absolute;
|
||||||
height: 70rpx;
|
border-radius: 50%;
|
||||||
perspective: 1600rpx;
|
border: 6rpx solid;
|
||||||
transform-style: preserve-3d;
|
}
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
}
|
.loading-anim .out {
|
||||||
|
top: 15%;
|
||||||
.loading-anim .border {
|
left: 15%;
|
||||||
position: absolute;
|
width: 70%;
|
||||||
border-radius: 50%;
|
height: 70%;
|
||||||
border: 6rpx solid;
|
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
|
||||||
}
|
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
|
||||||
|
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
|
||||||
.loading-anim .out {
|
border-left-color: var(--base-color, #FF4646);
|
||||||
top: 15%;
|
border-right-color: #C5C5C5 !important;
|
||||||
left: 15%;
|
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
|
||||||
width: 70%;
|
border-top-color: var(--base-color, #FF4646);
|
||||||
height: 70%;
|
border-bottom-color: #C5C5C5 !important;
|
||||||
// P0: 硬编码主色 → var(--base-color),主题切换时 loading 主色跟着变
|
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
|
||||||
// 兼容: 微信小程序 / 鸿蒙元服务 — border-color 接受 var() 值无问题
|
animation: spin 0.6s linear normal infinite;
|
||||||
// JS 端 :style 绑定已用 themeStyle.main_color 走 inline 风格(原行 9),CSS 端用 var() 双保险
|
}
|
||||||
border-left-color: var(--base-color, #FF4646);
|
|
||||||
border-right-color: #C5C5C5 !important;
|
.loading-anim .in {
|
||||||
// border-right-color: rgba($color: #000000, $alpha: 0) !important;
|
top: 25%;
|
||||||
border-top-color: var(--base-color, #FF4646);
|
left: 25%;
|
||||||
border-bottom-color: #C5C5C5 !important;
|
width: 50%;
|
||||||
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
|
height: 50%;
|
||||||
animation: spin 0.6s linear normal infinite;
|
border-top-color: transparent !important;
|
||||||
transform-origin: center center;
|
border-bottom-color: transparent !important;
|
||||||
}
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
.loading-anim .in {
|
|
||||||
top: 25%;
|
.loading-anim .mid {
|
||||||
left: 25%;
|
top: 40%;
|
||||||
width: 50%;
|
left: 40%;
|
||||||
height: 50%;
|
width: 20%;
|
||||||
border-top-color: transparent !important;
|
height: 20%;
|
||||||
border-bottom-color: transparent !important;
|
border-left-color: transparent;
|
||||||
animation: spin 0.8s linear infinite;
|
border-right-color: transparent;
|
||||||
transform-origin: center center;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-anim .mid {
|
|
||||||
top: 40%;
|
|
||||||
left: 40%;
|
|
||||||
width: 20%;
|
|
||||||
height: 20%;
|
|
||||||
border-left-color: transparent;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="form-wrap form-component" :style="formWrapStyle">
|
<view class="form-wrap form-component" :style="formWrapStyle">
|
||||||
<block v-for="(item, index) in formData" :key="index">
|
<block v-for="(item, index) in formData">
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box">
|
<view class="box">
|
||||||
<input
|
<input
|
||||||
@@ -22,21 +22,28 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
|
||||||
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
<view class="order-cell flex-box textarea" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box">
|
<view class="box">
|
||||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
<!-- #ifdef MP-HARMONY -->
|
||||||
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
|
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
|
||||||
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
|
|
||||||
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
|
|
||||||
<textarea
|
<textarea
|
||||||
:rows="item.value.rows || textareaRowsValue"
|
:rows="item.value.rows || 2"
|
||||||
:placeholder="item.value.placeholder"
|
:placeholder="item.value.placeholder"
|
||||||
placeholder-class="placeholder color-tip"
|
placeholder-class="placeholder color-tip"
|
||||||
v-model="item.val"
|
v-model="item.val"
|
||||||
:style="textareaStyle"
|
:style="inputStyle"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifndef MP-HARMONY -->
|
||||||
|
<textarea
|
||||||
|
:placeholder="item.value.placeholder"
|
||||||
|
placeholder-class="placeholder color-tip"
|
||||||
|
v-model="item.val"
|
||||||
|
:style="inputStyle"
|
||||||
|
></textarea>
|
||||||
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -48,11 +55,11 @@
|
|||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view :class="['box', 'box-flex']">
|
<view class="box">
|
||||||
<text v-if="item.val != ''">{{ item.val }}</text>
|
<text v-if="item.val != ''">{{ item.val }}</text>
|
||||||
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
|
||||||
<text class="iconfont icon-right"></text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="iconfont icon-right"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
@@ -60,11 +67,11 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
<view class="box check-group-box">
|
||||||
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
|
<checkbox-group @change="checkboxChange($event, index)">
|
||||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||||
<checkbox :value="v.value" :checked="v.checked" />
|
<checkbox :value="v.value" :checked="v.checked" />
|
||||||
<view class="checkbox">
|
<view class="checkbox">
|
||||||
@@ -80,11 +87,11 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
|
<view class="box radio-group-box">
|
||||||
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
|
<radio-group @change="radioChange($event, index)">
|
||||||
<label v-for="(v, k) in item.option_lists" :key="k">
|
<label v-for="(v, k) in item.option_lists" :key="k">
|
||||||
<radio :value="v.value" :checked="item.val == v.value" />
|
<radio :value="v.value" :checked="item.val == v.value" />
|
||||||
<view class="radio-box">
|
<view class="radio-box">
|
||||||
@@ -100,8 +107,8 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
|
||||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box img-boxs">
|
<view class="box img-boxs">
|
||||||
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
|
||||||
@@ -116,23 +123,23 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view :class="['box', 'box-flex']">
|
<view class="box box-flex">
|
||||||
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
|
||||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||||
</picker>
|
</picker>
|
||||||
<text class="iconfont icon-right"></text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="iconfont icon-right"></text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
|
||||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box date-boxs">
|
<view class="box date-boxs">
|
||||||
<view class="date-box">
|
<view class="date-box">
|
||||||
@@ -157,23 +164,23 @@
|
|||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view :class="['box', 'box-flex']">
|
<view class="box box-flex">
|
||||||
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
|
||||||
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
|
||||||
</picker>
|
</picker>
|
||||||
<text class="iconfont icon-right"></text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="iconfont icon-right"></text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
|
||||||
<view class="order-cell flex-box" :style="rowCellStyle">
|
<view class="order-cell flex-box" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="box date-boxs">
|
<view class="box date-boxs">
|
||||||
<view class="date-box">
|
<view class="date-box">
|
||||||
@@ -196,20 +203,20 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
|
||||||
<view class="order-cell" :style="rowCellStyle">
|
<view class="order-cell box-flex" :style="rowCellStyle">
|
||||||
<view class="name" :style="nameWidthStyle">
|
<view class="name" :style="nameWidthStyle">
|
||||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
<text class="tit">{{ item.value.title }}</text>
|
||||||
<text class="tit">{{ item.value.title }}</text>
|
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</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>
|
||||||
|
<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>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
@@ -263,33 +270,21 @@
|
|||||||
config() {
|
config() {
|
||||||
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
|
||||||
const defaults = {
|
const defaults = {
|
||||||
formPaddingX: 28,
|
formPaddingX: 0,
|
||||||
formBgColor: '#ffffff',
|
formBgColor: '#ffffff',
|
||||||
rowPaddingY: 24,
|
rowPaddingY: 20,
|
||||||
rowMinHeight: 60,
|
rowMinHeight: 60,
|
||||||
labelGap: 20,
|
labelGap: 16,
|
||||||
inputHeight: 84,
|
inputHeight: 60,
|
||||||
inputRadius: 16,
|
inputRadius: 0,
|
||||||
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
|
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
|
||||||
labelFixedWidth: 180,
|
labelFixedWidth: 180,
|
||||||
labelFontSize: 32,
|
labelFontSize: 28,
|
||||||
labelColor: '#1f2937',
|
labelColor: '#1f2937',
|
||||||
labelFontWeight: 700,
|
|
||||||
labelAutoMaxWidth: 200,
|
|
||||||
requiredColor: '#ef4444',
|
requiredColor: '#ef4444',
|
||||||
checkedColor: '#3b82f6',
|
checkedColor: '#ff4544',
|
||||||
placeholderColor: '#9ca3af',
|
placeholderColor: '#909399',
|
||||||
placeholderFontSize: 28,
|
placeholderFontSize: 28,
|
||||||
// textarea 显示行数 / 行高(与 ns-newform 对齐)
|
|
||||||
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
|
|
||||||
// line-height 用于在 textareaStyle 里算出对应的 px 高度;不再写死 176rpx。
|
|
||||||
textareaRows: 4,
|
|
||||||
textareaLineHeight: 44,
|
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
|
||||||
inputBorderColor: '#e5e7eb',
|
|
||||||
inputBgColor: '#f9fafb',
|
|
||||||
inputFocusBorderColor: '#ff4544',
|
|
||||||
inputFocusBgColor: '#ffffff',
|
|
||||||
showRowDivider: true,
|
showRowDivider: true,
|
||||||
rowDividerColor: '#f0f0f0'
|
rowDividerColor: '#f0f0f0'
|
||||||
};
|
};
|
||||||
@@ -313,17 +308,10 @@
|
|||||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||||
labelColor: pick('labelColor', 'label_color'),
|
labelColor: pick('labelColor', 'label_color'),
|
||||||
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
|
|
||||||
requiredColor: pick('requiredColor', 'required_color'),
|
requiredColor: pick('requiredColor', 'required_color'),
|
||||||
checkedColor: pick('checkedColor', 'checked_color'),
|
checkedColor: pick('checkedColor', 'checked_color'),
|
||||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
|
||||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
|
||||||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
|
||||||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
|
||||||
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
|
|
||||||
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
|
|
||||||
showRowDivider: pick('showRowDivider', 'show_row_divider'),
|
showRowDivider: pick('showRowDivider', 'show_row_divider'),
|
||||||
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
|
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
|
||||||
};
|
};
|
||||||
@@ -375,18 +363,10 @@
|
|||||||
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
|
||||||
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
|
||||||
'--label-color': cfg.labelColor,
|
'--label-color': cfg.labelColor,
|
||||||
'--label-font-weight': cfg.labelFontWeight,
|
|
||||||
'--required-color': cfg.requiredColor,
|
'--required-color': cfg.requiredColor,
|
||||||
'--checked-color': cfg.checkedColor,
|
'--checked-color': cfg.checkedColor,
|
||||||
'--placeholder-color': cfg.placeholderColor,
|
'--placeholder-color': cfg.placeholderColor,
|
||||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||||
'--textarea-rows': num(cfg.textareaRows, 4),
|
|
||||||
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
|
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
|
||||||
'--input-border-color': cfg.inputBorderColor,
|
|
||||||
'--input-bg-color': cfg.inputBgColor,
|
|
||||||
'--input-focus-border-color': cfg.inputFocusBorderColor,
|
|
||||||
'--input-focus-bg-color': cfg.inputFocusBgColor,
|
|
||||||
'--row-divider-color': cfg.rowDividerColor,
|
'--row-divider-color': cfg.rowDividerColor,
|
||||||
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
|
||||||
});
|
});
|
||||||
@@ -404,10 +384,13 @@
|
|||||||
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
'min-height': this.$util.rpx(this.config.rowMinHeight)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
|
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
|
||||||
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
|
|
||||||
nameWidthStyle() {
|
nameWidthStyle() {
|
||||||
return {};
|
const cfg = this.config;
|
||||||
|
if (cfg.labelWidthMode === 'fixed') {
|
||||||
|
return { width: this.$util.rpx(cfg.labelFixedWidth) };
|
||||||
|
}
|
||||||
|
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
|
||||||
},
|
},
|
||||||
// 输入框样式(input / textarea 共用)
|
// 输入框样式(input / textarea 共用)
|
||||||
inputStyle() {
|
inputStyle() {
|
||||||
@@ -419,42 +402,6 @@
|
|||||||
'line-height': this.$util.rpx(this.config.inputHeight)
|
'line-height': this.$util.rpx(this.config.inputHeight)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
|
|
||||||
textareaRowsValue() {
|
|
||||||
const n = parseInt(this.config.textareaRows, 10);
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : 4;
|
|
||||||
},
|
|
||||||
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出固定可视高度)
|
|
||||||
textareaLineHeightValue() {
|
|
||||||
const n = parseInt(this.config.textareaLineHeight, 10);
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : 44;
|
|
||||||
},
|
|
||||||
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
|
|
||||||
// 设计要点:
|
|
||||||
// 1) 不复用 inputStyle 的 min-height / line-height:那两个值取自 inputHeight(默认 60rpx),
|
|
||||||
// 对 textarea 太小,会让原生 textarea 单行过高、可视区域过小;
|
|
||||||
// 2) 不使用 auto-height:原生 textarea 上 auto-height 会让组件随内容无限扩展,
|
|
||||||
// 遮挡后续组件,导致"看不到下面的其他组件";
|
|
||||||
// 3) 高度 = rows × lineHeight(两个值都由 formConfig 动态下发,不再写死 176rpx):
|
|
||||||
// - H5 浏览器 / 微信原生层:原生 <textarea> 同时识别 :rows 和 CSS line-height,
|
|
||||||
// 高度由浏览器自然算出,与 :rows 完全一致;
|
|
||||||
// - 鸿蒙 ArkUI:原生 TextArea 只识别 :rows,不识别 CSS height,所以我们在 JS 里
|
|
||||||
// 显式算 height = rows × lineHeight 反向写出,保证三端可视高度像素级一致。
|
|
||||||
textareaStyle() {
|
|
||||||
const lh = this.$util.rpx(this.textareaLineHeightValue);
|
|
||||||
const rows = this.textareaRowsValue;
|
|
||||||
// 加上 padding(12rpx 上下) 和 border(1rpx 上下) 的占用,避免与 :rows 算出的高度不一致时被截断
|
|
||||||
const paddings = this.$util.rpx(24) + this.$util.rpx(2);
|
|
||||||
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
|
|
||||||
return this.$util.objToStyle({
|
|
||||||
'height': h,
|
|
||||||
'min-height': h,
|
|
||||||
'line-height': lh,
|
|
||||||
'border-radius': this.$util.rpx(this.config.inputRadius),
|
|
||||||
'font-size': this.$util.rpx(this.config.labelFontSize),
|
|
||||||
'color': this.config.labelColor
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 必填星号颜色
|
// 必填星号颜色
|
||||||
requiredColor() {
|
requiredColor() {
|
||||||
return this.config.requiredColor;
|
return this.config.requiredColor;
|
||||||
@@ -484,33 +431,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 预计算每项的 option_layout inline style(与 ns-newform 对齐)
|
|
||||||
// 跨端兼容:小程序 / 鸿蒙元服务 WXML 不支持模板里的方法调用,必须预计算到 item 上
|
|
||||||
recomputeItemStyles() {
|
|
||||||
if (!this.formData || this.formData.length === 0) return;
|
|
||||||
this.formData.forEach((item) => {
|
|
||||||
const itemValue = (item && item.value) || {};
|
|
||||||
const optLayout = itemValue.option_layout;
|
|
||||||
const optStyle = {};
|
|
||||||
if (optLayout === 'vertical') {
|
|
||||||
optStyle['flex-direction'] = 'column';
|
|
||||||
optStyle['align-items'] = 'flex-start';
|
|
||||||
optStyle['gap'] = '8px';
|
|
||||||
} else {
|
|
||||||
// wrap 模式
|
|
||||||
optStyle['flex-direction'] = 'row';
|
|
||||||
optStyle['align-items'] = 'center';
|
|
||||||
optStyle['gap'] = '6px';
|
|
||||||
}
|
|
||||||
this.$set(item, '_optionLayoutStyle', optStyle);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
initFormData() {
|
initFormData() {
|
||||||
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
|
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
|
||||||
this.formData = JSON.parse(JSON.stringify(this.data || []));
|
this.formData = JSON.parse(JSON.stringify(this.data || []));
|
||||||
this.processFormData();
|
this.processFormData();
|
||||||
// 预计算每项 inline style(与 ns-newform 对齐,跨端兼容)
|
|
||||||
this.recomputeItemStyles();
|
|
||||||
},
|
},
|
||||||
setFormData() {
|
setFormData() {
|
||||||
this.initFormData();
|
this.initFormData();
|
||||||
@@ -518,9 +442,7 @@
|
|||||||
},
|
},
|
||||||
processFormData() {
|
processFormData() {
|
||||||
this.formData.forEach(item => {
|
this.formData.forEach(item => {
|
||||||
// Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
|
if (!item.val) item.val = item.value.default ? item.value.default : '';
|
||||||
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
|
|
||||||
if (!item.val && !skipDefault) item.val = item.value.default ? item.value.default : '';
|
|
||||||
|
|
||||||
if (item.value.options) {
|
if (item.value.options) {
|
||||||
item.option_lists = [];
|
item.option_lists = [];
|
||||||
@@ -945,31 +867,22 @@
|
|||||||
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
|
||||||
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
|
||||||
.form-wrap {
|
.form-wrap {
|
||||||
--form-padding-x: 28rpx;
|
--form-padding-x: 0rpx;
|
||||||
--form-bg-color: #ffffff;
|
--form-bg-color: #ffffff;
|
||||||
--row-padding-y: 24rpx;
|
--row-padding-y: 20rpx;
|
||||||
--row-min-height: 60rpx;
|
--row-min-height: 60rpx;
|
||||||
--label-gap: 20rpx;
|
--label-gap: 16rpx;
|
||||||
--input-height: 84rpx;
|
--input-height: 60rpx;
|
||||||
--input-radius: 16rpx;
|
--input-radius: 0rpx;
|
||||||
--label-fixed-width: 180rpx;
|
--label-fixed-width: 180rpx;
|
||||||
--label-font-size: 32rpx;
|
--label-font-size: 28rpx;
|
||||||
--label-color: #1f2937;
|
--label-color: #1f2937;
|
||||||
--label-font-weight: 700;
|
|
||||||
--required-color: #ef4444;
|
--required-color: #ef4444;
|
||||||
--placeholder-font-size: 28rpx;
|
--placeholder-font-size: 28rpx;
|
||||||
--checked-color: #3b82f6;
|
--checked-color: #ff4544;
|
||||||
--placeholder-color: #9ca3af;
|
--placeholder-color: #909399;
|
||||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
|
||||||
--textarea-rows: 4;
|
|
||||||
--textarea-line-height: 48rpx;
|
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
|
||||||
--input-border-color: #e5e7eb;
|
|
||||||
--input-bg-color: #ffffff;
|
|
||||||
--input-focus-border-color: #3b82f6;
|
|
||||||
--input-focus-bg-color: #ffffff;
|
|
||||||
--row-divider-color: #f0f0f0;
|
--row-divider-color: #f0f0f0;
|
||||||
--show-row-divider: 0;
|
--show-row-divider: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-wrap {
|
.order-wrap {
|
||||||
@@ -987,8 +900,7 @@
|
|||||||
|
|
||||||
.order-cell {
|
.order-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
|
align-items: center;
|
||||||
align-items: flex-start;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: var(--row-min-height, 60rpx);
|
min-height: var(--row-min-height, 60rpx);
|
||||||
@@ -1021,23 +933,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
width: 100%;
|
width: var(--label-fixed-width, 180rpx);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: flex;
|
margin-right: var(--label-gap, 16rpx);
|
||||||
align-items: center;
|
|
||||||
min-height: var(--input-height, 60rpx);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: var(--label-gap, 20rpx);
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tit {
|
.tit {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
font-weight: var(--label-font-weight, 700);
|
|
||||||
|
|
||||||
text {
|
text {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
@@ -1047,49 +953,36 @@
|
|||||||
.required {
|
.required {
|
||||||
color: var(--required-color, #ef4444);
|
color: var(--required-color, #ef4444);
|
||||||
font-size: var(--label-font-size, 28rpx);
|
font-size: var(--label-font-size, 28rpx);
|
||||||
margin-right: 6rpx;
|
margin-left: 4rpx;
|
||||||
font-weight: bold;
|
width: 14rpx;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
padding: 0 10rpx;
|
||||||
padding: 0;
|
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
// H5 端 <input> 可能被 uni-app 编译为 <uni-input> 包装组件,::v-deep 穿透 scoped 命中真实原生 input
|
|
||||||
::v-deep input,
|
|
||||||
input {
|
input {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
|
||||||
// 避免 placeholder / 文字下半部分被裁切
|
// 避免 placeholder / 文字下半部分被裁切
|
||||||
line-height: var(--input-height, 84rpx);
|
line-height: var(--input-height, 60rpx);
|
||||||
height: var(--input-height, 84rpx);
|
height: var(--input-height, 60rpx);
|
||||||
min-height: var(--input-height, 84rpx);
|
min-height: var(--input-height, 60rpx);
|
||||||
border-radius: var(--input-radius, 16rpx);
|
border-radius: var(--input-radius, 0rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
padding: 0;
|
||||||
padding: 0 24rpx;
|
background: transparent;
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
|
||||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
|
||||||
background: var(--input-bg-color, #ffffff);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background: var(--input-focus-bg-color, #ffffff);
|
|
||||||
border-color: var(--input-focus-border-color, #3b82f6);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
|
||||||
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--placeholder-color, #9ca3af);
|
color: var(--placeholder-color, #909399);
|
||||||
font-size: var(--placeholder-font-size, 28rpx);
|
font-size: var(--placeholder-font-size, 28rpx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1097,47 +990,32 @@
|
|||||||
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
|
||||||
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
|
||||||
.placeholder {
|
.placeholder {
|
||||||
color: var(--placeholder-color, #9ca3af);
|
color: var(--placeholder-color, #909399);
|
||||||
font-size: var(--placeholder-font-size, 28rpx);
|
font-size: var(--placeholder-font-size, 28rpx);
|
||||||
line-height: var(--input-height, 84rpx);
|
line-height: var(--input-height, 60rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep textarea,
|
|
||||||
textarea {
|
textarea {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
|
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
|
||||||
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
|
||||||
// 真正做到了"rows 改 → 高度自动改"。
|
min-height: 88rpx;
|
||||||
line-height: var(--textarea-line-height, 48rpx);
|
height: 88rpx;
|
||||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
line-height: 44rpx;
|
||||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-radius: var(--input-radius, 16rpx);
|
border-radius: var(--input-radius, 0rpx);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 12rpx 24rpx;
|
padding: 0;
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
background: transparent;
|
||||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
|
||||||
background: var(--input-bg-color, #ffffff);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background: var(--input-focus-bg-color, #ffffff);
|
|
||||||
border-color: var(--input-focus-border-color, #3b82f6);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.uni-input {
|
.uni-input {
|
||||||
font-size: var(--label-font-size, $font-size-base);
|
font-size: var(--label-font-size, $font-size-base);
|
||||||
color: var(--label-color, #1f2937);
|
color: var(--label-color, #1f2937);
|
||||||
line-height: var(--input-height, 84rpx);
|
line-height: var(--input-height, 60rpx);
|
||||||
min-height: var(--input-height, 84rpx);
|
min-height: var(--input-height, 60rpx);
|
||||||
// 输入框视觉增强(与 ns-newform 对齐)
|
|
||||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
|
||||||
background: var(--input-bg-color, #ffffff);
|
|
||||||
border-radius: var(--input-radius, 16rpx);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-address {
|
.select-address {
|
||||||
@@ -1146,36 +1024,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-tip {
|
.color-tip {
|
||||||
color: var(--placeholder-color, #9ca3af);
|
color: var(--placeholder-color, #909399);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
|
checkbox-group {
|
||||||
picker,
|
display: flex;
|
||||||
.pick-regions {
|
flex-wrap: wrap;
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在 box-flex(带右侧箭头)场景下,picker 不应占满,留出右侧箭头空间
|
|
||||||
&.box-flex {
|
|
||||||
picker,
|
|
||||||
.pick-regions {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconfont.icon-right {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 8rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkbox-group,
|
|
||||||
radio-group {
|
radio-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
@@ -1185,39 +1044,6 @@
|
|||||||
margin-right: 30rpx;
|
margin-right: 30rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
|
|
||||||
// 注意:option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 会选不中
|
|
||||||
&.option-layout-vertical {
|
|
||||||
checkbox-group,
|
|
||||||
radio-group {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
width: 100%;
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.option-layout-wrap {
|
|
||||||
checkbox-group,
|
|
||||||
radio-group {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
margin-right: 30rpx;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.img-boxs {
|
&.img-boxs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1293,11 +1119,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 40rpx;
|
font-size: 32rpx;
|
||||||
margin-right: 10rpx;
|
margin-right: 10rpx;
|
||||||
color: #d1d5db;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,11 +1136,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 40rpx;
|
font-size: 32rpx;
|
||||||
margin-right: 10rpx;
|
margin-right: 10rpx;
|
||||||
color: #d1d5db;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
return {
|
return {
|
||||||
list: [],
|
list: [],
|
||||||
config: {
|
config: {
|
||||||
title: '',
|
title: '猜你喜欢',
|
||||||
sources: 'sort',
|
sources: 'sort',
|
||||||
supportPage: [],
|
supportPage: [],
|
||||||
goodsIds: [],
|
goodsIds: [],
|
||||||
fontWeight: false,
|
fontWeight: false,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cartEvent: 'detail',
|
cartEvent: 'detail',
|
||||||
text: '',
|
text: '购买',
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
aroundRadius: 25,
|
aroundRadius: 25,
|
||||||
@@ -67,13 +67,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// 初始化 i18n 默认文案,实际展示以服务端配置 config.title / config.text 为准
|
if (this.auto) {
|
||||||
if (!this.config.title) this.config.title = this.$lang('common.goodsRecommendTitle');
|
this.getLikeList();
|
||||||
if (!this.config.text) this.config.text = this.$lang('buyNow');
|
}
|
||||||
if (this.auto) {
|
},
|
||||||
this.getLikeList();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
this.list = [];
|
this.list = [];
|
||||||
|
|||||||
@@ -56,18 +56,10 @@
|
|||||||
|
|
||||||
<!-- 手机号授权按钮 -->
|
<!-- 手机号授权按钮 -->
|
||||||
<block v-if="isAgree">
|
<block v-if="isAgree">
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
|
||||||
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
|
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
|
||||||
@getphonenumber="getPhoneNumber">
|
@getphonenumber="getPhoneNumber">
|
||||||
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
|
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
|
||||||
</button>
|
</button>
|
||||||
<!-- #endif -->
|
|
||||||
<!-- #ifdef MP-HARMONY -->
|
|
||||||
<!-- 鸿蒙元服务无 getPhoneNumber 微信 API,改为弹出手机号+验证码表单 -->
|
|
||||||
<button class="auth-login save-btn border-0" style="border: none;" @tap="showHarmonyMobileForm = true">
|
|
||||||
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
|
|
||||||
</button>
|
|
||||||
<!-- #endif -->
|
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
|
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
|
||||||
@@ -75,27 +67,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
<!-- #ifdef MP-HARMONY -->
|
|
||||||
<!-- 鸿蒙专属:手机号+验证码表单 -->
|
|
||||||
<view v-if="showHarmonyMobileForm" class="harmony-mobile-form" @touchmove.prevent.stop>
|
|
||||||
<view class="item-wrap">
|
|
||||||
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
|
|
||||||
<input type="number" :placeholder="$lang('components.ns-login.mobilePlaceholder')" v-model="formData.mobile" maxlength="11" />
|
|
||||||
</view>
|
|
||||||
<view class="item-wrap" v-if="isOpenCaptcha == 1">
|
|
||||||
<text class="label">{{ $lang('components.ns-login.captchaLabel') }}</text>
|
|
||||||
<input type="number" :placeholder="$lang('components.ns-login.captchaPlaceholder')" v-model="formData.vercode" maxlength="4" />
|
|
||||||
<image :src="captcha.img" class="captcha" @tap="getCaptcha" show-menu-by-longpress></image>
|
|
||||||
</view>
|
|
||||||
<view class="item-wrap">
|
|
||||||
<text class="label">{{ $lang('components.ns-login.dynacodeLabel') }}</text>
|
|
||||||
<input type="number" :placeholder="$lang('components.ns-login.dynacodePlaceholder')" v-model="formData.dynacode" maxlength="4" />
|
|
||||||
<view class="send color-base-text" @tap="sendMobileCode">{{ dynacodeData.codeText }}</view>
|
|
||||||
</view>
|
|
||||||
<button type="default" class="save-btn" @tap="harmonySaveMobile" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
|
|
||||||
</view>
|
|
||||||
<!-- #endif -->
|
|
||||||
|
|
||||||
<view @tap="closeLogin"
|
<view @tap="closeLogin"
|
||||||
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
|
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
|
||||||
{{ $lang('components.ns-login.notNow') }}
|
{{ $lang('components.ns-login.notNow') }}
|
||||||
@@ -138,20 +109,11 @@
|
|||||||
|
|
||||||
<view class="item-wrap" v-if="forceBindingMobileControl">
|
<view class="item-wrap" v-if="forceBindingMobileControl">
|
||||||
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
|
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
|
||||||
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
|
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
|
||||||
@getphonenumber="getPhoneNumber">
|
@getphonenumber="getPhoneNumber">
|
||||||
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
|
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
|
||||||
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
|
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
|
||||||
</button>
|
</button>
|
||||||
<!-- #endif -->
|
|
||||||
<!-- #ifdef MP-HARMONY -->
|
|
||||||
<!-- 鸿蒙无 getPhoneNumber,展示手动输入的手机号,没有时显示"获取手机号"提示用户去上面表单录入 -->
|
|
||||||
<view class="auth-login border-0" :plain="true">
|
|
||||||
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
|
|
||||||
<text class="color-base-text" v-else @tap="showHarmonyMobileForm = true">{{ $lang('components.ns-login.getMobile') }}</text>
|
|
||||||
</view>
|
|
||||||
<!-- #endif -->
|
|
||||||
</view>
|
</view>
|
||||||
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
|
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -208,16 +170,12 @@ export default {
|
|||||||
isSend: false
|
isSend: false
|
||||||
},
|
},
|
||||||
// 小程序获取手机号所需数据
|
// 小程序获取手机号所需数据
|
||||||
authMobileData: {
|
authMobileData: {
|
||||||
iv: '',
|
iv: '',
|
||||||
encryptedData: ''
|
encryptedData: ''
|
||||||
},
|
},
|
||||||
site_name: '',
|
site_name: ''
|
||||||
// #ifdef MP-HARMONY
|
};
|
||||||
// 鸿蒙专属:控制手机号+验证码表单显示
|
|
||||||
showHarmonyMobileForm: false,
|
|
||||||
// #endif
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
styleIsolation: 'shared'
|
styleIsolation: 'shared'
|
||||||
@@ -245,7 +203,7 @@ export default {
|
|||||||
if (this.forceBindingMobileControl) {
|
if (this.forceBindingMobileControl) {
|
||||||
if (this.formData.mobile.length == 0) return true;
|
if (this.formData.mobile.length == 0) return true;
|
||||||
|
|
||||||
// #ifdef H5 || MP-HARMONY
|
// #ifdef H5
|
||||||
// 验证码
|
// 验证码
|
||||||
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
|
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
|
||||||
// 动态码
|
// 动态码
|
||||||
@@ -325,69 +283,14 @@ export default {
|
|||||||
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
|
this.getCode(authData => {
|
||||||
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
|
this.authLogin(authData, 'authOnlyLogin');
|
||||||
// "privacy permission is not authorized or buttonId is invalid"。
|
}, (err) => {
|
||||||
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
|
callback && callback(err);
|
||||||
this.ensureWxPrivacyAuthorized(() => {
|
|
||||||
this.getCode(authData => {
|
|
||||||
this.authLogin(authData, 'authOnlyLogin');
|
|
||||||
}, (err) => {
|
|
||||||
callback && callback(err);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
|
|
||||||
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
|
|
||||||
ensureWxPrivacyAuthorized(done) {
|
|
||||||
try {
|
|
||||||
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
|
|
||||||
// 低版本基础库无隐私 API, 直接放行
|
|
||||||
done();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wx.getPrivacySetting({
|
|
||||||
success: (res) => {
|
|
||||||
if (!res || !res.needAuthorization) {
|
|
||||||
// 已授权或无隐私指引配置, 直接放行
|
|
||||||
done();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
|
|
||||||
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
|
|
||||||
const popup = this.$refs.privacyPopup;
|
|
||||||
if (popup && typeof popup.showPrivacyPopup === 'function') {
|
|
||||||
const onAgreed = () => {
|
|
||||||
uni.$off('privacy:agreed', onAgreed);
|
|
||||||
uni.$off('privacy:disagreed', onDisagreed);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
const onDisagreed = () => {
|
|
||||||
uni.$off('privacy:agreed', onAgreed);
|
|
||||||
uni.$off('privacy:disagreed', onDisagreed);
|
|
||||||
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
|
|
||||||
};
|
|
||||||
uni.$off('privacy:agreed', onAgreed);
|
|
||||||
uni.$off('privacy:disagreed', onDisagreed);
|
|
||||||
uni.$on('privacy:agreed', onAgreed);
|
|
||||||
uni.$on('privacy:disagreed', onDisagreed);
|
|
||||||
popup.showPrivacyPopup();
|
|
||||||
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
|
|
||||||
// 无 privacy-popup 实例时退回微信官方隐私弹窗
|
|
||||||
wx.requirePrivacyAuthorize({
|
|
||||||
success: () => done(),
|
|
||||||
fail: () => { }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: () => done()
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 跳转去登录页
|
// 跳转去登录页
|
||||||
toLoginH5Page(mode = 'navigateTo') {
|
toLoginH5Page(mode = 'navigateTo') {
|
||||||
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
|
||||||
@@ -440,7 +343,7 @@ export default {
|
|||||||
openCompleteInfoPop() {
|
openCompleteInfoPop() {
|
||||||
this.getRegisterConfig();
|
this.getRegisterConfig();
|
||||||
|
|
||||||
// #ifdef H5 || MP-HARMONY
|
// #ifdef H5
|
||||||
if (!this.storeToken) this.getCaptchaConfig();
|
if (!this.storeToken) this.getCaptchaConfig();
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
@@ -669,24 +572,8 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errMsg = (e.detail && e.detail.errMsg) || '';
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
// 隐私协议未授权时 getPhoneNumber 会直接失败:
|
|
||||||
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
|
|
||||||
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
|
|
||||||
if (errMsg.indexOf('privacy') !== -1) {
|
|
||||||
this.$util.showToast({
|
|
||||||
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({
|
this.$util.showToast({
|
||||||
title: errMsg
|
title: e.detail.errMsg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -789,26 +676,6 @@ export default {
|
|||||||
this.forceBindMobile();
|
this.forceBindMobile();
|
||||||
},
|
},
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
// 鸿蒙元服务保存手机号:复用 H5 的强制绑定手机号流程
|
|
||||||
harmonySaveMobile() {
|
|
||||||
if (this.forceBindingMobileControl) {
|
|
||||||
let data = {
|
|
||||||
mobile: this.formData.mobile,
|
|
||||||
key: this.formData.key,
|
|
||||||
code: this.formData.dynacode
|
|
||||||
};
|
|
||||||
if (this.captcha.id != '') {
|
|
||||||
data.captcha_id = this.captcha.id;
|
|
||||||
data.captcha_code = this.formData.vercode;
|
|
||||||
}
|
|
||||||
if (!this.verify(data)) return;
|
|
||||||
}
|
|
||||||
// 鸿蒙环境下通常也开启了 forceBindingMobileControl(走 forceBindMobile 接口)
|
|
||||||
this.forceBindMobile();
|
|
||||||
},
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 微信小程序保存数据
|
// 微信小程序保存数据
|
||||||
saveMp() {
|
saveMp() {
|
||||||
if (this.formData.mobile === '') {
|
if (this.formData.mobile === '') {
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!--
|
|
||||||
自绘导航栏:把原生导航栏左侧的 "<" 换成 "‹ 返回" 文字
|
|
||||||
|
|
||||||
背景:微信小程序原生导航栏的返回按钮是客户端系统控件,pages.json 的任何配置、
|
|
||||||
官方 <navigation-bar> 组件(只支持 title / front-color / background-color / loading)
|
|
||||||
都无法修改它的内容。要显示"返回"二字,只能关闭原生栏后自绘。
|
|
||||||
|
|
||||||
使用前提:
|
|
||||||
1. 该页面在 pages.json 中开启 "navigationStyle": "custom"(组件只负责渲染,不自动改配置)
|
|
||||||
2. 该页面不能是 web-view 页面 —— 官方明确 "navigationStyle: custom 对 web-view 组件无效",
|
|
||||||
且 web-view 会铺满整页覆盖其他组件,自绘的导航栏不会显示
|
|
||||||
|
|
||||||
布局:组件是普通流式节点(非 fixed),放在页面顶部即可把内容自然下推。
|
|
||||||
-->
|
|
||||||
<view
|
|
||||||
v-if="visible"
|
|
||||||
class="ns-mp-navbar"
|
|
||||||
:class="{ 'is-fixed': fixed }"
|
|
||||||
:style="{ background: bgColor, paddingTop: statusBarHeight + 'px' }"
|
|
||||||
>
|
|
||||||
<view class="ns-mp-navbar__inner" :style="{ height: navBarHeight + 'px' }">
|
|
||||||
<view class="ns-mp-navbar__side">
|
|
||||||
<view
|
|
||||||
v-if="showBack && canBack"
|
|
||||||
class="ns-mp-navbar__back"
|
|
||||||
hover-class="ns-mp-navbar__back--hover"
|
|
||||||
hover-stay-time="80"
|
|
||||||
@tap="onBack"
|
|
||||||
>
|
|
||||||
<text class="iconfont icon-back_light ns-mp-navbar__arrow" :style="{ color }"></text>
|
|
||||||
<text class="ns-mp-navbar__text" :style="{ color }">{{ backText }}</text>
|
|
||||||
</view>
|
|
||||||
<slot name="left"></slot>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="ns-mp-navbar__title" :style="{ color }">{{ title }}</view>
|
|
||||||
|
|
||||||
<view class="ns-mp-navbar__side ns-mp-navbar__side--right">
|
|
||||||
<slot name="right"></slot>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 状态栏 / 胶囊信息:用于算出与原生导航栏一致的布局高度
|
|
||||||
let systemInfo = {};
|
|
||||||
let menuButtonInfo = { top: 0, height: 32, right: 0, width: 0 };
|
|
||||||
|
|
||||||
try {
|
|
||||||
systemInfo = uni.getSystemInfoSync() || {};
|
|
||||||
} catch (e) {
|
|
||||||
systemInfo = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
|
||||||
try {
|
|
||||||
menuButtonInfo = uni.getMenuButtonBoundingClientRect() || menuButtonInfo;
|
|
||||||
} catch (e) {}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ns-mp-navbar',
|
|
||||||
props: {
|
|
||||||
// 中间标题
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
// 导航栏背景色
|
|
||||||
bgColor: {
|
|
||||||
type: String,
|
|
||||||
default: '#ffffff'
|
|
||||||
},
|
|
||||||
// 文字 / 图标颜色
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: '#000000'
|
|
||||||
},
|
|
||||||
// 返回按钮文字(原生导航栏只能显示 "<",这里可自定义)
|
|
||||||
backText: {
|
|
||||||
type: String,
|
|
||||||
default: '返回'
|
|
||||||
},
|
|
||||||
// 是否显示返回按钮
|
|
||||||
showBack: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
// 是否悬浮在页面之上(沉浸式页面用;false 时占文档流,会把内容往下推)
|
|
||||||
fixed: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 无页面栈时(如分享直接进入)跳转的首页地址
|
|
||||||
homePath: {
|
|
||||||
type: String,
|
|
||||||
default: '/pages/index/index'
|
|
||||||
},
|
|
||||||
// 自定义返回逻辑
|
|
||||||
customBack: {
|
|
||||||
type: Function,
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
// H5 端是否渲染(H5 由 uni-app 自带头像栏,默认不渲染,避免双导航栏)
|
|
||||||
showOnH5: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
statusBarHeight: systemInfo.statusBarHeight || 20,
|
|
||||||
navBarHeight: 44,
|
|
||||||
visible: true,
|
|
||||||
// 页面栈里是否还有上级页面(无上级页面时原生导航栏也不显示返回键)
|
|
||||||
canBack: true
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
// 导航栏总高度(状态栏 + 内容区),页面如需自行占位可取该值
|
|
||||||
navbarHeight() {
|
|
||||||
return this.statusBarHeight + this.navBarHeight;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
// 导航栏内容区高度:MP 端按胶囊位置推算,与原生导航栏保持一致;其余端用 44
|
|
||||||
// #ifdef MP
|
|
||||||
const statusBarHeight = this.statusBarHeight;
|
|
||||||
const h = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height;
|
|
||||||
// 兜底:胶囊信息异常时退回 44
|
|
||||||
this.navBarHeight = h > 0 ? h : 44;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
this.visible = this.showOnH5;
|
|
||||||
// #endif
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// 页面栈只有一页(分享/扫码直达)时,原生导航栏也无返回键,这里保持一致
|
|
||||||
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
|
||||||
this.canBack = pages.length > 1;
|
|
||||||
// 把实际总高度抛给页面,便于页面修正内容区高度,避免多出一屏空白
|
|
||||||
this.$emit('ready', this.statusBarHeight + this.navBarHeight);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onBack() {
|
|
||||||
if (typeof this.customBack === 'function') {
|
|
||||||
this.customBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
|
||||||
if (pages.length > 1) {
|
|
||||||
uni.navigateBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 顶层页面(分享 / 扫码直接进入):回首页
|
|
||||||
uni.switchTab({
|
|
||||||
url: this.homePath,
|
|
||||||
fail: () => {
|
|
||||||
uni.reLaunch({ url: this.homePath });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.ns-mp-navbar {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar.is-fixed {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 990;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__inner {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__side {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 160rpx;
|
|
||||||
padding-left: 20rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__side--right {
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10rpx 20rpx 10rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__back--hover {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__arrow {
|
|
||||||
font-size: 34rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__text {
|
|
||||||
margin-left: 2rpx;
|
|
||||||
font-size: 30rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-mp-navbar__title {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 160rpx;
|
|
||||||
font-size: 32rpx;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
let systemInfo = uni.getSystemInfoSync();
|
let systemInfo = uni.getSystemInfoSync();
|
||||||
let menuButtonInfo = {};
|
let menuButtonInfo = {};
|
||||||
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
|
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
|
||||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||||
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
|
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -70,11 +70,37 @@ export default {
|
|||||||
return {
|
return {
|
||||||
isIphoneX: false,
|
isIphoneX: false,
|
||||||
payIndex: 0,
|
payIndex: 0,
|
||||||
payTypeList: [],
|
// #ifdef H5
|
||||||
|
payTypeList: [{
|
||||||
|
name: this.$lang('components.nsPayment.alipay'),
|
||||||
|
icon: 'icon-zhifubaozhifu-',
|
||||||
|
type: 'alipay'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: this.$lang('components.nsPayment.wechatpay'),
|
||||||
|
icon: 'icon-weixin1',
|
||||||
|
type: 'wechatpay'
|
||||||
|
}
|
||||||
|
],
|
||||||
timer: null,
|
timer: null,
|
||||||
|
// #endif
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
|
payTypeList: [{
|
||||||
|
name: this.$lang('components.nsPayment.wechatpay'),
|
||||||
|
provider: 'wxpay',
|
||||||
|
icon: 'icon-weixin1',
|
||||||
|
type: 'wechatpay'
|
||||||
|
}],
|
||||||
isMatched: 0, // 支付是否需要校验
|
isMatched: 0, // 支付是否需要校验
|
||||||
// #endif
|
// #endif
|
||||||
|
// #ifdef MP-ALIPAY
|
||||||
|
payTypeList: [{
|
||||||
|
name: this.$lang('components.nsPayment.alipay'),
|
||||||
|
icon: 'icon-zhifubaozhifu-',
|
||||||
|
type: 'alipay',
|
||||||
|
provider: 'alipay'
|
||||||
|
}],
|
||||||
|
// #endif
|
||||||
payInfo: {},
|
payInfo: {},
|
||||||
balanceConfig: 0,
|
balanceConfig: 0,
|
||||||
// 预售页面判断
|
// 预售页面判断
|
||||||
@@ -82,30 +108,6 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
created(e) {
|
created(e) {
|
||||||
// #ifdef H5
|
|
||||||
this.payTypeList = [
|
|
||||||
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay' },
|
|
||||||
{ name: this.$lang('components.nsPayment.wechatpay'), icon: 'icon-weixin1', type: 'wechatpay' },
|
|
||||||
];
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
this.payTypeList = [
|
|
||||||
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
|
|
||||||
];
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP-ALIPAY
|
|
||||||
this.payTypeList = [
|
|
||||||
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay', provider: 'alipay' },
|
|
||||||
];
|
|
||||||
// #endif
|
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
// 鸿蒙元服务的支付通道: 与微信小程序相同的 provider='wxpay',
|
|
||||||
// 但走鸿蒙 JSSDK 的 requestPayment(走鸿蒙支付服务而非 wx.requestPayment)
|
|
||||||
this.payTypeList = [
|
|
||||||
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
|
|
||||||
];
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
url = window.location.href
|
url = window.location.href
|
||||||
|
|||||||
@@ -9,13 +9,9 @@
|
|||||||
<view class="video-popup-body">
|
<view class="video-popup-body">
|
||||||
<video
|
<video
|
||||||
v-if="videoUrl"
|
v-if="videoUrl"
|
||||||
id="popupVideoPlayer"
|
|
||||||
:src="videoUrl"
|
:src="videoUrl"
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
|
||||||
webkit-playsinline
|
|
||||||
x5-playsinline
|
|
||||||
class="video-player"
|
class="video-player"
|
||||||
></video>
|
></video>
|
||||||
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
||||||
@@ -26,8 +22,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { playVideoInContext } from '@/common/js/video-helper.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ns-video-player-popup',
|
name: 'ns-video-player-popup',
|
||||||
props: {
|
props: {
|
||||||
@@ -82,10 +76,6 @@ export default {
|
|||||||
* @param {Object} e - 弹窗状态变化事件
|
* @param {Object} e - 弹窗状态变化事件
|
||||||
*/
|
*/
|
||||||
onVideoPopupChange(e) {
|
onVideoPopupChange(e) {
|
||||||
// H5 端弹窗显示后触发播放:避免 autoplay 被浏览器拦截 / 弹窗动画未完成被 pause() 打断
|
|
||||||
if (e && e.show) {
|
|
||||||
playVideoInContext('popupVideoPlayer', this);
|
|
||||||
}
|
|
||||||
this.$emit('popup-change', e);
|
this.$emit('popup-change', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,16 +95,6 @@
|
|||||||
i18nKey: 'components.payment.alipay'
|
i18nKey: 'components.payment.alipay'
|
||||||
}],
|
}],
|
||||||
// #endif
|
// #endif
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
// 鸿蒙元服务的支付通道: provider='wxpay' 由鸿蒙 JSSDK 接管
|
|
||||||
payTypeList: [{
|
|
||||||
name: '微信支付',
|
|
||||||
provider: 'wxpay',
|
|
||||||
icon: 'icon-weixin1',
|
|
||||||
type: 'wechatpay',
|
|
||||||
i18nKey: 'components.payment.wechatpay'
|
|
||||||
}],
|
|
||||||
// #endif
|
|
||||||
payInfo: null,
|
payInfo: null,
|
||||||
balanceConfig: 0,
|
balanceConfig: 0,
|
||||||
// 预售页面判断
|
// 预售页面判断
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- #ifdef MP-HARMONY -->
|
|
||||||
<!--
|
|
||||||
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
|
||||||
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
|
|
||||||
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
|
|
||||||
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
|
|
||||||
-->
|
|
||||||
<!-- #endif-->
|
|
||||||
<!-- #ifndef MP-HARMONY -->
|
|
||||||
<view>
|
<view>
|
||||||
<view class="privacy-mask" v-if="showPop">
|
<view class="privacy-mask" v-if="showPop">
|
||||||
<view class="privacy-wrap">
|
<view class="privacy-wrap">
|
||||||
@@ -15,7 +6,7 @@
|
|||||||
<view class="privacy-desc">
|
<view class="privacy-desc">
|
||||||
{{ i18n.descPrefix }}
|
{{ i18n.descPrefix }}
|
||||||
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
|
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
|
||||||
<!-- #ifdef QUICKAPP-WEBVIEW || H5 -->
|
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY -->
|
||||||
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
|
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
{{ i18n.descSuffix }}
|
{{ i18n.descSuffix }}
|
||||||
@@ -26,15 +17,13 @@
|
|||||||
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
|
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
|
||||||
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
|
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 || MP-HARMONY -->
|
||||||
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
|
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||||
}}</button>
|
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- #endif -->
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -59,12 +48,6 @@ export default {
|
|||||||
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
|
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
|
||||||
// 弹窗前才参与 i18n.privacyContractName 的填充
|
// 弹窗前才参与 i18n.privacyContractName 的填充
|
||||||
platformContractName: '',
|
platformContractName: '',
|
||||||
// 平台协议名称就绪 Promise:
|
|
||||||
// created() 中发起异步查询, success/fail 时 resolve
|
|
||||||
// popUp() 中 await 此 Promise 后再调 prepareI18n(), 保证 $lang() 与
|
|
||||||
// platformContractName 都已经准备好, 不会出现"空串被永久缓存"的问题
|
|
||||||
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"是同源契约)
|
|
||||||
platformContractNameReady: null,
|
|
||||||
// 弹窗文案: 模板只读 i18n.xxx
|
// 弹窗文案: 模板只读 i18n.xxx
|
||||||
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
|
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
|
||||||
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
|
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
|
||||||
@@ -87,22 +70,10 @@ export default {
|
|||||||
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
||||||
created() {
|
created() {
|
||||||
try {
|
try {
|
||||||
// 创建平台协议名称就绪 Promise
|
|
||||||
// 后续 wx/has/uni.* 三端的异步 API 都会挂到这个 promise 上 resolve,
|
|
||||||
// popUp() 中 await 后再 prepareI18n(), 保证 platformContractName 已就绪
|
|
||||||
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"契约同源)
|
|
||||||
this.platformContractNameReady = new Promise((resolve) => {
|
|
||||||
this._resolvePlatformContractName = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
wx.getPrivacySetting({
|
wx.getPrivacySetting({
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
this.platformContractName = res.privacyContractName || ''
|
this.platformContractName = res.privacyContractName || ''
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
@@ -114,28 +85,13 @@ export default {
|
|||||||
if (res.privacyContractName) {
|
if (res.privacyContractName) {
|
||||||
this.platformContractName = res.privacyContractName
|
this.platformContractName = res.privacyContractName
|
||||||
}
|
}
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
// 无 API 时立即 resolve (避免 await 永久阻塞)
|
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
|
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
|
||||||
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
|
|
||||||
// 立即 resolve, 让 popUp 的 await 不永久阻塞
|
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
// H5 无平台隐私 API, 立即 resolve
|
|
||||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
||||||
@@ -223,14 +179,14 @@ export default {
|
|||||||
* 统一弹窗 + 关闭其他页面的弹窗
|
* 统一弹窗 + 关闭其他页面的弹窗
|
||||||
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
||||||
*/
|
*/
|
||||||
async showPrivacyPopup() {
|
showPrivacyPopup() {
|
||||||
// 先通知其他实例关闭
|
// 先通知其他实例关闭
|
||||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||||
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
||||||
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
||||||
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
||||||
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
||||||
await this.popUp()
|
this.popUp()
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,18 +237,18 @@ export default {
|
|||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
if (wx.onNeedPrivacyAuthorization) {
|
if (wx.onNeedPrivacyAuthorization) {
|
||||||
wx.onNeedPrivacyAuthorization((resolve) => {
|
wx.onNeedPrivacyAuthorization((resolve) => {
|
||||||
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
|
// 检查是否已同意
|
||||||
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
|
if (!PrivacyManager.needShow()) {
|
||||||
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
|
// 已同意, 直接放行
|
||||||
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
|
resolve({ event: 'agree' })
|
||||||
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
|
|
||||||
if (typeof this.privacyAuthorization === 'function') {
|
|
||||||
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
|
|
||||||
this.privacyAuthorization(resolve)
|
|
||||||
} else {
|
} else {
|
||||||
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
|
// 需要弹窗, 存 resolve 等用户操作
|
||||||
// (disagree 无需 buttonId, 不会产生非法 resolve)
|
if (typeof this.privacyAuthorization === 'function') {
|
||||||
resolve({ event: 'disagree' })
|
this.privacyAuthorization(resolve)
|
||||||
|
} else {
|
||||||
|
// 兜底: 组件未就绪, 直接放行避免 API 卡死
|
||||||
|
resolve({ event: 'agree' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -309,8 +265,7 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
|
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
|
||||||
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
|
|
||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -329,22 +284,22 @@ export default {
|
|||||||
*/
|
*/
|
||||||
closeOtherPagePopUp(closePopUp) {
|
closeOtherPagePopUp(closePopUp) {
|
||||||
// 通过全局事件通知所有页面关闭弹窗
|
// 通过全局事件通知所有页面关闭弹窗
|
||||||
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) { }
|
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
openPrivacyContract() {
|
openPrivacyContract() {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
wx.openPrivacyContract({
|
wx.openPrivacyContract({
|
||||||
success(res) { },
|
success(res) {},
|
||||||
fail(err) { }
|
fail(err) {}
|
||||||
});
|
});
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef QUICKAPP-WEBVIEW
|
// #ifdef QUICKAPP-WEBVIEW
|
||||||
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
||||||
has.openPrivacySetting({
|
has.openPrivacySetting({
|
||||||
success: (res) => { },
|
success: (res) => {},
|
||||||
fail: (err) => { }
|
fail: (err) => {}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.$util.redirectTo(privacyContractPage);
|
this.$util.redirectTo(privacyContractPage);
|
||||||
@@ -352,8 +307,8 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
// 鸿蒙元服务: 使用应用内协议页面
|
||||||
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
|
this.$util.redirectTo(privacyContractPage);
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -365,8 +320,8 @@ export default {
|
|||||||
// #ifdef QUICKAPP-WEBVIEW
|
// #ifdef QUICKAPP-WEBVIEW
|
||||||
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
||||||
has.openPrivacySetting({
|
has.openPrivacySetting({
|
||||||
success: (res) => { },
|
success: (res) => {},
|
||||||
fail: (err) => { }
|
fail: (err) => {}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.$util.redirectTo(privacyServicePage);
|
this.$util.redirectTo(privacyServicePage);
|
||||||
@@ -374,7 +329,7 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef MP-HARMONY
|
// #ifdef MP-HARMONY
|
||||||
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
this.$util.redirectTo(privacyServicePage);
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -389,7 +344,7 @@ export default {
|
|||||||
this.privacyResolves.clear()
|
this.privacyResolves.clear()
|
||||||
|
|
||||||
// 先通知其他实例关闭, 再关闭自己, 避免叠层
|
// 先通知其他实例关闭, 再关闭自己, 避免叠层
|
||||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||||
this.disPopUp()
|
this.disPopUp()
|
||||||
|
|
||||||
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
|
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
|
||||||
@@ -414,7 +369,7 @@ export default {
|
|||||||
this.privacyResolves.clear()
|
this.privacyResolves.clear()
|
||||||
|
|
||||||
// 先通知其他实例关闭, 再关闭自己
|
// 先通知其他实例关闭, 再关闭自己
|
||||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||||
this.disPopUp()
|
this.disPopUp()
|
||||||
|
|
||||||
// 由 PrivacyManager 管理 storage
|
// 由 PrivacyManager 管理 storage
|
||||||
@@ -435,9 +390,9 @@ export default {
|
|||||||
*/
|
*/
|
||||||
i18nPrepareFn() {
|
i18nPrepareFn() {
|
||||||
let appName, privacyContractName, privacyServiceName;
|
let appName, privacyContractName, privacyServiceName;
|
||||||
|
|
||||||
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
||||||
// #ifdef H5
|
// #ifdef H5 || MP-HARMONY
|
||||||
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
||||||
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
||||||
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
||||||
@@ -461,33 +416,20 @@ export default {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async popUp() {
|
popUp() {
|
||||||
// 弹窗前**一次性**计算所有 i18n 文案
|
// 弹窗前**一次性**计算所有 i18n 文案
|
||||||
// 必须先等两类数据准备好:
|
// boot 阶段已用 isBundleComplete() 保证 bundle 完整
|
||||||
// 1) Lang.boot() 完成 — 由 i18n-lazy mixin 隐含保证
|
// $lang() 返回真实翻译(效果与 H5 一致)
|
||||||
// (调用方 App.vue 在 await PrivacyManager.show() 后才触发弹窗,
|
|
||||||
// PrivacyManager.show() await 了 boot 守卫)
|
|
||||||
// 2) platformContractName 就绪 — 微信/快应用/鸿蒙三端的 API 都是异步的,
|
|
||||||
// success 回调时机不确定, 直接 prepareI18n() 会读到空字符串, 导致
|
|
||||||
// "空串被永久缓存" (与 i18n-lazy mixin 警告的问题同源)
|
|
||||||
// 因此这里 await platformContractNameReady, 然后再 prepareI18n。
|
|
||||||
if (this.platformContractNameReady) {
|
|
||||||
try {
|
|
||||||
await this.platformContractNameReady
|
|
||||||
} catch (e) {
|
|
||||||
// 防御性: 即便 ready 出错也继续 (走 fallback 默认文案)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.prepareI18n();
|
this.prepareI18n();
|
||||||
this.showPop = true;
|
this.showPop = true;
|
||||||
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
||||||
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: true }) } catch (e) { }
|
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: true }) } catch (e) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
disPopUp() {
|
disPopUp() {
|
||||||
this.showPop = false;
|
this.showPop = false;
|
||||||
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
||||||
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: false }) } catch (e) { }
|
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: false }) } catch (e) {}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
@@ -496,11 +438,6 @@ export default {
|
|||||||
uni.$off('privacy:show', this.handlePrivacyShow)
|
uni.$off('privacy:show', this.handlePrivacyShow)
|
||||||
uni.$off('privacy:forceClose', this.handleForceClose)
|
uni.$off('privacy:forceClose', this.handleForceClose)
|
||||||
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
||||||
// 销毁时强制 resolve, 避免 popUp() 中的 await 永久挂起 (例如用户提前离开页面)
|
|
||||||
if (typeof this._resolvePlatformContractName === 'function') {
|
|
||||||
this._resolvePlatformContractName()
|
|
||||||
this._resolvePlatformContractName = null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -554,7 +491,7 @@ export default {
|
|||||||
.privacy-button-btn {
|
.privacy-button-btn {
|
||||||
color: #FFF;
|
color: #FFF;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
/* #ifdef QUICKAPP-WEBVIEW || H5 */
|
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
/* #endif */
|
/* #endif */
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -567,12 +504,6 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 30rpx;
|
margin-right: 30rpx;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
/* 关键修复: 微信 <button> 原生默认 padding-left/right: 14px 没有被重置,
|
|
||||||
* 会挤压按钮内容区。真机窄屏(375px 以下)上「同意并继续」5 个字被裁成
|
|
||||||
* 「同意并继」。这里显式清零 padding 并锁定 border-box。 */
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-button-btn::after {
|
.privacy-button-btn::after {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# 国际化(i18n)全量审计报告
|
# 国际化(i18n)全量审计报告
|
||||||
|
|
||||||
> 审计范围:项目根目录下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
> 审计范围:`z:\D\projects\shop-projects\frontend` 下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
||||||
> 审计时间:2026-06-29
|
> 审计时间:2026-06-29
|
||||||
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
||||||
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
||||||
|
|||||||
@@ -106,9 +106,8 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t
|
|||||||
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 以下命令均在项目根目录(含 package.json)下执行
|
|
||||||
|
|
||||||
# 1. 静态语法检查
|
# 1. 静态语法检查
|
||||||
|
cd D:/projects/shop-projects/frontend
|
||||||
node --check common/js/theme-override.js
|
node --check common/js/theme-override.js
|
||||||
node --check store/index.js
|
node --check store/index.js
|
||||||
# App.vue 走 vue 编译器,不在 node 范畴
|
# App.vue 走 vue 编译器,不在 node 范畴
|
||||||
|
|||||||
5177
lang-bundle.json
5177
lang-bundle.json
File diff suppressed because it is too large
Load Diff
@@ -12,29 +12,12 @@ export const lang = {
|
|||||||
timeFormatToday: 'Today',
|
timeFormatToday: 'Today',
|
||||||
timeFormatYesterday: 'Yesterday',
|
timeFormatYesterday: 'Yesterday',
|
||||||
timeFormatBeforeYesterday: 'Day before yesterday',
|
timeFormatBeforeYesterday: 'Day before yesterday',
|
||||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
|
|
||||||
timeJustNow: 'Just now',
|
|
||||||
timeMinutesAgo: '{n} min ago',
|
|
||||||
timeHoursAgo: '{n} h ago',
|
|
||||||
timeMonthDay: '{m}/{d}',
|
|
||||||
timeYearMonthDay: '{y}/{m}/{d}',
|
|
||||||
|
|
||||||
// ====== ai-chat-message ======
|
// ====== ai-chat-message ======
|
||||||
aiNickname: 'AI Assistant',
|
aiNickname: 'AI Assistant',
|
||||||
defaultNickname: 'Me',
|
defaultNickname: 'Me',
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): query text for attachment-only messages
|
|
||||||
// audioMessage / videoMessage: short label for the user-bubble filename fallback (still used by bubble template)
|
|
||||||
// *Instruction* keys: explicit directive sent to the AI as the query.
|
|
||||||
// Just a label gets ignored — the AI needs an explicit "please listen/look"
|
|
||||||
// to actually invoke STT/vision/file parsing on the attachment payload.
|
|
||||||
audioMessage: 'Voice Message',
|
audioMessage: 'Voice Message',
|
||||||
videoMessage: 'Video Message',
|
videoMessage: 'Video Message',
|
||||||
audioInstruction: 'I sent a voice message. Please listen to it and reply.',
|
|
||||||
audioInstructionWithDuration: 'I sent a voice message ({seconds}s). Please listen to it and reply.',
|
|
||||||
imageInstruction: 'I sent an image. Please look at it and reply.',
|
|
||||||
videoInstruction: 'I sent a video. Please watch it and reply.',
|
|
||||||
fileInstruction: 'I sent a file. Please review it and reply.',
|
|
||||||
attachmentInstruction: 'I sent attachments. Please review them and reply.',
|
|
||||||
inputPlaceholder: 'Please enter your question...',
|
inputPlaceholder: 'Please enter your question...',
|
||||||
expandEditorTitle: 'Edit Content',
|
expandEditorTitle: 'Edit Content',
|
||||||
loadMoreHistory: 'Load more history',
|
loadMoreHistory: 'Load more history',
|
||||||
@@ -129,48 +112,15 @@ export const lang = {
|
|||||||
// ====== ai-chat-tools-panel ======
|
// ====== ai-chat-tools-panel ======
|
||||||
moreFunctions: 'More Features',
|
moreFunctions: 'More Features',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
video: 'Video/Audio',
|
|
||||||
file: 'File',
|
file: 'File',
|
||||||
voice: 'Voice',
|
voice: 'Voice',
|
||||||
recordVoice: 'Record Voice',
|
|
||||||
uploadAudio: 'Upload Audio',
|
|
||||||
pickVideo: 'Choose Video',
|
|
||||||
pickAudio: 'Choose Audio',
|
|
||||||
chooseAudioFail: 'Failed to select audio',
|
|
||||||
chooseVideoFail: 'Failed to select video',
|
|
||||||
audioNotReady: 'Audio not ready',
|
|
||||||
location: 'Location',
|
location: 'Location',
|
||||||
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
|
|
||||||
locationPermissionTitle: 'Location Permission Required',
|
|
||||||
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
|
|
||||||
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
|
|
||||||
locationSettingBtn: 'Open Settings',
|
|
||||||
locationSkipBtn: 'Not Now',
|
|
||||||
locationH5GuideBtn: 'Got it',
|
|
||||||
|
|
||||||
// ====== ai-chat-voice-panel ======
|
// ====== ai-chat-voice-panel ======
|
||||||
voiceInputTitle: 'Voice Input',
|
voiceInputTitle: 'Voice Input',
|
||||||
recordingTip: 'Recording, release to end',
|
recordingTip: 'Recording, release to end',
|
||||||
startRecordTip: 'Tap to start recording',
|
startRecordTip: 'Tap to start recording',
|
||||||
voiceInputFailed: 'Voice input failed',
|
voiceInputFailed: 'Voice input failed',
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
|
|
||||||
holdToTalk: 'Hold to talk',
|
|
||||||
releaseToSend: 'Release to send',
|
|
||||||
releaseToCancel: 'Release to cancel',
|
|
||||||
slideUpToCancel: '↑ Slide up to cancel',
|
|
||||||
slideUpToCancelHint: '↑ Slide up to cancel',
|
|
||||||
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): dynamic input placeholder by context
|
|
||||||
placeholderDefault: 'Type a message or hold to talk',
|
|
||||||
placeholderWithAudio: 'Add a note for your voice (optional)',
|
|
||||||
placeholderWithMedia: 'Add a caption (optional)',
|
|
||||||
slideUpToCancelHint: '↑ Release to cancel',
|
|
||||||
voiceStartFailed: 'Cannot start recording',
|
|
||||||
voiceStopFailed: 'Recording failed, please try again',
|
|
||||||
voiceNotSupported: 'Voice input is not supported on this platform',
|
|
||||||
voiceTooShort: 'Speaking time too short',
|
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
|
|
||||||
uploadFailed: 'Upload failed',
|
|
||||||
uploadFailedWithReason: 'Upload failed: {reason}',
|
|
||||||
|
|
||||||
// ====== index.vue - Menu ======
|
// ====== index.vue - Menu ======
|
||||||
menuClearChat: 'Clear Chat',
|
menuClearChat: 'Clear Chat',
|
||||||
@@ -186,8 +136,4 @@ export const lang = {
|
|||||||
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
||||||
logChatComponentNotInit: 'Chat component not initialized',
|
logChatComponentNotInit: 'Chat component not initialized',
|
||||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
||||||
|
}
|
||||||
// ====== AI Agent general copy (customer service is the first agent) ======
|
|
||||||
aiAgentDefaultTitle: 'AI Agent',
|
|
||||||
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
|
|
||||||
aiAgentNotConfigured: 'This AI Agent is not enabled'}
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ export const lang = {
|
|||||||
|
|
||||||
// 底部选项卡
|
// 底部选项卡
|
||||||
tabBar: {
|
tabBar: {
|
||||||
home: 'Home',
|
home: 'index',
|
||||||
category: 'Category',
|
category: 'category',
|
||||||
cart: 'Cart',
|
cart: 'cart',
|
||||||
member: 'Me',
|
member: 'member',
|
||||||
},
|
},
|
||||||
|
|
||||||
//公共
|
//公共
|
||||||
@@ -54,15 +54,11 @@ export const lang = {
|
|||||||
serviceUnavailable: 'Navigation service temporarily unavailable',
|
serviceUnavailable: 'Navigation service temporarily unavailable',
|
||||||
},
|
},
|
||||||
|
|
||||||
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
|
// diy custom page
|
||||||
diy: {
|
diy: {
|
||||||
networkErrorTitle: 'Notice',
|
|
||||||
networkError: 'Network error, please try again later',
|
networkError: 'Network error, please try again later',
|
||||||
retry: 'Retry',
|
|
||||||
noDiyData: 'Custom page data not configured',
|
noDiyData: 'Custom page data not configured',
|
||||||
locationNotAvailable: 'Location not available',
|
locationNotAvailable: 'Location not available',
|
||||||
// detailed data-load failure template (diy-specific, {reason} is filled by generic http.*)
|
|
||||||
loadFailTpl: 'Failed to load page data: {reason}, please try again later',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
//customer service
|
//customer service
|
||||||
@@ -112,7 +108,6 @@ export const lang = {
|
|||||||
notSupported: 'Current environment does not support WeChat Channel',
|
notSupported: 'Current environment does not support WeChat Channel',
|
||||||
missingFields: 'Missing required field: {field}',
|
missingFields: 'Missing required field: {field}',
|
||||||
playFailed: 'WeChat video playback failed!',
|
playFailed: 'WeChat video playback failed!',
|
||||||
wechatMpOnly: 'WeChat Channel only plays in WeChat Mini Program',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// uniapp utility functions
|
// uniapp utility functions
|
||||||
@@ -139,18 +134,12 @@ export const lang = {
|
|||||||
requestFailed: '{url} request failed. {err}',
|
requestFailed: '{url} request failed. {err}',
|
||||||
// network timeout
|
// network timeout
|
||||||
timeout: 'Network request timeout, please try again later',
|
timeout: 'Network request timeout, please try again later',
|
||||||
// network connection failed / generic network error (unified copy, reused by diy and other generic scenarios)
|
// network connection failed
|
||||||
networkError: 'Network error, please try again later',
|
networkError: 'Network connection failed, please check your network',
|
||||||
// server error
|
// server error
|
||||||
serverError: 'Server error, please try again later',
|
serverError: 'Service temporarily unavailable, please try again later',
|
||||||
// API not found (HTTP 404)
|
|
||||||
notFound: 'API not found',
|
|
||||||
// no access permission (HTTP 401 / 403)
|
|
||||||
auth: 'No access permission',
|
|
||||||
// request aborted
|
// request aborted
|
||||||
aborted: 'Request cancelled',
|
aborted: 'Request cancelled',
|
||||||
// unknown error (errorType fallback, e.g. non-5xx/404/401/403 HTTP status)
|
|
||||||
unknown: 'Request failed, please try again later',
|
|
||||||
// unknown error
|
// unknown error
|
||||||
unknownError: 'Request failed, please try again later',
|
unknownError: 'Request failed, please try again later',
|
||||||
},
|
},
|
||||||
@@ -258,14 +247,7 @@ export const lang = {
|
|||||||
addToCartFailMin: 'Minimum purchase quantity not met',
|
addToCartFailMin: 'Minimum purchase quantity not met',
|
||||||
addToCartFailStock: 'Insufficient Stock',
|
addToCartFailStock: 'Insufficient Stock',
|
||||||
|
|
||||||
// generic submit/version/loading text (key lives in root because common.js's lang object IS the common namespace)
|
// 咨询
|
||||||
submitSuccess: 'Submit Success',
|
|
||||||
submitFail: 'Submit Failed',
|
|
||||||
versionInfo: 'Version Info',
|
|
||||||
commitId: 'Commit ID',
|
|
||||||
buildTime: 'Build Time',
|
|
||||||
loading: 'Loading...',
|
|
||||||
|
|
||||||
consult: 'Consult Now',
|
consult: 'Consult Now',
|
||||||
consultSuccess: 'Consultation Successful',
|
consultSuccess: 'Consultation Successful',
|
||||||
consultFail: 'Consultation Failed',
|
consultFail: 'Consultation Failed',
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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)',
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const lang = {
|
|
||||||
// diy-webview webview component
|
|
||||||
emptyTip: 'Please configure the web link',
|
|
||||||
loadError: 'Failed to load web page',
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,6 @@ export const lang = {
|
|||||||
loggingIn: 'Logging in',
|
loggingIn: 'Logging in',
|
||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
pleaseAuthorizeMobile: 'Please authorize your mobile number',
|
||||||
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
|
|
||||||
pleaseEnterNickname: 'Please enter nickname',
|
pleaseEnterNickname: 'Please enter nickname',
|
||||||
avatarPullFailed: 'Avatar pull failed',
|
avatarPullFailed: 'Avatar pull failed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ export const lang = {
|
|||||||
fileUrlNotExist: 'File URL does not exist',
|
fileUrlNotExist: 'File URL does not exist',
|
||||||
// Video URL does not exist
|
// Video URL does not exist
|
||||||
videoUrlNotExist: 'Video URL does not exist',
|
videoUrlNotExist: 'Video URL does not exist',
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): audio related
|
|
||||||
audioUrlNotExist: 'Audio URL does not exist',
|
|
||||||
audioDefaultTitle: 'Voice message',
|
|
||||||
audioPlaying: 'Playing…',
|
|
||||||
audioPaused: 'Paused',
|
|
||||||
// This file type preview is not supported
|
// This file type preview is not supported
|
||||||
unsupportedFileType: 'This file type preview is not supported',
|
unsupportedFileType: 'This file type preview is not supported',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const lang = {
|
|
||||||
//title为每个页面的标题
|
|
||||||
title: 'File Details',
|
|
||||||
}
|
|
||||||
@@ -7,18 +7,5 @@ export const lang = {
|
|||||||
submitSuccess: 'Submitted successfully',
|
submitSuccess: 'Submitted successfully',
|
||||||
// Form information not available
|
// Form information not available
|
||||||
noFormInfo: 'Form information not available',
|
noFormInfo: 'Form information not available',
|
||||||
// Behavior settings
|
|
||||||
// Captcha input placeholder
|
|
||||||
captchaPlaceholder: 'Please enter the captcha',
|
|
||||||
// Captcha required
|
|
||||||
captchaRequired: 'Please enter the captcha',
|
|
||||||
// Captcha error
|
|
||||||
captchaError: 'Incorrect captcha, please try again',
|
|
||||||
// Submit limit reached
|
|
||||||
submitLimit: 'Submission limit reached',
|
|
||||||
// Form not open
|
|
||||||
notOpen: 'The form is not open yet',
|
|
||||||
// Login required
|
|
||||||
loginRequired: 'Please log in first',
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export const lang = {
|
|||||||
|
|
||||||
// Product Message
|
// Product Message
|
||||||
productMessage: 'Product Message',
|
productMessage: 'Product Message',
|
||||||
formEmptyTip: 'No message fields to fill',
|
|
||||||
|
|
||||||
// AI Customer Service
|
// AI Customer Service
|
||||||
aiCustomerService: 'AI Assistant',
|
aiCustomerService: 'AI Assistant',
|
||||||
|
|||||||
@@ -3,6 +3,5 @@ export const lang = {
|
|||||||
title: 'Payment Method',
|
title: 'Payment Method',
|
||||||
paymentAmount: 'Payment Amount',
|
paymentAmount: 'Payment Amount',
|
||||||
confirmPayment: 'Confirm Payment',
|
confirmPayment: 'Confirm Payment',
|
||||||
seeOrder: 'View Order',
|
seeOrder: 'View Order'
|
||||||
payInfoNotFound: 'Payment info not found!'
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
//title为每个页面的标题
|
//title为每个页面的标题
|
||||||
title: 'AI智能',
|
title: 'AI智能客服',
|
||||||
|
|
||||||
// ====== 通用 ======
|
// ====== 通用 ======
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
@@ -12,28 +12,12 @@ export const lang = {
|
|||||||
timeFormatToday: '今天',
|
timeFormatToday: '今天',
|
||||||
timeFormatYesterday: '昨天',
|
timeFormatYesterday: '昨天',
|
||||||
timeFormatBeforeYesterday: '前天',
|
timeFormatBeforeYesterday: '前天',
|
||||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
|
|
||||||
timeJustNow: '刚刚',
|
|
||||||
timeMinutesAgo: '{n}分钟前',
|
|
||||||
timeHoursAgo: '{n}小时前',
|
|
||||||
timeMonthDay: '{m}月{d}日',
|
|
||||||
timeYearMonthDay: '{y}年{m}月{d}日',
|
|
||||||
|
|
||||||
// ====== ai-chat-message ======
|
// ====== ai-chat-message ======
|
||||||
aiNickname: '智能助手',
|
aiNickname: '智能助手',
|
||||||
defaultNickname: '我',
|
defaultNickname: '我',
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
|
|
||||||
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
|
|
||||||
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
|
|
||||||
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
|
|
||||||
audioMessage: '语音消息',
|
audioMessage: '语音消息',
|
||||||
videoMessage: '视频消息',
|
videoMessage: '视频消息',
|
||||||
audioInstruction: '我发送了一段语音,请听一下并回复我',
|
|
||||||
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
|
|
||||||
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
|
|
||||||
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
|
|
||||||
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
|
|
||||||
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
|
|
||||||
inputPlaceholder: '请输入您的问题...',
|
inputPlaceholder: '请输入您的问题...',
|
||||||
expandEditorTitle: '编辑内容',
|
expandEditorTitle: '编辑内容',
|
||||||
loadMoreHistory: '加载更多历史消息',
|
loadMoreHistory: '加载更多历史消息',
|
||||||
@@ -128,49 +112,15 @@ export const lang = {
|
|||||||
// ====== ai-chat-tools-panel ======
|
// ====== ai-chat-tools-panel ======
|
||||||
moreFunctions: '更多功能',
|
moreFunctions: '更多功能',
|
||||||
image: '图片',
|
image: '图片',
|
||||||
video: '视频/音频',
|
|
||||||
file: '文件',
|
file: '文件',
|
||||||
voice: '语音',
|
voice: '语音',
|
||||||
recordVoice: '录制语音',
|
|
||||||
uploadAudio: '上传音频',
|
|
||||||
pickVideo: '选择视频',
|
|
||||||
pickAudio: '选择音频',
|
|
||||||
chooseAudioFail: '选择音频失败',
|
|
||||||
chooseVideoFail: '选择视频失败',
|
|
||||||
audioNotReady: '音频尚未就绪',
|
|
||||||
location: '位置',
|
location: '位置',
|
||||||
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
|
|
||||||
locationPermissionTitle: '需要位置权限',
|
|
||||||
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
|
|
||||||
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
|
|
||||||
locationSettingBtn: '去设置',
|
|
||||||
locationSkipBtn: '暂不开启',
|
|
||||||
locationH5GuideBtn: '我知道了',
|
|
||||||
|
|
||||||
// ====== ai-chat-voice-panel ======
|
// ====== ai-chat-voice-panel ======
|
||||||
voiceInputTitle: '语音输入',
|
voiceInputTitle: '语音输入',
|
||||||
recordingTip: '正在录音,松开结束',
|
recordingTip: '正在录音,松开结束',
|
||||||
startRecordTip: '点击开始录音',
|
startRecordTip: '点击开始录音',
|
||||||
voiceInputFailed: '语音输入失败',
|
voiceInputFailed: '语音输入失败',
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
|
|
||||||
holdToTalk: '按住说话',
|
|
||||||
releaseToSend: '松开发送',
|
|
||||||
releaseToCancel: '松开取消',
|
|
||||||
slideUpToCancel: '↑ 上滑取消',
|
|
||||||
slideUpToCancelHint: '↑ 松开取消',
|
|
||||||
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
|
|
||||||
// 默认 / 有语音附件 / 有其他媒体附件, 三态文案互不冲突
|
|
||||||
placeholderDefault: '发消息或按住说话',
|
|
||||||
placeholderWithAudio: '给语音留言加个说明(可选)',
|
|
||||||
placeholderWithMedia: '添加文字说明(可选)',
|
|
||||||
voiceStartFailed: '无法开始录音',
|
|
||||||
voiceStopFailed: '录音失败,请重试',
|
|
||||||
voiceNotSupported: '当前平台不支持语音输入',
|
|
||||||
voiceTooShort: '说话时间太短',
|
|
||||||
voiceRecorderBusy: '录音器正在初始化,请稍后再试',
|
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
|
||||||
uploadFailed: '上传失败',
|
|
||||||
uploadFailedWithReason: '上传失败:{reason}',
|
|
||||||
|
|
||||||
// ====== index.vue - 菜单 ======
|
// ====== index.vue - 菜单 ======
|
||||||
menuClearChat: '清空聊天',
|
menuClearChat: '清空聊天',
|
||||||
@@ -186,8 +136,4 @@ export const lang = {
|
|||||||
logInitNavbarFailed: '初始化导航栏失败',
|
logInitNavbarFailed: '初始化导航栏失败',
|
||||||
logChatComponentNotInit: '聊天组件未初始化',
|
logChatComponentNotInit: '聊天组件未初始化',
|
||||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
||||||
|
}
|
||||||
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
|
||||||
aiAgentDefaultTitle: 'AI 智能体',
|
|
||||||
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
|
|
||||||
aiAgentNotConfigured: '该 AI 智能体暂未启用'}
|
|
||||||
|
|||||||
@@ -61,15 +61,11 @@ export const lang = {
|
|||||||
weChatWorkKefu: '企业微信客服',
|
weChatWorkKefu: '企业微信客服',
|
||||||
},
|
},
|
||||||
|
|
||||||
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
|
// diy 自定义页面
|
||||||
diy: {
|
diy: {
|
||||||
networkErrorTitle: '提示',
|
|
||||||
networkError: '网络异常,请稍后重试',
|
networkError: '网络异常,请稍后重试',
|
||||||
retry: '重试',
|
|
||||||
noDiyData: '未配置自定义页面数据',
|
noDiyData: '未配置自定义页面数据',
|
||||||
locationNotAvailable: '未获取到定位',
|
locationNotAvailable: '未获取到定位',
|
||||||
// 数据加载失败详细文案(diy 专属模板,内部 {reason} 由通用 http.* 填充)
|
|
||||||
loadFailTpl: '页面数据加载失败:{reason},请稍后重试',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 客服服务
|
// 客服服务
|
||||||
@@ -112,7 +108,6 @@ export const lang = {
|
|||||||
notSupported: '当前环境不支持微信视频号',
|
notSupported: '当前环境不支持微信视频号',
|
||||||
missingFields: '缺少必要字段: {field}',
|
missingFields: '缺少必要字段: {field}',
|
||||||
playFailed: '微信视频播放失败!',
|
playFailed: '微信视频播放失败!',
|
||||||
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// uniapp 工具函数
|
// uniapp 工具函数
|
||||||
@@ -139,18 +134,12 @@ export const lang = {
|
|||||||
requestFailed: '{url}请求失败. {err}',
|
requestFailed: '{url}请求失败. {err}',
|
||||||
// 网络超时
|
// 网络超时
|
||||||
timeout: '网络请求超时,请稍后重试',
|
timeout: '网络请求超时,请稍后重试',
|
||||||
// 网络连接失败 / 通用网络异常(统一文案,供 diy 等通用场景复用)
|
// 网络连接失败
|
||||||
networkError: '网络异常,请稍后重试',
|
networkError: '网络连接失败,请检查网络设置',
|
||||||
// 服务器错误
|
// 服务器错误
|
||||||
serverError: '服务器异常,请稍后重试',
|
serverError: '服务暂时不可用,请稍后重试',
|
||||||
// 接口不存在(HTTP 404)
|
|
||||||
notFound: '接口不存在',
|
|
||||||
// 无访问权限(HTTP 401 / 403)
|
|
||||||
auth: '无访问权限',
|
|
||||||
// 请求被中断
|
// 请求被中断
|
||||||
aborted: '请求已取消',
|
aborted: '请求已取消',
|
||||||
// 未知错误(errorType 分类兜底,如非 5xx/404/401/403 的 HTTP 状态码)
|
|
||||||
unknown: '请求失败,请稍后重试',
|
|
||||||
// 未知错误
|
// 未知错误
|
||||||
unknownError: '请求失败,请稍后重试',
|
unknownError: '请求失败,请稍后重试',
|
||||||
},
|
},
|
||||||
@@ -256,14 +245,6 @@ export const lang = {
|
|||||||
addToCartFailMin: '已达到最小购买数量',
|
addToCartFailMin: '已达到最小购买数量',
|
||||||
addToCartFailStock: '库存不足',
|
addToCartFailStock: '库存不足',
|
||||||
|
|
||||||
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
|
|
||||||
submitSuccess: '提交成功',
|
|
||||||
submitFail: '提交失败',
|
|
||||||
versionInfo: '版本信息',
|
|
||||||
commitId: '提交 ID',
|
|
||||||
buildTime: '构建时间',
|
|
||||||
loading: '加载中...',
|
|
||||||
|
|
||||||
consult: '立即咨询',
|
consult: '立即咨询',
|
||||||
consultSuccess: '咨询成功',
|
consultSuccess: '咨询成功',
|
||||||
consultFail: '咨询失败',
|
consultFail: '咨询失败',
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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: '(未生成)',
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const lang = {
|
|
||||||
// diy-webview 网页组件
|
|
||||||
emptyTip: '请配置网页链接',
|
|
||||||
loadError: '网页加载失败',
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,6 @@ export const lang = {
|
|||||||
loggingIn: '登录中',
|
loggingIn: '登录中',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
pleaseAuthorizeMobile: '请授权您的手机号码',
|
pleaseAuthorizeMobile: '请授权您的手机号码',
|
||||||
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
|
|
||||||
pleaseEnterNickname: '请输入昵称',
|
pleaseEnterNickname: '请输入昵称',
|
||||||
avatarPullFailed: '头像拉取失败',
|
avatarPullFailed: '头像拉取失败',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ export const lang = {
|
|||||||
fileUrlNotExist: '文件地址不存在',
|
fileUrlNotExist: '文件地址不存在',
|
||||||
// 视频地址不存在
|
// 视频地址不存在
|
||||||
videoUrlNotExist: '视频地址不存在',
|
videoUrlNotExist: '视频地址不存在',
|
||||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
|
|
||||||
audioUrlNotExist: '音频地址不存在',
|
|
||||||
audioDefaultTitle: '语音消息',
|
|
||||||
audioPlaying: '播放中…',
|
|
||||||
audioPaused: '已暂停',
|
|
||||||
// 暂不支持该类型文件预览
|
// 暂不支持该类型文件预览
|
||||||
unsupportedFileType: '暂不支持该类型文件预览',
|
unsupportedFileType: '暂不支持该类型文件预览',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const lang = {
|
|
||||||
//title为每个页面的标题
|
|
||||||
title: '文件详情',
|
|
||||||
}
|
|
||||||
@@ -6,17 +6,4 @@ export const lang = {
|
|||||||
submitSuccess: '提交成功',
|
submitSuccess: '提交成功',
|
||||||
// 未获取到表单信息
|
// 未获取到表单信息
|
||||||
noFormInfo: '未获取到表单信息',
|
noFormInfo: '未获取到表单信息',
|
||||||
// 行为设置相关
|
|
||||||
// 请输入验证码
|
|
||||||
captchaPlaceholder: '请输入验证码',
|
|
||||||
// 请输入验证码(校验提示)
|
|
||||||
captchaRequired: '请输入验证码',
|
|
||||||
// 验证码错误
|
|
||||||
captchaError: '验证码错误,请重试',
|
|
||||||
// 提交次数已达上限
|
|
||||||
submitLimit: '提交次数已达上限',
|
|
||||||
// 表单未开放
|
|
||||||
notOpen: '表单未开放',
|
|
||||||
// 请先登录
|
|
||||||
loginRequired: '请先登录',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export const lang = {
|
|||||||
|
|
||||||
// 商品留言
|
// 商品留言
|
||||||
productMessage: '商品留言',
|
productMessage: '商品留言',
|
||||||
formEmptyTip: '暂无需填写的留言字段',
|
|
||||||
|
|
||||||
// 智能客服
|
// 智能客服
|
||||||
aiCustomerService: '智能客服',
|
aiCustomerService: '智能客服',
|
||||||
|
|||||||
@@ -3,6 +3,5 @@ export const lang = {
|
|||||||
title: '支付方式',
|
title: '支付方式',
|
||||||
paymentAmount: '支付金额',
|
paymentAmount: '支付金额',
|
||||||
confirmPayment: '确认支付',
|
confirmPayment: '确认支付',
|
||||||
seeOrder: '查看订单',
|
seeOrder: '查看订单'
|
||||||
payInfoNotFound: '未获取到支付信息!'
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
main.js
10
main.js
@@ -6,7 +6,6 @@ import App from './App'
|
|||||||
import store from './store'
|
import store from './store'
|
||||||
import Util from './common/js/util.js'
|
import Util from './common/js/util.js'
|
||||||
import Http from './common/js/http.js'
|
import Http from './common/js/http.js'
|
||||||
import CardApi from './common/js/card-api.js'
|
|
||||||
import Lang from './common/js/lang.js'
|
import Lang from './common/js/lang.js'
|
||||||
import Config from './common/js/config.js'
|
import Config from './common/js/config.js'
|
||||||
import EventBus from './common/js/event-bus.js'
|
import EventBus from './common/js/event-bus.js'
|
||||||
@@ -26,7 +25,6 @@ Vue.config.productionTip = false
|
|||||||
|
|
||||||
Vue.prototype.$util = Util;
|
Vue.prototype.$util = Util;
|
||||||
Vue.prototype.$api = Http;
|
Vue.prototype.$api = Http;
|
||||||
Vue.prototype.$cardApi = CardApi; // 名片云(card addon) API 收口层
|
|
||||||
|
|
||||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||||
@@ -82,18 +80,10 @@ Vue.mixin(MpShareDefault);
|
|||||||
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
|
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
|
||||||
// golbalConfig.js 已把 componentRefresh 注册为全局 computed,
|
// golbalConfig.js 已把 componentRefresh 注册为全局 computed,
|
||||||
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
|
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
|
||||||
// 同时调用 lang.refresh() 让当前页面的 navigationBarTitleText 跟随语种变化
|
|
||||||
// (pages.json 里的硬编码中文标题需要被 $lang('title') 覆盖)
|
|
||||||
Vue.mixin({
|
Vue.mixin({
|
||||||
watch: {
|
watch: {
|
||||||
componentRefresh() {
|
componentRefresh() {
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
try {
|
|
||||||
if (this.$langConfig && typeof this.$langConfig.refresh === 'function') {
|
|
||||||
// emit=false: lang.js 已 emit 过,这里避免重复 store.commit
|
|
||||||
this.$langConfig.refresh(false);
|
|
||||||
}
|
|
||||||
} catch (e) { /* 容错:refresh 失败不影响主流程 */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,9 +79,6 @@
|
|||||||
},
|
},
|
||||||
"scope.writePhotosAlbum" : {
|
"scope.writePhotosAlbum" : {
|
||||||
"desc" : "用于从相册选择和保存头像"
|
"desc" : "用于从相册选择和保存头像"
|
||||||
},
|
|
||||||
"scope.record" : {
|
|
||||||
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||||
@@ -139,23 +136,7 @@
|
|||||||
"sassImplementationName" : "dart-sass",
|
"sassImplementationName" : "dart-sass",
|
||||||
"mp-harmony" : {
|
"mp-harmony" : {
|
||||||
"distribute" : {
|
"distribute" : {
|
||||||
"bundleName" : "com.atomicservice.6917607804641099460"
|
"bundleName" : "com.atomicservice.6917577984608507983"
|
||||||
},
|
|
||||||
// 隐私托管: 元服务必须使用 AGC 后台的标准化隐私声明托管服务,
|
|
||||||
// 弹窗由鸿蒙系统自动弹出, 应用层不能自建.
|
|
||||||
// 下方的 privacy 块仅作为应用内协议页兜底 (例如用户在系统设置里手动查看协议),
|
|
||||||
// 不参与首次启动弹窗逻辑.
|
|
||||||
"privacy" : {
|
|
||||||
"privacyContractUrl" : "YOUR_DOMAIN/api/agreementview/privacy?uniacid=YOUR_UNIACID",
|
|
||||||
"serviceContractUrl" : "YOUR_DOMAIN/api/agreementview/service?uniacid=YOUR_UNIACID",
|
|
||||||
"privacyContractName" : {
|
|
||||||
"zh" : "隐私政策",
|
|
||||||
"en" : "Privacy Policy"
|
|
||||||
},
|
|
||||||
"serviceContractName" : {
|
|
||||||
"zh" : "用户服务协议",
|
|
||||||
"en" : "User Service Agreement"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
165
package.json
165
package.json
@@ -1,93 +1,72 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
||||||
"pack:harmony": "node scripts/pack-harmony-project.js",
|
"i18n:check": "node scripts/i18n-check.js",
|
||||||
"i18n:check": "node scripts/i18n-check.js",
|
"test": "node scripts/test-gate.js",
|
||||||
"test": "node scripts/test-gate.js",
|
"test:util": "node --test tests/util.test.js",
|
||||||
"test:util": "node --test tests/util.test.js",
|
"prebuild": "npm run test",
|
||||||
"prebuild": "npm run test",
|
"prestart": "npm run test"
|
||||||
"prestart": "npm run test"
|
},
|
||||||
},
|
"devDependencies": {
|
||||||
"devDependencies": {
|
"dart-sass": "^1.25.0",
|
||||||
"dart-sass": "^1.25.0",
|
"terser-webpack-plugin": "^5.3.10",
|
||||||
"terser-webpack-plugin": "^5.3.10",
|
"zion-uniapp-mp-load-package": "^1.0.13"
|
||||||
"zion-uniapp-mp-load-package": "^1.0.13"
|
},
|
||||||
},
|
"dependencies": {
|
||||||
"dependencies": {
|
"jweixin-module": "^1.6.0"
|
||||||
"jweixin-module": "^1.6.0"
|
},
|
||||||
},
|
"uni-app": {
|
||||||
"uni-app": {
|
"scripts": {
|
||||||
"scripts": {
|
"weixin": {
|
||||||
"weixin": {
|
"title": "微信小程序自定义发行",
|
||||||
"title": "微信小程序自定义发行",
|
"env": {
|
||||||
"env": {
|
"UNI_PLATFORM": "mp-weixin"
|
||||||
"UNI_PLATFORM": "mp-weixin"
|
},
|
||||||
},
|
"define": {
|
||||||
"define": {
|
"MP_WEIXIN": true,
|
||||||
"MP_WEIXIN": true,
|
"PRODUCTION": true
|
||||||
"PRODUCTION": true
|
}
|
||||||
}
|
},
|
||||||
},
|
"h5-xcx20.5g-quickapp.com": {
|
||||||
"h5-xcx20.5g-quickapp.com": {
|
"title": "H5-xcx20.5g-quickapp.com",
|
||||||
"title": "H5-xcx20.5g-quickapp.com",
|
"env": {
|
||||||
"env": {
|
"UNI_PLATFORM": "h5",
|
||||||
"UNI_PLATFORM": "h5",
|
"UNI_APP_DOMAIN": "https://xcx20.5g-quickapp.com/"
|
||||||
"UNI_APP_DOMAIN": "https://xcx20.5g-quickapp.com/"
|
},
|
||||||
},
|
"define": {
|
||||||
"define": {
|
"PRODUCTION": true
|
||||||
"PRODUCTION": true
|
}
|
||||||
}
|
},
|
||||||
},
|
"h5-xcx.aigc-quickapp.com": {
|
||||||
"h5-xcx.aigc-quickapp.com": {
|
"title": "H5-xcx.aigc-quickapp.com",
|
||||||
"title": "H5-xcx.aigc-quickapp.com",
|
"env": {
|
||||||
"env": {
|
"UNI_PLATFORM": "h5",
|
||||||
"UNI_PLATFORM": "h5",
|
"UNI_APP_DOMAIN": "https://xcx.aigc-quickapp.com/"
|
||||||
"UNI_APP_DOMAIN": "https://xcx.aigc-quickapp.com/"
|
},
|
||||||
},
|
"define": {
|
||||||
"define": {
|
"PRODUCTION": true
|
||||||
"PRODUCTION": true
|
}
|
||||||
}
|
},
|
||||||
},
|
"h5-5g.aigc-quickapp.com": {
|
||||||
"h5-5g.aigc-quickapp.com": {
|
"title": "H5-5g.aigc-quickapp.com",
|
||||||
"title": "H5-5g.aigc-quickapp.com",
|
"env": {
|
||||||
"env": {
|
"UNI_PLATFORM": "h5",
|
||||||
"UNI_PLATFORM": "h5",
|
"UNI_APP_DOMAIN": "https://5g.aigc-quickapp.com/"
|
||||||
"UNI_APP_DOMAIN": "https://5g.aigc-quickapp.com/"
|
},
|
||||||
},
|
"define": {
|
||||||
"define": {
|
"PRODUCTION": true
|
||||||
"PRODUCTION": true
|
}
|
||||||
}
|
},
|
||||||
},
|
"h5-test.aigc-quickapp.com": {
|
||||||
"h5-test.aigc-quickapp.com": {
|
"title": "H5-test.aigc-quickapp.com",
|
||||||
"title": "H5-test.aigc-quickapp.com",
|
"env": {
|
||||||
"env": {
|
"UNI_PLATFORM": "h5",
|
||||||
"UNI_PLATFORM": "h5",
|
"UNI_APP_DOMAIN": "https://test.aigc-quickapp.com/"
|
||||||
"UNI_APP_DOMAIN": "https://test.aigc-quickapp.com/"
|
},
|
||||||
},
|
"define": {
|
||||||
"define": {
|
"PRODUCTION": true
|
||||||
"PRODUCTION": true
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"http://localhost:8050/": {
|
}
|
||||||
"title": "H5-localhost:8050",
|
}
|
||||||
"env": {
|
|
||||||
"UNI_PLATFORM": "h5",
|
|
||||||
"UNI_APP_DOMAIN": "http://localhost:8050/"
|
|
||||||
},
|
|
||||||
"define": {
|
|
||||||
"PRODUCTION": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"http://192.168.8.118:8050/": {
|
|
||||||
"title": "H5-192.168.8.118:8050",
|
|
||||||
"env": {
|
|
||||||
"UNI_PLATFORM": "h5",
|
|
||||||
"UNI_APP_DOMAIN": "http://192.168.8.118:8050/"
|
|
||||||
},
|
|
||||||
"define": {
|
|
||||||
"PRODUCTION": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
653
pages.json
653
pages.json
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,6 @@
|
|||||||
<view :style="themeColor">
|
<view :style="themeColor">
|
||||||
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
|
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
|
||||||
class="page-img">
|
class="page-img">
|
||||||
<!-- diy 数据网络加载失败提示(页面内横幅,各端可靠,悬浮在最上层) -->
|
|
||||||
<view v-if="diyLoadError" class="diy-load-error">
|
|
||||||
<view class="diy-load-error__body">
|
|
||||||
<text class="diy-load-error__msg">{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}</text>
|
|
||||||
<text v-if="diyLoadErrorUrl" class="diy-load-error__url" selectable user-select>{{ diyLoadErrorUrl }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="diy-load-error__btn" @tap="retryDiyInfo">{{ $lang('diy.retry') || '重试' }}</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="site-info-box"
|
<view class="site-info-box"
|
||||||
v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode">
|
v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode">
|
||||||
<view class="site-info">
|
<view class="site-info">
|
||||||
@@ -53,7 +44,7 @@
|
|||||||
<ns-copyright v-show="isShowCopyRight" />
|
<ns-copyright v-show="isShowCopyRight" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-if="adv && adv.advshow != -1">
|
<template v-if="adv.advshow != -1">
|
||||||
<view @touchmove.prevent.stop>
|
<view @touchmove.prevent.stop>
|
||||||
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
||||||
<view class="small-bot">
|
<view class="small-bot">
|
||||||
@@ -188,52 +179,6 @@ export default {
|
|||||||
z-index: 122;
|
z-index: 122;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diy-load-error {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 28rpx;
|
|
||||||
padding-top: calc(24rpx + var(--status-bar-height, 0px));
|
|
||||||
background-color: #fff5f5;
|
|
||||||
border-bottom: 1rpx solid #ffccc7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diy-load-error__body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diy-load-error__msg {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #cf1322;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diy-load-error__url {
|
|
||||||
margin-top: 6rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
line-height: 1.4;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diy-load-error__btn {
|
|
||||||
margin-left: 20rpx;
|
|
||||||
padding: 10rpx 28rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #fff;
|
|
||||||
background-color: #cf1322;
|
|
||||||
border-radius: 32rpx;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-bot swiper {
|
.small-bot swiper {
|
||||||
width: 560rpx;
|
width: 560rpx;
|
||||||
height: 784rpx;
|
height: 784rpx;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default {
|
|||||||
onShow() {
|
onShow() {
|
||||||
// 获取关注公众号二维码
|
// 获取关注公众号二维码
|
||||||
this.getFollowQrcode();
|
this.getFollowQrcode();
|
||||||
|
|
||||||
// 重新获取店铺信息
|
// 重新获取店铺信息
|
||||||
this.shopInfo = uni.getStorageSync('shopInfo');
|
this.shopInfo = uni.getStorageSync('shopInfo');
|
||||||
},
|
},
|
||||||
@@ -160,10 +160,10 @@ export default {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (v && v.commitId) {
|
if (v && v.commitId) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: this.$lang('versionInfo'),
|
title: '版本信息',
|
||||||
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
|
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
|
||||||
showCancel: false
|
showCancel: false
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error('未获取到构建版本信息');
|
console.error('未获取到构建版本信息');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view>
|
<view>
|
||||||
<!-- 骨架屏:商品详情加载时按结构分段占位(banner / 价格 / 标题 / 规格 / 商家 / 详情 / 推荐)
|
<view scroll-y="true" class="goods-detail" :class="isIphoneX ? 'active' : ''">
|
||||||
关闭条件: loading=false 且已拿到真实商品数据(goods_id>0); 任一不满足都保留骨架屏 -->
|
<view class="goods-container">
|
||||||
<view v-if="loading || !hasRealData" class="goods-detail-skeleton">
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-banner" :loading="true" :configs="skeletonBanner"></x-skeleton>
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-price" :loading="true" :configs="skeletonPrice"></x-skeleton>
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-title" :loading="true" :configs="skeletonTitle"></x-skeleton>
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-sku" :loading="true" :configs="skeletonSku"></x-skeleton>
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-merch" :loading="true" :configs="skeletonMerch"></x-skeleton>
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-detail" :loading="true" :configs="skeletonDetail"></x-skeleton>
|
|
||||||
<x-skeleton data-component-name="goods-detail-view-recommend" :loading="true" :configs="skeletonRecommend"></x-skeleton>
|
|
||||||
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
|
|
||||||
<view class="goods-detail-skeleton-action">
|
|
||||||
<view class="skeleton-action-icons">
|
|
||||||
<view class="skeleton-action-icon" v-for="i in 2" :key="i"></view>
|
|
||||||
</view>
|
|
||||||
<view class="skeleton-action-btn"></view>
|
|
||||||
<view class="skeleton-action-btn primary"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
|
|
||||||
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 view 子树静默不渲染, 导致整个详情页空白 -->
|
|
||||||
<view v-else class="goods-detail-wrap">
|
|
||||||
<!-- 操作区域 slot: 与 .goods-detail 同一 v-else 分支, 避免骨架屏状态下被父组件渲染真实按钮(基于默认 goods_state=0 显示"该商品已下架") -->
|
|
||||||
<slot name="action"></slot>
|
|
||||||
<view class="goods-detail" :class="isIphoneX ? 'active' : ''">
|
|
||||||
<view class="goods-container">
|
|
||||||
<!-- AI客服悬浮按钮 -->
|
<!-- AI客服悬浮按钮 -->
|
||||||
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
|
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
|
||||||
<image :src="aiAvatarUrl" mode="aspectFill" class="ai-avatar-icon" />
|
<image :src="aiAvatarUrl" mode="aspectFill" class="ai-avatar-icon" />
|
||||||
@@ -56,11 +32,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
|
<!-- 商品视频 -->
|
||||||
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
|
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
|
||||||
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
|
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
|
||||||
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
|
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"></video>
|
||||||
playsinline webkit-playsinline x5-playsinline></video>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 切换视频、图片 -->
|
<!-- 切换视频、图片 -->
|
||||||
@@ -484,7 +459,7 @@
|
|||||||
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
|
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<!-- #ifdef H5 || MP-HARMONY -->
|
<!-- #ifdef H5 -->
|
||||||
<view class="share-box" @click="copyUrl">
|
<view class="share-box" @click="copyUrl">
|
||||||
<button class="share-btn" :plain="true">
|
<button class="share-btn" :plain="true">
|
||||||
<view class="iconfont icon-fuzhilianjie"></view>
|
<view class="iconfont icon-fuzhilianjie"></view>
|
||||||
@@ -499,10 +474,11 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<slot name="fixedbtn"></slot>
|
<slot name="fixedbtn"></slot>
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作区域 -->
|
||||||
|
<slot name="action"></slot>
|
||||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||||
<ns-login ref="login"></ns-login>
|
<ns-login ref="login"></ns-login>
|
||||||
</view>
|
</view>
|
||||||
@@ -525,142 +501,11 @@ export default {
|
|||||||
default: () => {
|
default: () => {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
|
||||||
// 是否显示骨架屏(true:展示骨架;false:展示真实内容)
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
pengpaiFadeinOut
|
pengpaiFadeinOut
|
||||||
},
|
},
|
||||||
data() {
|
|
||||||
// 商品详情骨架屏:模拟「轮播图 + 价格行 + 标题简介 + 规格行 + 商家 + 详情 + 推荐」
|
|
||||||
return {
|
|
||||||
// 1. 轮播图:方形 banner 占位
|
|
||||||
skeletonBanner: {
|
|
||||||
padding: '0',
|
|
||||||
gridRows: 1,
|
|
||||||
gridColumns: 1,
|
|
||||||
gridRowsGap: '24rpx',
|
|
||||||
headShow: true,
|
|
||||||
headWidth: '100%',
|
|
||||||
headHeight: '750rpx',
|
|
||||||
headBorderRadius: '0',
|
|
||||||
textShow: false
|
|
||||||
},
|
|
||||||
// 2. 价格行:一行价 + 一行原价
|
|
||||||
skeletonPrice: {
|
|
||||||
padding: '24rpx 24rpx 16rpx',
|
|
||||||
gridRows: 2,
|
|
||||||
gridColumns: 1,
|
|
||||||
gridRowsGap: '16rpx',
|
|
||||||
itemDirection: 'column',
|
|
||||||
itemAlign: 'flex-start',
|
|
||||||
headShow: false,
|
|
||||||
textShow: true,
|
|
||||||
textRows: 1,
|
|
||||||
textRowsGap: '0',
|
|
||||||
textWidth: ['40%', '30%'],
|
|
||||||
textHeight: ['48rpx', '24rpx'],
|
|
||||||
textBorderRadius: '6rpx'
|
|
||||||
},
|
|
||||||
// 3. 标题/简介:标题两行 + 简介 + 标签
|
|
||||||
skeletonTitle: {
|
|
||||||
padding: '8rpx 24rpx 24rpx',
|
|
||||||
gridRows: 1,
|
|
||||||
gridColumns: 1,
|
|
||||||
gridRowsGap: '20rpx',
|
|
||||||
itemDirection: 'column',
|
|
||||||
itemAlign: 'flex-start',
|
|
||||||
headShow: false,
|
|
||||||
textShow: true,
|
|
||||||
textRows: 4,
|
|
||||||
textRowsGap: '16rpx',
|
|
||||||
textWidth: ['90%', '60%', '100%', '40%'],
|
|
||||||
textHeight: ['40rpx', '40rpx', '24rpx', '24rpx'],
|
|
||||||
textBorderRadius: '6rpx'
|
|
||||||
},
|
|
||||||
// 4. 规格行:单行高度的小条
|
|
||||||
skeletonSku: {
|
|
||||||
padding: '20rpx 24rpx',
|
|
||||||
gridRows: 1,
|
|
||||||
gridColumns: 1,
|
|
||||||
gridRowsGap: '0',
|
|
||||||
itemDirection: 'row',
|
|
||||||
itemAlign: 'center',
|
|
||||||
headShow: true,
|
|
||||||
headWidth: '120rpx',
|
|
||||||
headHeight: '32rpx',
|
|
||||||
headBorderRadius: '6rpx',
|
|
||||||
textShow: true,
|
|
||||||
textRows: 1,
|
|
||||||
textRowsGap: '0',
|
|
||||||
textWidth: '60%',
|
|
||||||
textHeight: '28rpx',
|
|
||||||
textBorderRadius: '6rpx',
|
|
||||||
itemGap: '20rpx'
|
|
||||||
},
|
|
||||||
// 5. 商家:头像 + 名称
|
|
||||||
skeletonMerch: {
|
|
||||||
padding: '24rpx',
|
|
||||||
gridRows: 1,
|
|
||||||
gridColumns: 1,
|
|
||||||
gridRowsGap: '0',
|
|
||||||
itemDirection: 'row',
|
|
||||||
itemAlign: 'center',
|
|
||||||
headShow: true,
|
|
||||||
headWidth: '80rpx',
|
|
||||||
headHeight: '80rpx',
|
|
||||||
headBorderRadius: '50%',
|
|
||||||
textShow: true,
|
|
||||||
textRows: 2,
|
|
||||||
textRowsGap: '12rpx',
|
|
||||||
textWidth: ['50%', '80%'],
|
|
||||||
textHeight: ['28rpx', '20rpx'],
|
|
||||||
textBorderRadius: '6rpx',
|
|
||||||
itemGap: '24rpx'
|
|
||||||
},
|
|
||||||
// 6. 商品详情:多行段落
|
|
||||||
skeletonDetail: {
|
|
||||||
padding: '24rpx',
|
|
||||||
gridRows: 1,
|
|
||||||
gridColumns: 1,
|
|
||||||
gridRowsGap: '0',
|
|
||||||
itemDirection: 'column',
|
|
||||||
itemAlign: 'flex-start',
|
|
||||||
headShow: false,
|
|
||||||
textShow: true,
|
|
||||||
textRows: 6,
|
|
||||||
textRowsGap: '16rpx',
|
|
||||||
textWidth: ['100%', '100%', '90%', '100%', '80%', '60%'],
|
|
||||||
textHeight: '24rpx',
|
|
||||||
textBorderRadius: '6rpx'
|
|
||||||
},
|
|
||||||
// 7. 推荐:两列卡片缩略图
|
|
||||||
skeletonRecommend: {
|
|
||||||
padding: '24rpx',
|
|
||||||
gridRows: 1,
|
|
||||||
gridColumns: 2,
|
|
||||||
gridRowsGap: '24rpx',
|
|
||||||
gridColumnsGap: '24rpx',
|
|
||||||
itemDirection: 'column',
|
|
||||||
itemAlign: 'flex-start',
|
|
||||||
headShow: true,
|
|
||||||
headWidth: '100%',
|
|
||||||
headHeight: '320rpx',
|
|
||||||
headBorderRadius: '12rpx',
|
|
||||||
textShow: true,
|
|
||||||
textRows: 2,
|
|
||||||
textRowsGap: '12rpx',
|
|
||||||
textWidth: ['100%', '70%'],
|
|
||||||
textHeight: ['24rpx', '24rpx'],
|
|
||||||
textBorderRadius: '6rpx',
|
|
||||||
itemGap: '12rpx'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
aiAvatarUrl() {
|
aiAvatarUrl() {
|
||||||
return this.$util.img(IMAGE_CONFIG.aiAvatar);
|
return this.$util.img(IMAGE_CONFIG.aiAvatar);
|
||||||
@@ -679,11 +524,6 @@ export default {
|
|||||||
hasDetailContent() {
|
hasDetailContent() {
|
||||||
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
|
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
|
||||||
return hasPdf || this.hasHtmlTab;
|
return hasPdf || this.hasHtmlTab;
|
||||||
},
|
|
||||||
// 是否有真实商品数据: goods_id > 0 才算拿到商品详情
|
|
||||||
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
|
|
||||||
hasRealData() {
|
|
||||||
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [scroll, detail]
|
mixins: [scroll, detail]
|
||||||
@@ -692,79 +532,6 @@ export default {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '@/common/css/goods_detail.scss';
|
@import '@/common/css/goods_detail.scss';
|
||||||
|
|
||||||
/* 骨架屏外层:与商品详情整体上对齐,左右内边距由各 skeleton 自己控制 */
|
|
||||||
.goods-detail-skeleton {
|
|
||||||
width: 100%;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 高度接近真实按钮 */
|
|
||||||
.goods-detail-skeleton-action {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 100rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #fff;
|
|
||||||
z-index: 9;
|
|
||||||
padding: 0 16rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-top: 1rpx solid #f2f2f2;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
.goods-detail-skeleton-action .skeleton-action-icons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goods-detail-skeleton-action .skeleton-action-icon {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #EAEDF5;
|
|
||||||
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: skActAnim 1.8s ease infinite;
|
|
||||||
margin-right: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goods-detail-skeleton-action .skeleton-action-icon:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goods-detail-skeleton-action .skeleton-action-btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 70rpx;
|
|
||||||
border-radius: 10rpx;
|
|
||||||
margin-right: 16rpx;
|
|
||||||
background-color: #EAEDF5;
|
|
||||||
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: skActAnim 1.8s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goods-detail-skeleton-action .skeleton-action-btn:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goods-detail-skeleton-action .skeleton-action-btn.primary {
|
|
||||||
background: linear-gradient(90deg, #d8d8d8 25%, #ececec 37%, #d8d8d8 50%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skActAnim {
|
|
||||||
0% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ai-chat-float-btn {
|
.ai-chat-float-btn {
|
||||||
|
|||||||
@@ -384,14 +384,5 @@ export default {
|
|||||||
border-radius: 20rpx 20rpx 0 0;
|
border-radius: 20rpx 20rpx 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 购物车页面SKU弹窗底部按钮上移,避开底部导航栏
|
|
||||||
::v-deep .goods-sku .sku-info .footer {
|
|
||||||
bottom: calc(30rpx + env(safe-area-inset-bottom) + 112rpx) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep .goods-sku .sku-info .body-item {
|
|
||||||
padding-bottom: 112rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
@import './public/css/cart.scss';
|
@import './public/css/cart.scss';
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view :style="themeColor">
|
<view :style="themeColor">
|
||||||
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
|
<view v-show="diyData && diyData.value && diyData.value.length">
|
||||||
<view v-if="diyData && diyData.value && diyData.value.length">
|
|
||||||
<block v-for="(item, index) in diyData.value" :key="index">
|
<block v-for="(item, index) in diyData.value" :key="index">
|
||||||
<view v-if="item.componentName == 'GoodsCategory'">
|
<view v-if="item.componentName == 'GoodsCategory'">
|
||||||
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
|
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
|
||||||
@@ -16,8 +15,8 @@
|
|||||||
<!-- 隐私弹窗 -->
|
<!-- 隐私弹窗 -->
|
||||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||||
|
|
||||||
<!-- 底部tabBar: loading 期间隐藏, 等 loading-cover 关闭后下一 tick 再挂载, 避免 TabBar 与 loading-cover 同时 GPU 合成导致频闪 -->
|
<!-- 底部tabBar -->
|
||||||
<view id="tab-bar" v-if="!loadingVisible">
|
<view id="tab-bar">
|
||||||
<diy-bottom-nav></diy-bottom-nav>
|
<diy-bottom-nav></diy-bottom-nav>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -28,13 +27,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
diyData: { value: [] },
|
diyData: { value: [] },
|
||||||
mpShareData: null, //小程序分享数据
|
mpShareData: null //小程序分享数据
|
||||||
// 修复底部 TabBar 频闪: 用本地 loadingVisible 状态镜像 <loading-cover> 的可见性,
|
|
||||||
// loading-cover 的 spin 动画与 diy-bottom-nav 都是 fixed/z-index 高 + GPU 合成,
|
|
||||||
// 在 MP-Weixin 下会互相触发重绘, 让用户看到 TabBar 抖动。
|
|
||||||
// 策略: loading-cover 显示期间, 也把 tabBar 先收起来, loading-cover 隐藏后
|
|
||||||
// 下一次 nextTick 再放出 tabBar, 把整段 loading 过程的合成层冲突降到最低。
|
|
||||||
loadingVisible: true
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -47,13 +40,8 @@ export default {
|
|||||||
this.$util.hideHomeButton();
|
this.$util.hideHomeButton();
|
||||||
try {
|
try {
|
||||||
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
|
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
|
||||||
// 节流: 避免 onShow 重复触发 pageShow() 中的 getHeightArea(-1) 高耗时选择器查询, 引起频闪和性能损耗
|
if (this.$refs.category && this.$refs.category[0]) {
|
||||||
const now = Date.now();
|
|
||||||
if (this._lastPageShowTime && now - this._lastPageShowTime < 500) {
|
|
||||||
// 短时间内重复触发, 跳过本次 pageShow
|
|
||||||
} else if (this.$refs.category && this.$refs.category[0]) {
|
|
||||||
this.$refs.category[0].pageShow();
|
this.$refs.category[0].pageShow();
|
||||||
this._lastPageShowTime = now;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('onShow pageShow failed', e);
|
console.warn('onShow pageShow failed', e);
|
||||||
@@ -121,13 +109,6 @@ export default {
|
|||||||
this._diyInfoLoading = false;
|
this._diyInfoLoading = false;
|
||||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
uni.stopPullDownRefresh();
|
uni.stopPullDownRefresh();
|
||||||
// loading-cover 收起后, 等下一 tick 让出 frame, 再挂载 diy-bottom-nav
|
|
||||||
// (避免两个 fixed 高 z-index GPU 合成层在同帧 mount 触发 MP-Weixin 重绘频闪)
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.loadingVisible = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 鸿蒙兼容: themeColor 是 CSS 变量字符串, 鸿蒙 JSVM 早期版本对 :style="字符串" 处理可能不稳.
|
<view :style="themeColor">
|
||||||
兜底: 只有非空字符串才绑定 :style. -->
|
|
||||||
<view :style="themeColor || undefined">
|
|
||||||
<!-- #ifndef H5 -->
|
<!-- #ifndef H5 -->
|
||||||
<!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0">
|
<!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0">
|
||||||
<ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar>
|
<ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar>
|
||||||
</view> -->
|
</view> -->
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
|
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
|
||||||
<!-- 价格区域 -->
|
<!-- 价格区域 -->
|
||||||
<template v-slot:price>
|
<template v-slot:price>
|
||||||
<!-- 限时折扣 -->
|
<!-- 限时折扣 -->
|
||||||
@@ -17,7 +15,7 @@
|
|||||||
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
|
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
|
||||||
<view class="price-box">
|
<view class="price-box">
|
||||||
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view>
|
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view>
|
||||||
<view class="sale-num" v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
|
<view class="sale-num" v-if="goodsSkuDetail.sale_show">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -74,9 +72,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="follow-and-share">
|
<view class="follow-and-share">
|
||||||
<text class="follow iconfont icon-share" @click="openSharePopup()"></text>
|
<text class="follow iconfont icon-share" @click="openSharePopup()"></text>
|
||||||
<!-- #ifndef MP-HARMONY -->
|
|
||||||
<text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text>
|
<text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text>
|
||||||
<!-- #endif -->
|
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
@@ -94,8 +90,8 @@
|
|||||||
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
|
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="logistics-wrap">
|
<view class="logistics-wrap">
|
||||||
<text v-if="goodsSkuDetail.stock_show && !isPhoneConsult">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
|
<text v-if="goodsSkuDetail.stock_show">{{$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>
|
<text v-if="goodsSkuDetail.sale_show">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 会员卡 -->
|
<!-- 会员卡 -->
|
||||||
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
|
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
|
||||||
@@ -415,12 +411,7 @@
|
|||||||
</view> -->
|
</view> -->
|
||||||
<!-- v-if="goodsSkuDetail.goodsForm" -->
|
<!-- v-if="goodsSkuDetail.goodsForm" -->
|
||||||
<view style="padding: 0 30rpx;">
|
<view style="padding: 0 30rpx;">
|
||||||
<!-- 表单暂无字段时的友好提示, 避免"打开弹窗却什么都不显示"困惑用户 -->
|
|
||||||
<view v-if="!formDiyValue || !formDiyValue.jsonData || !formDiyValue.jsonData.length" class="form-empty" style="padding: 60rpx 0; text-align: center; color: #999;">
|
|
||||||
{{ $lang('goods.detail.formEmptyTip') }}
|
|
||||||
</view>
|
|
||||||
<diy-form
|
<diy-form
|
||||||
v-else
|
|
||||||
:value="formDiyValue"
|
:value="formDiyValue"
|
||||||
dataMode="raw"
|
dataMode="raw"
|
||||||
:show-submit="false"
|
:show-submit="false"
|
||||||
@@ -459,21 +450,17 @@
|
|||||||
/>
|
/>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<ns-goods-action-icon :text="$lang('goods.detail.home')" icon="icon-shouye1" @click="goHome" />
|
<ns-goods-action-icon :text="$lang('goods.detail.home')" icon="icon-shouye1" @click="goHome" />
|
||||||
|
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<ns-goods-action-icon
|
<ns-goods-action-icon
|
||||||
v-if="!isPhoneConsult"
|
:text="$lang('goods.detail.cart')"
|
||||||
:text="$lang('goods.detail.cart')"
|
|
||||||
:cornerMarkBg="themeStyle.goods_cart_num_corner"
|
:cornerMarkBg="themeStyle.goods_cart_num_corner"
|
||||||
icon="icon-gouwuche2"
|
icon="icon-gouwuche2"
|
||||||
:corner-mark="cartNumber > 0 ? cartNumber + '' : ''"
|
:corner-mark="cartNumber > 0 ? cartNumber + '' : ''"
|
||||||
@click="goCart"
|
@click="goCart"
|
||||||
/>
|
/>
|
||||||
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
|
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifdef MP-HARMONY -->
|
|
||||||
<!-- 鸿蒙平台: 隐藏购物车、客服、收藏按钮 -->
|
|
||||||
<!-- #endif -->
|
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 -->
|
||||||
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
|
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
@@ -529,9 +516,7 @@
|
|||||||
</goods-detail-view>
|
</goods-detail-view>
|
||||||
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
|
||||||
<ns-login ref="login"></ns-login>
|
<ns-login ref="login"></ns-login>
|
||||||
<!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
|
<loading-cover ref="loadingCover"></loading-cover>
|
||||||
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
|
|
||||||
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
|
|
||||||
|
|
||||||
<!-- 隐私弹窗 -->
|
<!-- 隐私弹窗 -->
|
||||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||||
@@ -566,10 +551,6 @@ export default {
|
|||||||
},
|
},
|
||||||
mixins: [base, detail, scroll],
|
mixins: [base, detail, scroll],
|
||||||
computed: {
|
computed: {
|
||||||
// 后台选择"电话立即咨询"模式(isinformation == 1)时,隐藏购物车图标、库存和销量
|
|
||||||
isPhoneConsult() {
|
|
||||||
return this.goodsSkuDetail && this.goodsSkuDetail.isinformation == 1;
|
|
||||||
},
|
|
||||||
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
|
||||||
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
|
||||||
formDiyValue() {
|
formDiyValue() {
|
||||||
@@ -608,7 +589,7 @@ export default {
|
|||||||
uni.setStorageSync('selectedProduct', productInfo);
|
uni.setStorageSync('selectedProduct', productInfo);
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
|
url: '/pages_tool/ai-chat/index'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ export default {
|
|||||||
this.isAction = false;
|
this.isAction = false;
|
||||||
},
|
},
|
||||||
onUnload() {
|
onUnload() {
|
||||||
if (!this.storeToken && this.$refs.login && typeof this.$refs.login.cancelCompleteInfo === 'function') {
|
if (!this.storeToken && this.$refs.login) this.$refs.login.cancelCompleteInfo();
|
||||||
this.$refs.login.cancelCompleteInfo();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onReady() {
|
onReady() {
|
||||||
if (!this.storeToken) {
|
if (!this.storeToken) {
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ export default {
|
|||||||
goodsRoute: '/pages_goods/detail',
|
goodsRoute: '/pages_goods/detail',
|
||||||
couponList: [], //优惠券列表
|
couponList: [], //优惠券列表
|
||||||
couponBtnSwitch: false, //获取优惠券防止重复提交
|
couponBtnSwitch: false, //获取优惠券防止重复提交
|
||||||
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
|
|
||||||
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
|
|
||||||
loading: true,
|
|
||||||
|
|
||||||
posterApi: '/api/goods/poster',
|
posterApi: '/api/goods/poster',
|
||||||
|
|
||||||
//满减活动
|
//满减活动
|
||||||
@@ -56,19 +53,11 @@ export default {
|
|||||||
}
|
}
|
||||||
return discount.toFixed(2);
|
return discount.toFixed(2);
|
||||||
},
|
},
|
||||||
// 弹窗高度: 受限于视口, 内部表单 <scroll-view> 自带滚动, 不再按字段数无限加高
|
//表单高度
|
||||||
FormHeight() {
|
FormHeight(){
|
||||||
// 上限 65vh: 留足顶部状态栏/导航栏 + 底部 home indicator, 也不会显得"一打开就半屏遮脸"
|
let height = 48;
|
||||||
// 字段多时通过内部 <scroll-view scroll-y> 自然滚动
|
if (this.goodsForm && this.goodsForm.length) {
|
||||||
const formList = (this.goodsSkuDetail && this.goodsSkuDetail.goods_form)
|
height += this.goodsForm.length * 5;
|
||||||
|| this.goodsForm;
|
|
||||||
let height = 45;
|
|
||||||
if (formList && formList.length) {
|
|
||||||
// 少量字段: 每个约 5vh; 字段多时递增放缓并封顶, 避免接近屏顶
|
|
||||||
// 5 个字段 → 45 + min(10, 25) = 55vh
|
|
||||||
// 10 个字段 → 45 + min(10, 50) = 55vh (已封顶)
|
|
||||||
const dynamic = Math.min(10, formList.length * 5);
|
|
||||||
height = Math.min(55, height + dynamic);
|
|
||||||
}
|
}
|
||||||
height += 'vh';
|
height += 'vh';
|
||||||
return height;
|
return height;
|
||||||
@@ -100,19 +89,8 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
async onShow() {
|
async onShow() {
|
||||||
// 同步获取商品详情
|
//同步获取商品详情
|
||||||
// 鸿蒙元服务兼容: 包 try/catch, 防止内部任何抛错导致整个 page 显示空白
|
await this.getGoodsSkuDetail();
|
||||||
try {
|
|
||||||
await this.getGoodsSkuDetail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('getGoodsSkuDetail failed:', e);
|
|
||||||
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
|
|
||||||
// 避免错误地把页面切到"空详情"状态
|
|
||||||
uni.showToast({
|
|
||||||
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
|
|
||||||
icon: 'none'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
this.couponBtnSwitch = false;
|
this.couponBtnSwitch = false;
|
||||||
@@ -134,28 +112,14 @@ export default {
|
|||||||
},
|
},
|
||||||
// 获取商品详情
|
// 获取商品详情
|
||||||
async getGoodsSkuDetail() {
|
async getGoodsSkuDetail() {
|
||||||
let res;
|
let res = await this.$api.sendRequest({
|
||||||
try {
|
url: '/api/goodssku/detail',
|
||||||
// 修复鸿蒙元服务: 必须传 async: false !
|
async: false,
|
||||||
// 当 App.vue 的 boot (lang/theme/remote-config) 未完成时,
|
data: {
|
||||||
// sendRequest 在 async !== false 模式下会直接 return undefined (不返回 promise),
|
sku_id: this.skuId,
|
||||||
// 导致 res.code undefined / res.data 抛 TypeError, 整个 onShow 失败.
|
goods_id: this.goodsId,
|
||||||
res = await this.$api.sendRequest({
|
|
||||||
url: '/api/goodssku/detail',
|
|
||||||
async: false,
|
|
||||||
data: {
|
|
||||||
sku_id: this.skuId,
|
|
||||||
goods_id: this.goodsId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 防御: 即便 async:false 仍可能在极端情况下返回 undefined
|
|
||||||
if (!res || typeof res !== 'object') {
|
|
||||||
throw new Error('sendRequest returned non-object response (likely boot not complete)');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
});
|
||||||
console.error('getGoodsSkuDetail sendRequest failed:', e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
let data = res.data;
|
let data = res.data;
|
||||||
if (data.goods_sku_detail != null) {
|
if (data.goods_sku_detail != null) {
|
||||||
if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) {
|
if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) {
|
||||||
@@ -166,10 +130,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name
|
if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name
|
||||||
|
|
||||||
// 关键改动: 先一次性赋值, 释放事件循环让 MP-Weixin 把首屏 (含媒体/价格/标题) 渲出来,
|
|
||||||
// 再 $nextTick 后才跑同步字段变换 (sku_images split / JSON.parse / 属性去重 等),
|
|
||||||
// 避免连续多次 Vue 响应式写触发同一帧内多次 vdom 重算, 显著改善"打开详情很慢"的体感。
|
|
||||||
this.goodsSkuDetail = data.goods_sku_detail;
|
this.goodsSkuDetail = data.goods_sku_detail;
|
||||||
if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id;
|
if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id;
|
||||||
if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id;
|
if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id;
|
||||||
@@ -181,10 +142,7 @@ export default {
|
|||||||
this.chatRoomParams = {
|
this.chatRoomParams = {
|
||||||
sku_id: this.goodsSkuDetail.sku_id
|
sku_id: this.goodsSkuDetail.sku_id
|
||||||
};
|
};
|
||||||
// 鸿蒙兼容: 后端在部分场景下可能不返回 goods_promotion, 缺字段直接 .[0] 会抛 TypeError,
|
let typeId = this.goodsSkuDetail.goods_promotion[0];
|
||||||
// 整个 onShow 走 catch 路径无法关闭骨架屏, 表现为"数据已回来但永远显示骨架屏"
|
|
||||||
const promotionList = this.goodsSkuDetail?.goods_promotion || [];
|
|
||||||
let typeId = promotionList.length > 0 ? promotionList[0] : undefined;
|
|
||||||
if (typeId) {
|
if (typeId) {
|
||||||
// 限时折扣
|
// 限时折扣
|
||||||
if (typeId.discount_id) {
|
if (typeId.discount_id) {
|
||||||
@@ -198,27 +156,7 @@ export default {
|
|||||||
goods_id: this.goodsId
|
goods_id: this.goodsId
|
||||||
};
|
};
|
||||||
|
|
||||||
// 让首屏主区域先绘制
|
// 处理商品数据
|
||||||
// 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
|
|
||||||
// #ifdef MP-HARMONY
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 16));
|
|
||||||
// 修复鸿蒙元服务: 不依赖 setTimeout 关闭骨架屏.
|
|
||||||
// 鸿蒙 JSVM 在 onShow 早期窗口的 setTimeout 回调有时被推迟/丢弃, 表现为"数据已回来但永远显示骨架屏".
|
|
||||||
// 数据已赋值 + 首屏让步, 同步直接关闭骨架屏, 由 hasRealData 兜底避免空详情.
|
|
||||||
this.loading = false;
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifndef MP-HARMONY
|
|
||||||
await this.$nextTick();
|
|
||||||
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
|
|
||||||
// 用户会看到骨架屏 → 真实详情 的清晰过渡
|
|
||||||
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
|
|
||||||
setTimeout(() => {
|
|
||||||
this.loading = false;
|
|
||||||
}, 300);
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
|
|
||||||
await this.handleGoodsSkuData();
|
await this.handleGoodsSkuData();
|
||||||
|
|
||||||
// 限时折扣
|
// 限时折扣
|
||||||
@@ -255,7 +193,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 优惠券
|
// 优惠券
|
||||||
if (Array.isArray(this.goodsSkuDetail.coupon_list)) {
|
if (this.goodsSkuDetail.coupon_list) {
|
||||||
this.couponList = this.goodsSkuDetail.coupon_list;
|
this.couponList = this.goodsSkuDetail.coupon_list;
|
||||||
this.couponList.forEach(v => {
|
this.couponList.forEach(v => {
|
||||||
if (v.count == v.lead_count) v.useState = 2;
|
if (v.count == v.lead_count) v.useState = 2;
|
||||||
@@ -270,6 +208,8 @@ export default {
|
|||||||
// this.handleBundlingData(this.goodsSkuDetail.bundling_list);
|
// this.handleBundlingData(this.goodsSkuDetail.bundling_list);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo');
|
this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo');
|
||||||
}
|
}
|
||||||
@@ -281,35 +221,8 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
//商品留言打开
|
//商品留言打开
|
||||||
async showinformation(){
|
showinformation(){
|
||||||
// 修复: 第一次打开时, uni-popup 内部 <scroll-view><diy-form> 还没完成首次渲染,
|
this.$refs.informationPopup.open();
|
||||||
// 直接 open() 会让用户看到空白弹窗。这里:
|
|
||||||
// 1) 若商品表单后台请求尚未完成 (或根本没触发), 先发起并 await 该请求
|
|
||||||
// 2) 用 $nextTick 等组件挂载完成后再打开
|
|
||||||
try {
|
|
||||||
// 若页面已进入但 _goodsFormPromise 还没建 (如 sku 切换/数据未到等场景),
|
|
||||||
// 这里补一个并发请求; 若已存在, 复用旧 promise 不重复请求。
|
|
||||||
if (!this._goodsFormPromise || typeof this._goodsFormPromise.then !== 'function') {
|
|
||||||
const loader = (this.getGoodsForm && typeof this.getGoodsForm === 'function')
|
|
||||||
? this.getGoodsForm()
|
|
||||||
: Promise.resolve();
|
|
||||||
this._goodsFormPromise = loader;
|
|
||||||
await loader;
|
|
||||||
} else {
|
|
||||||
await this._goodsFormPromise;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('await goods form promise failed', e);
|
|
||||||
}
|
|
||||||
// 第一次强制刷新 popup 内部组件布局 (nextTick), 确保表单完整渲染
|
|
||||||
// 兼容鸿蒙元服务: $nextTick 不可靠时降级到 setTimeout
|
|
||||||
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
|
|
||||||
// 微信小程序下 scroll-view 在首次以 transform 隐藏后再显示时, 内部子节点布局可能未刷新
|
|
||||||
// 双 nextTick 兜底, 保证表单完整可见
|
|
||||||
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
|
|
||||||
if (this.$refs.informationPopup && typeof this.$refs.informationPopup.open === 'function') {
|
|
||||||
this.$refs.informationPopup.open();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
//商品留言关闭
|
//商品留言关闭
|
||||||
closeinformationPopup(){
|
closeinformationPopup(){
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default {
|
|||||||
fontWeight: false,
|
fontWeight: false,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cartEvent: "detail",
|
cartEvent: "detail",
|
||||||
text: "",
|
text: "购买",
|
||||||
textColor: "#FFFFFF",
|
textColor: "#FFFFFF",
|
||||||
theme: "default",
|
theme: "default",
|
||||||
aroundRadius: 25,
|
aroundRadius: 25,
|
||||||
@@ -67,7 +67,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
this.config.text = this.$lang('goods.list.buyText') || this.$lang('buyNow');
|
this.config.text = this.$lang('goods.list.buyText');
|
||||||
|
|
||||||
this.$api.sendRequest({
|
this.$api.sendRequest({
|
||||||
url: '/api/lucky/status',
|
url: '/api/lucky/status',
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
<view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1">
|
<view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1">
|
||||||
<text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text>
|
<text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text>
|
||||||
<view class="box text-overflow"@click="openSiteCoupon(calculateGoodsData.merch_id)">
|
<view class="box text-overflow"@click="openSiteCoupon(calculateGoodsData.merch_id)">
|
||||||
<template v-if="(orderCreateData.coupon && orderCreateData.coupon[calculateGoodsData.merch_id] && orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id)">
|
<template v-if="orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id">
|
||||||
<text class="money price-font" style="max-width: 100%;">{{ $lang('order.payment.orderMoney.couponSelected', { count: 1 }) }}</text>
|
<text class="money price-font" style="max-width: 100%;">{{ $lang('order.payment.orderMoney.couponSelected', { count: 1 }) }}</text>
|
||||||
<!-- <text class="unit price-font">优惠</text>
|
<!-- <text class="unit price-font">优惠</text>
|
||||||
<text class="money price-font">{{ (calculateData && calculateData.coupon_money ? calculateData.coupon_money : 0) | moneyFormat }}</text> -->
|
<text class="money price-font">{{ (calculateData && calculateData.coupon_money ? calculateData.coupon_money : 0) | moneyFormat }}</text> -->
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
<view v-if="couponItem.type == 'discount' && couponItem.discount_limit > 0" class="limit">{{ $lang('order.payment.couponPopup.maxDiscount', { amount: couponItem.discount_limit }) }}</view>
|
<view v-if="couponItem.type == 'discount' && couponItem.discount_limit > 0" class="limit">{{ $lang('order.payment.couponPopup.maxDiscount', { amount: couponItem.discount_limit }) }}</view>
|
||||||
<view class="time font-size-goods-tag">{{ $lang('order.payment.couponPopup.validPeriod') }}{{ couponItem.end_time ? $util.timeStampTurnTime(couponItem.end_time) : $lang('order.payment.couponPopup.permanentValid') }}</view>
|
<view class="time font-size-goods-tag">{{ $lang('order.payment.couponPopup.validPeriod') }}{{ couponItem.end_time ? $util.timeStampTurnTime(couponItem.end_time) : $lang('order.payment.couponPopup.permanentValid') }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="iconfont" :class="selectCouponMap[merchCoupon.merch_id] == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
|
<view class="iconfont" :class="selectCouponId == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ export default {
|
|||||||
merch_id: 0,
|
merch_id: 0,
|
||||||
data: []
|
data: []
|
||||||
},
|
},
|
||||||
// 按 merch_id 隔离,避免多商户之间互相污染
|
selectCouponId:0
|
||||||
selectCouponMap: {}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
inject: ['promotion'],
|
inject: ['promotion'],
|
||||||
@@ -86,7 +85,7 @@ export default {
|
|||||||
goodsData() {
|
goodsData() {
|
||||||
if (this.paymentData) {
|
if (this.paymentData) {
|
||||||
this.paymentData.goods_list.forEach(item => {
|
this.paymentData.goods_list.forEach(item => {
|
||||||
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||||
})
|
})
|
||||||
return this.paymentData;
|
return this.paymentData;
|
||||||
}
|
}
|
||||||
@@ -94,7 +93,7 @@ export default {
|
|||||||
calculateGoodsData() {
|
calculateGoodsData() {
|
||||||
if (this.calculateData) {
|
if (this.calculateData) {
|
||||||
this.calculateData.goods_list.forEach(item => {
|
this.calculateData.goods_list.forEach(item => {
|
||||||
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||||
})
|
})
|
||||||
return this.calculateData;
|
return this.calculateData;
|
||||||
}
|
}
|
||||||
@@ -190,7 +189,7 @@ export default {
|
|||||||
//多商户商品分类
|
//多商户商品分类
|
||||||
goodsSpecFormat(goods_list){
|
goodsSpecFormat(goods_list){
|
||||||
goods_list.forEach(item => {
|
goods_list.forEach(item => {
|
||||||
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
|
||||||
})
|
})
|
||||||
return goods_list
|
return goods_list
|
||||||
},
|
},
|
||||||
@@ -250,11 +249,7 @@ export default {
|
|||||||
this.modules = data.modules;
|
this.modules = data.modules;
|
||||||
|
|
||||||
//记录多商户优惠券
|
//记录多商户优惠券
|
||||||
// 兜底为对象,避免后续被当作数组处理
|
this.orderCreateData.coupon = data.coupon
|
||||||
if (!data.coupon || typeof data.coupon !== 'object' || Array.isArray(data.coupon)) {
|
|
||||||
data.coupon = {};
|
|
||||||
}
|
|
||||||
this.orderCreateData.coupon = data.coupon;
|
|
||||||
|
|
||||||
//查询优惠券
|
//查询优惠券
|
||||||
this.getCouponList();
|
this.getCouponList();
|
||||||
@@ -370,17 +365,7 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
//多商户
|
//多商户
|
||||||
// 保留用户已选的 coupon_id,后端回传的 res.data.coupon 默认都是 0 会覆盖掉
|
this.orderCreateData.coupon = res.data.coupon
|
||||||
const prevCoupon = (this.orderCreateData.coupon && typeof this.orderCreateData.coupon === 'object' && !Array.isArray(this.orderCreateData.coupon)) ? this.orderCreateData.coupon : {};
|
|
||||||
this.orderCreateData.coupon = res.data.coupon || {};
|
|
||||||
if (this.orderCreateData.coupon && typeof this.orderCreateData.coupon === 'object' && !Array.isArray(this.orderCreateData.coupon)) {
|
|
||||||
Object.keys(prevCoupon).forEach(merch_id => {
|
|
||||||
const chosen = prevCoupon[merch_id] && prevCoupon[merch_id].coupon_id;
|
|
||||||
if (chosen && (!this.orderCreateData.coupon[merch_id] || !this.orderCreateData.coupon[merch_id].coupon_id)) {
|
|
||||||
this.orderCreateData.coupon[merch_id] = { coupon_id: chosen };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
|
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
|
||||||
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
|
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
|
||||||
|
|
||||||
@@ -746,52 +731,44 @@ export default {
|
|||||||
this.tempData = null;
|
this.tempData = null;
|
||||||
},
|
},
|
||||||
//优惠券单独多商户
|
//优惠券单独多商户
|
||||||
/**
|
/**
|
||||||
* 显示店铺优惠券信息
|
* 显示店铺优惠券信息
|
||||||
* @param {Object} siteId
|
* @param {Object} siteId
|
||||||
* @param {Object} couponData
|
* @param {Object} couponData
|
||||||
*/
|
*/
|
||||||
openSiteCoupon(merch_id) {
|
openSiteCoupon(merch_id) {
|
||||||
|
|
||||||
this.tempData = this.$util.deepClone(this.orderCreateData);
|
this.tempData = this.$util.deepClone(this.orderCreateData);
|
||||||
this.merchCoupon.merch_id = merch_id;
|
this.merchCoupon.merch_id = merch_id;
|
||||||
// 从当前订单已选的 coupon_id 取值,没有则取弹窗里缓存的,再没有为 0
|
this.selectCouponId = this.orderCreateData.coupon[merch_id].coupon_id;
|
||||||
const exist = this.orderCreateData.coupon && this.orderCreateData.coupon[merch_id];
|
// this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
|
||||||
const cached = this.selectCouponMap[merch_id] || 0;
|
|
||||||
this.selectCouponMap[merch_id] = exist ? (exist.coupon_id || 0) : cached;
|
this.merchCoupon.data = this.coupon_list[merch_id];
|
||||||
// this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
|
this.$refs.couponPopup.open();
|
||||||
|
this.$forceUpdate();
|
||||||
// 兜底: 列表为空时给一个空数组,弹窗不会渲染空指针
|
},
|
||||||
this.merchCoupon.data = (this.coupon_list && this.coupon_list[merch_id]) || [];
|
/**商户选择券*/
|
||||||
this.$refs.couponPopup.open();
|
selectCoupon(data,merch_id) {
|
||||||
this.$forceUpdate();
|
|
||||||
},
|
// if (this.orderCreateData.coupon[merch_id].coupon_id == data.coupon_id){
|
||||||
/**商户选择券*/
|
// this.orderCreateData.coupon[merch_id].coupon_id = 0
|
||||||
selectCoupon(data, merch_id) {
|
// }else{
|
||||||
|
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
||||||
// if (this.orderCreateData.coupon[merch_id].coupon_id == data.coupon_id){
|
// };
|
||||||
// this.orderCreateData.coupon[merch_id].coupon_id = 0
|
if (this.selectCouponId != data.coupon_id) {
|
||||||
// }else{
|
this.selectCouponId = data.coupon_id;
|
||||||
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
} else {
|
||||||
// };
|
this.selectCouponId = 0;
|
||||||
const cur = this.selectCouponMap[merch_id] || 0;
|
}
|
||||||
this.selectCouponMap[merch_id] = (cur != data.coupon_id) ? data.coupon_id : 0;
|
this.$forceUpdate();
|
||||||
this.$forceUpdate();
|
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
||||||
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
|
},
|
||||||
},
|
//确定使用优惠券
|
||||||
//确定使用优惠券
|
useCpopon() {
|
||||||
useCpopon() {
|
this.orderCreateData.coupon[this.merchCoupon.merch_id].coupon_id = this.selectCouponId;
|
||||||
const merch_id = this.merchCoupon.merch_id;
|
this.$refs.couponPopup.close();
|
||||||
// 确保 orderCreateData.coupon 是对象结构,后端按 merch_id 取值
|
this.calculate();
|
||||||
if (!this.orderCreateData.coupon || typeof this.orderCreateData.coupon !== 'object' || Array.isArray(this.orderCreateData.coupon)) {
|
},
|
||||||
this.orderCreateData.coupon = {};
|
|
||||||
}
|
|
||||||
this.orderCreateData.coupon[merch_id] = {
|
|
||||||
coupon_id: this.selectCouponMap[merch_id] || 0
|
|
||||||
};
|
|
||||||
this.$refs.couponPopup.close();
|
|
||||||
this.calculate();
|
|
||||||
},
|
|
||||||
|
|
||||||
//-------------------
|
//-------------------
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
fontWeight: false,
|
fontWeight: false,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cartEvent: "detail",
|
cartEvent: "detail",
|
||||||
text: '',
|
text: this.$lang('merch.list.buyText'),
|
||||||
textColor: "#FFFFFF",
|
textColor: "#FFFFFF",
|
||||||
theme: "default",
|
theme: "default",
|
||||||
aroundRadius: 25,
|
aroundRadius: 25,
|
||||||
@@ -66,7 +66,6 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
this.config.text = this.$lang('merch.list.buyText');
|
|
||||||
this.categoryId = options.category_id || 0;
|
this.categoryId = options.category_id || 0;
|
||||||
this.keyword = options.keyword || '';
|
this.keyword = options.keyword || '';
|
||||||
this.coupon = options.coupon || 0;
|
this.coupon = options.coupon || 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user