面包屑自动推导的算法设计:从“最短路径匹配”到工程可落地

面包屑自动推导的算法设计:从"最短路径匹配"到工程可落地

js 复制代码
/**
 * 面包屑组合式函数
 * @description 基于路由栈、菜单树与持久化缓存动态生成面包屑
 * @date 2025-12-11
 * @updated 2026-4-28 - 优化逻辑与类型定义
 */
import type { BreadcrumbItem } from '~/router/types'
import type { ResolvedBreadcrumbNode } from '~/types/breadcrumb'

import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { CACHE_KEY, useWebCache } from '~/composables/cache'
import { RouteConfig } from '~/config'
import { buildBreadcrumbRouteKey, useBreadcrumbStore } from '~/store/core/breadcrumb'
import { buildMenuTree, getCommonSegmentCount, normalizePath } from '~/utils/navigation'

/**
 * =============================================================================
 * 文件导读
 * =============================================================================
 * 这份 composable 的目标:把"当前路由"解析成可展示的面包屑列表。
 *
 * 一、核心输入来源
 * - 当前路由:`useRoute()`(path/name/meta/query/params)
 * - 后端菜单:`CACHE_KEY.ROLE_ROUTERS`(用于自动推导父级)
 * - 路由配置:`RouteConfig`(用于补全文案)
 * - 历史状态:`breadcrumbStore`(用于上下文继承与缓存)
 *
 * 二、策略优先级(由高到低)
 * 1)contextual:命中当前菜单链路
 * 2)closestMenu:按路径相似度自动猜父级菜单链路
 * 3)cached:当前 routeKey 对应的历史已解析结果
 * 4)inherited:从上一次访问轨迹弱继承(详情跳详情)
 * 5)currentOnly:只显示当前页
 *
 * 三、代码分区建议阅读顺序
 * 1)基础工具函数:去重/合并/比较(纯函数)
 * 2)menuTrails:把菜单树拍平成"根 → 叶子"链路集合
 * 3)关键策略函数:findClosestMenuTrail / buildContextualTrail
 * 4)弱兜底策略:buildInheritedTrail
 * 5)输出层:resolvedTrail → breadcrumbs → shouldShow
 *
 * 四、维护原则
 * - 先改注释中的策略描述,再改实现,保证"文档与代码一致"。
 * - 新增策略优先级时,务必同步 `resolveTrailByStrategies` 顺序。
 * - 避免在模板层做字符串拼装,统一在本文件完成。
 * =============================================================================
 */

/**
 * 后端菜单原始节点结构(来自 ROLE_ROUTERS 缓存)。
 * 注意:字段全部可选是为了兼容历史数据与不同后端版本返回。
 */
interface MenuNode {
  id?: string | number
  parentId?: string | number
  path?: string
  name?: string
  nameEn?: string
  visible?: boolean
  children?: MenuNode[]
}

/** 本地路由与配置可复用的最小元信息结构。 */
interface LocalRouteMeta {
  title?: string
  i18nKey?: string
}

type StrategySource = 'home' | 'contextual' | 'closestMenu' | 'cached' | 'inherited' | 'currentOnly'
type TrailScoreFn = (leafPath: string, currentPath: string) => number

/**
 * 将节点标准化为"可点击"节点。
 * 约定:除当前页节点外,其它面包屑节点默认可点击,最终点击态会在输出阶段再次收敛。
 */
function toBreadcrumbNode(item: Omit<ResolvedBreadcrumbNode, 'isClickable'>): ResolvedBreadcrumbNode {
  return {
    ...item,
    isClickable: true,
  }
}

/**
 * 去重并合并相邻重复节点。
 * 合并规则:
 * - 相邻且 path/name 相同视为同一节点;
 * - 以"后者覆盖前者"方式合并,保留最新字段(例如 i18nKey/title 修正值)。
 */
function dedupeTrail(items: ResolvedBreadcrumbNode[]): ResolvedBreadcrumbNode[] {
  return items.reduce<ResolvedBreadcrumbNode[]>((acc, item) => {
    const last = acc[acc.length - 1]
    if (last && last.path === item.path && last.name === item.name) {
      acc[acc.length - 1] = {
        ...last,
        ...item,
      }
      return acc
    }
    acc.push(item)
    return acc
  }, [])
}

/**
 * 将当前页节点合并到已有 trail 尾部。
 * - 若尾节点与 current path 相同:只更新尾节点,避免重复"当前节点";
 * - 否则直接 append。
 */
function mergeTrailWithCurrent(trail: ResolvedBreadcrumbNode[], currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
  if (!trail.length) {
    return [currentNode]
  }

  const last = trail[trail.length - 1]
  if (last.path === currentNode.path) {
    const mergedTrail = [...trail]
    mergedTrail[mergedTrail.length - 1] = {
      ...last,
      ...currentNode,
    }
    return mergedTrail
  }

  return [...trail, currentNode]
}

/**
 * 深比较两个 trail 是否语义一致。
 * 用于避免把完全相同的结果重复写入 store,减少无意义状态更新与 watcher 链式触发。
 */
function isSameTrail(a: ResolvedBreadcrumbNode[], b: ResolvedBreadcrumbNode[]): boolean {
  if (a.length !== b.length) {
    return false
  }
  return a.every((item, index) => {
    const other = b[index]
    if (!other) {
      return false
    }
    return item.title === other.title
      && item.titleEn === other.titleEn
      && item.path === other.path
      && item.name === other.name
      && item.i18nKey === other.i18nKey
      && item.isClickable === other.isClickable
  })
}

/**
 * 识别"详情类页面"路径:
 * 这类页面通常不在菜单中,应该优先回挂到同域"管理页"作为父级面包屑。
 */
function isDetailLikePath(path: string): boolean {
  return /\/(?:detail|edit|view|create|pay|statement)(?:\/|$)/i.test(path)
}

/**
 * 判断菜单路径是否带有"管理页"特征。
 * 在最短公共前缀分数相同的情况下,详情类页面优先选择该类菜单链路。
 */
function hasManageHint(path: string): boolean {
  return /\/[^/]*manage(?:ment)?(?:\/|$)/i.test(path)
}

/** 取路径首段(例:/import/si-manage -> import),用于候选分组裁剪。 */
function getPathRootSegment(path: string): string {
  return normalizePath(path).split('/').filter(Boolean)[0] || ''
}

/**
 * 面包屑策略分发器(纯函数)。
 * 按优先级选取第一条可用策略结果,确保行为可预测:
 * home > contextual > closest-menu > cached > inherited > current-only。
 */
export function resolveTrailByStrategies(params: {
  isHomeRoute: boolean
  homeTrail: ResolvedBreadcrumbNode[]
  contextualTrail: ResolvedBreadcrumbNode[]
  closestMenuTrail: ResolvedBreadcrumbNode[]
  cachedTrail: ResolvedBreadcrumbNode[]
  inheritedTrail: ResolvedBreadcrumbNode[]
  currentNode: ResolvedBreadcrumbNode
}): ResolvedBreadcrumbNode[] {
  if (params.isHomeRoute) {
    return dedupeTrail(params.homeTrail)
  }

  if (params.contextualTrail.length > 0) {
    return params.contextualTrail
  }

  if (params.closestMenuTrail.length > 0) {
    return dedupeTrail(mergeTrailWithCurrent(params.closestMenuTrail, params.currentNode))
  }

  if (params.cachedTrail.length > 0) {
    return dedupeTrail(mergeTrailWithCurrent(params.cachedTrail.slice(0, -1), params.currentNode))
  }

  if (params.inheritedTrail.length > 0) {
    return params.inheritedTrail
  }

  return [params.currentNode]
}

export function useBreadcrumb() {
  /** 当前路由对象(响应式) */
  const route = useRoute()
  /** 路由实例(用于按 name/path 查找注册路由) */
  const router = useRouter()
  /** 多语言能力(title 文案渲染) */
  const { t, locale } = useI18n()
  /** 本地缓存访问器(读取后端菜单树) */
  const { webCache } = useWebCache()
  /** 面包屑状态仓库(上下文/历史轨迹) */
  const breadcrumbStore = useBreadcrumbStore()

  /** 当前路由唯一键(path + query + params 归一后组合) */
  const routeKey = computed(() => buildBreadcrumbRouteKey(route))
  /** 首页路由标识(走首页特判策略) */
  const isHomeRoute = computed(() => route.meta?.routeSource === 'home')
  /** 调试开关:URL 带 breadcrumbDebug=1 时输出匹配日志(仅开发环境)。 */
  const breadcrumbDebugEnabled = computed(() => import.meta.env.DEV && String(route.query?.breadcrumbDebug || '') === '1')
  /** A/B 开关:URL 带 breadcrumbScoreAB=1 时打印实验评分对比(仅开发环境)。 */
  const breadcrumbScoreABEnabled = computed(() => import.meta.env.DEV && String(route.query?.breadcrumbScoreAB || '') === '1')

  /** router.getRoutes() -> path 元信息索引,避免在菜单 DFS 内部重复 find。 */
  const routeMetaByPath = computed<Map<string, LocalRouteMeta>>(() => {
    const metaMap = new Map<string, LocalRouteMeta>()
    router.getRoutes().forEach((routeRecord) => {
      const normalizedPath = normalizePath(routeRecord.path)
      if (!normalizedPath) return
      const title = typeof routeRecord.meta?.title === 'string' ? routeRecord.meta.title : undefined
      const i18nKey = typeof routeRecord.meta?.i18nKey === 'string' ? routeRecord.meta.i18nKey : undefined
      if (title || i18nKey) {
        metaMap.set(normalizedPath, {
          title,
          i18nKey,
        })
      }
    })
    return metaMap
  })

  /** RouteConfig -> path 元信息索引,作为本地路由元信息的二级兜底。 */
  const configMetaByPath = computed<Map<string, LocalRouteMeta>>(() => {
    const metaMap = new Map<string, LocalRouteMeta>()
    Object.values(RouteConfig).forEach((config) => {
      const record = config as { path?: string
        title?: string
        i18nKey?: string }
      const normalizedPath = normalizePath(record.path)
      if (!normalizedPath) return
      if (record.title || record.i18nKey) {
        metaMap.set(normalizedPath, {
          title: record.title,
          i18nKey: record.i18nKey,
        })
      }
    })
    return metaMap
  })

  /**
   * 按 path 获取本地路由文案元信息。
   * 优先级:
   * 1)已注册路由 meta;
   * 2)RouteConfig 静态配置;
   * 3)无匹配返回空对象。
   */
  function getLocalMetaByPath(path: string): LocalRouteMeta {
    const normalizedPath = normalizePath(path)
    return routeMetaByPath.value.get(normalizedPath) || configMetaByPath.value.get(normalizedPath) || {}
  }

  /**
   * 将后端菜单树拍平成"根 -> 叶子"的路径集合,供后续策略匹配使用。
   * 这里是自动面包屑的基础数据源:
   * 1) 命中 exact trail:当前路由路径与菜单叶子路径完全一致;
   * 2) 命中 closest trail:当前路由是详情页等非菜单页,按路径相似度回挂父菜单。
   */
  const menuTrails = computed<ResolvedBreadcrumbNode[][]>(() => {
    const menuList = webCache.get(CACHE_KEY.ROLE_ROUTERS) as MenuNode[] | null
    const trails: ResolvedBreadcrumbNode[][] = []
    const menuTree = Array.isArray(menuList) ? buildMenuTree(menuList) : []

    // 防御性限制菜单递归深度,避免异常树结构或循环引用导致无限递归与额外遍历开销。
    const MAX_MENU_WALK_DEPTH = 10

    /**
     * DFS 遍历菜单树并生成 trails。
     * @param nodes 当前层节点列表
     * @param parentTrail 父链路(不含当前节点)
     * @param depth 当前递归深度(用于保护递归)
     */
    function walk(nodes: MenuNode[], parentTrail: ResolvedBreadcrumbNode[] = [], depth = 0) {
      if (depth > MAX_MENU_WALK_DEPTH) {
        return
      }

      nodes.forEach((node) => {
        const backendTitle = typeof node.name === 'string' ? node.name.trim() : ''
        const backendTitleEn = typeof node.nameEn === 'string' ? node.nameEn.trim() : ''
        const currentPath = normalizePath(node.path)
        const localMeta = currentPath ? getLocalMetaByPath(currentPath) : {}

        const title = backendTitle || localMeta.title || ''
        const titleEn = backendTitleEn || backendTitle || localMeta.title || ''
        const i18nKey = backendTitle ? undefined : localMeta.i18nKey

        const hasChildren = Array.isArray(node.children) && node.children.length > 0

        // 分组节点(无 path)只作为层级容器,不入最终可跳转叶子集合。
        if (!currentPath) {
          if (!hasChildren) {
            return
          }

          const groupNode: ResolvedBreadcrumbNode = {
            title,
            titleEn,
            path: '',
            name: String(node.id || title || ''),
            i18nKey,
            isClickable: false,
          }
          const groupTrail = [...parentTrail, groupNode]
          walk(node.children || [], groupTrail, depth + 1)
          return
        }

        // 真实菜单路由节点,记录为一条可命中的 trail。
        const currentNode = toBreadcrumbNode({
          title,
          titleEn,
          path: currentPath,
          name: currentPath,
          i18nKey,
        })
        const currentTrail = [...parentTrail, currentNode]
        trails.push(currentTrail)

        if (hasChildren) {
          walk(node.children || [], currentTrail, depth + 1)
        }
      })
    }

    if (menuTree.length > 0) {
      walk(menuTree)
    }

    return trails
  })

  /** path -> exact trail 索引:用于 O(1) 命中精确菜单链路。 */
  const exactTrailByPath = computed<Map<string, ResolvedBreadcrumbNode[]>>(() => {
    const pathMap = new Map<string, ResolvedBreadcrumbNode[]>()
    menuTrails.value.forEach((trail) => {
      const leafPath = normalizePath(trail[trail.length - 1]?.path)
      if (!leafPath || pathMap.has(leafPath)) return
      pathMap.set(leafPath, trail)
    })
    return pathMap
  })

  /** 首段前缀 -> trails 索引:用于 closest 候选裁剪(同域优先)。 */
  const menuTrailsByRootSegment = computed<Map<string, ResolvedBreadcrumbNode[][]>>(() => {
    const segmentMap = new Map<string, ResolvedBreadcrumbNode[][]>()
    menuTrails.value.forEach((trail) => {
      const leafPath = trail[trail.length - 1]?.path
      const rootSegment = getPathRootSegment(leafPath || '')
      if (!rootSegment) return
      const group = segmentMap.get(rootSegment)
      if (group) {
        group.push(trail)
      } else {
        segmentMap.set(rootSegment, [trail])
      }
    })
    return segmentMap
  })

  /**
   * 所有菜单叶子 path 集合:
   * 用于快速判断某个节点是否"真正来自菜单",影响 inherited 策略拼接行为。
   */
  const exactMenuPaths = computed(() => new Set(exactTrailByPath.value.keys()))

  /**
   * 解析展示标题:
   * - 有 i18nKey 时优先走国际化;
   * - 无 i18nKey 时按语言选择 title/titleEn。
   */
  function resolveDisplayTitle(item: ResolvedBreadcrumbNode): string {
    const isEn = String(locale.value).toLowerCase().startsWith('en')
    if (item.i18nKey) {
      return t(`router.${item.i18nKey}`, item.title)
    }
    return isEn ? (item.titleEn || item.title) : (item.title || item.titleEn || '')
  }

  /**
   * 构造当前路由节点(最终 trail 的尾节点)。
   * 优先级:
   * 1) store 中自定义标题(业务运行态覆盖);
   * 2) 当前 route.meta 回退。
   */
  function createCurrentRouteNode(): ResolvedBreadcrumbNode {
    const customContext = breadcrumbStore.contextByRouteKey[routeKey.value]
    const routeName = typeof route.name === 'string' ? route.name : route.path

    if (customContext?.customTitle) {
      return {
        title: customContext.customTitle,
        titleEn: customContext.customTitleEn || customContext.customTitle,
        path: normalizePath(route.path),
        name: routeName,
        isClickable: false,
      }
    }

    return {
      title: typeof route.meta?.title === 'string' ? route.meta.title : routeName,
      titleEn: typeof route.meta?.title === 'string' ? route.meta.title : routeName,
      path: normalizePath(route.path),
      name: routeName,
      i18nKey: typeof route.meta?.i18nKey === 'string' ? route.meta.i18nKey : undefined,
      isClickable: false,
    }
  }

  /**
   * 精确匹配菜单 trail:仅 path 完全一致才命中。
   */
  function findExactMenuTrail(path: string): ResolvedBreadcrumbNode[] {
    const normalizedPath = normalizePath(path)
    return exactTrailByPath.value.get(normalizedPath) || []
  }

  /** 默认评分:公共路径段数量越多,说明语义越接近。 */
  const scoreByCommonSegments: TrailScoreFn = (leafPath, currentPath) => getCommonSegmentCount(leafPath, currentPath)

  /**
   * 实验评分:在公共段基础上轻微偏向"更短叶子路径"。
   * 仅用于 A/B 观测,不直接影响线上策略结果。
   */
  const scoreByCompactTrail: TrailScoreFn = (leafPath, currentPath) => {
    const commonSegments = getCommonSegmentCount(leafPath, currentPath)
    if (commonSegments <= 0) return 0
    const leafSegments = leafPath.split('/').filter(Boolean).length || 1
    return commonSegments + (1 / leafSegments) * 0.01
  }

  function pickBestTrail(
    candidates: ResolvedBreadcrumbNode[][],
    normalizedPath: string,
    scoreFn: TrailScoreFn,
  ): { bestTrail: ResolvedBreadcrumbNode[]
    bestScore: number
    tieBreakReason: string } {
    /** 当前最优候选 trail */
    let bestTrail: ResolvedBreadcrumbNode[] = []
    /** 当前最优分值 */
    let bestScore = 0
    /** 记录最后一次生效的 tie-break 原因,便于观测与回放。 */
    let tieBreakReason = 'none'
    /** 详情页标识:同分时启用"优先管理页"策略 */
    const currentIsDetailLike = isDetailLikePath(normalizedPath)

    candidates.forEach((trail) => {
      const leaf = trail[trail.length - 1]
      if (!leaf?.path) return

      const score = scoreFn(leaf.path, normalizedPath)
      if (score > bestScore) {
        bestScore = score
        bestTrail = trail
        tieBreakReason = 'higher_score'
      } else if (score === bestScore && score > 0) {
        // 同分时:详情类页面优先回挂"管理页"菜单(如 /import/si-manage)
        if (currentIsDetailLike) {
          const currentHasManageHint = hasManageHint(leaf.path)
          const bestHasManageHint = hasManageHint(bestTrail[bestTrail.length - 1]?.path || '')
          if (currentHasManageHint !== bestHasManageHint) {
            if (currentHasManageHint) {
              bestTrail = trail
              tieBreakReason = 'detail_manage_hint'
            }
            return
          }
        }

        // 仍同分时,选择层级更浅的菜单,减少错误挂到过深子页面。
        const currentLeafSegments = leaf.path.split('/').filter(Boolean).length
        const bestLeafSegments = bestTrail[bestTrail.length - 1]?.path?.split('/').filter(Boolean).length || Infinity
        if (currentLeafSegments < bestLeafSegments) {
          bestTrail = trail
          tieBreakReason = 'shallower_leaf'
        }
      }
    })

    return {
      bestTrail,
      bestScore,
      tieBreakReason,
    }
  }

  /**
   * 自动猜测"最可能父级菜单链路":
   * - 先尝试 exact 命中;
   * - 否则按路径公共段得分选择最接近链路;
   * - 若同分,详情类页面优先选择包含 manage/management 的菜单;
   * - 再同分时选择层级更浅的链路,避免误挂过深叶子节点。
   */
  function findClosestMenuTrail(path: string): ResolvedBreadcrumbNode[] {
    const normalizedPath = normalizePath(path)
    const exactTrail = findExactMenuTrail(normalizedPath)
    if (exactTrail.length > 0) {
      if (breadcrumbDebugEnabled.value) {
        console.info('[breadcrumb.closest] exact_match', {
          path: normalizedPath,
          source: 'exact',
          score: Number.POSITIVE_INFINITY,
        })
      }
      return exactTrail
    }

    const rootSegment = getPathRootSegment(normalizedPath)
    const groupedCandidates = rootSegment ? menuTrailsByRootSegment.value.get(rootSegment) : undefined
    const activeCandidates = groupedCandidates && groupedCandidates.length > 0 ? groupedCandidates : menuTrails.value
    const candidateSource = groupedCandidates && groupedCandidates.length > 0 ? 'root_segment' : 'global_fallback'

    const baselineResult = pickBestTrail(activeCandidates, normalizedPath, scoreByCommonSegments)

    if (breadcrumbDebugEnabled.value) {
      console.info('[breadcrumb.closest] matched', {
        path: normalizedPath,
        source: candidateSource,
        candidateCount: activeCandidates.length,
        score: baselineResult.bestScore,
        tieBreakReason: baselineResult.tieBreakReason,
        matchedPath: baselineResult.bestTrail[baselineResult.bestTrail.length - 1]?.path || '',
      })
    }

    if (breadcrumbScoreABEnabled.value) {
      const experimentResult = pickBestTrail(activeCandidates, normalizedPath, scoreByCompactTrail)
      console.info('[breadcrumb.closest] score_ab', {
        path: normalizedPath,
        baselineScore: baselineResult.bestScore,
        baselinePath: baselineResult.bestTrail[baselineResult.bestTrail.length - 1]?.path || '',
        experimentScore: experimentResult.bestScore,
        experimentPath: experimentResult.bestTrail[experimentResult.bestTrail.length - 1]?.path || '',
      })
    }

    return baselineResult.bestScore > 0 ? baselineResult.bestTrail : []
  }

  /**
   * contextual 策略:仅命中当前路由对应的精确菜单链路。
   */
  function buildContextualTrail(): ResolvedBreadcrumbNode[] {
    return findExactMenuTrail(route.path)
  }

  /**
   * 继承上一次已解析面包屑(弱兜底):
   * 用于详情跳详情、列表跳详情等连续跳转,减少"只剩当前节点"的退化情况。
   * 仅在存在 query/params 或详情类路径时启用,避免污染普通菜单页结果。
   */
  function buildInheritedTrail(currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
    const normalizedCurrentPath = normalizePath(route.path)
    const isHomeLikeRoute = normalizedCurrentPath === normalizePath(RouteConfig.Index.path)
      || route.path === '/'
      || route.path === '/index'
      || route.path === '/dashboard'
    if (isHomeLikeRoute) {
      return []
    }

    // 仅"详情类跳转"场景尝试继承,普通列表页不继承,避免脏链路。
    const hasQuery = Object.keys(route.query || {}).length > 0
    const hasParams = Object.keys(route.params || {}).length > 0
    const isDetailLikeRoutePath = isDetailLikePath(normalizedCurrentPath)
    if (!hasQuery && !hasParams && !isDetailLikeRoutePath) {
      return []
    }

    // 同一路由键不重复继承,避免形成自引用。
    if (breadcrumbStore.lastResolvedRouteKey === routeKey.value) {
      return []
    }

    // 上一次已解析并落库的 trail。
    const previousTrail = breadcrumbStore.lastResolvedTrail
    if (!previousTrail.length) {
      return []
    }

    const previousLast = previousTrail[previousTrail.length - 1]
    const commonSegments = getCommonSegmentCount(previousLast?.path, route.path)
    if (commonSegments < 1) {
      return []
    }

    // 若上一个尾节点本身是菜单节点,则整条继承;否则去掉旧尾节点再拼当前节点。
    const previousLastIsMenu = exactMenuPaths.value.has(normalizePath(previousLast?.path))
    const baseTrail = previousLastIsMenu ? [...previousTrail] : previousTrail.slice(0, -1)
    if (!baseTrail.length) {
      return []
    }

    if (baseTrail[baseTrail.length - 1]?.path === currentNode.path) {
      return baseTrail
    }

    return dedupeTrail([...baseTrail, currentNode])
  }

  /**
   * 首页链路构造:
   * - 当前就是首页:仅返回当前节点;
   * - 其它页面:返回"首页 > 当前页"。
   */
  function buildHomeTrail(currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
    const normalizedCurrentPath = normalizePath(route.path)
    const normalizedHomePath = normalizePath(RouteConfig.Index.path)
    if (!normalizedCurrentPath || normalizedCurrentPath === normalizedHomePath) {
      return [currentNode]
    }

    return [
      {
        title: RouteConfig.Index.title,
        titleEn: RouteConfig.Index.title,
        i18nKey: RouteConfig.Index.i18nKey,
        path: normalizedHomePath,
        name: RouteConfig.Index.name,
        isClickable: true,
      },
      currentNode,
    ]
  }

  function resolveStrategySource(params: {
    isHomeRoute: boolean
    contextualTrail: ResolvedBreadcrumbNode[]
    closestMenuTrail: ResolvedBreadcrumbNode[]
    cachedTrail: ResolvedBreadcrumbNode[]
    inheritedTrail: ResolvedBreadcrumbNode[]
  }): StrategySource {
    if (params.isHomeRoute) return 'home'
    if (params.contextualTrail.length > 0) return 'contextual'
    if (params.closestMenuTrail.length > 0) return 'closestMenu'
    if (params.cachedTrail.length > 0) return 'cached'
    if (params.inheritedTrail.length > 0) return 'inherited'
    return 'currentOnly'
  }

  const resolvedTrail = computed<ResolvedBreadcrumbNode[]>(() => {
    const currentNode = createCurrentRouteNode()
    // 面包屑策略优先级(从高到低):
    // 1) contextualTrail:命中当前菜单;
    // 2) closestMenuTrail:按路径相似度自动猜测父链路;
    // 3) cached/inherited:保留用户连续浏览上下文;
    // 4) currentOnly:仅保留当前页。
    const homeTrail = buildHomeTrail(currentNode)
    const contextualTrail = buildContextualTrail()
    const closestMenuTrail = findClosestMenuTrail(route.path)
    const cachedTrail = breadcrumbStore.getResolvedTrail(routeKey.value)
    const inheritedTrail = buildInheritedTrail(currentNode)
    const strategySource = resolveStrategySource({
      isHomeRoute: isHomeRoute.value,
      contextualTrail,
      closestMenuTrail,
      cachedTrail,
      inheritedTrail,
    })

    const resolved = resolveTrailByStrategies({
      isHomeRoute: isHomeRoute.value,
      homeTrail,
      contextualTrail,
      closestMenuTrail,
      cachedTrail,
      inheritedTrail,
      currentNode,
    })

    if (breadcrumbDebugEnabled.value) {
      console.info('[breadcrumb.strategy] resolved', {
        routePath: normalizePath(route.path),
        source: strategySource,
        trail: resolved.map(item => item.path),
      })
    }

    return resolved
  })

  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    // 主动读取 locale,确保语言切换时 computed 能触发重算。
    const _currentLocale = locale.value

    // 最终输出给 UI 的面包屑模型:统一在这里计算点击态。
    return resolvedTrail.value.map((item, index) => ({
      title: resolveDisplayTitle(item),
      path: item.path,
      name: item.name,
      i18nKey: item.i18nKey,
      isClickable: index < resolvedTrail.value.length - 1 && Boolean(item.path),
    }))
  })

  watch(
    () => [routeKey.value, resolvedTrail.value] as const,
    ([currentRouteKey, trail]) => {
      // 空键或空链路不入库,避免污染历史。
      if (!currentRouteKey || trail.length === 0) return
      // 持久化时再次收敛点击态,保证 store 中数据结构稳定。
      const persistedTrail = trail.map((item, index) => ({
        ...item,
        isClickable: index < trail.length - 1 && Boolean(item.path),
      }))
      const existingTrail = breadcrumbStore.getResolvedTrail(currentRouteKey)
      // 等价则跳过写入,减少重复状态变更。
      if (isSameTrail(existingTrail, persistedTrail)) {
        return
      }
      breadcrumbStore.saveResolvedTrail(currentRouteKey, persistedTrail)
    },
    {
      immediate: true,
    },
  )

  /**
   * 是否展示面包屑组件。
   * 规则:
   * - blank/purePage 布局隐藏;
   * - 登录注册等认证页面隐藏;
   * - 首页隐藏;
   * - 其余场景有面包屑数据才展示。
   */
  const shouldShow = computed<boolean>(() => {
    const hiddenLayouts = ['blank', 'purePage']
    const layoutType = route.meta?.layoutType

    if (hiddenLayouts.includes(layoutType as string)) {
      return false
    }

    const authPaths = ['/login', '/register', '/certification', '/forget']
    if (authPaths.includes(route.path)) {
      return false
    }

    if (route.path === '/' || route.path === '/index' || route.path === '/dashboard') {
      return false
    }

    return breadcrumbs.value.length > 0
  })

  return {
    breadcrumbs,
    shouldShow,
  }
}

1. 问题背景

在业务系统里,很多详情页并不直接出现在菜单树中。 如果只靠静态配置面包屑,维护成本高且容易错。 我们的目标是:让面包屑以菜单与路径自动推导为主,并保持策略可解释

2. 把面包屑问题抽象成"路径匹配"

可把它看成一个简化版最短路径匹配问题:

  • 输入:当前路由 route.path,历史访问上下文,菜单树。
  • 候选:菜单树中所有"根→叶子"链路。
  • 目标:找到最合理的父链路,再拼上当前节点。

这与"地图匹配"的思想相似: 观测是当前 URL,路网是菜单拓扑,最优路径是最终面包屑链路。

3. 当前方案:规则优先的近似最优

系统并没有走复杂的全局最优算法,而是采用了"可解释、可维护"的策略优先级:

  1. contextual(当前菜单精确命中)
  2. closestMenu(相似路径自动匹配)
  3. cached(同 routeKey 历史结果)
  4. inherited(连续跳转弱继承)
  5. currentOnly(只显示当前页)

优点很明显:

  • 可解释:每一步都能说明"为什么这么选"。
  • 稳定:策略顺序固定,行为可预测。
  • 成本低:前端实时计算压力可控。

4. 核心算法点

4.1 候选空间构建

先将菜单树拍平为"根→叶子"链路集合(trail),作为匹配候选集。 这一步决定了后续匹配上限。

4.2 相似度匹配(closestMenu)

对当前路径与候选叶子路径计算公共段得分。 同分时用两级 tie-break:

  • 详情页优先回挂 manage/management 菜单;
  • 再同分时选择更浅层级,减少误挂深节点。

4.3 时序信息(inherited)

对于"详情跳详情"场景,尝试继承上一条已解析链路,避免退化成"仅当前节点"。

5. 复杂度与瓶颈

当前复杂度主要来自两类线性扫描:

  • 多处 findExactMenuTrail 的全量遍历;
  • closestMenu 的全候选打分遍历。

在菜单规模增大时,这会放大开销,但仍是"可优化而非重构"。

6. 优化策略(保持简单)

6.1 建索引,替代重复扫描

  • Map<path, trail>:O(1) 命中 exact trail;
  • Map<path, meta>:O(1) 读取 route/config 文案。

6.2 候选裁剪

先按首段前缀分组(如 /import/export), closestMenu 仅在组内打分,再回退全局。

6.3 保持策略优先,不升级到复杂概率模型

HMM/Viterbi 适合长序列全局最优,但对前端面包屑属于过度设计。 当前场景下,策略优先 + 轻量评分是更优工程解。

7. 结语

这套方案的价值不在"数学最优",而在"业务最优":

  • 解释性强;
  • 运维成本低;
  • 扩展点明确(规则、评分、索引)。

一句话总结: 面包屑不是在拼字符串,而是在做一套可治理的轻量路径匹配系统。

8. 已落地演进(2026-04-28)

按"简单可维护 + 不改策略行为"的原则,已完成以下三步:

8.1 第一步:索引化改造(已完成)

  • 新增 exactTrailByPathpath -> trail,用于 O(1) 精确命中。
  • 新增 routeMetaByPathpath -> route meta,避免菜单 DFS 内反复 router.getRoutes().find(...)
  • 新增 configMetaByPathpath -> config meta,替代 Object.values(RouteConfig).find(...) 线性扫描。

收益:

  • 降低重复遍历,热点查询从"多次线性搜索"变为"哈希查找"。
  • 逻辑行为保持一致,只优化读取路径。

8.2 第二步:closest 候选裁剪 + 可观测日志(已完成)

  • 新增 menuTrailsByRootSegment:按首段前缀分组候选(如 importexport)。
  • closest 匹配优先在同前缀组内打分,无组时回退全量候选。
  • 新增调试日志(开发环境):
    • breadcrumbDebug=1:输出命中来源、候选规模、得分、tie-break 原因、最终命中路径。
    • 日志主题:[breadcrumb.closest][breadcrumb.strategy]

收益:

  • 常见场景减少无关候选打分,提升稳定性与可解释性。
  • 线上行为不受影响,调试信息按开关输出。

8.3 第三步:评分函数抽象 + A/B 评估(已完成)

  • 抽象评分函数接口 TrailScoreFn
  • 基线评分 scoreByCommonSegments 继续作为实际决策函数(保证兼容)。
  • 新增实验评分 scoreByCompactTrail 仅用于对比观测。
  • 新增 A/B 调试开关(开发环境):
    • breadcrumbScoreAB=1:输出 baseline 与 experiment 的分值和命中路径对比。

收益:

  • 为后续评分策略迭代提供低成本实验框架。
  • 避免"直接切算法"带来的不可控风险。
相关推荐
CinzWS1 小时前
A53性能验证:从微架构到系统级——芯片性能的“全息检测“
架构·芯片验证·原型验证·a53
不才小强2 小时前
gRPC实战指南:高性能微服务通信框架
微服务·云原生·架构
zandy10112 小时前
HENGSHI SENSE 6.2 架构全景解析:Data Agent、指标引擎与Headless语义层的工程实现
大数据·人工智能·架构
隔壁大炮2 小时前
Day07-RNN介绍
人工智能·pytorch·rnn·深度学习·神经网络·算法·numpy
WL_Aurora2 小时前
Python 算法基础篇之什么是算法
python·算法
墨染天姬3 小时前
[AI]DeepSeek-R1的GRPO算法
人工智能·算法·php
D_C_tyu3 小时前
JavaScript | 数独游戏核心算法实现
javascript·算法·游戏
qiqsevenqiqiqiqi3 小时前
MT2048三连 暴力→数学推导→O (n) 优化
数据结构·c++·算法