1072 lines
41 KiB
Vue
1072 lines
41 KiB
Vue
<template>
|
||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||
<view>
|
||
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
||
:style="[customContainerStyle, {
|
||
height: containerHeight,
|
||
bottom: containerBottom,
|
||
right: containerRight,
|
||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||
backgroundSize: 'cover'
|
||
}]">
|
||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||
<view
|
||
v-if="isLanguageSwitchEnabled && hasLangButton"
|
||
class="btn-item common-bg"
|
||
:style="[btnItemStyle, { order: orderIndex.lang }]"
|
||
@click="handleLangButtonClick"
|
||
>
|
||
<view class="lang-btn-text">{{ langButtonText }}</view>
|
||
</view>
|
||
<!-- 微信官方客服按钮-->
|
||
<button
|
||
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
||
class="btn-item common-bg"
|
||
:style="[btnItemStyle, { 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"
|
||
:style="[btnItemStyle, { 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"
|
||
:style="[btnItemStyle, { 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"
|
||
:style="[btnItemStyle, { 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"
|
||
:style="[btnItemStyle, { 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();
|
||
}
|
||
|
||
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
|
||
// 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;
|
||
},
|
||
// 总开关 + 强制隐藏 + 页面范围 判定结果
|
||
fbVisible() {
|
||
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,直接锁死 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',
|
||
};
|
||
},
|
||
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();
|
||
},
|
||
|
||
//根据显示的按钮数量动态计算容器高度
|
||
// 计算明细(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() {
|
||
const b = this.visibleButtons;
|
||
if (b && b.length === 0) return '0rpx';
|
||
|
||
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`;
|
||
},
|
||
// 浮动按钮组底部 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() {
|
||
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() {
|
||
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
|
||
},
|
||
// 关键修复(全新启动无缓存场景):
|
||
// 旧逻辑: 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);
|
||
},
|
||
beforeDestroy() {
|
||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||
// #ifdef H5
|
||
if (this._origParent && this.$el && this.$el.parentNode === document.body) {
|
||
this._origParent.appendChild(this.$el);
|
||
this._origParent = null;
|
||
}
|
||
// #endif
|
||
},
|
||
methods: {
|
||
// 刷新当前路由与页面栈深度(供 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);
|
||
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
|
||
|
||
/**
|
||
* 把 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;
|
||
transition: 0.3s;
|
||
overflow: hidden;
|
||
/* 与 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);
|
||
}
|
||
|
||
.btn-item text {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.iconfont,
|
||
.icox {
|
||
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);
|
||
}
|
||
|
||
.iconfont text,
|
||
.icox text {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.kefu-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.ai-icon {
|
||
font-size: 40rpx;
|
||
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%;
|
||
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;
|
||
/* 与 .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> |