chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 . '...';
|
||||
}
|
||||
}
|
||||
@@ -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