Files
lucky_shop/components/hover-nav/hover-nav.vue

1325 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 端需要隐藏悬浮按钮的页面路由(仅 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;
},
// H5 端在指定页面隐藏悬浮按钮(商品分类 / 电子名片)
h5HiddenOnRoute() {
// #ifdef H5
const r = this.currentRoute;
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
// #endif
// #ifndef H5
return false;
// #endif
},
// 总开关 + 强制隐藏 + 页面范围 判定结果
fbVisible() {
if (this.h5HiddenOnRoute) return false; // H5 指定页面直接隐藏
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 跑马灯): 把悬浮按钮整体放到公告栏"正下方",
// 按钮 top 紧贴公告栏底边之下,互不遮挡。公告栏是页面流/固定底部的 DIY
// 组件,位置与高度动态,这里用实时测量的视口坐标计算其底边距视口底部的距离。
const rect = this.noticeRect;
let target = null;
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
const distFromBottom = (this.winH - rect.bottom) * scaleInv; // 公告栏底边距视口底部
// 按钮 bottom = 公告栏底边距 + 间距 - 按钮自身高度 → 按钮 top 落在公告栏底边下方 gap 处
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
// 只在挂载时测量一次公告栏位置, 算出固定的 bottom 后:
// - 不监听 scroll —— 否则每次滚动都重测并重算 containerBottom, 按钮会上下跳动
// - 仅监听 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,
'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;
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;
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
* 直接固定 72x72 更稳。 */
width: 72rpx;
height: 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>