|
|
@@ -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,
|
|
|
+ };
|
|
|
+});
|