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
+564
View File
@@ -0,0 +1,564 @@
<?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;
/**
* 文档导入服务
*
* 支持三种来源:
* 1. .docx 优先使用 phpoffice/phpword,未安装时退回内置 ZIP + XML 解析
* 2. .md 内置轻量 Markdown 转 HTML
* 3. .html 直接清洗后入库
*
* @author ywxapp <admin@ywxapp.cn>
*/
class DocImportService
{
/**
* 允许导入的扩展名
*/
const ALLOW_EXT = ['docx', 'md', 'markdown', 'html', 'htm', 'txt'];
/**
* 解析上传文件为文档内容
*
* @param string $filePath 文件绝对路径
* @param string $ext 扩展名(小写)
* @return array{title: string, content: string, content_md: string}
* @throws \Exception 解析失败时抛出
*/
public static function parse(string $filePath, string $ext): array
{
if (!is_file($filePath)) {
throw new \Exception('导入文件不存在');
}
$ext = strtolower($ext);
if (!in_array($ext, self::ALLOW_EXT, true)) {
throw new \Exception('不支持的文件类型:' . $ext);
}
switch ($ext) {
case 'docx':
return self::parseDocx($filePath);
case 'md':
case 'markdown':
return self::parseMarkdown($filePath);
default:
return self::parseHtml($filePath);
}
}
/**
* 解析 docx
*
* @param string $filePath 文件路径
* @return array
* @throws \Exception
*/
protected static function parseDocx(string $filePath): array
{
if (class_exists('\PhpOffice\PhpWord\IOFactory')) {
try {
return self::parseDocxByPhpWord($filePath);
} catch (\Throwable $e) {
// PhpWord 解析异常时退回内置解析,保证功能可用
}
}
return self::parseDocxByZip($filePath);
}
/**
* 使用 phpoffice/phpword 解析 docx
*
* @param string $filePath 文件路径
* @return array
* @throws \Exception
*/
protected static function parseDocxByPhpWord(string $filePath): array
{
$reader = \PhpOffice\PhpWord\IOFactory::createReader('Word2007');
$document = $reader->load($filePath);
$writer = \PhpOffice\PhpWord\IOFactory::createWriter($document, 'HTML');
ob_start();
$writer->save('php://output');
$html = (string) ob_get_clean();
// PhpWord 输出整页 HTML,仅保留 body 内部
if (preg_match('#<body[^>]*>(.*?)</body>#is', $html, $m)) {
$html = $m[1];
}
$html = self::sanitize($html);
$title = self::guessTitle($html, $filePath);
return ['title' => $title, 'content' => $html, 'content_md' => ''];
}
/**
* 内置 docx 解析:解压 word/document.xml 后提取段落
*
* 不依赖任何扩展包,能还原段落、标题层级与加粗,适用于纯文字文档。
*
* @param string $filePath 文件路径
* @return array
* @throws \Exception
*/
protected static function parseDocxByZip(string $filePath): array
{
if (!class_exists('\ZipArchive')) {
throw new \Exception('解析 docx 需要 PHP 开启 zip 扩展,或安装 phpoffice/phpword');
}
$zip = new \ZipArchive();
if ($zip->open($filePath) !== true) {
throw new \Exception('无法打开 docx 文件,请确认文件未损坏');
}
$xml = $zip->getFromName('word/document.xml');
$zip->close();
if ($xml === false || $xml === '') {
throw new \Exception('docx 内容为空或格式不正确');
}
$prev = libxml_use_internal_errors(true);
$dom = new \DOMDocument();
$dom->loadXML($xml, LIBXML_NOCDATA | LIBXML_NONET);
libxml_clear_errors();
libxml_use_internal_errors($prev);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$paragraphs = $xpath->query('//w:body/w:p');
$htmlParts = [];
if ($paragraphs !== false) {
foreach ($paragraphs as $p) {
$text = '';
$runs = $xpath->query('.//w:t', $p);
if ($runs !== false) {
foreach ($runs as $t) {
$text .= $t->textContent;
}
}
$text = trim($text);
if ($text === '') {
continue;
}
// 读取段落样式名判断标题层级,如 Heading1 / 标题 1
$level = 0;
$styleNode = $xpath->query('.//w:pStyle/@w:val', $p);
if ($styleNode !== false && $styleNode->length > 0) {
$style = (string) $styleNode->item(0)->nodeValue;
if (preg_match('/(?:heading|标题)\s*([1-6])/i', $style, $m)) {
$level = (int) $m[1];
}
}
$safe = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($level >= 1 && $level <= 6) {
$htmlParts[] = '<h' . $level . '>' . $safe . '</h' . $level . '>';
} else {
$htmlParts[] = '<p>' . $safe . '</p>';
}
}
}
if (empty($htmlParts)) {
throw new \Exception('未能从 docx 中提取到文本内容');
}
$html = implode("\n", $htmlParts);
$title = self::guessTitle($html, $filePath);
return ['title' => $title, 'content' => $html, 'content_md' => ''];
}
/**
* 解析 Markdown 文件
*
* @param string $filePath 文件路径
* @return array
*/
protected static function parseMarkdown(string $filePath): array
{
$md = (string) file_get_contents($filePath);
$md = self::normalizeEncoding($md);
$html = self::markdownToHtml($md);
$title = '';
// 优先取首个一级标题作为文档标题
if (preg_match('/^\s*#\s+(.+)$/m', $md, $m)) {
$title = trim($m[1]);
}
if ($title === '') {
$title = self::guessTitle($html, $filePath);
}
return ['title' => $title, 'content' => $html, 'content_md' => $md];
}
/**
* 解析 HTML / 纯文本文件
*
* @param string $filePath 文件路径
* @return array
*/
protected static function parseHtml(string $filePath): array
{
$raw = (string) file_get_contents($filePath);
$raw = self::normalizeEncoding($raw);
if (preg_match('#<body[^>]*>(.*?)</body>#is', $raw, $m)) {
$raw = $m[1];
}
// 纯文本按行包 p 标签
if (strip_tags($raw) === $raw) {
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$parts = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '') {
$parts[] = '<p>' . htmlspecialchars($line, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</p>';
}
}
$raw = implode("\n", $parts);
}
$html = self::sanitize($raw);
$title = self::guessTitle($html, $filePath);
return ['title' => $title, 'content' => $html, 'content_md' => ''];
}
/**
* 轻量 Markdown 转 HTML
*
* 覆盖文档站常用语法:标题、围栏代码块、行内代码、粗斜体、
* 链接、图片、无序 / 有序列表、引用、分隔线、表格。
* 若项目已安装 erusev/parsedown 则优先使用。
*
* @param string $md Markdown 源码
* @return string
*/
public static function markdownToHtml(string $md): string
{
if (class_exists('\Parsedown')) {
try {
$parser = new \Parsedown();
if (method_exists($parser, 'setSafeMode')) {
$parser->setSafeMode(true);
}
return (string) $parser->text($md);
} catch (\Throwable $e) {
// 解析失败退回内置实现
}
}
$md = str_replace(["\r\n", "\r"], "\n", $md);
// 先抽出围栏代码块,避免其内部符号被后续规则误处理
$blocks = [];
$md = preg_replace_callback('/```([a-zA-Z0-9#+\-]*)\n(.*?)```/s', function ($m) use (&$blocks) {
$lang = $m[1] !== '' ? ' class="language-' . htmlspecialchars($m[1], ENT_QUOTES) . '"' : '';
$code = htmlspecialchars($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$token = '@@DOCS_CODE_' . count($blocks) . '@@';
$blocks[$token] = '<pre><code' . $lang . '>' . $code . '</code></pre>';
return "\n" . $token . "\n";
}, $md) ?? $md;
$lines = explode("\n", $md);
$out = [];
$listType = ''; // ul / ol / 空
$inQuote = false;
$inTable = false;
$closeList = function () use (&$listType, &$out) {
if ($listType !== '') {
$out[] = '</' . $listType . '>';
$listType = '';
}
};
$closeQuote = function () use (&$inQuote, &$out) {
if ($inQuote) {
$out[] = '</blockquote>';
$inQuote = false;
}
};
$closeTable = function () use (&$inTable, &$out) {
if ($inTable) {
$out[] = '</tbody></table>';
$inTable = false;
}
};
foreach ($lines as $i => $line) {
$trim = trim($line);
// 代码块占位符原样输出
if (isset($blocks[$trim])) {
$closeList();
$closeQuote();
$closeTable();
$out[] = $trim;
continue;
}
if ($trim === '') {
$closeList();
$closeQuote();
$closeTable();
continue;
}
// 分隔线
if (preg_match('/^(\*{3,}|-{3,}|_{3,})$/', $trim)) {
$closeList();
$closeQuote();
$closeTable();
$out[] = '<hr>';
continue;
}
// 标题
if (preg_match('/^(#{1,6})\s+(.*)$/', $trim, $m)) {
$closeList();
$closeQuote();
$closeTable();
$level = strlen($m[1]);
$out[] = '<h' . $level . '>' . self::inline($m[2]) . '</h' . $level . '>';
continue;
}
// 表格:当前行是 | a | b |,下一行是分隔行
if (!$inTable && strpos($trim, '|') !== false
&& isset($lines[$i + 1]) && preg_match('/^\s*\|?[\s:\-\|]+\|[\s:\-\|]*$/', $lines[$i + 1])) {
$closeList();
$closeQuote();
$cells = self::tableCells($trim);
$out[] = '<table><thead><tr>';
foreach ($cells as $cell) {
$out[] = '<th>' . self::inline($cell) . '</th>';
}
$out[] = '</tr></thead><tbody>';
$inTable = true;
continue;
}
if ($inTable) {
// 跳过分隔行
if (preg_match('/^\s*\|?[\s:\-\|]+\|[\s:\-\|]*$/', $trim)) {
continue;
}
if (strpos($trim, '|') !== false) {
$cells = self::tableCells($trim);
$out[] = '<tr>';
foreach ($cells as $cell) {
$out[] = '<td>' . self::inline($cell) . '</td>';
}
$out[] = '</tr>';
continue;
}
$closeTable();
}
// 引用
if (preg_match('/^>\s?(.*)$/', $trim, $m)) {
$closeList();
if (!$inQuote) {
$out[] = '<blockquote>';
$inQuote = true;
}
$out[] = '<p>' . self::inline($m[1]) . '</p>';
continue;
}
$closeQuote();
// 无序列表
if (preg_match('/^[\*\-\+]\s+(.*)$/', $trim, $m)) {
if ($listType !== 'ul') {
$closeList();
$out[] = '<ul>';
$listType = 'ul';
}
$out[] = '<li>' . self::inline($m[1]) . '</li>';
continue;
}
// 有序列表
if (preg_match('/^\d+\.\s+(.*)$/', $trim, $m)) {
if ($listType !== 'ol') {
$closeList();
$out[] = '<ol>';
$listType = 'ol';
}
$out[] = '<li>' . self::inline($m[1]) . '</li>';
continue;
}
$closeList();
$out[] = '<p>' . self::inline($trim) . '</p>';
}
$closeList();
$closeQuote();
$closeTable();
$html = implode("\n", $out);
// 还原代码块
if (!empty($blocks)) {
$html = strtr($html, $blocks);
}
return $html;
}
/**
* 拆分表格行单元格
*
* @param string $line 表格行
* @return array
*/
protected static function tableCells(string $line): array
{
$line = trim($line, "| \t");
$cells = explode('|', $line);
return array_map('trim', $cells);
}
/**
* 处理行内 Markdown 语法
*
* @param string $text 行内文本
* @return string
*/
protected static function inline(string $text): string
{
$text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// 行内代码优先,避免其中的星号被当作强调
$codes = [];
$text = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codes) {
$token = '@@DOCS_IC_' . count($codes) . '@@';
$codes[$token] = '<code>' . $m[1] . '</code>';
return $token;
}, $text) ?? $text;
// 图片
$text = preg_replace('/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/', '<img src="$2" alt="$1">', $text) ?? $text;
// 链接
$text = preg_replace('/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/', '<a href="$2" target="_blank" rel="noopener">$1</a>', $text) ?? $text;
// 加粗
$text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/__([^_]+)__/', '<strong>$1</strong>', $text) ?? $text;
// 斜体
$text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text) ?? $text;
// 删除线
$text = preg_replace('/~~([^~]+)~~/', '<del>$1</del>', $text) ?? $text;
if (!empty($codes)) {
$text = strtr($text, $codes);
}
return $text;
}
/**
* 清洗 HTML,移除脚本与事件属性
*
* @param string $html 原始 HTML
* @return string
*/
public static function sanitize(string $html): string
{
if ($html === '') {
return '';
}
$html = preg_replace('#<(script|style|iframe|object|embed)\b[^>]*>.*?</\1>#is', '', $html) ?? $html;
$html = preg_replace('#<\?php.*?\?>#is', '', $html) ?? $html;
// 移除 onclick 等事件属性
$html = preg_replace('/\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html) ?? $html;
// 移除 javascript: 协议
$html = preg_replace('/(href|src)\s*=\s*("|\')\s*javascript:[^"\']*\2/i', '$1="#"', $html) ?? $html;
return trim($html);
}
/**
* 推测标题:优先取首个标题标签,其次取文件名
*
* @param string $html 内容
* @param string $filePath 文件路径
* @return string
*/
protected static function guessTitle(string $html, string $filePath): string
{
if (preg_match('#<h[1-3][^>]*>(.*?)</h[1-3]>#is', $html, $m)) {
$title = trim(strip_tags($m[1]));
if ($title !== '') {
return mb_substr($title, 0, 200, 'UTF-8');
}
}
$base = pathinfo($filePath, PATHINFO_FILENAME);
return $base !== '' ? mb_substr($base, 0, 200, 'UTF-8') : '未命名文档';
}
/**
* 统一转 UTF-8,兼容 GBK 编码的文本文件
*
* @param string $text 原始文本
* @return string
*/
protected static function normalizeEncoding(string $text): string
{
if ($text === '' || !function_exists('mb_detect_encoding')) {
return $text;
}
// 去除 UTF-8 BOM
$text = preg_replace('/^\xEF\xBB\xBF/', '', $text) ?? $text;
$encoding = mb_detect_encoding($text, ['UTF-8', 'GB18030', 'GBK', 'BIG5'], true);
if ($encoding && strtoupper($encoding) !== 'UTF-8') {
$converted = @mb_convert_encoding($text, 'UTF-8', $encoding);
if ($converted !== false) {
return $converted;
}
}
return $text;
}
/**
* 由标题生成 URL 标识
*
* @param string $title 标题
* @return string
*/
public static function slugify(string $title): string
{
$slug = mb_strtolower(trim($title), 'UTF-8');
// 仅保留字母数字与中划线,中文标题回退为拼音无关的哈希短码
$ascii = preg_replace('/[^a-z0-9]+/u', '-', $slug) ?? '';
$ascii = trim($ascii, '-');
if ($ascii === '' || !preg_match('/[a-z0-9]/', $ascii)) {
return 'doc-' . substr(md5($title . microtime(true)), 0, 8);
}
return mb_substr($ascii, 0, 80, 'UTF-8');
}
}
+197
View File
@@ -0,0 +1,197 @@
<?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 addon\docs\model\DocsSearch;
/**
* 文档搜索服务
*
* 索引表保存正文纯文本副本,避免每次搜索都对 longtext 富文本做 LIKE。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class DocSearchService
{
/**
* 摘要截取长度
*/
const SNIPPET_LENGTH = 120;
/**
* 写入 / 更新单篇文档索引
*
* @param DocsDoc|array $doc 文档模型或数组
* @return void
*/
public static function index($doc): void
{
$data = $doc instanceof DocsDoc ? $doc->toArray() : (array) $doc;
$id = (int) ($data['id'] ?? 0);
if ($id <= 0) {
return;
}
// 纯目录节点或隐藏文档不进索引
if ((int) ($data['is_dir'] ?? 0) === 1 || (int) ($data['status'] ?? 1) !== 1) {
self::remove($id);
return;
}
$plain = self::toPlainText((string) ($data['content'] ?? ''));
$payload = [
'doc_id' => $id,
'project_id' => (int) ($data['project_id'] ?? 0),
'version_id' => (int) ($data['version_id'] ?? 0),
'title' => (string) ($data['title'] ?? ''),
'keywords' => (string) ($data['seo_keywords'] ?? ''),
'plain_text' => $plain,
];
$exists = DocsSearch::where('doc_id', $id)->find();
if ($exists) {
$exists->save($payload);
} else {
DocsSearch::create($payload);
}
}
/**
* 删除单篇文档索引
*
* @param int $docId 文档ID
* @return void
*/
public static function remove(int $docId): void
{
if ($docId > 0) {
DocsSearch::where('doc_id', $docId)->delete();
}
}
/**
* 重建全部索引
*
* @param int $projectId 限定项目,0 表示全部
* @return int 已索引条数
*/
public static function rebuild(int $projectId = 0): int
{
$query = DocsDoc::where('status', 1)->where('is_dir', 0);
if ($projectId > 0) {
$query->where('project_id', $projectId);
}
$count = 0;
$query->chunk(100, function ($docs) use (&$count) {
foreach ($docs as $doc) {
self::index($doc);
$count++;
}
});
return $count;
}
/**
* 执行搜索
*
* @param string $keyword 关键词
* @param int $projectId 限定项目,0 表示全部
* @param int $limit 返回条数
* @return array
*/
public static function search(string $keyword, int $projectId = 0, int $limit = 30): array
{
$keyword = trim($keyword);
if ($keyword === '') {
return [];
}
// 转义 LIKE 通配符,防止用户输入 % 造成全表扫描
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword);
$like = '%' . $escaped . '%';
$query = DocsSearch::alias('s')
->join('docs_doc d', 'd.id = s.doc_id')
->join('docs_project p', 'p.id = s.project_id')
->join('docs_version v', 'v.id = s.version_id')
->field('s.doc_id,s.title,s.plain_text,d.name as doc_name,p.name as project_name,p.title as project_title,v.name as version_name')
->where('d.status', 1)
->where('p.status', 1)
->where(function ($q) use ($like) {
$q->whereLike('s.title', $like)
->whereOr('s.keywords', 'like', $like)
->whereOr('s.plain_text', 'like', $like);
});
if ($projectId > 0) {
$query->where('s.project_id', $projectId);
}
$rows = $query->limit($limit)->select()->toArray();
foreach ($rows as &$row) {
$row['snippet'] = self::snippet((string) $row['plain_text'], $keyword);
unset($row['plain_text']);
}
unset($row);
return $rows;
}
/**
* HTML 转纯文本
*
* @param string $html 富文本
* @return string
*/
public static function toPlainText(string $html): string
{
if ($html === '') {
return '';
}
// 先移除脚本与样式,避免其内容混入正文
$html = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
// 块级标签替换为空格,防止相邻段落文字粘连
$html = preg_replace('#<(br|/p|/div|/li|/h[1-6]|/tr)\s*/?>#i', ' ', $html) ?? $html;
$text = strip_tags($html);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
}
/**
* 生成命中摘要
*
* @param string $text 纯文本
* @param string $keyword 关键词
* @return string
*/
protected static function snippet(string $text, string $keyword): string
{
if ($text === '') {
return '';
}
$pos = mb_stripos($text, $keyword, 0, 'UTF-8');
if ($pos === false) {
return mb_substr($text, 0, self::SNIPPET_LENGTH, 'UTF-8');
}
$start = max(0, $pos - 40);
$snippet = mb_substr($text, $start, self::SNIPPET_LENGTH, 'UTF-8');
return ($start > 0 ? '...' : '') . $snippet . '...';
}
}
+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;
}
}