// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\docs\controller; use addon\docs\model\DocsDoc; use addon\docs\model\DocsProject; use addon\docs\model\DocsVersion; use addon\docs\service\DocTreeService; /** * 文档阅读页(三栏布局) * * @author ywxapp */ class Read extends DocsFrontend { /** * 项目入口:跳转到该项目第一篇文档 * * @param string $project 项目标识 */ public function entry($project = '') { $projectModel = DocsProject::findByName((string) $project); if (!$projectModel) { return $this->notFound('文档项目不存在或已下线'); } $version = DocsVersion::resolve((int) $projectModel->id, (string) $projectModel->default_version); if (!$version) { return $this->notFound('该项目尚未创建版本'); } $first = DocTreeService::firstDoc((int) $projectModel->id, (int) $version->id); if (!$first) { return $this->notFound('该项目暂无可阅读的文档'); } return redirect('/docs/' . $projectModel->name . '/' . $first['name']); } /** * 文档正文 * * @param string $project 项目标识 * @param string $name 文档标识 * @param string $version 版本标识,可为空 */ public function index($project = '', $name = '', $version = '') { $projectModel = DocsProject::findByName((string) $project); if (!$projectModel) { return $this->notFound('文档项目不存在或已下线'); } $versionModel = DocsVersion::resolve( (int) $projectModel->id, (string) ($version !== '' ? $version : $projectModel->default_version) ); if (!$versionModel) { return $this->notFound('该项目尚未创建版本'); } $projectId = (int) $projectModel->id; $versionId = (int) $versionModel->id; $doc = DocsDoc::findByName($projectId, $versionId, (string) $name); if (!$doc) { return $this->notFound('文档不存在或已下线'); } // 纯目录节点没有正文,跳到它下面第一篇可读文档 if ((int) $doc->is_dir === 1) { $child = DocsDoc::where('project_id', $projectId) ->where('version_id', $versionId) ->where('pid', $doc->id) ->where('status', 1) ->where('is_dir', 0) ->order('sort', 'asc')->order('id', 'asc') ->find(); if ($child) { return redirect($this->docUrl($projectModel, $versionModel, (string) $child->name)); } } DocsDoc::addViews((int) $doc->id); $tree = DocTreeService::buildTree($projectId, $versionId); $siblings = DocTreeService::siblings($projectId, $versionId, (int) $doc->id); $crumbs = DocTreeService::breadcrumb($projectId, $versionId, (int) $doc->id); // 给正文标题补 id 锚点,供右侧目录跳转 [$content, $toc] = $this->buildToc((string) $doc->content); // 默认版本用短地址,非默认版本地址里带版本段 $baseUrl = '/docs/' . $projectModel->name; if ((int) $versionModel->is_default !== 1) { $baseUrl .= '/' . $versionModel->name; } $treeHtml = $this->renderTree( $tree, $baseUrl, (int) $doc->id, array_column($crumbs, 'id') ); $updateAt = $doc->update_at; $updateTs = !is_numeric($updateAt) ? strtotime((string) $updateAt) : (int) $updateAt; $updateDate = date('Y-m-d', (int) $updateTs); $this->assign([ 'baseUrl' => $baseUrl, 'project' => $projectModel, 'version' => $versionModel, 'versionList' => DocsVersion::listByProject($projectId), 'doc' => $doc, 'content' => $content, 'toc' => $toc, 'treeHtml' => $treeHtml, 'updateDate' => $updateDate, 'crumbs' => $crumbs, 'prev' => $siblings['prev'], 'next' => $siblings['next'], 'activeId' => (int) $doc->id, 'openIds' => array_column($crumbs, 'id'), 'pageTitle' => ($doc->seo_title !== '' ? $doc->seo_title : $doc->title) . ' - ' . $projectModel->title, 'seoKeywords' => $doc->seo_keywords, 'seoDesc' => $doc->seo_desc !== '' ? $doc->seo_desc : mb_substr(strip_tags((string) $doc->content), 0, 150, 'UTF-8'), ]); return $this->fetch('read/index'); } /** * 递归渲染侧边文档树为 HTML(避免模板 {php} 块内定义函数导致的编译错误) * * @param array $nodes 树节点 * @param string $baseUrl 文档基础 URL * @param int $activeId 当前文档 ID * @param array $openIds 需要展开的祖先节点 ID * @return string */ protected function renderTree(array $nodes, string $baseUrl, int $activeId, array $openIds): string { if (empty($nodes)) { return ''; } $html = ''; return $html; } /** * 为正文中的 h2 / h3 标题补充锚点并抽取目录 * * @param string $html 正文 HTML * @return array{0: string, 1: array} */ protected function buildToc(string $html): array { if ($html === '') { return ['', []]; } $toc = []; $index = 0; $result = preg_replace_callback( '#]*)>(.*?)#is', function ($m) use (&$toc, &$index) { $level = (int) $m[1]; $attrs = $m[2]; $inner = $m[3]; $text = trim(strip_tags($inner)); if ($text === '') { return $m[0]; } $index++; $anchor = 'doc-h-' . $index; // 已有 id 时沿用,避免破坏作者自定义锚点 if (preg_match('/\sid\s*=\s*["\']([^"\']+)["\']/i', $attrs, $idMatch)) { $anchor = $idMatch[1]; } else { $attrs .= ' id="' . $anchor . '"'; } $toc[] = [ 'level' => $level, 'text' => $text, 'anchor' => $anchor, ]; return '' . $inner . ''; }, $html ); return [$result ?? $html, $toc]; } /** * 生成文档访问地址 * * @param DocsProject $project 项目 * @param DocsVersion $version 版本 * @param string $name 文档标识 * @return string */ protected function docUrl(DocsProject $project, DocsVersion $version, string $name): string { // 默认版本走短地址,非默认版本带上版本段 if ((int) $version->is_default === 1) { return '/docs/' . $project->name . '/' . $name; } return '/docs/' . $project->name . '/' . $version->name . '/' . $name; } /** * 渲染 404 提示页 * * @param string $message 提示语 */ protected function notFound(string $message) { $this->assign('message', $message); return $this->fetch('read/notfound'); } }