refactor(common): 统一浮动按钮与侧边导航的尺寸变量与布局
1. 新增全局CSS变量管理浮动按钮和侧边导航的尺寸参数,实现多组件样式同步 2. 重构to-top.vue和hover-nav.vue,改用CSS变量驱动布局,修复多平台适配问题 3. 新增多语言切换弹窗与智能切换逻辑,优化多语言展示与配置能力 4. 修复原容器高度计算的硬编码适配问题,改用rpx单位实现全平台自适应
This commit is contained in:
@@ -1,31 +1,38 @@
|
||||
<template>
|
||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||
<view>
|
||||
<view v-if="pageCount === 1 || need" class="fixed-box"
|
||||
:style="[customContainerStyle, {
|
||||
:style="[customContainerStyle, {
|
||||
height: containerHeight,
|
||||
bottom: containerBottom,
|
||||
right: containerRight,
|
||||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||||
backgroundSize: 'cover'
|
||||
}]">
|
||||
<!-- 中英文切换按钮 -->
|
||||
<view
|
||||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||||
<view
|
||||
v-if="isLanguageSwitchEnabled && fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
@click="toggleLanguage"
|
||||
:style="btnItemStyle"
|
||||
@click="handleLangButtonClick"
|
||||
>
|
||||
<text>{{ currentLangDisplayName }}</text>
|
||||
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
|
||||
</view>
|
||||
<!-- 微信官方客服按钮-->
|
||||
<button
|
||||
<button
|
||||
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
open-type="contact"
|
||||
>
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
</button>
|
||||
|
||||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||||
<view
|
||||
<view
|
||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
@click="handleUnifiedKefuClick"
|
||||
>
|
||||
<image
|
||||
@@ -40,15 +47,16 @@
|
||||
</view>
|
||||
|
||||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||||
<view
|
||||
<view
|
||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
@click="handleUnifiedKefuClick"
|
||||
>
|
||||
<image
|
||||
v-if="currentKefuImg"
|
||||
:src="currentKefuImg"
|
||||
mode="aspectFill"
|
||||
<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>
|
||||
@@ -57,34 +65,105 @@
|
||||
</view>
|
||||
|
||||
<!-- 小程序系统客服按钮 -->
|
||||
<button
|
||||
<button
|
||||
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
open-type="contact"
|
||||
>
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<!-- 电话按钮 -->
|
||||
<view
|
||||
v-if="fixBtnShow"
|
||||
<view
|
||||
v-if="fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
@click="call"
|
||||
:style="[{ backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
>
|
||||
<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';
|
||||
|
||||
// 语言短码映射表(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();
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'hover-nav',
|
||||
mixins: [i18nLazyMixin],
|
||||
props: {
|
||||
need: { type: Boolean, default: false }
|
||||
},
|
||||
@@ -95,7 +174,28 @@ export default {
|
||||
shopInfo: null,
|
||||
currentLangIndex: 0,
|
||||
langIndexMap: {},
|
||||
customerService: null,
|
||||
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
|
||||
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
||||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||||
i18n: {
|
||||
langPickerTitle: '',
|
||||
langPickerCancel: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -114,12 +214,42 @@ export default {
|
||||
tel() {
|
||||
return this.shopInfo?.mobile || '';
|
||||
},
|
||||
// 是否启用语言切换:
|
||||
// 1) 依赖 shopInfo.ischina(后端业务开关)
|
||||
// 2) 且可用语言数 >= 2(只有 1 种语言时不需要切)
|
||||
isLanguageSwitchEnabled() {
|
||||
return !!this.shopInfo?.ischina;
|
||||
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;
|
||||
},
|
||||
// 按钮上显示的文案:
|
||||
// - ≥3 种语言(选择器模式):展示"当前选中语言"的短码(选择器里有 ✓ 标识)
|
||||
// - =2 种语言(原循环模式):展示"下一个要切到的语言"的短码(原 hover-nav 行为)
|
||||
// 短码来源:initLanguage 已用合并后的 langDisplayMap 填充到 langList[i].name,
|
||||
// 优先用后端 langDisplayNames,否则用前端 LANG_DISPLAY_NAMES
|
||||
currentLangDisplayName() {
|
||||
const lang = this.langIndexMap[this.currentLangIndex];
|
||||
return lang === 'zh-cn' ? 'EN' : 'CN';
|
||||
const list = this.langList;
|
||||
if (!Array.isArray(list) || list.length === 0) return '';
|
||||
const len = list.length;
|
||||
|
||||
// ≥3 种语言:展示当前
|
||||
if (len >= 3) {
|
||||
const currentItem = list[this.currentLangIndex];
|
||||
if (!currentItem) return '';
|
||||
return currentItem.name || getLangShortCode(currentItem.value, this.langDisplayMap);
|
||||
}
|
||||
|
||||
// =2 种语言:展示下一个(原逻辑)
|
||||
if (len === 2) {
|
||||
const nextIndex = (this.currentLangIndex + 1) % len;
|
||||
const nextItem = list[nextIndex];
|
||||
if (!nextItem) return '';
|
||||
return nextItem.name || getLangShortCode(nextItem.value, this.langDisplayMap);
|
||||
}
|
||||
|
||||
// =1 种语言(理论不会显示按钮,但兜底):展示当前
|
||||
const currentItem = list[this.currentLangIndex];
|
||||
if (!currentItem) return '';
|
||||
return currentItem.name || getLangShortCode(currentItem.value, this.langDisplayMap);
|
||||
},
|
||||
customContainerStyle() {
|
||||
return this.shopInfo?.floatingButton?.container || {};
|
||||
@@ -127,6 +257,31 @@ export default {
|
||||
customButtonStyle() {
|
||||
return this.shopInfo?.floatingButton?.button || {};
|
||||
},
|
||||
// 所有 .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,直接锁死 80x80 + 正圆。
|
||||
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||
btnItemStyle() {
|
||||
return {
|
||||
width: '80rpx',
|
||||
height: '80rpx',
|
||||
'min-width': '80rpx',
|
||||
'min-height': '80rpx',
|
||||
'max-width': '80rpx',
|
||||
'max-height': '80rpx',
|
||||
'aspect-ratio': '1',
|
||||
'border-radius': '50%',
|
||||
'flex-shrink': '0',
|
||||
'flex-grow': '0',
|
||||
'align-self': 'center',
|
||||
'box-sizing': 'border-box',
|
||||
padding: '0',
|
||||
margin: '14rpx 0',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
},
|
||||
unreadCount() {
|
||||
const customerService = createCustomerService(this);
|
||||
return customerService.getUnreadCount();
|
||||
@@ -159,45 +314,221 @@ export default {
|
||||
const customerService = createCustomerService(this);
|
||||
return customerService.hasValidKefuConfig();
|
||||
},
|
||||
//根据显示的按钮数量动态计算容器高度
|
||||
|
||||
//根据显示的按钮数量动态计算容器高度
|
||||
// 计算明细(buttonCount 按钮场景):
|
||||
// 每个按钮槽位:80rpx(按钮) + 14rpx(上 margin) + 14rpx(下 margin) = 108rpx
|
||||
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
|
||||
// 总高度 (rpx):buttonCount × 108 + 40
|
||||
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
|
||||
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
|
||||
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
|
||||
containerHeight() {
|
||||
if (!this.fixBtnShow) return '320rpx';
|
||||
|
||||
|
||||
let buttonCount = 1;
|
||||
if (this.isLanguageSwitchEnabled) buttonCount++;
|
||||
if (this.showWeappSystemKefu) buttonCount++;
|
||||
buttonCount++;
|
||||
const totalRpx = 94 * buttonCount - 14;
|
||||
const pxValue = Math.round(totalRpx * 0.5);
|
||||
|
||||
return ` $ {pxValue}px`;
|
||||
}
|
||||
buttonCount++;
|
||||
const totalRpx = buttonCount * 108 + 40;
|
||||
|
||||
return `${totalRpx}rpx`;
|
||||
},
|
||||
// 浮动按钮组底部 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 是按"视觉上不挤"原则手调出来的,反过来都不行。
|
||||
// ------------------------------------------------------------
|
||||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||||
// 两者会被安全区同步上推,space 保持不变
|
||||
containerBottom() {
|
||||
if (!this.fixBtnShow) return '240rpx';
|
||||
let space = 18 * 5;
|
||||
// #ifdef MP-WEIXIN
|
||||
space = 18 * 1;
|
||||
// #endif
|
||||
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
|
||||
},
|
||||
// 浮动按钮组水平 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() {
|
||||
if (!this.fixBtnShow) return '0rpx';
|
||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
|
||||
return `${offset}rpx`;
|
||||
},
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.initLanguage();
|
||||
this.pageCount = getCurrentPages().length;
|
||||
uni.getStorage({
|
||||
key: 'shopInfo',
|
||||
success: (e) => {
|
||||
this.shopInfo = e.data;
|
||||
},
|
||||
fail: () => {
|
||||
console.warn('未获取到 shopInfo,使用默认设置');
|
||||
|
||||
// 三源兜底(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
|
||||
},
|
||||
methods: {
|
||||
// #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);
|
||||
const getNum = (name, fallback) => {
|
||||
const v = parseFloat(computed.getPropertyValue(name));
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
};
|
||||
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
|
||||
initLanguage() {
|
||||
this.langList = this.$langConfig.list();
|
||||
// 数据源优先级:
|
||||
// 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;
|
||||
@@ -219,19 +550,109 @@ export default {
|
||||
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() {
|
||||
this.currentLangIndex = this.currentLangIndex === 0 ? 1 : 0;
|
||||
const targetLang = this.langIndexMap[this.currentLangIndex];
|
||||
|
||||
uni.setStorageSync("lang", targetLang);
|
||||
|
||||
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.route;
|
||||
const currentRoute = (currentPage && (currentPage.route || (currentPage.$page && currentPage.$page.path))) || 'pages/index/index';
|
||||
const url = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
|
||||
this.$langConfig.change(targetLang, url);
|
||||
},
|
||||
|
||||
uni.reLaunch({
|
||||
url: '/' + currentRoute
|
||||
});
|
||||
/**
|
||||
* 打开语言选择器
|
||||
*/
|
||||
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);
|
||||
},
|
||||
|
||||
handleUnifiedKefuClick() {
|
||||
@@ -247,7 +668,9 @@ export default {
|
||||
<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;
|
||||
@@ -257,11 +680,17 @@ export default {
|
||||
padding: 20rpx 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: 100rpx;
|
||||
/* width 改用 CSS 变量驱动,与 App.vue 的 --floating-btn-width 保持同步;
|
||||
* JS 端(containerRight)也读同一个变量计算水平 right,
|
||||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||||
width: var(--floating-btn-width, 100rpx);
|
||||
box-sizing: border-box;
|
||||
transition: 0.3s;
|
||||
overflow: hidden;
|
||||
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
||||
margin-bottom: var(--window-bottom);
|
||||
}
|
||||
|
||||
.btn-item {
|
||||
@@ -276,8 +705,27 @@ export default {
|
||||
border-radius: 50%;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
/* 关键:微信小程序的 <button> 元素有默认 appearance/background/border,
|
||||
* 必须显式重置,否则 80rpx 高度会被默认样式吃掉,渲染成椭圆。 */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
/* CSS 端兜底:即使 inline-style 失效,也能保证 1:1 比例
|
||||
* (微信小程序基础库 < 3.0 不支持 aspect-ratio,但 width/height 已固定 80rpx,
|
||||
* 此处主要给 H5/新版小程序双保险) */
|
||||
aspect-ratio: 1;
|
||||
/* 锁死宽高,防止父级 flex 容器压缩 */
|
||||
min-width: 80rpx;
|
||||
min-height: 80rpx;
|
||||
max-width: 80rpx;
|
||||
max-height: 80rpx;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.common-bg {
|
||||
@@ -292,17 +740,20 @@ export default {
|
||||
.icox {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 14rpx 0;
|
||||
border-radius: 50rpx;
|
||||
/* 不再设 width: 100% / height: 100% 再覆盖为 80rpx 的写法,
|
||||
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
|
||||
* 直接固定 80x80 更稳。 */
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.iconfont text,
|
||||
@@ -326,4 +777,87 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 语言按钮内的短码文字(关键,之前缺失导致 CN/EN 文字撑大按钮变椭圆):
|
||||
* 语言按钮的 inner 用 <view class="lang-btn-text"> 包裹文字,
|
||||
* 如果没有这个规则,view 会用默认 font-size/line-height,
|
||||
* "CN" / "EN" / "JP" 在中文/日文系统下会渲染成 32-40rpx 高,
|
||||
* 撑高按钮导致 80rpx width × 38rpx height 的"伪正圆"实际是椭圆。 */
|
||||
.lang-btn-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 24rpx;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 语言选择弹窗(底部弹出) */
|
||||
.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>
|
||||
@@ -25,14 +25,21 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 回到顶部的按钮 */
|
||||
/* 回到顶部的按钮
|
||||
* ------------------------------------------------------------
|
||||
* 关键尺寸(right/bottom/width)改用全局 CSS 变量驱动,
|
||||
* 权威源在 App.vue 的 :root 定义。hover-nav.vue 的 JS 会读取
|
||||
* 同一组变量计算自身位置,保证两组件的相对关系始终一致。
|
||||
* 这里给每个 var() 都加 hardcoded fallback,
|
||||
* 防止 CSS 变量因某种原因未挂上时按钮定位完全失效。
|
||||
* ------------------------------------------------------------ */
|
||||
.mescroll-totop {
|
||||
z-index: 2147483647;
|
||||
position: fixed !important; /* 加上important避免编译到H5,在多mescroll中定位失效 */
|
||||
right: 46rpx !important;
|
||||
bottom: 272rpx !important;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
right: var(--top-btn-right, 46rpx) !important;
|
||||
bottom: var(--top-btn-bottom, 272rpx) !important;
|
||||
width: var(--top-btn-size, 72rpx);
|
||||
height: var(--top-btn-size, 72rpx);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s; /* 过渡 */
|
||||
|
||||
Reference in New Issue
Block a user