chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+301
View File
@@ -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;
}
}