From df5be6045b3f7890d2fcc2f68e713d28ccc59aba Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Mon, 12 Oct 2026 15:41:28 +0800
Subject: [PATCH] common\js\floating-button.js common\js\startup-ad.js
components\ns-mp-navbar\ns-mp-navbar.vue
---
common/js/floating-button.js | 134 ++++++++++++
common/js/startup-ad.js | 30 +++
components/ns-mp-navbar/ns-mp-navbar.vue | 247 +++++++++++++++++++++++
3 files changed, 411 insertions(+)
create mode 100644 common/js/floating-button.js
create mode 100644 common/js/startup-ad.js
create mode 100644 components/ns-mp-navbar/ns-mp-navbar.vue
diff --git a/common/js/floating-button.js b/common/js/floating-button.js
new file mode 100644
index 0000000..91072be
--- /dev/null
+++ b/common/js/floating-button.js
@@ -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); // 老行为兜底
+}
diff --git a/common/js/startup-ad.js b/common/js/startup-ad.js
new file mode 100644
index 0000000..575edff
--- /dev/null
+++ b/common/js/startup-ad.js
@@ -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;
+}
diff --git a/components/ns-mp-navbar/ns-mp-navbar.vue b/components/ns-mp-navbar/ns-mp-navbar.vue
new file mode 100644
index 0000000..6685d0d
--- /dev/null
+++ b/components/ns-mp-navbar/ns-mp-navbar.vue
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+ {{ backText }}
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+