common\js\floating-button.js
common\js\startup-ad.js components\ns-mp-navbar\ns-mp-navbar.vue
This commit is contained in:
134
common/js/floating-button.js
Normal file
134
common/js/floating-button.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 浮动侧边栏配置消费 (字段来源: /api/config/init → data.floating_button)
|
||||||
|
* 纯函数模块, 无副作用, 可直接单测
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 归一化路由: 'pages/index/index' → '/pages/index/index' */
|
||||||
|
export function normalizeRoute(route) {
|
||||||
|
if (!route) return '';
|
||||||
|
return String(route).charAt(0) === '/' ? String(route) : '/' + String(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取当前页面路由 (兼容 H5 / 小程序; route 已含分包 root, 如 'pages_goods/detail') */
|
||||||
|
export function getCurrentRoute() {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const cur = pages[pages.length - 1];
|
||||||
|
return normalizeRoute((cur && (cur.route || (cur.$page && cur.$page.path))) || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前缀命中判定
|
||||||
|
* '/pages/index' 命中 '/pages/index' 与 '/pages/index/xxx'
|
||||||
|
* '/pages_tool/' 命中 '/pages_tool/xxx' (前缀自带尾 /)
|
||||||
|
* '/pages/order/payment' 只命中它自己及其子路径
|
||||||
|
*/
|
||||||
|
export function hitPrefix(route, prefix) {
|
||||||
|
if (!route || !prefix) return false;
|
||||||
|
const p = prefix.charAt(prefix.length - 1) === '/' ? prefix : prefix + '/';
|
||||||
|
return route === prefix || route.indexOf(p) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任一前缀命中 */
|
||||||
|
function hitAny(route, prefixes) {
|
||||||
|
if (!Array.isArray(prefixes)) return false;
|
||||||
|
for (let i = 0; i < prefixes.length; i++) {
|
||||||
|
if (hitPrefix(route, prefixes[i])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找出路由命中的页面分组 key (未命中返回 null) */
|
||||||
|
export function matchPageGroup(route, prefixMap) {
|
||||||
|
if (!prefixMap) return null;
|
||||||
|
const keys = Object.keys(prefixMap);
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
if (hitAny(normalizeRoute(route), prefixMap[keys[i]])) return keys[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧边栏在当前页面是否显示 —— 唯一判定入口
|
||||||
|
*
|
||||||
|
* 优先级: 总开关 → 强制隐藏 → 显示范围
|
||||||
|
* 1) fb 不存在 (老缓存响应) → fallbackVisible (默认 true, 保持老行为)
|
||||||
|
* 2) enabled != 1 → false
|
||||||
|
* 3) 命中 force_hidden_prefixes 任意前缀 → false (最高优先级)
|
||||||
|
* 4) 命中 custom_pages / page_prefixes._custom → true
|
||||||
|
* 5) 命中 pages[组] == 1 → true
|
||||||
|
* 6) 都不命中 (如 /pages_goods/*) → false
|
||||||
|
*/
|
||||||
|
export function shouldShowOnPage(fb, route, fallbackVisible) {
|
||||||
|
if (!fb || typeof fb !== 'object') return fallbackVisible !== false;
|
||||||
|
if (Number(fb.enabled) !== 1) return false;
|
||||||
|
|
||||||
|
const r = normalizeRoute(route);
|
||||||
|
if (!r) return false;
|
||||||
|
|
||||||
|
// 强制隐藏 — 优先级最高
|
||||||
|
if (matchPageGroup(r, fb.force_hidden_prefixes)) return false;
|
||||||
|
|
||||||
|
// 自定义前缀优先判定
|
||||||
|
if (hitAny(r, fb.custom_pages) || hitAny(r, (fb.page_prefixes || {})['_custom'])) return true;
|
||||||
|
|
||||||
|
// 预设分组
|
||||||
|
const group = matchPageGroup(r, fb.page_prefixes);
|
||||||
|
if (group) return Number((fb.pages || {})[group]) === 1;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前页面要渲染的按钮列表 (已按 button_order 排序 + 显隐过滤)
|
||||||
|
* 返回 ['lang','kefu','phone'] 的子集, 空数组 = 整个侧边栏不渲染
|
||||||
|
* 返回 null = 配置缺失, 由调用方决定老行为
|
||||||
|
*/
|
||||||
|
export function resolveVisibleButtons(fb) {
|
||||||
|
if (!fb || typeof fb !== 'object') return null;
|
||||||
|
const order = Array.isArray(fb.button_order) ? fb.button_order : ['lang', 'kefu', 'phone'];
|
||||||
|
const map = fb.buttons || {};
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
const k = order[i];
|
||||||
|
if (Number(map[k]) === 1) out.push(k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言按钮显示文案
|
||||||
|
* @param {Object} fb data.floating_button
|
||||||
|
* @param {Array} langList [{ value:'zh-cn', name:'CN' }, ...]
|
||||||
|
* @param {Number} currentIdx 当前语言下标
|
||||||
|
*/
|
||||||
|
export function resolveLangButtonText(fb, langList, currentIdx) {
|
||||||
|
const list = Array.isArray(langList) ? langList : [];
|
||||||
|
if (!list.length) return '';
|
||||||
|
const style = (fb && fb.lang_switch && fb.lang_switch.style) || 'target';
|
||||||
|
|
||||||
|
if (style === 'text') {
|
||||||
|
const t = (fb && fb.lang_switch && fb.lang_switch.text) || '';
|
||||||
|
if (t) return t; // 空文案兜底走 target
|
||||||
|
}
|
||||||
|
if (style === 'current' || list.length < 2) {
|
||||||
|
return (list[currentIdx] && list[currentIdx].name) || '';
|
||||||
|
}
|
||||||
|
// target: 显示「点下去会切到的那个」语言
|
||||||
|
// 2 种语言 → 另一个; >2 种 → 取第一个不是当前的那个
|
||||||
|
let targetIdx = 0;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
if (i !== currentIdx) { targetIdx = i; break; }
|
||||||
|
}
|
||||||
|
return list[targetIdx] ? (list[targetIdx].name || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语言切换按钮是否可见 (含老「双语模式」fallback) */
|
||||||
|
export function isLangButtonVisible(fb, shopInfo, langCount) {
|
||||||
|
if (Number(langCount) < 2) return false;
|
||||||
|
if (fb && fb.lang_switch) return Number(fb.lang_switch.enabled) === 1;
|
||||||
|
return !!(shopInfo && shopInfo.ischina); // 老行为兜底
|
||||||
|
}
|
||||||
30
common/js/startup-ad.js
Normal file
30
common/js/startup-ad.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// 启动广告弹出信号
|
||||||
|
// 用于判定"当前是否需要弹出启动广告":
|
||||||
|
// - 小程序冷启动(首次进入): 由模块级 _coldLaunchedShown 标记判定, 整个 app 进程只记一次
|
||||||
|
// - 小程序重新进入(从后台切回 / 微信"重新进入小程序"菜单):
|
||||||
|
// 由 App.onShow 在"非首次展示"时调用 markNeedStartupAd() 置位
|
||||||
|
// 信号消费一次即清除, 不落地到持久化存储, 因此无需清缓存即可每次重新进入都弹出。
|
||||||
|
// 关键: 切换 Tab 回到首页只触发"页面级 onShow", 不会触发 App.onShow, 也不会重置
|
||||||
|
// 模块级冷启动标记, 因此不会在 Tab 切换时误弹。
|
||||||
|
|
||||||
|
let _needStartupAd = false;
|
||||||
|
let _coldLaunchedShown = false;
|
||||||
|
|
||||||
|
// App 层检测到"重新进入小程序"时调用, 标记本次需要弹出启动广告
|
||||||
|
export function markNeedStartupAd() {
|
||||||
|
_needStartupAd = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面层读取并消费该信号(消费后自动复位)
|
||||||
|
export function consumeNeedStartupAd() {
|
||||||
|
const v = _needStartupAd;
|
||||||
|
_needStartupAd = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否为本 app 进程的首次冷启动(只返回一次 true, 之后恒为 false)
|
||||||
|
export function isColdLaunchPending() {
|
||||||
|
if (_coldLaunchedShown) return false;
|
||||||
|
_coldLaunchedShown = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
自绘导航栏:把原生导航栏左侧的 "<" 换成 "‹ 返回" 文字
|
||||||
|
|
||||||
|
背景:微信小程序原生导航栏的返回按钮是客户端系统控件,pages.json 的任何配置、
|
||||||
|
官方 <navigation-bar> 组件(只支持 title / front-color / background-color / loading)
|
||||||
|
都无法修改它的内容。要显示"返回"二字,只能关闭原生栏后自绘。
|
||||||
|
|
||||||
|
使用前提:
|
||||||
|
1. 该页面在 pages.json 中开启 "navigationStyle": "custom"(组件只负责渲染,不自动改配置)
|
||||||
|
2. 该页面不能是 web-view 页面 —— 官方明确 "navigationStyle: custom 对 web-view 组件无效",
|
||||||
|
且 web-view 会铺满整页覆盖其他组件,自绘的导航栏不会显示
|
||||||
|
|
||||||
|
布局:组件是普通流式节点(非 fixed),放在页面顶部即可把内容自然下推。
|
||||||
|
-->
|
||||||
|
<view
|
||||||
|
v-if="visible"
|
||||||
|
class="ns-mp-navbar"
|
||||||
|
:class="{ 'is-fixed': fixed }"
|
||||||
|
:style="{ background: bgColor, paddingTop: statusBarHeight + 'px' }"
|
||||||
|
>
|
||||||
|
<view class="ns-mp-navbar__inner" :style="{ height: navBarHeight + 'px' }">
|
||||||
|
<view class="ns-mp-navbar__side">
|
||||||
|
<view
|
||||||
|
v-if="showBack && canBack"
|
||||||
|
class="ns-mp-navbar__back"
|
||||||
|
hover-class="ns-mp-navbar__back--hover"
|
||||||
|
hover-stay-time="80"
|
||||||
|
@tap="onBack"
|
||||||
|
>
|
||||||
|
<text class="iconfont icon-back_light ns-mp-navbar__arrow" :style="{ color }"></text>
|
||||||
|
<text class="ns-mp-navbar__text" :style="{ color }">{{ backText }}</text>
|
||||||
|
</view>
|
||||||
|
<slot name="left"></slot>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ns-mp-navbar__title" :style="{ color }">{{ title }}</view>
|
||||||
|
|
||||||
|
<view class="ns-mp-navbar__side ns-mp-navbar__side--right">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 状态栏 / 胶囊信息:用于算出与原生导航栏一致的布局高度
|
||||||
|
let systemInfo = {};
|
||||||
|
let menuButtonInfo = { top: 0, height: 32, right: 0, width: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
systemInfo = uni.getSystemInfoSync() || {};
|
||||||
|
} catch (e) {
|
||||||
|
systemInfo = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
||||||
|
try {
|
||||||
|
menuButtonInfo = uni.getMenuButtonBoundingClientRect() || menuButtonInfo;
|
||||||
|
} catch (e) {}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ns-mp-navbar',
|
||||||
|
props: {
|
||||||
|
// 中间标题
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 导航栏背景色
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#ffffff'
|
||||||
|
},
|
||||||
|
// 文字 / 图标颜色
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#000000'
|
||||||
|
},
|
||||||
|
// 返回按钮文字(原生导航栏只能显示 "<",这里可自定义)
|
||||||
|
backText: {
|
||||||
|
type: String,
|
||||||
|
default: '返回'
|
||||||
|
},
|
||||||
|
// 是否显示返回按钮
|
||||||
|
showBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 是否悬浮在页面之上(沉浸式页面用;false 时占文档流,会把内容往下推)
|
||||||
|
fixed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 无页面栈时(如分享直接进入)跳转的首页地址
|
||||||
|
homePath: {
|
||||||
|
type: String,
|
||||||
|
default: '/pages/index/index'
|
||||||
|
},
|
||||||
|
// 自定义返回逻辑
|
||||||
|
customBack: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
// H5 端是否渲染(H5 由 uni-app 自带头像栏,默认不渲染,避免双导航栏)
|
||||||
|
showOnH5: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
statusBarHeight: systemInfo.statusBarHeight || 20,
|
||||||
|
navBarHeight: 44,
|
||||||
|
visible: true,
|
||||||
|
// 页面栈里是否还有上级页面(无上级页面时原生导航栏也不显示返回键)
|
||||||
|
canBack: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 导航栏总高度(状态栏 + 内容区),页面如需自行占位可取该值
|
||||||
|
navbarHeight() {
|
||||||
|
return this.statusBarHeight + this.navBarHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// 导航栏内容区高度:MP 端按胶囊位置推算,与原生导航栏保持一致;其余端用 44
|
||||||
|
// #ifdef MP
|
||||||
|
const statusBarHeight = this.statusBarHeight;
|
||||||
|
const h = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height;
|
||||||
|
// 兜底:胶囊信息异常时退回 44
|
||||||
|
this.navBarHeight = h > 0 ? h : 44;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
this.visible = this.showOnH5;
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// 页面栈只有一页(分享/扫码直达)时,原生导航栏也无返回键,这里保持一致
|
||||||
|
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||||
|
this.canBack = pages.length > 1;
|
||||||
|
// 把实际总高度抛给页面,便于页面修正内容区高度,避免多出一屏空白
|
||||||
|
this.$emit('ready', this.statusBarHeight + this.navBarHeight);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onBack() {
|
||||||
|
if (typeof this.customBack === 'function') {
|
||||||
|
this.customBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 顶层页面(分享 / 扫码直接进入):回首页
|
||||||
|
uni.switchTab({
|
||||||
|
url: this.homePath,
|
||||||
|
fail: () => {
|
||||||
|
uni.reLaunch({ url: this.homePath });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ns-mp-navbar {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar.is-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__side {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 160rpx;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__side--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10rpx 20rpx 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__back--hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__arrow {
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__text {
|
||||||
|
margin-left: 2rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-mp-navbar__title {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 160rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user