281 lines
9.3 KiB
PHP
281 lines
9.3 KiB
PHP
<?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\controller;
|
|
|
|
use addon\docs\model\DocsDoc;
|
|
use addon\docs\model\DocsProject;
|
|
use addon\docs\model\DocsVersion;
|
|
use addon\docs\service\DocTreeService;
|
|
|
|
/**
|
|
* 文档阅读页(三栏布局)
|
|
*
|
|
* @author ywxapp <admin@ywxapp.cn>
|
|
*/
|
|
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 = '<ul class="dc-tree">';
|
|
foreach ($nodes as $node) {
|
|
$id = (int) $node['id'];
|
|
$isDir = (int) ($node['is_dir'] ?? 0) === 1;
|
|
$hasChild = !empty($node['children']);
|
|
$isActive = $id === $activeId;
|
|
$isOpen = $isActive || in_array($id, $openIds, true);
|
|
$title = htmlspecialchars((string) $node['title'], ENT_QUOTES, 'UTF-8');
|
|
$url = $baseUrl . '/' . rawurlencode((string) $node['name']);
|
|
|
|
$liClass = [];
|
|
if ($hasChild) {
|
|
$liClass[] = 'has-child';
|
|
}
|
|
if ($isOpen) {
|
|
$liClass[] = 'is-open';
|
|
}
|
|
|
|
$html .= '<li class="' . implode(' ', $liClass) . '">';
|
|
$html .= '<div class="dc-tree-item' . ($isActive ? ' is-active' : '') . '">';
|
|
|
|
if ($hasChild) {
|
|
$html .= '<span class="dc-tree-toggle" aria-label="展开"></span>';
|
|
}
|
|
|
|
if ($isDir && $hasChild) {
|
|
$html .= '<span class="dc-tree-link is-dir">' . $title . '</span>';
|
|
} else {
|
|
$html .= '<a class="dc-tree-link" href="' . htmlspecialchars($url, ENT_QUOTES) . '">' . $title . '</a>';
|
|
}
|
|
|
|
$html .= '</div>';
|
|
|
|
if ($hasChild) {
|
|
$html .= $this->renderTree($node['children'], $baseUrl, $activeId, $openIds);
|
|
}
|
|
|
|
$html .= '</li>';
|
|
}
|
|
$html .= '</ul>';
|
|
|
|
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(
|
|
'#<h([23])([^>]*)>(.*?)</h\1>#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 '<h' . $level . $attrs . '>' . $inner . '</h' . $level . '>';
|
|
},
|
|
$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');
|
|
}
|
|
}
|