refactor(common): 统一浮动按钮与侧边导航的尺寸变量与布局

1.  新增全局CSS变量管理浮动按钮和侧边导航的尺寸参数,实现多组件样式同步
2.  重构to-top.vue和hover-nav.vue,改用CSS变量驱动布局,修复多平台适配问题
3.  新增多语言切换弹窗与智能切换逻辑,优化多语言展示与配置能力
4.  修复原容器高度计算的硬编码适配问题,改用rpx单位实现全平台自适应
This commit is contained in:
2026-07-02 11:45:33 +08:00
parent e749209128
commit de52406be8
3 changed files with 615 additions and 58 deletions

View File

@@ -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>

View File

@@ -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; /* 过渡 */