// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\docs\service; use addon\docs\model\DocsDoc; use think\facade\Cache; /** * 文档目录树服务 * * 负责目录树构建、扁平化、上下篇计算与面包屑生成。 * 一次性取出项目 + 版本下的全部节点,在内存中建树,避免递归查库。 * * @author ywxapp */ class DocTreeService { /** * 缓存标签,便于文档变更时整体失效 */ const CACHE_TAG = 'docs_tree'; /** * 构建目录树 * * @param int $projectId 项目ID * @param int $versionId 版本ID * @param bool $onlyEnable 是否只取启用节点,后台管理传 false * @return array 树形数组,每个节点含 children 键 */ public static function buildTree(int $projectId, int $versionId, bool $onlyEnable = true): array { $nodes = self::rawNodes($projectId, $versionId, $onlyEnable); return self::toTree($nodes); } /** * 获取扁平的有序文档列表(深度优先,与目录树展示顺序一致) * * 用于计算上一篇 / 下一篇,纯目录节点会被排除。 * * @param int $projectId 项目ID * @param int $versionId 版本ID * @param bool $onlyEnable 是否只取启用节点 * @return array */ public static function flatten(int $projectId, int $versionId, bool $onlyEnable = true): array { $tree = self::buildTree($projectId, $versionId, $onlyEnable); $list = []; self::flattenTree($tree, $list); return $list; } /** * 计算上一篇 / 下一篇 * * @param int $projectId 项目ID * @param int $versionId 版本ID * @param int $currentId 当前文档ID * @return array{prev: array|null, next: array|null} */ public static function siblings(int $projectId, int $versionId, int $currentId): array { $list = self::flatten($projectId, $versionId); $index = -1; foreach ($list as $i => $item) { if ((int) $item['id'] === $currentId) { $index = $i; break; } } if ($index < 0) { return ['prev' => null, 'next' => null]; } return [ 'prev' => $list[$index - 1] ?? null, 'next' => $list[$index + 1] ?? null, ]; } /** * 生成面包屑路径(从顶级到当前节点) * * @param int $projectId 项目ID * @param int $versionId 版本ID * @param int $currentId 当前文档ID * @return array */ public static function breadcrumb(int $projectId, int $versionId, int $currentId): array { $nodes = self::rawNodes($projectId, $versionId, true); $map = []; foreach ($nodes as $node) { $map[(int) $node['id']] = $node; } $crumbs = []; $cursor = $currentId; $guard = 0; while (isset($map[$cursor]) && $guard < 50) { array_unshift($crumbs, $map[$cursor]); $cursor = (int) $map[$cursor]['pid']; $guard++; } return $crumbs; } /** * 获取目录树中第一篇可阅读的文档 * * @param int $projectId 项目ID * @param int $versionId 版本ID * @return array|null */ public static function firstDoc(int $projectId, int $versionId): ?array { $list = self::flatten($projectId, $versionId); return $list[0] ?? null; } /** * 构建下拉选择用的层级选项(标题带缩进前缀) * * @param int $projectId 项目ID * @param int $versionId 版本ID * @param int $excludeId 需排除的节点ID(编辑时排除自身及其子树) * @return array */ public static function selectOptions(int $projectId, int $versionId, int $excludeId = 0): array { $tree = self::buildTree($projectId, $versionId, false); $options = []; self::walkOptions($tree, $options, 0, $excludeId); return $options; } /** * 清空目录树缓存 * * @return void */ public static function clearCache(): void { try { Cache::tag(self::CACHE_TAG)->clear(); } catch (\Throwable $e) { // 缓存驱动不支持标签时忽略,不影响业务 } } /** * 读取原始节点列表(带缓存) * * @param int $projectId 项目ID * @param int $versionId 版本ID * @param bool $onlyEnable 是否只取启用节点 * @return array */ protected static function rawNodes(int $projectId, int $versionId, bool $onlyEnable): array { $ttl = (int) self::configValue('tree_cache_ttl', 3600); $key = 'docs_nodes_' . $projectId . '_' . $versionId . '_' . ($onlyEnable ? 1 : 0); if ($ttl > 0) { try { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } } catch (\Throwable $e) { // 缓存读取失败时直接查库 } } $query = DocsDoc::field('id,pid,name,title,is_dir,sort,status,project_id,version_id') ->where('project_id', $projectId) ->where('version_id', $versionId); if ($onlyEnable) { $query->where('status', 1); } $nodes = $query->order('sort', 'asc')->order('id', 'asc')->select()->toArray(); if ($ttl > 0) { try { Cache::tag(self::CACHE_TAG)->set($key, $nodes, $ttl); } catch (\Throwable $e) { // 缓存写入失败不影响返回 } } return $nodes; } /** * 数组建树 * * @param array $nodes 平铺节点 * @return array */ protected static function toTree(array $nodes): array { $map = []; foreach ($nodes as $node) { $node['children'] = []; $map[(int) $node['id']] = $node; } $tree = []; foreach ($map as $id => $node) { $pid = (int) $node['pid']; if ($pid > 0 && isset($map[$pid])) { $map[$pid]['children'][] = &$map[$id]; } else { $tree[] = &$map[$id]; } } unset($node); return $tree; } /** * 深度优先展开树,仅收集可阅读文档 * * @param array $tree 树 * @param array $list 输出列表(引用) * @return void */ protected static function flattenTree(array $tree, array &$list): void { foreach ($tree as $node) { if ((int) $node['is_dir'] !== 1) { $item = $node; unset($item['children']); $list[] = $item; } if (!empty($node['children'])) { self::flattenTree($node['children'], $list); } } } /** * 递归生成层级选项 * * @param array $tree 树 * @param array $options 输出(引用) * @param int $depth 当前深度 * @param int $excludeId 排除节点ID * @return void */ protected static function walkOptions(array $tree, array &$options, int $depth, int $excludeId): void { foreach ($tree as $node) { if ($excludeId > 0 && (int) $node['id'] === $excludeId) { continue; } $options[] = [ 'id' => (int) $node['id'], 'title' => str_repeat(' ', $depth) . ($depth > 0 ? '└ ' : '') . $node['title'], ]; if (!empty($node['children'])) { self::walkOptions($node['children'], $options, $depth + 1, $excludeId); } } } /** * 读取插件配置项 * * @param string $key 配置键 * @param mixed $default 默认值 * @return mixed */ protected static function configValue(string $key, $default = null) { try { $config = get_addon_config('docs'); if (is_array($config) && isset($config[$key]) && $config[$key] !== '') { return $config[$key]; } } catch (\Throwable $e) { // 配置读取失败时使用默认值 } return $default; } }