Files
YwxAppThink/addon/docs/service/DocTreeService.php
T

302 lines
8.7 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare (strict_types = 1);
namespace addon\docs\service;
use addon\docs\model\DocsDoc;
use think\facade\Cache;
/**
* 文档目录树服务
*
* 负责目录树构建、扁平化、上下篇计算与面包屑生成。
* 一次性取出项目 + 版本下的全部节点,在内存中建树,避免递归查库。
*
* @author ywxapp <admin@ywxapp.cn>
*/
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;
}
}