@@ -4,6 +4,7 @@
< 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})` : '',
@@ -12,8 +13,8 @@
<!-- 多语言切换按钮 : 根据语言数智能切换交互 -- >
< view
v-if = "isLanguageSwitchEnabled && hasLangButton"
class = "btn-item common-bg"
: style = "[btnItemStyle, { order: orderIndex.lang }]"
class = "btn-item common-bg btn-lang "
: style = "[btnItemStyle('lang') , { order: orderIndex.lang }]"
@click ="handleLangButtonClick"
>
< view class = "lang-btn-text" > { { langButtonText } } < / view >
@@ -21,8 +22,8 @@
<!-- 微信官方客服按钮 -- >
< button
v-if = "hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
class = "btn-item common-bg"
: style = "[btnItemStyle, { order: orderIndex.kefu }]"
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 >
@@ -31,8 +32,8 @@
< ! - - 其他类型客服按钮 ( 智能客服 、 企业微信等 ) - - >
< view
v-if = "hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
class = "btn-item common-bg"
: style = "[btnItemStyle, { order: orderIndex.kefu }]"
class = "btn-item common-bg btn-kefu "
: style = "[btnItemStyle('kefu') , { order: orderIndex.kefu }]"
@click ="handleUnifiedKefuClick"
>
< image
@@ -49,8 +50,8 @@
< ! - - 智能客服按钮 ( 当同时显示小程序系统客服时 ) - - >
< view
v-if = "hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
class = "btn-item common-bg"
: style = "[btnItemStyle, { order: orderIndex.kefu }]"
class = "btn-item common-bg btn-kefu "
: style = "[btnItemStyle('kefu') , { order: orderIndex.kefu }]"
@click ="handleUnifiedKefuClick"
>
< image
@@ -67,8 +68,8 @@
< ! - - 小程序系统客服按钮 - - >
< button
v-if = "hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
class = "btn-item common-bg"
: style = "[btnItemStyle, { order: orderIndex.kefu }]"
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 >
@@ -77,8 +78,8 @@
< ! - - 电话按钮 - - >
< view
v-if = "hasPhoneButton"
class = "btn-item common-bg"
: style = "[btnItemStyle, { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
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 >
@@ -165,6 +166,20 @@ function getLangShortCode(langValue, customMap) {
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 ] ,
@@ -194,6 +209,12 @@ export default {
layoutTopBottom : 272 , // --top-btn-bottom
layoutTopSize : 72 , // --top-btn-size(Top 按钮正方形边长)
layoutFloatingWidth : 100 , // --floating-btn-width
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
noticeRect : null , // {top,bottom,height} 视口坐标(px)
winW : 0 , // 视口宽(px)
winH : 0 , // 视口高(px)
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
i18n : {
@@ -250,8 +271,19 @@ export default {
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 = 用老行为
@@ -296,28 +328,14 @@ export default {
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
btnItemStyle ( ) {
return {
width : '72rpx' ,
height : '72rpx' ,
'min-width' : '72rpx' ,
'min-height' : '72rpx' ,
'max-width' : '72rpx' ,
'max-height' : '72rpx' ,
'aspect-ratio' : '1' ,
// border-radius 保留作 clip-path 不支持时的 fallback
'border-radius' : '50%' ,
'flex-shrink' : '0' ,
'flex-grow' : '0' ,
'align-self' : 'center' ,
'box-sizing' : 'border-box' ,
padding : '0' ,
margin : '14rpx 0' ,
overflow : 'hidden' ,
} ;
} ,
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
// hover_nav_lang_btn_size / hover_nav_lang_size
// hover_nav_kefu_btn_size / hover_nav_kefu_size
// hover_nav_phone_btn_size / hover_nav_phone_size
// 未配置时回退 72rpx(与原行为一致)。
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
unreadCount ( ) {
const customerService = createCustomerService ( this ) ;
return customerService . getUnreadCount ( ) ;
@@ -351,53 +369,86 @@ export default {
return customerService . hasValidKefuConfig ( ) ;
} ,
//根据显示的按钮数量动态计算容器高度
// 计算明细(buttonCount 按钮场景):
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
// 总高度 (rpx):buttonCount × 100 + 40
// 新配置生效时按 visibleButtons 的实际按钮数算,避免隐藏按钮后留下空白档;
// 全部按钮被关掉时返回 0rpx(根节点 v-if 已含 hasAnyButton, 这里是双保险);
// 配置缺失(fbConfig 为 null)时保持老行为: 电话恒显 + 可选语言 + 可选系统客服。
// extraKefuSlot: 小程序系统客服与客服主按钮并存时模板会多渲染一个按钮,
// 需要为它多留一个槽位(fallback 分支与老逻辑等价)。
containerHeight ( ) {
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
renderedBtnSizes ( ) {
const b = this . visibleButtons ;
if ( b && b . length === 0 ) return '0rpx' ;
if ( b && b . length === 0 ) return [ ] ;
const extraKefuSlot = ( this . hasKefuButton && this . showWeappSystemKefu ) ? 1 : 0 ;
const buttonCount = b
? b . length + extraKefuSlot
: 1 + ( this . isLanguageSwitchEnabled ? 1 : 0 ) + extraKefuSlot ;
const totalRpx = buttonCount * 100 + 40 ;
return ` ${ totalRpx } rpx ` ;
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 是经验调优值(非数学推导),手调结果 :
// H5 : 18 * 5 = 90rpx
// MP-WEIXIN: 18 * 1 = 18rpx
// 为什么 H5 反而大、微信反而小:
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
// 18rpx 就能有清晰的呼吸感。
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
// space 两端统一为 18rpx :
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
// ------------------------------------------------------------
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
// 两者会被安全区同步上推,space 保持不变
containerBottom ( ) {
le t space = 18 * 5 ;
// #ifdef MP-WEIXIN
space = 18 * 1 ;
// #endif
return ` ${ this . layoutTopBottom + this . layoutTopSize + space } rpx ` ;
cons t 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
@@ -412,8 +463,10 @@ export default {
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
// center 对齐不仅中心精准,突出量也更小
containerRight ( ) {
const offset = this . layoutTopRight + this . layoutTopSize / 2 - this . layoutFloatingWidth / 2 ;
return ` ${ offset } rpx ` ;
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
const offset = this . layoutTopRight + this . layoutTopSize / 2 - this . containerWidthNum / 2 ;
return this . rpxStr ( offset ) ;
} ,
} ,
watch : {
@@ -571,6 +624,22 @@ export default {
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
this . _applyThemeColor ( this . $store && this . $store . state && this . $store . state . themeColor ) ;
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
this . updateViewport ( ) ;
this . measureNotice ( ) ;
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
this . _noticeTimers = [
setTimeout ( ( ) => this . measureNotice ( ) , 300 ) ,
setTimeout ( ( ) => this . measureNotice ( ) , 800 ) ,
] ;
// #ifdef H5
// 只在挂载时测量一次公告栏位置, 算出固定的 bottom 后:
// - 不监听 scroll —— 否则每次滚动都重测并重算 containerBottom, 按钮会上下跳动
// - 仅监听 resize(旋转/尺寸变化,极少触发), 让按钮在视口尺寸变化时重新避让
this . _scrollHandler = this . _onScroll . bind ( this ) ;
window . addEventListener ( 'resize' , this . _scrollHandler ) ;
// #endif
} ,
beforeDestroy ( ) {
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
@@ -579,9 +648,154 @@ export default {
this . _origParent . appendChild ( this . $el ) ;
this . _origParent = null ;
}
// 清理公告栏避让的滚动/缩放监听与定时器
if ( this . _scrollHandler ) {
window . removeEventListener ( 'scroll' , this . _scrollHandler ) ;
window . removeEventListener ( 'resize' , this . _scrollHandler ) ;
this . _scrollHandler = null ;
}
if ( this . _noticeTimers ) {
this . _noticeTimers . forEach ( ( t ) => clearTimeout ( t ) ) ;
this . _noticeTimers = null ;
}
if ( this . _scrollRAF ) {
cancelAnimationFrame ( this . _scrollRAF ) ;
this . _scrollRAF = null ;
}
// #endif
} ,
methods : {
// ============================================================
// rpx 换算工具(内联样式单位跨端兼容的关键)
// ------------------------------------------------------------
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
// 小程序端保持 rpx 原样输出。
// ============================================================
// #ifdef H5
rpxNum ( n ) {
const n2 = Number ( n ) || 0 ;
try {
const w = ( typeof window !== 'undefined' && window . innerWidth ) || 375 ;
return ( n2 * w ) / 750 ;
} catch ( e ) {
return n2 / 2 ; // window 异常时按 375px 视口兜底
}
} ,
rpxStr ( n ) {
return this . rpxNum ( n ) + 'px' ;
} ,
// #endif
// #ifndef H5
rpxNum ( n ) {
return Number ( n ) || 0 ;
} ,
rpxStr ( n ) {
return ( Number ( n ) || 0 ) + 'rpx' ;
} ,
// #endif
// ============================================================
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
// ------------------------------------------------------------
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
// containerBottom 据此把按钮放到公告栏底边之下。
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
// ============================================================
// 更新视口尺寸(换算 rpx 用)
updateViewport ( ) {
try {
if ( typeof window !== 'undefined' && window . innerWidth ) {
this . winW = window . innerWidth ;
this . winH = window . innerHeight ;
} else {
const info = uni . getSystemInfoSync ( ) ;
this . winW = info . windowWidth || 375 ;
this . winH = info . windowHeight || 667 ;
}
} catch ( e ) {
this . winW = this . winW || 375 ;
this . winH = this . winH || 667 ;
}
} ,
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
measureNotice ( ) {
try {
const q = uni . createSelectorQuery ( ) ;
q . select ( '.diy-notice' ) . boundingClientRect ( ( rect ) => {
if ( rect && rect . height > 0 ) {
this . noticeRect = {
top : rect . top ,
bottom : rect . bottom ,
height : rect . height ,
} ;
} else {
this . noticeRect = null ; // 无公告栏 → 基础定位
}
} ) . exec ( ) ;
} catch ( e ) {
this . noticeRect = null ;
}
} ,
// #ifdef H5
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
_onScroll ( ) {
if ( this . _scrollRAF ) return ;
this . _scrollRAF = requestAnimationFrame ( ( ) => {
this . _scrollRAF = null ;
this . updateViewport ( ) ;
this . measureNotice ( ) ;
} ) ;
} ,
// #endif
// 读取逐按钮尺寸 token(rpx 数值):
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
btnSizeRpx ( type ) {
let theme = null ;
try {
theme = ( this . $store && this . $store . state ) ? this . $store . state . themeStyle : null ;
} catch ( e ) { theme = null ; }
const keys = [ 'hover_nav_' + type + '_btn_size' , 'hover_nav_' + type + '_size' ] ;
if ( theme && typeof theme === 'object' ) {
for ( let i = 0 ; i < keys . length ; i ++ ) {
const raw = theme [ keys [ i ] ] ;
if ( raw === undefined || raw === null || raw === '' ) continue ;
const n = parseFloat ( raw ) ;
if ( Number . isFinite ( n ) && n > 0 ) return n ;
}
}
return 72 ;
} ,
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
btnItemStyle ( type ) {
const s = this . rpxStr ( this . btnSizeRpx ( type ) ) ;
return {
width : s ,
height : s ,
'min-width' : s ,
'min-height' : s ,
'max-width' : s ,
'max-height' : s ,
'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 ( ) ;
@@ -598,9 +812,13 @@ export default {
if ( typeof document === 'undefined' ) return ; // SSR/非浏览器环境兜底
const root = document . documentElement ;
const computed = getComputedStyle ( root ) ;
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
const scale = ( ( typeof window !== 'undefined' && window . innerWidth ) || 375 ) / 750 ;
const getNum = ( name , fallback ) => {
const v = parseFloat ( computed . getPropertyValue ( name ) ) ;
return Number . isFinite ( v ) ? v : fallback ;
if ( ! Number . isFinite ( v ) || scale <= 0 ) return fallback ;
return v / scale ; // px → 设计稿 rpx 值
} ;
this . layoutTopRight = getNum ( '--top-btn-right' , 46 ) ;
this . layoutTopBottom = getNum ( '--top-btn-bottom' , 272 ) ;
@@ -885,8 +1103,13 @@ export default {
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
width : var ( -- floating - btn - width , 100 rpx ) ;
box - sizing : border - box ;
transition : 0.3 s ;
overflow : hidden ;
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
overflow : visible ;
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
margin - bottom : var ( -- window - bottom ) ;
}
@@ -928,13 +1151,33 @@ export default {
background - color : var ( -- hover - nav - bg - color ) ;
}
/* 逐按钮背景/文字颜色覆写(与后台 9 个 key 对应, 未配置时回退全局 --hover-nav-*):
* hover_nav_lang_bg_color / hover_nav_lang_text_color → --hover-nav-lang-bg-color / --hover-nav-lang-text-color
* hover_nav_kefu_bg_color / hover_nav_kefu_text_color → --hover-nav-kefu-bg-color / --hover-nav-kefu-text-color
* hover_nav_phone_bg_color / hover_nav_phone_text_color → --hover-nav-phone-bg-color / --hover-nav-phone-text-color
* 文字色经 .btn-item 的 color 继承给内部 text/图标(.lang-btn-text 为 color: inherit) */
. btn - lang {
background - color : var ( -- hover - nav - lang - bg - color , var ( -- hover - nav - bg - color ) ) ;
color : var ( -- hover - nav - lang - text - color , var ( -- hover - nav - text - color ) ) ;
}
. btn - kefu {
background - color : var ( -- hover - nav - kefu - bg - color , var ( -- hover - nav - bg - color ) ) ;
color : var ( -- hover - nav - kefu - text - color , var ( -- hover - nav - text - color ) ) ;
}
. btn - phone {
background - color : var ( -- hover - nav - phone - bg - color , var ( -- hover - nav - bg - color ) ) ;
color : var ( -- hover - nav - phone - text - color , var ( -- hover - nav - text - color ) ) ;
}
. btn - item text {
font - size : 28 rpx ;
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
font - size : var ( -- hover - nav - font - size , 36 rpx ) ;
}
. iconfont ,
. icox {
font - size : 36 rpx ;
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
font - size : var ( -- hover - nav - font - size , 36 rpx ) ;
font - weight : bold ;
display : flex ;
justify - content : center ;
@@ -961,9 +1204,17 @@ export default {
0 - 1 rpx 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 , 36 rpx ) ) ;
}
. icox . icox - kefu {
font - size : var ( -- hover - nav - kefu - font - size , var ( -- hover - nav - font - size , 36 rpx ) ) ;
}
. iconfont text ,
. icox text {
font - size : 36 rpx ;
font - size : var ( -- hover - nav - font - size , 36 rpx ) ;
font - weight : bold ;
}
@@ -975,7 +1226,8 @@ export default {
}
. ai - icon {
font - size : 40 rpx ;
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 × 1.11(原 40/36) */
font - size : calc ( var ( -- hover - nav - kefu - font - size , var ( -- hover - nav - font - size , 36 rpx ) ) * 1.11 ) ;
width : 100 % ;
height : 100 % ;
display : flex ;
@@ -994,7 +1246,8 @@ export default {
justify - content : center ;
width : 100 % ;
height : 100 % ;
font - size : 24 rpx ;
/* 语言短码文字: 基准字号用 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 , 36 rpx ) ) * 0.667 ) ;
line - height : 1 ;
font - weight : 600 ;
letter - spacing : 0 ;