chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user