178 lines
6.2 KiB
PHP
178 lines
6.2 KiB
PHP
<?php
|
||
namespace app\common\service;
|
||
|
||
use support\Db;
|
||
|
||
class MenuBadgeService
|
||
{
|
||
/**
|
||
* 构造某个节点向上的角标链路(根->叶),每一级返回“完整url + 角标”
|
||
* @param int $menuId 叶子或中间节点ID(directory/menu均可)
|
||
* @param int $leafCount 该节点需要向上累加的消息数(正数可表示新增)
|
||
* @param bool $includeHidden 是否包含隐藏/禁用菜单(默认 false:过滤 status != 1)
|
||
* @return array<int, array{id:int,name:string,type:string,url:string,badge:int}>
|
||
*/
|
||
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<int, array{menu_id:int,count:int}> $items
|
||
* @param bool $includeHidden
|
||
* @return array<int, array{url:string,badge:int}>
|
||
*/
|
||
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);
|
||
}
|
||
} |