叶),每一级返回“完整url + 角标” * @param int $menuId 叶子或中间节点ID(directory/menu均可) * @param int $leafCount 该节点需要向上累加的消息数(正数可表示新增) * @param bool $includeHidden 是否包含隐藏/禁用菜单(默认 false:过滤 status != 1) * @return array */ public static function buildBadgeChain( int $menuId, int $leafCount, bool $includeHidden = false, bool $forceIncludeSelf = true ): array { // 取到从“叶->根”的节点列表 $nodes = self::getAncestorNodesIterative($menuId, $includeHidden, $forceIncludeSelf); if (empty($nodes)) { return []; } // 调整为“根->叶” $nodes = array_reverse($nodes); $segments = []; $result = []; $leafId = $menuId; foreach ($nodes as $n) { $isLeaf = ((int)$n['id'] === $leafId); // 中间层的 permission 不展示(但若有内部url段,仍需累计到segments) if (($n['type'] ?? '') === 'permission' && !$isLeaf) { // 若这是 internal 且有 url 段,仍拼到段里,便于子级正确拼接 $openType = strtolower((string)($n['open_type'] ?? 'internal')); if ($openType === 'internal') { $seg = trim((string)($n['url'] ?? ''), '/'); if ($seg !== '') { $segments[] = $seg; } } continue; } // 计算完整URL(internal 按段拼接;external/iframe 直接用 link/url) $fullUrl = self::composeFullUrl($segments, $n); // 叶子(传入ID)必须返回;其他节点照常 if ($isLeaf || ($n['type'] ?? '') !== 'permission') { $result[] = [ 'id' => (int)$n['id'], 'name' => (string)$n['name'], 'type' => (string)$n['type'], 'url' => $fullUrl, 'badge' => (int)$leafCount, ]; } } return $result; } /** * 批量聚合多个叶子节点的角标,把同一路径处的角标相加(父级=所有子级之和) * @param array $items * @param bool $includeHidden * @return array */ public static function aggregateChains(array $items, bool $includeHidden = false): array { $acc = []; // key = url foreach ($items as $it) { $menuId = (int)($it['menu_id'] ?? 0); $leafCount = (int)($it['count'] ?? 0); if ($menuId <= 0 || $leafCount === 0) { continue; } $chain = self::buildBadgeChain($menuId, $leafCount, $includeHidden); foreach ($chain as $node) { $key = $node['url']; if (!isset($acc[$key])) { $acc[$key] = ['url' => $node['url'], 'badge' => 0]; } $acc[$key]['badge'] += $node['badge']; } } // 父级在前(更短的路径在前),便于前端逐级渲染 usort($acc, function ($a, $b) { return substr_count($a['url'], '/') <=> substr_count($b['url'], '/'); }); return array_values($acc); } /** * 迭代法:从叶子一路查到根(MySQL版本无关) * $forceIncludeSelf=true 时,无论叶子 status 是否为1,都会把叶子纳入; * 但祖先节点仍按 $includeHidden 规则过滤。 */ private static function getAncestorNodesIterative( int $id, bool $includeHidden = false, bool $forceIncludeSelf = true ): array { $cols = ['id', 'parent_id', 'name', 'type', 'url', 'open_type', 'link', 'is_hide', 'status']; $nodes = []; $seen = []; $cur = $id; $maxHop = 32; // 防环 $isFirst = true; for ($i = 0; $i < $maxHop && $cur > 0; $i++) { /** @var object|null $row */ $row = Db::table('resources')->select($cols)->where('id', $cur)->first(); if (!$row) break; $rowArr = (array)$row; if (!$includeHidden) { // 叶子节点:若 $forceIncludeSelf=true 则无视 status 过滤;祖先照常过滤 if (!$isFirst && (int)$rowArr['status'] !== 1) { break; } if ($isFirst && !$forceIncludeSelf && (int)$rowArr['status'] !== 1) { // 如果明确不强制包含叶子,叶子也按规则过滤 break; } } $nodes[] = $rowArr; if (in_array($cur, $seen, true)) { break; // 防环 } $seen[] = $cur; $cur = (int)$rowArr['parent_id']; $isFirst = false; } return $nodes; // 叶 -> 根 } /** * 组装“完整路由” * - internal:把父级段 + 自身 url 段拼成形如 /a/b/c * - external/iframe:直接用 link(若无则退回自身 url) * - 忽略空段(避免出现重复斜杠) */ private static function composeFullUrl(array &$segments, array $node): string { $openType = strtolower((string)($node['open_type'] ?? 'internal')); // 外链或 iframe:直接返回 link/url,不再参与分段拼接 if ($openType !== 'internal') { $target = trim((string)($node['link'] ?? ''), " \t\r\n"); if ($target === '') { $target = (string)($node['url'] ?? ''); } return $target; } // internal:把自身 url 作为一个“段”拼到前面来的 segments $seg = trim((string)($node['url'] ?? ''), '/'); if ($seg !== '') { $segments[] = $seg; } return '/' . implode('/', $segments); } }