Просмотр исходного кода

fix: 补提交动态导航前端依赖文件

mine 页依赖的以下文件之前未进 git,导致服务器构建失败:
- api/navigation.ts: 导航配置接口客户端
- store/navigation.ts: 导航 pinia store
- types/navigation.ts: 导航类型定义
- components/CustomTabBar.vue: 自定义底部 tab 栏

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User 2 месяцев назад
Родитель
Сommit
b4ce69f34d

+ 71 - 0
my-uniapp-vue3/src/api/navigation.ts

@@ -0,0 +1,71 @@
+/**
+ * 导航模块 API 客户端
+ *
+ * 单一职责:调用 GET /api/navigation/config 并返回 NavigationConfig。
+ *
+ * 重要:该接口是 App 启动的「第一件事」,任何错误都不能用 uni.showToast
+ * 提示用户(因为此时 UI 还是初始启动状态)。所以本文件直接用 uni.request
+ * 调用,绕开 utils/request.ts 的副作用(toast、登录跳转等)。
+ */
+import { getApiBaseUrl } from '../utils/config';
+import type { NavigationConfig } from '../types/navigation';
+
+interface ApiEnvelope<T> {
+  code: number;
+  message: string;
+  data?: T;
+}
+
+export interface FetchNavigationOptions {
+  /** 超时时间(ms),默认 8000 */
+  timeout?: number;
+}
+
+/**
+ * 获取导航配置
+ *
+ * 失败时抛出 Error,错误信息可读;不抛 toast、不跳转登录。
+ */
+export async function fetchNavigationConfig(
+  options: FetchNavigationOptions = {}
+): Promise<NavigationConfig> {
+  const { timeout = 8000 } = options;
+  const baseUrl = getApiBaseUrl();
+  const fullUrl = baseUrl + '/navigation/config';
+
+  // 读取 token(若有),导航接口对登录无要求但带上没坏处
+  let token: string | null = null;
+  try {
+    token = uni.getStorageSync('token') || null;
+  } catch (_) {}
+
+  const header: Record<string, string> = {
+    'Content-Type': 'application/json',
+  };
+  if (token) header['Authorization'] = `Bearer ${token}`;
+
+  return new Promise<NavigationConfig>((resolve, reject) => {
+    uni.request({
+      url: fullUrl,
+      method: 'GET',
+      header,
+      timeout,
+      success: (res) => {
+        const status = res.statusCode;
+        const body = res.data as ApiEnvelope<NavigationConfig> | null;
+        if (status >= 200 && status < 300 && body && body.code === 0 && body.data) {
+          resolve(body.data);
+          return;
+        }
+        const msg =
+          (body && (body.message || (body as any).error)) ||
+          `HTTP ${status}`;
+        reject(new Error(`获取导航配置失败: ${msg}`));
+      },
+      fail: (err) => {
+        const detail = (err && (err as any).errMsg) || '';
+        reject(new Error(`网络请求失败${detail ? ': ' + detail : ''}`));
+      },
+    });
+  });
+}

+ 202 - 0
my-uniapp-vue3/src/components/CustomTabBar.vue

@@ -0,0 +1,202 @@
+<!--
+  CustomTabBar.vue
+  ============================================================================
+  全局底部 Tab Bar 组件(单一组件,被 5 个 tab 页各自挂载一次)
+
+  设计原则:
+  1. 数据完全来自 navigation store(后端 /api/navigation/config 返回)
+  2. 仅在当前页面是「tab 页」时显示(其它页面隐藏)
+  3. 支持徽标 (badge)、激活色、emoji / 图片图标
+  4. 适配安全区底部 (iPhone X+)
+
+  说明:
+    pages.json tabBar.custom = true(仅占位,渲染由本组件接管)
+    每个 tab 页用 `<CustomTabBar />` 引用本组件。组件本身是全局唯一的。
+
+  注意:
+    pages.json 的 tabBar.list 必须保留所有可能的 pagePath,
+    否则 uni.switchTab 找不到页面。本组件只负责「显示什么」,
+    不负责「能不能跳转」。
+-->
+<template>
+  <view
+    v-if="visible"
+    class="custom-tabbar safe-area-bottom"
+    :style="containerStyle"
+  >
+    <view
+      v-for="tab in tabList"
+      :key="tab.id"
+      class="tab-item"
+      :class="{ active: tab.id === currentTabId }"
+      :style="tab.id === currentTabId ? activeItemStyle : itemStyle"
+      @click="handleTap(tab)"
+    >
+      <view class="tab-icon-wrap">
+        <image
+          v-if="isImageIcon(tab)"
+          class="tab-icon-img"
+          :src="tab.id === currentTabId ? (tab.selectedIconPath || tab.iconPath) : tab.iconPath"
+          mode="aspectFit"
+        />
+        <text
+          v-else
+          class="tab-icon-text"
+          :style="tab.id === currentTabId ? { color: selectedColor } : { color: color }"
+        >{{ tab.id === currentTabId ? (tab.selectedIcon || tab.icon) : tab.icon }}</text>
+        <view v-if="getBadgeText(tab)" class="tab-badge">{{ getBadgeText(tab) }}</view>
+      </view>
+      <text
+        class="tab-text"
+        :style="tab.id === currentTabId ? { color: selectedColor, fontWeight: '600' } : { color }"
+      >{{ tab.text }}</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useNavigationStore } from '../store/navigation';
+import type { TabItem } from '../types/navigation';
+
+const navStore = useNavigationStore();
+const tabList = computed(() => navStore.tabList);
+const color = computed(() => navStore.style.color);
+const selectedColor = computed(() => navStore.style.selectedColor);
+const currentTabId = computed(() => navStore.currentTabId);
+
+// 当前页面路径:组件在 Vue 上下文里运行,getCurrentPages 正常可用
+const currentPagePath = computed<string>(() => {
+  try {
+    const pages = getCurrentPages();
+    const last = pages[pages.length - 1];
+    const route = (last as any)?.route;
+    return route ? String(route) : '';
+  } catch (_) {
+    return '';
+  }
+});
+
+// 当前是否在 tab 页(用于显隐控制)
+const isOnTabPage = computed(() => {
+  const path = currentPagePath.value;
+  if (!path) return false;
+  return tabList.value.some(t => t.pagePath === path);
+});
+
+const visible = computed(() => navStore.isReady && isOnTabPage.value);
+
+const containerStyle = computed(() => ({
+  backgroundColor: navStore.style.backgroundColor,
+  borderTop: `1rpx solid ${navStore.style.borderStyle === 'black' ? '#1f2937' : '#e5e7eb'}`,
+}));
+
+const itemStyle = computed(() => ({}));
+const activeItemStyle = computed(() => ({}));
+
+function isImageIcon(tab: TabItem): boolean {
+  return !!(tab.iconPath && (tab.iconPath.startsWith('/') || tab.iconPath.startsWith('http')));
+}
+
+function getBadgeText(tab: TabItem): string {
+  if (tab.badge === undefined || tab.badge === null) return '';
+  if (tab.badge === '' || tab.badge === 0) return '';
+  return String(tab.badge);
+}
+
+async function handleTap(tab: TabItem) {
+  if (!tab || !tab.pagePath) return;
+  if (tab.id === currentTabId.value) {
+    return;
+  }
+  try {
+    await navStore.switchTabByPath(tab.pagePath);
+  } catch (err) {
+    console.error('[CustomTabBar] 切换失败:', err);
+    uni.showToast({ title: '页面跳转失败', icon: 'none' });
+  }
+}
+</script>
+
+<style scoped>
+.custom-tabbar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: stretch;
+  justify-content: space-around;
+  height: 110rpx;
+  z-index: 999;
+  box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04);
+  transition: background-color 0.2s;
+}
+
+.tab-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+  padding: 8rpx 0;
+  transition: opacity 0.15s;
+}
+
+.tab-item:active {
+  opacity: 0.6;
+}
+
+.tab-item.active {
+  transform: scale(1.02);
+}
+
+.tab-icon-wrap {
+  position: relative;
+  width: 56rpx;
+  height: 56rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.tab-icon-img {
+  width: 48rpx;
+  height: 48rpx;
+}
+
+.tab-icon-text {
+  font-size: 40rpx;
+  line-height: 1;
+}
+
+.tab-text {
+  font-size: 20rpx;
+  margin-top: 4rpx;
+  line-height: 1.2;
+}
+
+.tab-badge {
+  position: absolute;
+  top: -6rpx;
+  right: -10rpx;
+  min-width: 28rpx;
+  height: 28rpx;
+  padding: 0 8rpx;
+  border-radius: 14rpx;
+  background: #ef4444;
+  color: #ffffff;
+  font-size: 18rpx;
+  line-height: 28rpx;
+  text-align: center;
+  font-weight: 700;
+  box-sizing: border-box;
+  box-shadow: 0 2rpx 6rpx rgba(239, 68, 68, 0.4);
+}
+
+.safe-area-bottom {
+  padding-bottom: constant(safe-area-inset-bottom);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+</style>

+ 205 - 0
my-uniapp-vue3/src/store/navigation.ts

@@ -0,0 +1,205 @@
+/**
+ * 导航 Store
+ *
+ * 单一职责:
+ * 1. 持有底部 tab 列表、用户中心菜单、TabBar 样式
+ * 2. 提供 loadConfig() 方法(首次启动 + 后续刷新都可用)
+ * 3. 提供 switchTabByPath() 等路由辅助方法
+ *
+ * 「isReady」必须为 true 之后,前端才能渲染业务页面(App.vue 强制)。
+ */
+import { defineStore } from 'pinia';
+import { ref, computed } from 'vue';
+import { fetchNavigationConfig } from '../api/navigation';
+import type { NavigationConfig, TabItem, UserCenterMenuItem } from '../types/navigation';
+
+export const useNavigationStore = defineStore('navigation', () => {
+  // ============== 状态 ==============
+  /** 底部 tab 列表(按 order 升序、已过滤 enabled=false) */
+  const tabList = ref<TabItem[]>([]);
+  /** 用户中心菜单列表(按 order 升序、已过滤 enabled=false) */
+  const userCenterMenu = ref<UserCenterMenuItem[]>([]);
+  /** TabBar 视觉样式 */
+  const style = ref({
+    color: '#9ca3af',
+    selectedColor: '#4F46E5',
+    backgroundColor: '#ffffff',
+    borderStyle: 'white' as 'black' | 'white',
+  });
+
+  /** 是否正在加载(首屏阻塞加载态) */
+  const loading = ref(false);
+  /** 是否加载成功(首屏渲染开关) */
+  const loaded = ref(false);
+  /** 加载失败时的错误信息(首次失败时显示重试 UI) */
+  const loadError = ref<string | null>(null);
+
+  /** 当前 tab id(用于自定义 tabBar 高亮) */
+  const currentTabId = ref<string | null>(null);
+
+  // ============== 计算属性 ==============
+  const isReady = computed(() => loaded.value && loadError.value === null);
+
+  /** 当前 tab 在列表中的索引(找不到返回 -1) */
+  const currentTabIndex = computed(() =>
+    tabList.value.findIndex(t => t.id === currentTabId.value)
+  );
+
+  /** 当前激活的 tab 配置 */
+  const currentTab = computed<TabItem | null>(() => {
+    const idx = currentTabIndex.value;
+    return idx >= 0 ? tabList.value[idx] : null;
+  });
+
+  // ============== 方法 ==============
+
+  /**
+   * 加载导航配置
+   *
+   * 行为:
+   * - 第一次:App.vue 在 onLaunch 同步阻塞调用,失败抛错给上层
+   * - 后续:手动刷新(如设置页拉取新菜单)使用,失败不抛错只设置 loadError
+   *
+   * @param options.timeout 超时(ms),默认 8000
+   * @param options.throwOnError 失败时是否抛出(默认 false;App.vue 调用时传 true)
+   */
+  async function loadConfig(
+    options: { timeout?: number; throwOnError?: boolean } = {}
+  ): Promise<NavigationConfig | null> {
+    const { timeout = 8000, throwOnError = false } = options;
+    loading.value = true;
+    loadError.value = null;
+    try {
+      const data = await fetchNavigationConfig({ timeout });
+      applyConfig(data);
+      loaded.value = true;
+      return data;
+    } catch (err: any) {
+      const msg = err?.message || '获取导航配置失败';
+      loadError.value = msg;
+      loaded.value = false;
+      if (throwOnError) throw err;
+      return null;
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  /** 把接口返回的数据应用到状态(供测试或后续扩展使用) */
+  function applyConfig(data: NavigationConfig) {
+    const tabs = (data.tabs || [])
+      .filter(t => t && t.enabled !== false)
+      .slice()
+      .sort((a, b) => (a.order || 0) - (b.order || 0));
+    const menus = (data.userCenterMenu || [])
+      .filter(m => m && m.enabled !== false)
+      .slice()
+      .sort((a, b) => (a.order || 0) - (b.order || 0));
+
+    // 防御:tabs 为空时不允许(否则没有底部导航没法用)
+    if (tabs.length === 0) {
+      throw new Error('导航配置异常:tabs 为空');
+    }
+
+    tabList.value = tabs;
+    userCenterMenu.value = menus;
+    style.value = {
+      color: data.style?.color || '#9ca3af',
+      selectedColor: data.style?.selectedColor || '#4F46E5',
+      backgroundColor: data.style?.backgroundColor || '#ffffff',
+      borderStyle: (data.style?.borderStyle as 'black' | 'white') || 'white',
+    };
+
+    // 第一次设置默认激活的 tab
+    if (!currentTabId.value) {
+      currentTabId.value = tabs[0].id;
+    } else {
+      // 若 currentTabId 已被服务端移除,回到第一个
+      const stillExists = tabs.some(t => t.id === currentTabId.value);
+      if (!stillExists) currentTabId.value = tabs[0].id;
+    }
+  }
+
+  /**
+   * 根据当前页面路径同步 currentTabId
+   * (供 tabBar 渲染时高亮当前页)
+   */
+  function syncCurrentTabByPagePath(pagePath: string) {
+    if (!pagePath) return;
+    // 去除前缀 / 和相对路径
+    const normalized = pagePath.replace(/^\//, '');
+    // tabList 可能是 ref 或已 unwrap 的数组
+    const list = (tabList.value as any)?.value !== undefined
+      ? (tabList.value as any).value
+      : (tabList.value as any);
+    const match = (list || []).find((t: any) => t.pagePath === normalized || t.pagePath === pagePath);
+    if (match) currentTabId.value = match.id;
+  }
+
+  /**
+   * 通过 pagePath 切换 tab
+   *
+   * 行为:
+   * - pagePath 在 pages.json tabBar.list 中 → uni.switchTab
+   * - 否则 → uni.reLaunch(兜底,避免切不过去)
+   */
+  function switchTabByPath(pagePath: string): Promise<void> {
+    return new Promise((resolve, reject) => {
+      if (!pagePath) {
+        reject(new Error('pagePath 不能为空'));
+        return;
+      }
+      const target = pagePath.startsWith('/') ? pagePath : '/' + pagePath;
+      // 同步当前 tab 高亮(提前,避免时序问题)
+      syncCurrentTabByPagePath(pagePath);
+      uni.switchTab({
+        url: target,
+        success: () => {
+          resolve();
+        },
+        fail: (err) => {
+          console.warn('[Navigation] switchTab 失败,尝试 reLaunch:', err);
+          uni.reLaunch({
+            url: target,
+            success: () => {
+              resolve();
+            },
+            fail: (err2) => {
+              reject(err2);
+            },
+          });
+        },
+      });
+    });
+  }
+
+  /** 重置(用于登出或调试) */
+  function reset() {
+    tabList.value = [];
+    userCenterMenu.value = [];
+    loaded.value = false;
+    loadError.value = null;
+    currentTabId.value = null;
+  }
+
+  return {
+    // 状态
+    tabList,
+    userCenterMenu,
+    style,
+    loading,
+    loaded,
+    loadError,
+    currentTabId,
+    // 计算属性
+    isReady,
+    currentTab,
+    currentTabIndex,
+    // 方法
+    loadConfig,
+    applyConfig,
+    syncCurrentTabByPagePath,
+    switchTabByPath,
+    reset,
+  };
+});

+ 56 - 0
my-uniapp-vue3/src/types/navigation.ts

@@ -0,0 +1,56 @@
+/**
+ * 导航模块前端类型定义
+ *
+ * 字段命名与 server/src/modules/navigation/navigation.types.ts 完全一致。
+ * 后端字段变更时此处同步更新。
+ */
+
+/** 底部 tab 项 */
+export interface TabItem {
+  id: string;
+  /** uni-app 页面路径,例如 pages/index/index */
+  pagePath: string;
+  /** 显示文字 */
+  text: string;
+  /** emoji 或 unicode 符号 */
+  icon: string;
+  /** 选中态的 emoji 或符号 */
+  selectedIcon?: string;
+  iconPath?: string;
+  selectedIconPath?: string;
+  badge?: number | string;
+  order: number;
+  enabled: boolean;
+}
+
+/** 用户中心菜单项 */
+export interface UserCenterMenuItem {
+  id: string;
+  name: string;
+  icon: string;
+  type: 'page' | 'tab' | 'webview' | 'modal' | 'logout' | 'copy';
+  path?: string;
+  url?: string;
+  content?: string;
+  requireLogin: boolean;
+  tag?: string;
+  badge?: number | string;
+  order: number;
+  enabled: boolean;
+}
+
+/** TabBar 样式 */
+export interface TabBarStyle {
+  color: string;
+  selectedColor: string;
+  backgroundColor: string;
+  borderStyle: 'black' | 'white';
+}
+
+/** 导航配置整体响应 */
+export interface NavigationConfig {
+  tabs: TabItem[];
+  userCenterMenu: UserCenterMenuItem[];
+  style: TabBarStyle;
+  updatedAt: number;
+}