1343 lines
55 KiB
Vue
1343 lines
55 KiB
Vue
<template>
|
||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||
<view>
|
||
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
||
:style="[customContainerStyle, {
|
||
height: containerHeight,
|
||
width: containerWidth,
|
||
bottom: containerBottom,
|
||
right: containerRight,
|
||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||
backgroundSize: 'cover'
|
||
}]">
|
||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||
<view
|
||
v-if="isLanguageSwitchEnabled && hasLangButton"
|
||
class="btn-item common-bg btn-lang"
|
||
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
|
||
@click="handleLangButtonClick"
|
||
>
|
||
<view class="lang-btn-text">{{ langButtonText }}</view>
|
||
</view>
|
||
<!-- 微信官方客服按钮-->
|
||
<button
|
||
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
||
class="btn-item common-bg btn-kefu"
|
||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||
open-type="contact"
|
||
>
|
||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||
</button>
|
||
|
||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||
<view
|
||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||
class="btn-item common-bg btn-kefu"
|
||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||
@click="handleUnifiedKefuClick"
|
||
>
|
||
<image
|
||
v-if="currentKefuImg"
|
||
:src="currentKefuImg"
|
||
mode="aspectFill"
|
||
class="kefu-img"
|
||
show-menu-by-longpress></image>
|
||
<text class="ai-icon" v-if="isAIKefu && !currentKefuImg">🤖</text>
|
||
<text class="ai-icon" v-if="isWxworkKefu && !currentKefuImg">💬</text>
|
||
<text class="ai-icon" v-if="!isAIKefu && !isWxworkKefu && !currentKefuImg">👤</text>
|
||
</view>
|
||
|
||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||
<view
|
||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||
class="btn-item common-bg btn-kefu"
|
||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||
@click="handleUnifiedKefuClick"
|
||
>
|
||
<image
|
||
v-if="currentKefuImg"
|
||
:src="currentKefuImg"
|
||
mode="aspectFill"
|
||
class="kefu-img"
|
||
show-menu-by-longpress></image>
|
||
<text class="ai-icon" v-if="isAIKefu && !currentKefuImg">🤖</text>
|
||
<text class="ai-icon" v-if="isWxworkKefu && !currentKefuImg">💬</text>
|
||
<text class="ai-icon" v-if="!isAIKefu && !isWxworkKefu && !currentKefuImg">👤</text>
|
||
</view>
|
||
|
||
<!-- 小程序系统客服按钮 -->
|
||
<button
|
||
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
|
||
class="btn-item common-bg btn-kefu"
|
||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||
open-type="contact"
|
||
>
|
||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||
</button>
|
||
|
||
<!-- 电话按钮 -->
|
||
<view
|
||
v-if="hasPhoneButton"
|
||
class="btn-item common-bg btn-phone"
|
||
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||
@click="call"
|
||
>
|
||
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
||
</view>
|
||
|
||
</view>
|
||
|
||
<!-- 语言选择弹窗(底部弹出) -->
|
||
<view v-if="showLangPopup" class="lang-mask" @click="closeLangPicker">
|
||
<view class="lang-panel" @click.stop>
|
||
<view class="lang-panel-title">{{ i18n.langPickerTitle }}</view>
|
||
<view
|
||
v-for="(item, idx) in langList"
|
||
:key="item.value"
|
||
class="lang-item"
|
||
:class="{ 'lang-item-active': idx === currentLangIndex }"
|
||
@click="selectLanguage(idx)"
|
||
>
|
||
<text class="lang-item-name">{{ item.name }}</text>
|
||
<text v-if="idx === currentLangIndex" class="lang-item-check">✓</text>
|
||
</view>
|
||
<view class="lang-cancel" @click="closeLangPicker">{{ i18n.langPickerCancel }}</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { createCustomerService } from '@/common/js/customer-service.js';
|
||
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
||
import {
|
||
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
|
||
resolveLangButtonText, isLangButtonVisible,
|
||
} from '@/common/js/floating-button.js';
|
||
|
||
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
||
// key: 语言 value,value: 显示短码(按钮上展示)
|
||
const LANG_DISPLAY_NAMES = {
|
||
'zh-cn': 'CN',
|
||
'zh-tw': 'TW',
|
||
'zh-hk': 'HK',
|
||
'en-us': 'EN',
|
||
'en-gb': 'EN',
|
||
'ja-jp': 'JP',
|
||
'ko-kr': 'KR',
|
||
'fr-fr': 'FR',
|
||
'de-de': 'DE',
|
||
'es-es': 'ES',
|
||
'ru-ru': 'RU',
|
||
'th-th': 'TH',
|
||
'vi-vn': 'VN',
|
||
'id-id': 'ID',
|
||
'ar-ae': 'AR',
|
||
'pt-pt': 'PT',
|
||
'ms-my': 'MY',
|
||
};
|
||
|
||
/**
|
||
* 把语言 value(如 'en-us')转成展示短码(如 'EN')
|
||
* 1) 优先精确匹配 customMap(后端下发,可覆盖默认值)
|
||
* 2) 再回退到 LANG_DISPLAY_NAMES(前端默认)
|
||
* 3) 前缀匹配(后端 value 写了 'en' 而不是 'en-us' 也能匹配)
|
||
* 4) 兜底返回大写前缀
|
||
*
|
||
* @param {String} langValue 语言 value
|
||
* @param {Object} [customMap] 后端下发的覆盖映射(优先级最高)
|
||
*/
|
||
function getLangShortCode(langValue, customMap) {
|
||
if (!langValue) return '';
|
||
// 1) 后端覆盖 > 2) 默认映射
|
||
if (customMap && customMap[langValue]) return customMap[langValue];
|
||
if (LANG_DISPLAY_NAMES[langValue]) return LANG_DISPLAY_NAMES[langValue];
|
||
// 3) 前缀匹配:先后端,再默认
|
||
const prefix = String(langValue).split('-')[0].toLowerCase();
|
||
if (customMap) {
|
||
for (const key of Object.keys(customMap)) {
|
||
if (key.startsWith(prefix + '-')) return customMap[key];
|
||
}
|
||
}
|
||
for (const key of Object.keys(LANG_DISPLAY_NAMES)) {
|
||
if (key.startsWith(prefix + '-')) return LANG_DISPLAY_NAMES[key];
|
||
}
|
||
// 4) 兜底
|
||
return prefix.toUpperCase();
|
||
}
|
||
|
||
// 跨页面/重挂载复用的悬浮按钮避让值(设计稿 rpx)。
|
||
// H5 切换页面时 hover-nav 会重新挂载并重新测量公告栏: 无公告栏的页面(如分类页)
|
||
// noticeRect 为 null, 若直接回落基础位置, 会与首页"公告下方"位置不一致,
|
||
// 切回首页时按钮突然上/下跳。缓存上次算出的避让值, 让所有页面位置恒定。
|
||
let cachedAvoidBottom = null;
|
||
|
||
// 需要隐藏悬浮按钮的页面路由(全平台生效: H5 + 小程序)
|
||
// /pages_goods/category 商品分类
|
||
// /pages_tool/contact/contact 电子名片(名片)
|
||
const H5_HIDDEN_ROUTES = [
|
||
'/pages_goods/category',
|
||
'/pages_tool/contact/contact',
|
||
];
|
||
|
||
export default {
|
||
name: 'hover-nav',
|
||
mixins: [i18nLazyMixin],
|
||
props: {
|
||
need: { type: Boolean, default: false }
|
||
},
|
||
data() {
|
||
return {
|
||
pageCount: 0,
|
||
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
|
||
shopInfo: null,
|
||
currentLangIndex: 0,
|
||
langIndexMap: {},
|
||
langList: [],
|
||
langDisplayMap: { ...LANG_DISPLAY_NAMES }, // 后端 langDisplayNames 会覆盖默认值
|
||
showLangPopup: false, // 语言选择弹窗显隐
|
||
customerService: null,
|
||
// ============================================================
|
||
// 布局参数(从 App.vue 的 :root CSS 变量读取,权威源在 App.vue)
|
||
// 关键设计:
|
||
// 1) data 中给 fallback 默认值(46/72/272/100),保证小程序/未挂载时也能用
|
||
// 2) H5 下 mounted() 中从 getComputedStyle 读 :root 上的 CSS 变量覆盖,
|
||
// 这样改 App.vue 的 CSS 变量,JS 端会自动跟随(真正的"动态化")
|
||
// 3) containerRight / containerBottom 公式都基于这 4 个变量
|
||
// ============================================================
|
||
layoutTopRight: 46, // --top-btn-right
|
||
layoutTopBottom: 272, // --top-btn-bottom
|
||
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
||
layoutFloatingWidth: 100, // --floating-btn-width
|
||
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
|
||
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
|
||
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
|
||
noticeRect: null, // {top,bottom,height} 视口坐标(px)
|
||
winW: 0, // 视口宽(px)
|
||
winH: 0, // 视口高(px)
|
||
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||
i18n: {
|
||
langPickerTitle: '',
|
||
langPickerCancel: '',
|
||
},
|
||
};
|
||
},
|
||
computed: {
|
||
bgUrl() {
|
||
return this.shopInfo?.bgUrl || '';
|
||
},
|
||
aiAgentimg() {
|
||
return this.shopInfo?.aiAgentimg || '';
|
||
},
|
||
kefuimg() {
|
||
return this.shopInfo?.kefuimg || this.$util.getDefaultImage().kefu;
|
||
},
|
||
phoneimg() {
|
||
return this.shopInfo?.phoneimg || this.$util.getDefaultImage().phone;
|
||
},
|
||
tel() {
|
||
return this.shopInfo?.mobile || '';
|
||
},
|
||
// 是否启用语言切换:
|
||
// 1) 依赖 shopInfo.ischina(后端业务开关)
|
||
// 2) 且可用语言数 >= 2(只有 1 种语言时不需要切)
|
||
isLanguageSwitchEnabled() {
|
||
const info = this.$store && this.$store.state ? (this.$store.state.shopInfo || this.shopInfo) : this.shopInfo;
|
||
return !!(info && info.ischina) && Array.isArray(this.langList) && this.langList.length >= 2;
|
||
},
|
||
// 按钮上显示的文案:始终展示当前选中语言的短码
|
||
// 短码来源:initLanguage 已用合并后的 langDisplayMap 填充到 langList[i].name,
|
||
// 优先用后端 langDisplayNames,否则用前端 LANG_DISPLAY_NAMES
|
||
currentLangDisplayName() {
|
||
const list = this.langList;
|
||
if (!Array.isArray(list) || list.length === 0) return '';
|
||
const currentItem = list[this.currentLangIndex];
|
||
if (!currentItem) return '';
|
||
return currentItem.name || getLangShortCode(currentItem.value, this.langDisplayMap);
|
||
},
|
||
customContainerStyle() {
|
||
return this.shopInfo?.floatingButton?.container || {};
|
||
},
|
||
customButtonStyle() {
|
||
return this.shopInfo?.floatingButton?.button || {};
|
||
},
|
||
// ============================================================
|
||
// 浮动侧边栏配置消费 (来源: /api/config/init → data.floating_button,
|
||
// store/index.js 的 floatingButtonConfig, 初始 state 已声明 → 响应式)
|
||
// fbConfig 为 null (老缓存响应缺字段) 时, 各判定全部走老行为兜底。
|
||
// ============================================================
|
||
// 接口下发的浮动侧边栏配置 (老缓存响应 → null)
|
||
fbConfig() {
|
||
return (this.$store && this.$store.state && this.$store.state.floatingButtonConfig) || null;
|
||
},
|
||
// 指定页面隐藏悬浮按钮(商品分类 / 电子名片), 全平台生效
|
||
hiddenOnRoute() {
|
||
const r = this.currentRoute;
|
||
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
|
||
},
|
||
// 总开关 + 强制隐藏 + 页面范围 判定结果
|
||
fbVisible() {
|
||
if (this.hiddenOnRoute) return false; // 指定页面直接隐藏
|
||
return shouldShowOnPage(this.fbConfig, this.currentRoute, true);
|
||
},
|
||
// 最终要渲染的按钮 (已按 button_order 排序 + 显隐过滤); null = 用老行为
|
||
visibleButtons() {
|
||
return resolveVisibleButtons(this.fbConfig);
|
||
},
|
||
hasAnyButton() {
|
||
const b = this.visibleButtons;
|
||
return b ? b.length > 0 : true; // 老行为: 电话按钮恒显
|
||
},
|
||
hasLangButton() {
|
||
const b = this.visibleButtons;
|
||
return b ? b.indexOf('lang') !== -1
|
||
: isLangButtonVisible(this.fbConfig, this.shopInfo, this.langList.length);
|
||
},
|
||
hasKefuButton() {
|
||
const b = this.visibleButtons;
|
||
return b ? b.indexOf('kefu') !== -1 : true;
|
||
},
|
||
hasPhoneButton() {
|
||
const b = this.visibleButtons;
|
||
return b ? b.indexOf('phone') !== -1 : true;
|
||
},
|
||
// 语言按钮文案: 商家可配「目标语言 / 当前语言 / 固定文案」。
|
||
// 配置缺失时回退老行为 currentLangDisplayName(展示当前语言)。
|
||
langButtonText() {
|
||
if (!this.fbConfig) return this.currentLangDisplayName;
|
||
return resolveLangButtonText(this.fbConfig, this.langList, this.currentLangIndex);
|
||
},
|
||
// 按钮渲染顺序 (button_order 自上而下); .fixed-box 是 flex-direction: column,
|
||
// 用 CSS order 控制三段(语言/客服/电话)的先后, 配置缺失时退回老顺序。
|
||
orderIndex() {
|
||
const order = (this.fbConfig && Array.isArray(this.fbConfig.button_order))
|
||
? this.fbConfig.button_order
|
||
: ['lang', 'kefu', 'phone'];
|
||
const map = {};
|
||
for (let i = 0; i < order.length; i++) map[order[i]] = i + 1;
|
||
map.lang = map.lang || 1; map.kefu = map.kefu || 2; map.phone = map.phone || 3;
|
||
return map;
|
||
},
|
||
// 所有 .btn-item 的 inline-style 兜底:
|
||
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
||
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
||
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
||
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
|
||
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
|
||
// hover_nav_lang_btn_size / hover_nav_lang_size
|
||
// hover_nav_kefu_btn_size / hover_nav_kefu_size
|
||
// hover_nav_phone_btn_size / hover_nav_phone_size
|
||
// 未配置时回退 72rpx(与原行为一致)。
|
||
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
|
||
unreadCount() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.getUnreadCount();
|
||
},
|
||
currentKefuImg() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.getKefuButtonIcon();
|
||
},
|
||
isWeappOfficialKefu() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.isWeappOfficialKefu();
|
||
},
|
||
isAIKefu() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.isAIKefu();
|
||
},
|
||
isWxworkKefu() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.isWxworkKefu();
|
||
},
|
||
isThirdKefu() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.isThirdKefu();
|
||
},
|
||
showWeappSystemKefu() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.shouldShowExtraWeappSystemKefu();
|
||
},
|
||
hasValidKefuConfig() {
|
||
const customerService = createCustomerService(this);
|
||
return customerService.hasValidKefuConfig();
|
||
},
|
||
|
||
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
|
||
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
|
||
renderedBtnSizes() {
|
||
const b = this.visibleButtons;
|
||
if (b && b.length === 0) return [];
|
||
|
||
const sizes = [];
|
||
if (this.isLanguageSwitchEnabled && this.hasLangButton) {
|
||
sizes.push(this.btnSizeRpx('lang'));
|
||
}
|
||
if (this.hasKefuButton && this.hasValidKefuConfig) {
|
||
sizes.push(this.btnSizeRpx('kefu'));
|
||
// 小程序系统客服与客服主按钮并存时, 模板会多渲染一个按钮
|
||
if (this.showWeappSystemKefu && !this.isWeappOfficialKefu) {
|
||
sizes.push(this.btnSizeRpx('kefu'));
|
||
}
|
||
}
|
||
if (this.hasPhoneButton) {
|
||
sizes.push(this.btnSizeRpx('phone'));
|
||
}
|
||
return sizes;
|
||
},
|
||
// 根据显示的按钮数量与各自尺寸动态计算容器高度
|
||
// 每个按钮槽位 = 按钮尺寸 + 14rpx(上 margin) + 14rpx(下 margin)
|
||
// 容器 padding: 20rpx 上 + 20rpx 下 = 40rpx
|
||
// 旧公式 buttonCount*100+40 是"所有按钮 72rpx"的特例, 现按逐按钮实际尺寸累加
|
||
containerHeightNum() {
|
||
const sizes = this.renderedBtnSizes;
|
||
if (sizes.length === 0) return 0;
|
||
return sizes.reduce((sum, s) => sum + s + 28, 0) + 40;
|
||
},
|
||
containerHeight() {
|
||
return this.rpxStr(this.containerHeightNum);
|
||
},
|
||
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
|
||
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
|
||
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
|
||
containerWidthNum() {
|
||
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
|
||
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
|
||
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
|
||
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
|
||
},
|
||
containerWidth() {
|
||
return this.rpxStr(this.containerWidthNum);
|
||
},
|
||
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
||
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
||
// containerBottom = Top 顶部边界 + space
|
||
// ------------------------------------------------------------
|
||
// space 两端统一为 18rpx:
|
||
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
|
||
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
|
||
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
|
||
// ------------------------------------------------------------
|
||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||
// 两者会被安全区同步上推,space 保持不变
|
||
containerBottom() {
|
||
const space = 18;
|
||
const base = this.layoutTopBottom + this.layoutTopSize + space;
|
||
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
|
||
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
|
||
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
|
||
// 若直接 (winH - rect.bottom) 算, 滚动时按钮会跟着公告栏上下移动。
|
||
// 这里把 rect 加上当前滚动距离得到"公告栏在文档中的底边",
|
||
// 再算 (winH - 文档底边): 该值与滚动无关, 按钮固定在"页面顶部时公告栏正下方",
|
||
// 滚动时按钮静止(公告栏自行滚走), 不再来回滑动。
|
||
const rect = this.noticeRect;
|
||
let target = null;
|
||
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
|
||
// 当前滚动距离(H5: window.pageYOffset; 小程序无 window 时回退 0)
|
||
let scrollY = 0;
|
||
try {
|
||
if (typeof window !== 'undefined' && 'pageYOffset' in window) {
|
||
scrollY = window.pageYOffset || 0;
|
||
} else if (typeof document !== 'undefined' && document.documentElement) {
|
||
scrollY = document.documentElement.scrollTop || 0;
|
||
}
|
||
} catch (e) { scrollY = 0; }
|
||
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
|
||
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
|
||
// 公告栏底边在文档中的坐标 = 视口坐标 + 滚动距离(滚动无关, 恒定)
|
||
const docBottom = rect.bottom + scrollY;
|
||
const distFromBottom = (this.winH - docBottom) * scaleInv;
|
||
// 按钮 bottom = 文档底边距视口底部 + 间距 - 按钮自身高度
|
||
const below = distFromBottom - gap - this.containerHeightNum;
|
||
if (Number.isFinite(below)) {
|
||
target = Math.max(0, below);
|
||
cachedAvoidBottom = target; // 缓存: 跨页面/重挂载复用, 位置恒定
|
||
}
|
||
}
|
||
// 无公告栏的页面(如分类页)复用上一次算出的避让值, 使悬浮按钮在所有页面位置一致
|
||
if (target === null && cachedAvoidBottom !== null) target = cachedAvoidBottom;
|
||
return this.rpxStr(target === null ? base : target);
|
||
},
|
||
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
||
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
||
// 含义:让浮动容器中心落在 Top 按钮中心上
|
||
// 浮动中心 = containerRight + FloatingWidth/2
|
||
// Top 中心 = layoutTopRight + layoutTopSize/2
|
||
// 两者相等 → 中心对齐
|
||
// 例(46 + 36 - 50 = 32rpx)
|
||
// 注意:浮动容器 width(layoutFloatingWidth=100)比 Top(layoutTopSize=72)宽 28rpx,
|
||
// 无论怎么对齐都会有"左侧突出"现象。当前选 center 对齐,因为:
|
||
// - center 对齐(32rpx):左侧突出 14rpx,中心 0 错位 ← 当前
|
||
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
||
// center 对齐不仅中心精准,突出量也更小
|
||
containerRight() {
|
||
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
|
||
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
|
||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
|
||
return this.rpxStr(offset);
|
||
},
|
||
},
|
||
watch: {
|
||
// 监听本地缓存
|
||
shopInfo: {
|
||
handler(newVal) {
|
||
},
|
||
immediate: true
|
||
},
|
||
// 关键修复:
|
||
// store/index.js 初始 state 中**没有声明 shopInfo 字段**,
|
||
// setShopInfo 用的是 state.shopInfo = value 的直接赋值,Vue 2 对"后加属性"非响应式,
|
||
// 所以 watch('$store.state.shopInfo') / computed 都不会被触发(无效的依赖)。
|
||
//
|
||
// 正确做法:监听 initStatus(初始 state 已声明,是响应式的)。
|
||
// init 流程里 setShopInfo 在 setInitStatus(true) 之前调用,
|
||
// 所以 initStatus === true 时 shopInfo 一定已就绪。
|
||
'$store.state.initStatus': {
|
||
handler(newVal) {
|
||
if (newVal === true) {
|
||
const fromStore = this.$store && this.$store.state && this.$store.state.shopInfo;
|
||
if (fromStore) this.shopInfo = fromStore;
|
||
// 即便 store.shopInfo 不是响应式(属性后加),storage 是同步可读的,做兜底
|
||
if (!this.shopInfo) {
|
||
try {
|
||
const fromStorage = uni.getStorageSync('shopInfo');
|
||
if (fromStorage) this.shopInfo = fromStorage;
|
||
} catch (e) {}
|
||
}
|
||
// 关键:shopInfo 就绪后重新初始化 langList,
|
||
// 让 initLanguage 重新从 shopInfo.langList 读取并重算 currentLangIndex
|
||
// (created 阶段 shopInfo 可能还没到,那时只能兜底 $langConfig.list())
|
||
this.initLanguage();
|
||
}
|
||
},
|
||
immediate: true
|
||
},
|
||
// 关键修复(全新启动无缓存场景):
|
||
// 旧逻辑: mounted() 中同步读一次 $store.state.themeColor 写到 this.$el.style.cssText
|
||
// 但 App.vue 的 boot 是异步的,首屏渲染时主题色还没就绪 → 写入被跳过
|
||
// 之后主题色就绪,但 hover-nav 不在页面 DOM 树里(已被 Teleport 到 body),
|
||
// 且**没有任何 watch 监听 themeColor 变化** → cssText 永远不更新
|
||
// 结果: --hover-nav-bg-color / --hover-nav-text-color 等变量读不到,
|
||
// 按钮 background-color 退到 transparent,box-shadow 退到 none,
|
||
// 按钮视觉上"消失"或样式错乱
|
||
//
|
||
// 新逻辑:
|
||
// - 首次启动(无缓存,themeColor 异步就绪): watch 监听 themeColor 变化,
|
||
// 就绪时注入 cssText
|
||
// - 刷新(有缓存,themeColor 同步就绪): immediate: true 在 watcher 创建时触发,
|
||
// 但此时 this.$el 还不存在(_applyThemeColor return),watch 之后再无变化,
|
||
// 导致 cssText 永远不注入
|
||
// - 修复: 移除 immediate,改在 mounted() 中 Teleport 完成后手动调一次
|
||
// _applyThemeColor(currentTc),此时 $el 已就位,同步路径也能正确注入
|
||
'$store.state.themeColor': {
|
||
handler(newTc) {
|
||
this._applyThemeColor(newTc);
|
||
}
|
||
}
|
||
},
|
||
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
|
||
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
|
||
onShow() {
|
||
this.refreshRoute();
|
||
},
|
||
created() {
|
||
this.initLanguage();
|
||
this.refreshRoute();
|
||
|
||
// 三源兜底(store / 同步 storage / 异步 storage),
|
||
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
||
const fromStore = this.$store && this.$store.state && this.$store.state.shopInfo;
|
||
if (fromStore) {
|
||
this.shopInfo = fromStore;
|
||
} else {
|
||
// initStatus 已为 true 但 store.shopInfo 暂时还是空(极少见,理论上不会发生)
|
||
// 直接从 storage 兜底
|
||
if (this.$store && this.$store.state && this.$store.state.initStatus === true) {
|
||
try {
|
||
const sync = uni.getStorageSync('shopInfo');
|
||
if (sync) this.shopInfo = sync;
|
||
} catch (e) {}
|
||
} else {
|
||
// 冷启动或未初始化:同步读 storage(上次会话的缓存)
|
||
try {
|
||
const sync = uni.getStorageSync('shopInfo');
|
||
if (sync) {
|
||
this.shopInfo = sync;
|
||
} else {
|
||
uni.getStorage({
|
||
key: 'shopInfo',
|
||
success: (e) => { this.shopInfo = e.data; },
|
||
fail: () => { console.warn('[hover-nav] 未获取到 shopInfo,使用默认设置'); }
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('[hover-nav] 读取 shopInfo 异常:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化 customerService 实例
|
||
this.customerService = createCustomerService(this);
|
||
},
|
||
mounted() {
|
||
// 读取全局 CSS 变量(layout 参数),让 containerRight / containerBottom
|
||
// 可以跟着 App.vue 中的 :root 变量动态变化。
|
||
// H5 下 document.getComputedStyle 可用;小程序没有 document,
|
||
// 直接使用 data() 中的 fallback 默认值(46/72/272/100)。
|
||
// #ifdef H5
|
||
this.readLayoutVars();
|
||
// #endif
|
||
|
||
// ============================================================
|
||
// H5 下把 hover-nav 从页面 DOM 树搬到 document.body(Teleport)
|
||
// ------------------------------------------------------------
|
||
// 问题: 切换语言时 $langConfig.change() 内部调用 uni.reLaunch(),
|
||
// H5 的 reLaunch 会触发页面过渡动画(给页面容器加 transform)。
|
||
// CSS 规范: position: fixed 在 transform 父级下会失效,
|
||
// 变成相对于被 transform 的父级定位,导致 hover-nav 跟着
|
||
// 页面过渡一起"上移-下移"。
|
||
//
|
||
// 解决: 在 mounted() 中把 this.$el 搬到 document.body 下。
|
||
// 这样 hover-nav 不再是页面组件的子元素,页面过渡的 transform
|
||
// 完全不影响它。position: fixed 相对于 body(viewport) 正常工作。
|
||
//
|
||
// 副作用: 搬家后,页面根元素上的 CSS 变量(themeColor,如
|
||
// --hover-nav-bg-color)不再通过继承传递给 hover-nav,
|
||
// 因为 body 不在页面的 DOM 子树里。
|
||
// 修复: 由 watch('$store.state.themeColor') 监听主题色变化,
|
||
// 同步到 this.$el 的 inline style,让 hover-nav 自己持有这些 CSS 变量。
|
||
// 注意:必须在 mounted 中执行 Teleport,**之后** watch 才能找到
|
||
// 正确的 this.$el 元素;否则 watch 触发时,hover-nav 还在原父节点下,
|
||
// 主题色仍会通过页面继承自动应用,无需手动注入(但 Teleport 后会丢失)。
|
||
//
|
||
// 关键: mounted 末尾手动调一次 _applyThemeColor(currentTc),
|
||
// 解决刷新(有缓存,themeColor 同步就绪)场景下:
|
||
// - watch immediate 触发时 $el 不存在 → return
|
||
// - 之后 themeColor 已就绪、无变化 → watch 不再触发
|
||
// - → cssText 永远不注入
|
||
// 在这里调用能保证 $el 已就位,即使 themeColor 已有值也能正确注入。
|
||
//
|
||
// 注意: this.$el 是 Vue 组件的根元素(模板最外层的 <view>),
|
||
// 搬走它后 Vue 的响应式、事件、生命周期**完全不受影响**,
|
||
// 因为 Vue 的虚拟 DOM 与真实 DOM 位置解耦。
|
||
// 这是 Vue 2 的合法模式,等同于 Vue 3 的 <Teleport to="body">。
|
||
// ============================================================
|
||
// #ifdef H5
|
||
if (typeof document !== 'undefined' && document.body && this.$el) {
|
||
this._origParent = this.$el.parentNode;
|
||
document.body.appendChild(this.$el);
|
||
}
|
||
// #endif
|
||
|
||
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
||
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
||
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
||
|
||
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
|
||
this.updateViewport();
|
||
this.measureNotice();
|
||
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
|
||
this._noticeTimers = [
|
||
setTimeout(() => this.measureNotice(), 300),
|
||
setTimeout(() => this.measureNotice(), 800),
|
||
];
|
||
// #ifdef H5
|
||
// 不再监听 scroll: containerBottom 已改用「文档坐标」(视口坐标 + 滚动距离)计算,
|
||
// 得到与滚动无关的恒定 bottom —— 按钮固定在"页面顶部时公告栏正下方",
|
||
// 滚动时按钮静止、公告栏自行滚走, 不再来回滑动。
|
||
// 仅监听 resize(旋转/尺寸变化), 让按钮在视口尺寸变化时重新避让一次。
|
||
this._scrollHandler = this._onScroll.bind(this);
|
||
window.addEventListener('resize', this._scrollHandler);
|
||
// #endif
|
||
},
|
||
beforeDestroy() {
|
||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||
// #ifdef H5
|
||
if (this._origParent && this.$el && this.$el.parentNode === document.body) {
|
||
this._origParent.appendChild(this.$el);
|
||
this._origParent = null;
|
||
}
|
||
// 清理公告栏避让的滚动/缩放监听与定时器
|
||
if (this._scrollHandler) {
|
||
window.removeEventListener('scroll', this._scrollHandler);
|
||
window.removeEventListener('resize', this._scrollHandler);
|
||
this._scrollHandler = null;
|
||
}
|
||
if (this._noticeTimers) {
|
||
this._noticeTimers.forEach((t) => clearTimeout(t));
|
||
this._noticeTimers = null;
|
||
}
|
||
if (this._scrollRAF) {
|
||
cancelAnimationFrame(this._scrollRAF);
|
||
this._scrollRAF = null;
|
||
}
|
||
// #endif
|
||
},
|
||
methods: {
|
||
// ============================================================
|
||
// rpx 换算工具(内联样式单位跨端兼容的关键)
|
||
// ------------------------------------------------------------
|
||
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
|
||
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
|
||
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
|
||
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
|
||
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
|
||
// 小程序端保持 rpx 原样输出。
|
||
// ============================================================
|
||
// #ifdef H5
|
||
rpxNum(n) {
|
||
const n2 = Number(n) || 0;
|
||
try {
|
||
const w = (typeof window !== 'undefined' && window.innerWidth) || 375;
|
||
return (n2 * w) / 750;
|
||
} catch (e) {
|
||
return n2 / 2; // window 异常时按 375px 视口兜底
|
||
}
|
||
},
|
||
rpxStr(n) {
|
||
return this.rpxNum(n) + 'px';
|
||
},
|
||
// #endif
|
||
// #ifndef H5
|
||
rpxNum(n) {
|
||
return Number(n) || 0;
|
||
},
|
||
rpxStr(n) {
|
||
return (Number(n) || 0) + 'rpx';
|
||
},
|
||
// #endif
|
||
|
||
// ============================================================
|
||
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
|
||
// ------------------------------------------------------------
|
||
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
|
||
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
|
||
// containerBottom 据此把按钮放到公告栏底边之下。
|
||
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
|
||
// ============================================================
|
||
// 更新视口尺寸(换算 rpx 用)
|
||
updateViewport() {
|
||
try {
|
||
if (typeof window !== 'undefined' && window.innerWidth) {
|
||
this.winW = window.innerWidth;
|
||
this.winH = window.innerHeight;
|
||
} else {
|
||
const info = uni.getSystemInfoSync();
|
||
this.winW = info.windowWidth || 375;
|
||
this.winH = info.windowHeight || 667;
|
||
}
|
||
} catch (e) {
|
||
this.winW = this.winW || 375;
|
||
this.winH = this.winH || 667;
|
||
}
|
||
},
|
||
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
|
||
measureNotice() {
|
||
try {
|
||
const q = uni.createSelectorQuery();
|
||
q.select('.diy-notice').boundingClientRect((rect) => {
|
||
if (rect && rect.height > 0) {
|
||
this.noticeRect = {
|
||
top: rect.top,
|
||
bottom: rect.bottom,
|
||
height: rect.height,
|
||
};
|
||
} else {
|
||
this.noticeRect = null; // 无公告栏 → 基础定位
|
||
}
|
||
}).exec();
|
||
} catch (e) {
|
||
this.noticeRect = null;
|
||
}
|
||
},
|
||
// #ifdef H5
|
||
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
|
||
_onScroll() {
|
||
if (this._scrollRAF) return;
|
||
this._scrollRAF = requestAnimationFrame(() => {
|
||
this._scrollRAF = null;
|
||
this.updateViewport();
|
||
this.measureNotice();
|
||
});
|
||
},
|
||
// #endif
|
||
|
||
// 读取逐按钮尺寸 token(rpx 数值):
|
||
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
|
||
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
|
||
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
|
||
btnSizeRpx(type) {
|
||
let theme = null;
|
||
try {
|
||
theme = (this.$store && this.$store.state) ? this.$store.state.themeStyle : null;
|
||
} catch (e) { theme = null; }
|
||
const keys = ['hover_nav_' + type + '_btn_size', 'hover_nav_' + type + '_size'];
|
||
if (theme && typeof theme === 'object') {
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const raw = theme[keys[i]];
|
||
if (raw === undefined || raw === null || raw === '') continue;
|
||
const n = parseFloat(raw);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
}
|
||
}
|
||
return 72;
|
||
},
|
||
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
|
||
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
|
||
btnItemStyle(type) {
|
||
const s = this.rpxStr(this.btnSizeRpx(type));
|
||
return {
|
||
width: s,
|
||
height: s,
|
||
'min-width': s,
|
||
'min-height': s,
|
||
'max-width': s,
|
||
'max-height': s,
|
||
// 内部图标(.iconfont/.icox)通过该变量跟随按钮尺寸:
|
||
// 图标盒子里原本锁死 72rpx, 按钮调大后图标仍 72rpx,
|
||
// 又因列 flex 横轴默认贴左 → 图标偏在按钮左上角
|
||
'--btn-size': s,
|
||
'aspect-ratio': '1',
|
||
// border-radius 保留作 clip-path 不支持时的 fallback
|
||
'border-radius': '50%',
|
||
'flex-shrink': '0',
|
||
'flex-grow': '0',
|
||
'align-self': 'center',
|
||
'box-sizing': 'border-box',
|
||
padding: '0',
|
||
margin: this.rpxStr(14) + ' 0',
|
||
overflow: 'hidden',
|
||
};
|
||
},
|
||
// 刷新当前路由与页面栈深度(供 fbVisible 的页面范围判定 / pageCount 老判定)
|
||
refreshRoute() {
|
||
this.currentRoute = getCurrentRoute();
|
||
this.pageCount = getCurrentPages().length;
|
||
},
|
||
// #ifdef H5
|
||
/**
|
||
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
||
* 让 containerRight / containerBottom 真正动态化。
|
||
* 任意一个变量解析失败都保留 data 中原值(fallback),不抛错。
|
||
*/
|
||
readLayoutVars() {
|
||
try {
|
||
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
||
const root = document.documentElement;
|
||
const computed = getComputedStyle(root);
|
||
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
|
||
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
|
||
const scale = ((typeof window !== 'undefined' && window.innerWidth) || 375) / 750;
|
||
const getNum = (name, fallback) => {
|
||
const v = parseFloat(computed.getPropertyValue(name));
|
||
if (!Number.isFinite(v) || scale <= 0) return fallback;
|
||
return v / scale; // px → 设计稿 rpx 值
|
||
};
|
||
this.layoutTopRight = getNum('--top-btn-right', 46);
|
||
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
||
this.layoutTopSize = getNum('--top-btn-size', 72);
|
||
this.layoutFloatingWidth = getNum('--floating-btn-width', 100);
|
||
} catch (e) {
|
||
console.warn('[hover-nav] 读取 CSS 变量失败,使用 fallback 默认值:', e);
|
||
}
|
||
},
|
||
// #endif
|
||
|
||
/**
|
||
* 把 themeColor 注入到 this.$el 的 cssText,
|
||
* 让 Teleport 到 body 后仍能读到 --hover-nav-bg-color 等 CSS 变量。
|
||
*
|
||
* 关键设计:
|
||
* - 用 data._appliedThemeColor 记录"已注入的 themeColor",
|
||
* 再次触发时**先剥离**旧值,再追加新值,避免重复拼接。
|
||
* - 主题切换时,旧值要先剥离(否则 cssText 会一直累积,文件膨胀)。
|
||
* - themeColor 为空时只剥离、不追加(themeColorSet action
|
||
* 内部会去重,不会频繁触发)。
|
||
*/
|
||
_applyThemeColor(tc) {
|
||
if (!this.$el || !this.$el.style) return;
|
||
// 1) 剥离旧注入(避免重复 + 处理主题切换场景)
|
||
if (this._appliedThemeColor) {
|
||
const cur = this.$el.style.cssText || '';
|
||
const suffix = ';' + this._appliedThemeColor;
|
||
if (cur.endsWith(suffix)) {
|
||
this.$el.style.cssText = cur.slice(0, -suffix.length);
|
||
}
|
||
this._appliedThemeColor = '';
|
||
}
|
||
// 2) 追加新值
|
||
if (tc) {
|
||
this._appliedThemeColor = tc;
|
||
this.$el.style.cssText = (this.$el.style.cssText || '') + ';' + tc;
|
||
}
|
||
},
|
||
initLanguage() {
|
||
// 数据源优先级:
|
||
// 1) shopInfo.langList(后端按租户/店铺动态下发的语言配置,推荐)
|
||
// 2) $langConfig.list()(前端 config 兜底,目前是 ['zh-cn','en-us'])
|
||
const info = (this.$store && this.$store.state && this.$store.state.shopInfo) || this.shopInfo;
|
||
|
||
// 合并短码映射:后端 langDisplayNames 覆盖前端默认 LANG_DISPLAY_NAMES
|
||
// 例:后端下发 { "zh-cn": "中文", "ja-jp": "日本語" } 时,
|
||
// - zh-cn 用 "中文" 而不是 "CN"
|
||
// - en-us 仍用默认的 "EN"
|
||
const backendMap = (info && info.langDisplayNames && typeof info.langDisplayNames === 'object' && !Array.isArray(info.langDisplayNames))
|
||
? info.langDisplayNames
|
||
: null;
|
||
this.langDisplayMap = backendMap
|
||
? Object.assign({}, LANG_DISPLAY_NAMES, backendMap) // 后端优先
|
||
: Object.assign({}, LANG_DISPLAY_NAMES);
|
||
const displayMap = this.langDisplayMap;
|
||
|
||
let rawList = null;
|
||
if (info && Array.isArray(info.langList) && info.langList.length > 0) {
|
||
// shopInfo 下发的可能是字符串数组 ['zh-cn','en-us'] 或 {value,name} 对象数组,都兼容
|
||
rawList = info.langList.map(item => {
|
||
if (typeof item === 'string') {
|
||
return { value: item, name: getLangShortCode(item, displayMap) };
|
||
}
|
||
// 对象形式:有 name 字段直接用,否则用合并后的映射
|
||
// (后端配置的 name 原样使用,不经过 $lang 翻译)
|
||
return { value: item.value, name: item.name || getLangShortCode(item.value, displayMap) };
|
||
});
|
||
} else {
|
||
// 兜底:用静态的 langConfig.langList + getLangShortCode 拼 {value, name}
|
||
// 关键:不要用 this.$langConfig.list(),那会读取 lang/{lang}/common.js 里的
|
||
// common.name(已经过 $lang 翻译),与需求"后端配什么就显示什么"冲突
|
||
const fallbackList = (this.$langConfig && this.$langConfig.langList) || [];
|
||
rawList = fallbackList.map(value => ({
|
||
value,
|
||
name: getLangShortCode(value, displayMap),
|
||
}));
|
||
}
|
||
|
||
this.langList = rawList;
|
||
this.langIndexMap = {};
|
||
for (let i = 0; i < this.langList.length; i++) {
|
||
this.langIndexMap[i] = this.langList[i].value;
|
||
}
|
||
const savedLang = this.$langConfig.getCurrentLocale();
|
||
if (savedLang) {
|
||
for (let i = 0; i < this.langList.length; i++) {
|
||
if (this.langList[i].value === savedLang) {
|
||
this.currentLangIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
this.currentLangIndex = 0;
|
||
}
|
||
},
|
||
|
||
call() {
|
||
this.customerService.makePhoneCallByNumber(this.tel);
|
||
},
|
||
|
||
/**
|
||
* 按钮点击的统一入口:
|
||
* - ≥3 种语言:弹底部选择器,用户选哪项就切到哪项
|
||
* - =2 种语言:走原 hover-nav 的循环逻辑(切到下一个)
|
||
*/
|
||
handleLangButtonClick() {
|
||
const list = this.langList;
|
||
if (!Array.isArray(list) || list.length < 2) return;
|
||
if (list.length >= 3) {
|
||
this.openLangPicker();
|
||
} else {
|
||
this.toggleLanguage();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 原 hover-nav 的循环切换逻辑(只有 2 种语言时使用)
|
||
* 点击 → 切到下一个语言
|
||
*/
|
||
toggleLanguage() {
|
||
const list = this.langList;
|
||
if (!Array.isArray(list) || list.length === 0) {
|
||
console.warn('[hover-nav] 切换语言失败:langList 为空');
|
||
return;
|
||
}
|
||
// 循环到下一个语言
|
||
this.currentLangIndex = (this.currentLangIndex + 1) % list.length;
|
||
const targetLang = list[this.currentLangIndex] && list[this.currentLangIndex].value;
|
||
if (!targetLang) {
|
||
console.warn('[hover-nav] 切换语言失败:目标语种为空', this.langIndexMap, this.currentLangIndex);
|
||
return;
|
||
}
|
||
// 取当前页面路由(兼容 H5/Mini Program)
|
||
const pages = getCurrentPages();
|
||
const currentPage = pages[pages.length - 1];
|
||
const currentRoute = (currentPage && (currentPage.route || (currentPage.$page && currentPage.$page.path))) || 'pages/index/index';
|
||
const url = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
|
||
this.$langConfig.change(targetLang, url);
|
||
},
|
||
|
||
/**
|
||
* 打开语言选择器
|
||
*/
|
||
openLangPicker() {
|
||
if (!Array.isArray(this.langList) || this.langList.length < 2) {
|
||
// 只有 1 种语言(或 0 种)没必要打开选择器
|
||
return;
|
||
}
|
||
// 弹窗打开前一次性准备 i18n(此时 boot 已完成,$lang() 返回真实翻译)
|
||
this.prepareI18n();
|
||
this.showLangPopup = true;
|
||
},
|
||
|
||
/**
|
||
* i18n-lazy mixin 要求的方法:返回一个对象,key 与 data().i18n 字段一一对应
|
||
* 在 openLangPicker() 中调用,此时 Lang.boot() 已完成
|
||
*/
|
||
i18nPrepareFn() {
|
||
return {
|
||
langPickerTitle: this.$lang('components.hoverNav.langPickerTitle'),
|
||
langPickerCancel: this.$lang('components.hoverNav.langPickerCancel'),
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 关闭语言选择器
|
||
*/
|
||
closeLangPicker() {
|
||
this.showLangPopup = false;
|
||
},
|
||
|
||
/**
|
||
* 选择某个语言
|
||
* @param {Number} idx 在 langList 中的下标
|
||
*/
|
||
selectLanguage(idx) {
|
||
if (!Array.isArray(this.langList) || idx < 0 || idx >= this.langList.length) {
|
||
console.warn('[hover-nav] selectLanguage 越界:', idx, this.langList);
|
||
return;
|
||
}
|
||
// 同一个语言不切换
|
||
if (idx === this.currentLangIndex) {
|
||
this.showLangPopup = false;
|
||
return;
|
||
}
|
||
|
||
this.currentLangIndex = idx;
|
||
const targetLang = this.langList[idx].value;
|
||
this.showLangPopup = false;
|
||
|
||
// 取当前页面路由(兼容 H5/Mini Program)
|
||
const pages = getCurrentPages();
|
||
const currentPage = pages[pages.length - 1];
|
||
const currentRoute = (currentPage && (currentPage.route || (currentPage.$page && currentPage.$page.path))) || 'pages/index/index';
|
||
const url = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
|
||
|
||
// 使用统一的 $langConfig.change() 完成:
|
||
// 1) 写 storage + 失效缓存
|
||
// 2) 异步加载新语种 bundle/override
|
||
// 3) emitUpdated 触发 componentRefresh,所有 $lang() 重渲染
|
||
// 4) refresh 标题/tabbar
|
||
// 5) reLaunch 到当前路由,避免栈污染
|
||
this.$langConfig.change(targetLang, url);
|
||
},
|
||
|
||
/**
|
||
* 客服按钮点击的统一入口。
|
||
*
|
||
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
|
||
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
|
||
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
|
||
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
|
||
*
|
||
* 为什么不在 customer-service.js#openAiChatService 统一注入:
|
||
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
|
||
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
|
||
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
|
||
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
|
||
* 其他入口保持原样。
|
||
*
|
||
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
|
||
*/
|
||
handleUnifiedKefuClick() {
|
||
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
|
||
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
|
||
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
|
||
// 与底部 tabBar 无关,继续走原统一入口。
|
||
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
|
||
// 清除未读数(与 openAiChatService 行为保持一致)
|
||
if (typeof this.setAiUnreadCount === 'function') {
|
||
this.setAiUnreadCount(0);
|
||
} else if (this.$store && typeof this.$store.commit === 'function') {
|
||
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
|
||
}
|
||
|
||
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
|
||
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
|
||
uni.navigateTo({
|
||
url: aiChatUrl,
|
||
fail: (err) => {
|
||
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
|
||
// H5 兜底
|
||
// #ifdef H5
|
||
window.location.href = aiChatUrl;
|
||
// #endif
|
||
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
|
||
this.customerService.handleUnifiedKefuClick({
|
||
sendMessageTitle: '来自悬浮按钮的咨询',
|
||
sendMessagePath: '/pages/index/index'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.fixed-box {
|
||
position: fixed;
|
||
/* right 改由 inline-style(containerRight)动态计算,这里留旧值作 fallback */
|
||
right: 0rpx;
|
||
/* bottom 改由 inline-style(containerBottom)动态计算,这里留旧值作 fallback */
|
||
bottom: 240rpx;
|
||
/* #ifdef H5 */
|
||
bottom: 320rpx;
|
||
/* #endif */
|
||
z-index: 10;
|
||
border-radius: 120rpx;
|
||
padding: 20rpx 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
/* width 改用 CSS 变量驱动,与 App.vue 的 --floating-btn-width 保持同步;
|
||
* JS 端(containerRight)也读同一个变量计算水平 right,
|
||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||
width: var(--floating-btn-width, 100rpx);
|
||
box-sizing: border-box;
|
||
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
|
||
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
|
||
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
|
||
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
|
||
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
|
||
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
|
||
overflow: visible;
|
||
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
||
margin-bottom: var(--window-bottom);
|
||
}
|
||
|
||
.btn-item {
|
||
display: flex;
|
||
justify-content: center;
|
||
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
|
||
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
|
||
align-items: center;
|
||
text-align: center;
|
||
flex-direction: column;
|
||
line-height: 1;
|
||
margin: 14rpx 0;
|
||
transition: 0.1s;
|
||
color: var(--hover-nav-text-color);
|
||
border-radius: 50%;
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
/* 关键:微信小程序的 <button> 元素有默认 appearance/background/border,
|
||
* 必须显式重置,否则 72rpx 高度会被默认样式吃掉,渲染成椭圆。 */
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: transparent;
|
||
border: 0;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
/* CSS 端兜底:即使 inline-style 失效,也能保证 1:1 比例 */
|
||
aspect-ratio: 1;
|
||
/* 锁死宽高,防止父级 flex 容器压缩 */
|
||
min-width: 72rpx;
|
||
min-height: 72rpx;
|
||
max-width: 72rpx;
|
||
max-height: 72rpx;
|
||
flex-shrink: 0;
|
||
flex-grow: 0;
|
||
align-self: center;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.common-bg {
|
||
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 {
|
||
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
|
||
font-size: var(--hover-nav-font-size, 36rpx);
|
||
}
|
||
|
||
.iconfont,
|
||
.icox {
|
||
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
|
||
font-size: var(--hover-nav-font-size, 36rpx);
|
||
font-weight: bold;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
|
||
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
|
||
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
|
||
width: var(--btn-size, 72rpx);
|
||
height: var(--btn-size, 72rpx);
|
||
margin: 0;
|
||
border-radius: 50%;
|
||
padding: 0;
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
aspect-ratio: 1;
|
||
/* 立体效果:文字/图标本身与按钮同色,无法用普通阴影做出"凸起"。
|
||
* 这里用 text-shadow 做"凹陷雕刻"效果:
|
||
* - 上方一道更亮的阴影,模拟从顶部打光时文字上半部的高光;
|
||
* - 下方一道更暗的阴影,模拟文字下半部的背光。
|
||
* 两道同向偏移的小阴影叠加后,文字在按钮平面上被"压"出立体感。
|
||
* 用透明文字 + 阴影颜色与背景色相协调,保证按钮整体风格统一。 */
|
||
text-shadow:
|
||
0 1rpx 0 rgba(255, 255, 255, 0.45),
|
||
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
/* 逐按钮字号覆写: 后台 hover_nav_phone/kefu_font_size 可调, 未设时回退全局 --hover-nav-font-size */
|
||
.iconfont.icon-dianhua {
|
||
font-size: var(--hover-nav-phone-font-size, var(--hover-nav-font-size, 36rpx));
|
||
}
|
||
.icox.icox-kefu {
|
||
font-size: var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx));
|
||
}
|
||
|
||
.iconfont text,
|
||
.icox text {
|
||
font-size: var(--hover-nav-font-size, 36rpx);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.kefu-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.ai-icon {
|
||
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 ×1.11(原 40/36) */
|
||
font-size: calc(var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx)) * 1.11);
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 语言按钮内的短码文字(关键,之前缺失导致 CN/EN 文字撑大按钮变椭圆):
|
||
* 语言按钮的 inner 用 <view class="lang-btn-text"> 包裹文字,
|
||
* 如果没有这个规则,view 会用默认 font-size/line-height,
|
||
* "CN" / "EN" / "JP" 在中文/日文系统下会渲染成 32-40rpx 高,
|
||
* 撑高按钮导致 72rpx width × 38rpx height 的"伪正圆"实际是椭圆。 */
|
||
.lang-btn-text {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
/* 语言短码文字: 基准字号用 hover_nav_lang_font_size(回退全局 hover_nav_font_size), 文字实际按 0.667 缩放 */
|
||
font-size: calc(var(--hover-nav-lang-font-size, var(--hover-nav-font-size, 36rpx)) * 0.667);
|
||
line-height: 1;
|
||
font-weight: 600;
|
||
letter-spacing: 0;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: clip;
|
||
color: inherit;
|
||
font-weight: inherit;
|
||
/* 与 .iconfont 同一套凹陷雕刻式 text-shadow,保证语言文字也有立体感 */
|
||
text-shadow:
|
||
0 1rpx 0 rgba(255, 255, 255, 0.45),
|
||
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
/* 语言选择弹窗(底部弹出) */
|
||
.lang-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 999;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
}
|
||
.lang-panel {
|
||
width: 100%;
|
||
background: #fff;
|
||
border-radius: 24rpx 24rpx 0 0;
|
||
padding: 20rpx 0 40rpx;
|
||
box-sizing: border-box;
|
||
/* #ifdef H5 */
|
||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||
/* #endif */
|
||
}
|
||
.lang-panel-title {
|
||
text-align: center;
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
padding: 20rpx 0 24rpx;
|
||
border-bottom: 1rpx solid #eee;
|
||
}
|
||
.lang-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 28rpx 40rpx;
|
||
font-size: 30rpx;
|
||
color: #333;
|
||
border-bottom: 1rpx solid #f5f5f5;
|
||
}
|
||
.lang-item-active {
|
||
color: #1989fa;
|
||
font-weight: 600;
|
||
}
|
||
.lang-item-name {
|
||
flex: 1;
|
||
}
|
||
.lang-item-check {
|
||
color: #1989fa;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
}
|
||
.lang-cancel {
|
||
text-align: center;
|
||
padding: 28rpx 0;
|
||
margin-top: 16rpx;
|
||
color: #666;
|
||
font-size: 30rpx;
|
||
border-top: 1rpx solid #eee;
|
||
}
|
||
</style> |