chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<?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;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\AddonBase;
|
||||
|
||||
/**
|
||||
* 文档中心插件
|
||||
*
|
||||
* 菜单与建表/种子数据由框架统一处理:
|
||||
* - 菜单:addon/docs/menu.json
|
||||
* - 数据:addon/docs/install.sql(框架 importsql 白名单执行 CREATE TABLE / INSERT)
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Addon extends addon
|
||||
{
|
||||
/**
|
||||
* 安装钩子
|
||||
*/
|
||||
public function install()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载钩子
|
||||
*
|
||||
* 框架 AddonService::deleteMenu() 只清理 admin_power,
|
||||
* 这里补清 user_rule 中本插件的前端菜单(按 name 前缀),避免残留。
|
||||
* 采用物理删除,防止软删除后唯一键 name 冲突导致重装失败。
|
||||
*/
|
||||
public function uninstall()
|
||||
{
|
||||
Db::execute("DELETE FROM wxapp_user_rule WHERE name LIKE 'docs:%'");
|
||||
Db::execute("DELETE FROM wxapp_user_rule WHERE name LIKE 'docs/%'");
|
||||
Db::execute("DELETE FROM wxapp_admin_power WHERE addon='docs'");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用钩子:清理目录树缓存
|
||||
*/
|
||||
public function enabled()
|
||||
{
|
||||
\think\facade\Cache::tag('docs_tree')->clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用钩子:清理目录树缓存
|
||||
*/
|
||||
public function disabled()
|
||||
{
|
||||
\think\facade\Cache::tag('docs_tree')->clear();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// 文档中心插件配置定义
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => 'site_title',
|
||||
'title' => '文档站标题',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'YwxApp 文档中心',
|
||||
'rule' => 'require',
|
||||
'msg' => '请填写文档站标题',
|
||||
'tip' => '显示在文档中心页面顶部与浏览器标题',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'search_limit',
|
||||
'title' => '搜索结果条数',
|
||||
'type' => 'number',
|
||||
'content' => [],
|
||||
'value' => '30',
|
||||
'rule' => 'require',
|
||||
'msg' => '请填写搜索结果条数',
|
||||
'tip' => '全文搜索单次返回的最大结果数',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'tree_cache_ttl',
|
||||
'title' => '目录树缓存秒数',
|
||||
'type' => 'number',
|
||||
'content' => [],
|
||||
'value' => '3600',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '文档目录树缓存时长,0 表示不缓存;后台保存文档会自动清缓存',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'upload_dir',
|
||||
'title' => '编辑器上传目录',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'uploads/docs',
|
||||
'rule' => 'require',
|
||||
'msg' => '请填写上传目录',
|
||||
'tip' => '相对 public 的目录,UEditor 图片/附件将保存到此处',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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\DocsProject;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
/**
|
||||
* 文档中心前台基类
|
||||
*
|
||||
* 统一注入站点标题与项目列表,供顶部导航与项目切换使用。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class DocsFrontend extends FrontendBase
|
||||
{
|
||||
// 文档为公开内容,不做登录拦截
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 插件配置
|
||||
* @var array
|
||||
*/
|
||||
protected $docsConfig = [];
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
|
||||
$this->docsConfig = $this->loadConfig();
|
||||
|
||||
$this->view->assign([
|
||||
'docsTitle' => $this->docsConfig['site_title'] ?? 'YwxApp 文档中心',
|
||||
'projectList' => DocsProject::enabledList(),
|
||||
'currentYear' => date('Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板变量赋值
|
||||
*
|
||||
* AddonFrontend 基类只暴露 $this->view,这里补一个代理方法,
|
||||
* 使前台控制器与后台 AddonBackend 保持一致的 assign() 写法。
|
||||
*
|
||||
* @param string|array $name 变量名或键值数组
|
||||
* @param mixed $value 变量值
|
||||
* @return $this
|
||||
*/
|
||||
public function assign($name, $value = null)
|
||||
{
|
||||
$this->view->assign($name, $value);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件配置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
try {
|
||||
$config = get_addon_config('docs');
|
||||
return is_array($config) ? $config : [];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 文档中心首页:项目卡片墙
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Index extends DocsFrontend
|
||||
{
|
||||
/**
|
||||
* 首页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$projects = DocsProject::enabledList()->toArray();
|
||||
|
||||
foreach ($projects as &$project) {
|
||||
$projectId = (int) $project['id'];
|
||||
|
||||
$project['doc_count'] = DocsDoc::where('project_id', $projectId)
|
||||
->where('status', 1)->where('is_dir', 0)->count();
|
||||
|
||||
$version = DocsVersion::resolve($projectId, (string) ($project['default_version'] ?? ''));
|
||||
$project['version_name'] = $version ? $version->name : '';
|
||||
|
||||
// 取该项目最近更新的几篇,作为卡片上的快捷入口
|
||||
$project['recent'] = DocsDoc::field('id,name,title,update_at')
|
||||
->where('project_id', $projectId)
|
||||
->where('status', 1)
|
||||
->where('is_dir', 0)
|
||||
->order('update_at', 'desc')
|
||||
->limit(4)
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
unset($project);
|
||||
|
||||
$this->assign('projects', $projects);
|
||||
$this->assign('totalDocs', DocsDoc::where('status', 1)->where('is_dir', 0)->count());
|
||||
return $this->fetch('index/index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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\DocsProject;
|
||||
use addon\docs\service\DocSearchService;
|
||||
|
||||
/**
|
||||
* 文档搜索
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Search extends DocsFrontend
|
||||
{
|
||||
/**
|
||||
* 搜索结果页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$keyword = trim((string) $this->request->get('kw', ''));
|
||||
$projectId = (int) $this->request->get('project_id', 0);
|
||||
|
||||
$limit = (int) ($this->docsConfig['search_limit'] ?? 30);
|
||||
$limit = $limit > 0 && $limit <= 100 ? $limit : 30;
|
||||
|
||||
$list = [];
|
||||
if ($keyword !== '') {
|
||||
// 限制关键词长度,避免超长串拖慢 LIKE 查询
|
||||
$keyword = mb_substr($keyword, 0, 50, 'UTF-8');
|
||||
$list = DocSearchService::search($keyword, $projectId, $limit);
|
||||
}
|
||||
|
||||
$this->assign([
|
||||
'keyword' => $keyword,
|
||||
'projectId' => $projectId,
|
||||
'list' => $list,
|
||||
'total' => count($list),
|
||||
'projects' => DocsProject::enabledList(),
|
||||
'pageTitle' => $keyword !== '' ? '搜索:' . $keyword : '文档搜索',
|
||||
]);
|
||||
|
||||
return $this->fetch('search/index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
<?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\backend;
|
||||
|
||||
use addon\docs\model\DocsDoc;
|
||||
use addon\docs\model\DocsProject;
|
||||
use addon\docs\model\DocsVersion;
|
||||
use addon\docs\service\DocImportService;
|
||||
use addon\docs\service\DocSearchService;
|
||||
use addon\docs\service\DocTreeService;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* 文档正文管理
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Doc extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 文档树列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
[$project, $version, $projects, $versions] = $this->scope();
|
||||
if (!$project || !$version) {
|
||||
$this->assign('projects', $projects);
|
||||
$this->assign('versions', []);
|
||||
$this->assign('tree', []);
|
||||
$this->assign('project', null);
|
||||
$this->assign('version', null);
|
||||
return $this->fetch('doc/index');
|
||||
}
|
||||
|
||||
$tree = DocTreeService::buildTree((int) $project['id'], (int) $version['id'], false);
|
||||
|
||||
$this->assign('projects', $projects);
|
||||
$this->assign('versions', $versions);
|
||||
$this->assign('project', $project);
|
||||
$this->assign('version', $version);
|
||||
$this->assign('tree', $tree);
|
||||
$this->assign('treeJson', json_encode($tree, JSON_UNESCAPED_UNICODE));
|
||||
return $this->fetch('doc/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回目录树 JSON,供前端异步刷新
|
||||
*/
|
||||
public function tree()
|
||||
{
|
||||
$projectId = (int) $this->request->get('project_id', 0);
|
||||
$versionId = (int) $this->request->get('version_id', 0);
|
||||
|
||||
if ($projectId <= 0 || $versionId <= 0) {
|
||||
return $this->result->error('缺少项目或版本参数');
|
||||
}
|
||||
|
||||
$tree = DocTreeService::buildTree($projectId, $versionId, false);
|
||||
return $this->result->success($tree, '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增页面
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
[$project, $version, $projects, $versions] = $this->scope();
|
||||
if (!$project || !$version) {
|
||||
return $this->result->error('请先创建文档项目与版本');
|
||||
}
|
||||
|
||||
$this->assign('item', [
|
||||
'id' => 0,
|
||||
'project_id' => $project['id'],
|
||||
'version_id' => $version['id'],
|
||||
'pid' => (int) $this->request->get('pid', 0),
|
||||
'name' => '',
|
||||
'title' => '',
|
||||
'content' => '',
|
||||
'editor_type' => 1,
|
||||
'is_dir' => 0,
|
||||
'seo_title' => '',
|
||||
'seo_keywords' => '',
|
||||
'seo_desc' => '',
|
||||
'sort' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
$this->assign('project', $project);
|
||||
$this->assign('version', $version);
|
||||
$this->assign('projects', $projects);
|
||||
$this->assign('versions', $versions);
|
||||
$this->assign('parentOptions', DocTreeService::selectOptions((int) $project['id'], (int) $version['id']));
|
||||
return $this->fetch('doc/add');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新增
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$data = $this->collect();
|
||||
if (is_string($data)) {
|
||||
return $this->result->error($data);
|
||||
}
|
||||
|
||||
$exists = DocsDoc::where('project_id', $data['project_id'])
|
||||
->where('version_id', $data['version_id'])
|
||||
->where('name', $data['name'])
|
||||
->find();
|
||||
if ($exists) {
|
||||
return $this->result->error('当前版本下已存在相同的文档标识');
|
||||
}
|
||||
|
||||
$doc = DocsDoc::create($data);
|
||||
|
||||
DocSearchService::index($doc);
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(['id' => $doc->id], '添加成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑页面
|
||||
*
|
||||
* @param int $id 文档ID
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
$doc = DocsDoc::find((int) $id);
|
||||
if (!$doc) {
|
||||
return $this->result->error('文档不存在');
|
||||
}
|
||||
|
||||
$project = DocsProject::find($doc->project_id);
|
||||
$version = DocsVersion::find($doc->version_id);
|
||||
|
||||
$this->assign('item', $doc->toArray());
|
||||
$this->assign('project', $project ? $project->toArray() : null);
|
||||
$this->assign('version', $version ? $version->toArray() : null);
|
||||
$this->assign('projects', DocsProject::order('sort', 'asc')->select()->toArray());
|
||||
$this->assign('versions', DocsVersion::where('project_id', $doc->project_id)->order('sort', 'asc')->select()->toArray());
|
||||
$this->assign('parentOptions', DocTreeService::selectOptions((int) $doc->project_id, (int) $doc->version_id, (int) $doc->id));
|
||||
return $this->fetch('doc/edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存编辑
|
||||
*
|
||||
* @param int $id 文档ID
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$doc = DocsDoc::find((int) $id);
|
||||
if (!$doc) {
|
||||
return $this->result->error('文档不存在');
|
||||
}
|
||||
|
||||
$data = $this->collect();
|
||||
if (is_string($data)) {
|
||||
return $this->result->error($data);
|
||||
}
|
||||
|
||||
// 不允许把自己设为自己的父级,否则建树时会形成孤岛
|
||||
if ((int) $data['pid'] === (int) $doc->id) {
|
||||
return $this->result->error('父级文档不能选择自身');
|
||||
}
|
||||
if ($this->isDescendant((int) $doc->id, (int) $data['pid'])) {
|
||||
return $this->result->error('父级文档不能选择自身的下级');
|
||||
}
|
||||
|
||||
$exists = DocsDoc::where('project_id', $data['project_id'])
|
||||
->where('version_id', $data['version_id'])
|
||||
->where('name', $data['name'])
|
||||
->where('id', '<>', $doc->id)
|
||||
->find();
|
||||
if ($exists) {
|
||||
return $this->result->error('当前版本下已存在相同的文档标识');
|
||||
}
|
||||
|
||||
$doc->save($data);
|
||||
|
||||
DocSearchService::index($doc);
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(['id' => $doc->id], '保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档(含所有下级)
|
||||
*
|
||||
* @param int $id 文档ID
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$doc = DocsDoc::find((int) $id);
|
||||
if (!$doc) {
|
||||
return $this->result->error('文档不存在');
|
||||
}
|
||||
|
||||
$ids = $this->descendantIds((int) $doc->id);
|
||||
$ids[] = (int) $doc->id;
|
||||
|
||||
foreach ($ids as $docId) {
|
||||
DocSearchService::remove((int) $docId);
|
||||
}
|
||||
DocsDoc::whereIn('id', $ids)->delete();
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '删除成功,共移除 ' . count($ids) . ' 篇');
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽排序:接收有序的 id 数组与父级关系
|
||||
*/
|
||||
public function sort()
|
||||
{
|
||||
$payload = $this->request->post('nodes', '');
|
||||
$nodes = is_string($payload) ? json_decode($payload, true) : $payload;
|
||||
|
||||
if (!is_array($nodes) || empty($nodes)) {
|
||||
return $this->result->error('排序数据为空');
|
||||
}
|
||||
|
||||
foreach ($nodes as $index => $node) {
|
||||
$docId = (int) ($node['id'] ?? 0);
|
||||
if ($docId <= 0) {
|
||||
continue;
|
||||
}
|
||||
DocsDoc::where('id', $docId)->update([
|
||||
'pid' => (int) ($node['pid'] ?? 0),
|
||||
'sort' => (int) ($node['sort'] ?? $index),
|
||||
]);
|
||||
}
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '排序已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入页面
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
[$project, $version, $projects, $versions] = $this->scope();
|
||||
|
||||
$this->assign('projects', $projects);
|
||||
$this->assign('versions', $versions);
|
||||
$this->assign('project', $project);
|
||||
$this->assign('version', $version);
|
||||
$this->assign('parentOptions', $project && $version
|
||||
? DocTreeService::selectOptions((int) $project['id'], (int) $version['id'])
|
||||
: []);
|
||||
$this->assign('hasPhpWord', class_exists('\PhpOffice\PhpWord\IOFactory'));
|
||||
return $this->fetch('doc/import');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行导入
|
||||
*/
|
||||
public function doImport()
|
||||
{
|
||||
$projectId = (int) $this->request->post('project_id', 0);
|
||||
$versionId = (int) $this->request->post('version_id', 0);
|
||||
$pid = (int) $this->request->post('pid', 0);
|
||||
|
||||
if ($projectId <= 0 || $versionId <= 0) {
|
||||
return $this->result->error('请选择目标项目与版本');
|
||||
}
|
||||
|
||||
$file = $this->request->file('file');
|
||||
if (!$file) {
|
||||
return $this->result->error('请选择要导入的文件');
|
||||
}
|
||||
|
||||
$ext = strtolower($file->getOriginalExtension());
|
||||
if (!in_array($ext, DocImportService::ALLOW_EXT, true)) {
|
||||
return $this->result->error('仅支持 ' . implode(' / ', DocImportService::ALLOW_EXT) . ' 格式');
|
||||
}
|
||||
|
||||
// 限制 20MB,防止超大文件拖垮解析
|
||||
if ($file->getSize() > 20 * 1024 * 1024) {
|
||||
return $this->result->error('文件不能超过 20MB');
|
||||
}
|
||||
|
||||
$tmpDir = runtime_path() . 'docs_import';
|
||||
if (!is_dir($tmpDir)) {
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
}
|
||||
|
||||
$saveName = uniqid('imp_', true) . '.' . $ext;
|
||||
try {
|
||||
$file->move($tmpDir, $saveName);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->result->error('文件保存失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$fullPath = $tmpDir . DIRECTORY_SEPARATOR . $saveName;
|
||||
|
||||
try {
|
||||
$parsed = DocImportService::parse($fullPath, $ext);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($fullPath);
|
||||
return $this->result->error('解析失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
@unlink($fullPath);
|
||||
|
||||
$title = trim((string) $this->request->post('title', ''));
|
||||
if ($title === '') {
|
||||
$title = $parsed['title'];
|
||||
}
|
||||
|
||||
$name = trim((string) $this->request->post('name', ''));
|
||||
if ($name === '') {
|
||||
$name = DocImportService::slugify($title);
|
||||
}
|
||||
$name = $this->uniqueName($projectId, $versionId, $name);
|
||||
|
||||
$doc = DocsDoc::create([
|
||||
'project_id' => $projectId,
|
||||
'version_id' => $versionId,
|
||||
'pid' => $pid,
|
||||
'name' => $name,
|
||||
'title' => mb_substr($title, 0, 200, 'UTF-8'),
|
||||
'content' => $parsed['content'],
|
||||
'content_md' => $parsed['content_md'],
|
||||
'editor_type' => $parsed['content_md'] !== '' ? 2 : 1,
|
||||
'is_dir' => 0,
|
||||
'sort' => (int) $this->request->post('sort', 0),
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
DocSearchService::index($doc);
|
||||
DocTreeService::clearCache();
|
||||
|
||||
return $this->result->success(['id' => $doc->id], '导入成功:' . $doc->title);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建搜索索引
|
||||
*/
|
||||
public function rebuildIndex()
|
||||
{
|
||||
$projectId = (int) $this->request->post('project_id', 0);
|
||||
$count = DocSearchService::rebuild($projectId);
|
||||
return $this->result->success(['count' => $count], '索引重建完成,共 ' . $count . ' 篇');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集并校验表单数据
|
||||
*
|
||||
* @return array|string
|
||||
*/
|
||||
protected function collect()
|
||||
{
|
||||
$projectId = (int) $this->request->post('project_id', 0);
|
||||
$versionId = (int) $this->request->post('version_id', 0);
|
||||
$title = trim((string) $this->request->post('title', ''));
|
||||
$name = trim((string) $this->request->post('name', ''));
|
||||
$isDir = (int) $this->request->post('is_dir', 0) === 1 ? 1 : 0;
|
||||
|
||||
if ($projectId <= 0 || !DocsProject::find($projectId)) {
|
||||
return '请选择所属项目';
|
||||
}
|
||||
if ($versionId <= 0 || !DocsVersion::find($versionId)) {
|
||||
return '请选择所属版本';
|
||||
}
|
||||
if ($title === '') {
|
||||
return '请填写文档标题';
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
$name = DocImportService::slugify($title);
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_\-]{0,99}$/', $name)) {
|
||||
return '文档标识只能为字母数字与下划线中划线,且以字母数字开头';
|
||||
}
|
||||
|
||||
// 富文本正文统一清洗,剥离脚本与事件属性
|
||||
$content = (string) $this->request->post('content', '');
|
||||
$content = DocImportService::sanitize($content);
|
||||
|
||||
return [
|
||||
'project_id' => $projectId,
|
||||
'version_id' => $versionId,
|
||||
'pid' => (int) $this->request->post('pid', 0),
|
||||
'name' => $name,
|
||||
'title' => mb_substr($title, 0, 200, 'UTF-8'),
|
||||
'content' => $isDir === 1 ? '' : $content,
|
||||
'content_md' => (string) $this->request->post('content_md', ''),
|
||||
'editor_type' => (int) $this->request->post('editor_type', 1) === 2 ? 2 : 1,
|
||||
'is_dir' => $isDir,
|
||||
'seo_title' => trim((string) $this->request->post('seo_title', '')),
|
||||
'seo_keywords' => trim((string) $this->request->post('seo_keywords', '')),
|
||||
'seo_desc' => trim((string) $this->request->post('seo_desc', '')),
|
||||
'sort' => (int) $this->request->post('sort', 0),
|
||||
'status' => (int) $this->request->post('status', 1) === 1 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前操作的项目与版本上下文
|
||||
*
|
||||
* @return array{0: array|null, 1: array|null, 2: array, 3: array}
|
||||
*/
|
||||
protected function scope(): array
|
||||
{
|
||||
$projects = DocsProject::order('sort', 'asc')->order('id', 'asc')->select()->toArray();
|
||||
$projectId = (int) $this->request->param('project_id', 0);
|
||||
|
||||
if ($projectId <= 0 && !empty($projects)) {
|
||||
$projectId = (int) $projects[0]['id'];
|
||||
}
|
||||
|
||||
$project = null;
|
||||
foreach ($projects as $item) {
|
||||
if ((int) $item['id'] === $projectId) {
|
||||
$project = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$project) {
|
||||
return [null, null, $projects, []];
|
||||
}
|
||||
|
||||
$versions = DocsVersion::where('project_id', $projectId)
|
||||
->order('sort', 'asc')->order('id', 'asc')->select()->toArray();
|
||||
$versionId = (int) $this->request->param('version_id', 0);
|
||||
|
||||
$version = null;
|
||||
foreach ($versions as $item) {
|
||||
if ((int) $item['id'] === $versionId) {
|
||||
$version = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 未显式指定版本时,优先取默认版本,其次第一个
|
||||
if (!$version) {
|
||||
foreach ($versions as $item) {
|
||||
if ((int) $item['is_default'] === 1) {
|
||||
$version = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$version && !empty($versions)) {
|
||||
$version = $versions[0];
|
||||
}
|
||||
|
||||
return [$project, $version, $projects, $versions];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某节点的全部后代 ID
|
||||
*
|
||||
* @param int $id 节点ID
|
||||
* @return array
|
||||
*/
|
||||
protected function descendantIds(int $id): array
|
||||
{
|
||||
$result = [];
|
||||
$queue = [$id];
|
||||
$guard = 0;
|
||||
|
||||
while (!empty($queue) && $guard < 100) {
|
||||
$children = DocsDoc::whereIn('pid', $queue)->column('id');
|
||||
if (empty($children)) {
|
||||
break;
|
||||
}
|
||||
$children = array_map('intval', $children);
|
||||
$result = array_merge($result, $children);
|
||||
$queue = $children;
|
||||
$guard++;
|
||||
}
|
||||
|
||||
return array_unique($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 $targetPid 是否为 $id 的后代
|
||||
*
|
||||
* @param int $id 当前节点
|
||||
* @param int $targetPid 目标父级
|
||||
* @return bool
|
||||
*/
|
||||
protected function isDescendant(int $id, int $targetPid): bool
|
||||
{
|
||||
if ($targetPid <= 0) {
|
||||
return false;
|
||||
}
|
||||
return in_array($targetPid, $this->descendantIds($id), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成不冲突的文档标识
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @param int $versionId 版本ID
|
||||
* @param string $name 期望标识
|
||||
* @return string
|
||||
*/
|
||||
protected function uniqueName(int $projectId, int $versionId, string $name): string
|
||||
{
|
||||
$base = $name;
|
||||
$suffix = 1;
|
||||
|
||||
while (DocsDoc::where('project_id', $projectId)
|
||||
->where('version_id', $versionId)
|
||||
->where('name', $name)->find()) {
|
||||
$name = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
if ($suffix > 100) {
|
||||
$name = $base . '-' . substr(md5((string) microtime(true)), 0, 6);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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\backend;
|
||||
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* 文档中心后台入口
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Index extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 默认跳转到文档项目列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return redirect('/docs/backend/project');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?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\backend;
|
||||
|
||||
use addon\docs\model\DocsDoc;
|
||||
use addon\docs\model\DocsProject;
|
||||
use addon\docs\model\DocsVersion;
|
||||
use addon\docs\service\DocSearchService;
|
||||
use addon\docs\service\DocTreeService;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* 文档项目管理
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Project extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 项目列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$keyword = trim((string) $this->request->get('keyword', ''));
|
||||
|
||||
$query = DocsProject::order('sort', 'asc')->order('id', 'asc');
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->whereLike('title', '%' . $keyword . '%')
|
||||
->whereOr('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$list = $query->select()->toArray();
|
||||
|
||||
// 附加每个项目的版本数与文档数,便于列表直观查看
|
||||
foreach ($list as &$item) {
|
||||
$item['version_count'] = DocsVersion::where('project_id', $item['id'])->count();
|
||||
$item['doc_count'] = DocsDoc::where('project_id', $item['id'])->count();
|
||||
}
|
||||
unset($item);
|
||||
|
||||
$this->assign('list', $list);
|
||||
$this->assign('keyword', $keyword);
|
||||
return $this->fetch('project/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增页面
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$this->assign('addonList', $this->addonOptions());
|
||||
return $this->fetch('project/add');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新增
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$data = $this->collect();
|
||||
if (is_string($data)) {
|
||||
return $this->result->error($data);
|
||||
}
|
||||
|
||||
if (DocsProject::where('name', $data['name'])->find()) {
|
||||
return $this->result->error('项目标识已存在,请更换');
|
||||
}
|
||||
|
||||
$project = DocsProject::create($data);
|
||||
|
||||
// 新项目自动建默认版本,避免建完项目无法直接添加文档
|
||||
DocsVersion::create([
|
||||
'project_id' => $project->id,
|
||||
'name' => 'v1',
|
||||
'title' => '默认版本',
|
||||
'is_default' => 1,
|
||||
'sort' => 1,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '添加成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑页面
|
||||
*
|
||||
* @param int $id 项目ID
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
$project = DocsProject::find((int) $id);
|
||||
if (!$project) {
|
||||
return $this->result->error('项目不存在');
|
||||
}
|
||||
|
||||
$this->assign('item', $project);
|
||||
$this->assign('addonList', $this->addonOptions());
|
||||
$this->assign('versionList', DocsVersion::where('project_id', $project->id)->order('sort', 'asc')->select());
|
||||
return $this->fetch('project/edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存编辑
|
||||
*
|
||||
* @param int $id 项目ID
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$project = DocsProject::find((int) $id);
|
||||
if (!$project) {
|
||||
return $this->result->error('项目不存在');
|
||||
}
|
||||
|
||||
$data = $this->collect();
|
||||
if (is_string($data)) {
|
||||
return $this->result->error($data);
|
||||
}
|
||||
|
||||
if (DocsProject::where('name', $data['name'])->where('id', '<>', $project->id)->find()) {
|
||||
return $this->result->error('项目标识已存在,请更换');
|
||||
}
|
||||
|
||||
$project->save($data);
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目(连同版本与文档)
|
||||
*
|
||||
* @param int $id 项目ID
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$id = (int) $id;
|
||||
$project = DocsProject::find($id);
|
||||
if (!$project) {
|
||||
return $this->result->error('项目不存在');
|
||||
}
|
||||
|
||||
$docIds = DocsDoc::where('project_id', $id)->column('id');
|
||||
foreach ($docIds as $docId) {
|
||||
DocSearchService::remove((int) $docId);
|
||||
}
|
||||
|
||||
DocsDoc::where('project_id', $id)->delete();
|
||||
DocsVersion::where('project_id', $id)->delete();
|
||||
$project->delete();
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集并校验表单数据
|
||||
*
|
||||
* @return array|string 校验失败返回错误提示字符串
|
||||
*/
|
||||
protected function collect()
|
||||
{
|
||||
$name = trim((string) $this->request->post('name', ''));
|
||||
$title = trim((string) $this->request->post('title', ''));
|
||||
|
||||
if ($name === '' || !preg_match('/^[a-z][a-z0-9_\-]{1,49}$/i', $name)) {
|
||||
return '项目标识只能为字母开头的字母数字组合,长度 2-50';
|
||||
}
|
||||
if ($title === '') {
|
||||
return '请填写项目名称';
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'title' => $title,
|
||||
'intro' => trim((string) $this->request->post('intro', '')),
|
||||
'logo' => trim((string) $this->request->post('logo', '')),
|
||||
'addon' => trim((string) $this->request->post('addon', '')),
|
||||
'default_version' => trim((string) $this->request->post('default_version', '')),
|
||||
'sort' => (int) $this->request->post('sort', 0),
|
||||
'status' => (int) $this->request->post('status', 1) === 1 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描已安装插件,供项目归属下拉使用
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function addonOptions(): array
|
||||
{
|
||||
$dir = root_path() . 'addon';
|
||||
$list = [];
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return $list;
|
||||
}
|
||||
|
||||
foreach ((array) scandir($dir) as $item) {
|
||||
if ($item === '.' || $item === '..' || !is_dir($dir . DIRECTORY_SEPARATOR . $item)) {
|
||||
continue;
|
||||
}
|
||||
$infoFile = $dir . DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . 'info.php';
|
||||
if (!is_file($infoFile)) {
|
||||
continue;
|
||||
}
|
||||
$info = @include $infoFile;
|
||||
if (is_array($info) && !empty($info['name'])) {
|
||||
$list[] = [
|
||||
'name' => (string) $info['name'],
|
||||
'title' => (string) ($info['title'] ?? $info['name']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
<?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\backend;
|
||||
|
||||
use think\Response;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* UEditor 服务端统一入口
|
||||
*
|
||||
* 按 action 参数分发:
|
||||
* config 返回编辑器配置
|
||||
* uploadimage 图片上传
|
||||
* uploadfile 附件上传
|
||||
* uploadvideo 视频上传
|
||||
* listimage 图片管理器列表
|
||||
* listfile 附件管理器列表
|
||||
* catchimage 远程图片抓取(粘贴 Word 时把外链图落地)
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Ueditor extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 图片允许的扩展名
|
||||
*/
|
||||
protected $imageExt = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
||||
|
||||
/**
|
||||
* 附件允许的扩展名
|
||||
*/
|
||||
protected $fileExt = ['zip', 'rar', '7z', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md'];
|
||||
|
||||
/**
|
||||
* 视频允许的扩展名
|
||||
*/
|
||||
protected $videoExt = ['mp4', 'webm', 'ogg', 'mov'];
|
||||
|
||||
/**
|
||||
* 单文件大小上限(字节)
|
||||
*/
|
||||
protected $maxSize = 10485760;
|
||||
|
||||
/**
|
||||
* 统一入口
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$action = (string) $this->request->param('action', '');
|
||||
|
||||
switch ($action) {
|
||||
case 'config':
|
||||
return $this->jsonp($this->editorConfig());
|
||||
case 'uploadimage':
|
||||
return $this->jsonp($this->upload('upfile', $this->imageExt, 'image'));
|
||||
case 'uploadfile':
|
||||
return $this->jsonp($this->upload('upfile', $this->fileExt, 'file'));
|
||||
case 'uploadvideo':
|
||||
return $this->jsonp($this->upload('upfile', $this->videoExt, 'video'));
|
||||
case 'uploadscrawl':
|
||||
return $this->jsonp($this->uploadScrawl());
|
||||
case 'listimage':
|
||||
return $this->jsonp($this->listFiles('image', $this->imageExt));
|
||||
case 'listfile':
|
||||
return $this->jsonp($this->listFiles('file', $this->fileExt));
|
||||
case 'catchimage':
|
||||
return $this->jsonp($this->catchImage());
|
||||
default:
|
||||
return $this->jsonp(['state' => '请求地址出错']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出 JSON,兼容 UEditor 的 jsonp 回调
|
||||
*
|
||||
* @param array $data 响应数据
|
||||
* @return Response
|
||||
*/
|
||||
protected function jsonp(array $data): Response
|
||||
{
|
||||
$callback = (string) $this->request->param('callback', '');
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// 回调名必须是合法标识符,防止 XSS 注入
|
||||
if ($callback !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_\.]*$/', $callback)) {
|
||||
return Response::create($callback . '(' . $json . ')', 'html')
|
||||
->contentType('application/javascript');
|
||||
}
|
||||
|
||||
return Response::create($json, 'html')->contentType('application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器服务端配置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function editorConfig(): array
|
||||
{
|
||||
$prefix = '/' . trim($this->uploadDir(), '/') . '/';
|
||||
|
||||
return [
|
||||
'imageActionName' => 'uploadimage',
|
||||
'imageFieldName' => 'upfile',
|
||||
'imageMaxSize' => $this->maxSize,
|
||||
'imageAllowFiles' => array_map(fn($e) => '.' . $e, $this->imageExt),
|
||||
'imageCompressEnable' => true,
|
||||
'imageCompressBorder' => 1600,
|
||||
'imageInsertAlign' => 'none',
|
||||
'imageUrlPrefix' => '',
|
||||
'imagePathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
|
||||
'scrawlActionName' => 'uploadscrawl',
|
||||
'scrawlFieldName' => 'upfile',
|
||||
'scrawlPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'scrawlMaxSize' => $this->maxSize,
|
||||
'scrawlUrlPrefix' => '',
|
||||
'scrawlInsertAlign' => 'none',
|
||||
|
||||
'catcherLocalDomain' => ['127.0.0.1', 'localhost'],
|
||||
'catcherActionName' => 'catchimage',
|
||||
'catcherFieldName' => 'source',
|
||||
'catcherPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'catcherUrlPrefix' => '',
|
||||
'catcherMaxSize' => $this->maxSize,
|
||||
'catcherAllowFiles' => array_map(fn($e) => '.' . $e, $this->imageExt),
|
||||
|
||||
'videoActionName' => 'uploadvideo',
|
||||
'videoFieldName' => 'upfile',
|
||||
'videoPathFormat' => $prefix . 'video/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'videoUrlPrefix' => '',
|
||||
'videoMaxSize' => 102400000,
|
||||
'videoAllowFiles' => array_map(fn($e) => '.' . $e, $this->videoExt),
|
||||
|
||||
'fileActionName' => 'uploadfile',
|
||||
'fileFieldName' => 'upfile',
|
||||
'filePathFormat' => $prefix . 'file/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'fileUrlPrefix' => '',
|
||||
'fileMaxSize' => 51200000,
|
||||
'fileAllowFiles' => array_map(fn($e) => '.' . $e, $this->fileExt),
|
||||
|
||||
'imageManagerActionName' => 'listimage',
|
||||
'imageManagerListPath' => $prefix . 'image/',
|
||||
'imageManagerListSize' => 20,
|
||||
'imageManagerUrlPrefix' => '',
|
||||
'imageManagerInsertAlign' => 'none',
|
||||
'imageManagerAllowFiles' => array_map(fn($e) => '.' . $e, $this->imageExt),
|
||||
|
||||
'fileManagerActionName' => 'listfile',
|
||||
'fileManagerListPath' => $prefix . 'file/',
|
||||
'fileManagerUrlPrefix' => '',
|
||||
'fileManagerListSize' => 20,
|
||||
'fileManagerAllowFiles' => array_map(fn($e) => '.' . $e, $this->fileExt),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用上传处理
|
||||
*
|
||||
* @param string $field 表单字段名
|
||||
* @param array $allowExt 允许的扩展名
|
||||
* @param string $group 分组目录:image / file / video
|
||||
* @return array
|
||||
*/
|
||||
protected function upload(string $field, array $allowExt, string $group): array
|
||||
{
|
||||
$file = $this->request->file($field);
|
||||
if (!$file) {
|
||||
return ['state' => '未找到上传文件'];
|
||||
}
|
||||
|
||||
$ext = strtolower($file->getOriginalExtension());
|
||||
if (!in_array($ext, $allowExt, true)) {
|
||||
return ['state' => '不允许的文件类型:' . $ext];
|
||||
}
|
||||
|
||||
$limit = $group === 'video' ? 102400000 : $this->maxSize;
|
||||
if ($file->getSize() > $limit) {
|
||||
return ['state' => '文件大小超出限制'];
|
||||
}
|
||||
|
||||
// 图片二次校验,防止改扩展名上传脚本
|
||||
if ($group === 'image') {
|
||||
$info = @getimagesize($file->getRealPath());
|
||||
if ($info === false) {
|
||||
return ['state' => '文件不是有效的图片'];
|
||||
}
|
||||
}
|
||||
|
||||
$relativeDir = trim($this->uploadDir(), '/') . '/' . $group . '/' . date('Ymd');
|
||||
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
|
||||
|
||||
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return ['state' => '上传目录创建失败,请检查权限'];
|
||||
}
|
||||
|
||||
$original = $file->getOriginalName();
|
||||
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext;
|
||||
|
||||
try {
|
||||
$file->move($targetDir, $saveName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['state' => '文件保存失败:' . $e->getMessage()];
|
||||
}
|
||||
|
||||
$url = '/' . $relativeDir . '/' . $saveName;
|
||||
|
||||
return [
|
||||
'state' => 'SUCCESS',
|
||||
'url' => $url,
|
||||
'title' => $original,
|
||||
'original' => $original,
|
||||
'type' => '.' . $ext,
|
||||
'size' => (string) filesize($targetDir . DIRECTORY_SEPARATOR . $saveName),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 涂鸦上传:接收 base64 数据
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function uploadScrawl(): array
|
||||
{
|
||||
$base64 = (string) $this->request->post('upfile', '');
|
||||
if ($base64 === '') {
|
||||
return ['state' => '未接收到涂鸦数据'];
|
||||
}
|
||||
|
||||
$binary = base64_decode($base64, true);
|
||||
if ($binary === false || strlen($binary) > $this->maxSize) {
|
||||
return ['state' => '涂鸦数据无效或过大'];
|
||||
}
|
||||
|
||||
$relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd');
|
||||
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
|
||||
|
||||
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return ['state' => '上传目录创建失败,请检查权限'];
|
||||
}
|
||||
|
||||
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.png';
|
||||
if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) {
|
||||
return ['state' => '涂鸦保存失败'];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'SUCCESS',
|
||||
'url' => '/' . $relativeDir . '/' . $saveName,
|
||||
'title' => $saveName,
|
||||
'original' => $saveName,
|
||||
'type' => '.png',
|
||||
'size' => (string) strlen($binary),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程图片抓取
|
||||
*
|
||||
* 粘贴 Word / 网页内容时,UEditor 会把外链图片提交到这里落地保存。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function catchImage(): array
|
||||
{
|
||||
$field = 'source';
|
||||
$sources = $this->request->param($field, []);
|
||||
if (!is_array($sources)) {
|
||||
$sources = [$sources];
|
||||
}
|
||||
|
||||
if (empty($sources)) {
|
||||
return ['state' => '未接收到图片地址'];
|
||||
}
|
||||
|
||||
$relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd');
|
||||
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
|
||||
|
||||
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return ['state' => '上传目录创建失败,请检查权限'];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($sources as $remote) {
|
||||
$remote = (string) $remote;
|
||||
$item = ['state' => '抓取失败', 'source' => $remote, 'url' => ''];
|
||||
|
||||
if (!$this->isSafeRemoteUrl($remote)) {
|
||||
$item['state'] = '非法的图片地址';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$binary = $this->fetchRemote($remote);
|
||||
if ($binary === null) {
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 用图片指纹判定真实类型,忽略 URL 上的扩展名
|
||||
$info = @getimagesizefromstring($binary);
|
||||
if ($info === false) {
|
||||
$item['state'] = '远程文件不是有效图片';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = image_type_to_extension($info[2], false);
|
||||
if (!in_array(strtolower((string) $ext), $this->imageExt, true)) {
|
||||
$item['state'] = '不允许的图片类型';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext;
|
||||
if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) {
|
||||
$item['state'] = '图片保存失败';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'state' => 'SUCCESS',
|
||||
'url' => '/' . $relativeDir . '/' . $saveName,
|
||||
'size' => (string) strlen($binary),
|
||||
'title' => $saveName,
|
||||
'original' => basename(parse_url($remote, PHP_URL_PATH) ?: $saveName),
|
||||
'source' => $remote,
|
||||
];
|
||||
}
|
||||
|
||||
return ['state' => 'SUCCESS', 'list' => $list];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片 / 附件管理器列表
|
||||
*
|
||||
* @param string $group 分组目录
|
||||
* @param array $allowExt 允许的扩展名
|
||||
* @return array
|
||||
*/
|
||||
protected function listFiles(string $group, array $allowExt): array
|
||||
{
|
||||
$start = (int) $this->request->param('start', 0);
|
||||
$size = (int) $this->request->param('size', 20);
|
||||
$size = $size > 0 && $size <= 100 ? $size : 20;
|
||||
|
||||
$relativeRoot = trim($this->uploadDir(), '/') . '/' . $group;
|
||||
$rootDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeRoot);
|
||||
|
||||
if (!is_dir($rootDir)) {
|
||||
return ['state' => 'SUCCESS', 'list' => [], 'start' => $start, 'total' => 0];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iter as $fileInfo) {
|
||||
if (!$fileInfo->isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array(strtolower($fileInfo->getExtension()), $allowExt, true)) {
|
||||
continue;
|
||||
}
|
||||
$relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen(public_path())));
|
||||
$files[] = [
|
||||
'url' => '/' . ltrim($relative, '/'),
|
||||
'mtime' => $fileInfo->getMTime(),
|
||||
];
|
||||
}
|
||||
|
||||
// 新上传的排前面,符合使用直觉
|
||||
usort($files, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
|
||||
|
||||
$total = count($files);
|
||||
$page = array_slice($files, $start, $size);
|
||||
|
||||
return ['state' => 'SUCCESS', 'list' => $page, 'start' => $start, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验远程地址是否安全,阻断 SSRF
|
||||
*
|
||||
* @param string $url 远程地址
|
||||
* @return bool
|
||||
*/
|
||||
protected function isSafeRemoteUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $parts['host'];
|
||||
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 拒绝内网与保留地址,防止服务端被用作内网探测跳板
|
||||
$public = filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
);
|
||||
|
||||
return $public !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程内容
|
||||
*
|
||||
* @param string $url 远程地址
|
||||
* @return string|null
|
||||
*/
|
||||
protected function fetchRemote(string $url): ?string
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'YwxApp-Docs/1.0',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $code !== 200 || strlen((string) $body) > $this->maxSize) {
|
||||
return null;
|
||||
}
|
||||
return (string) $body;
|
||||
}
|
||||
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 15, 'follow_location' => 0]]);
|
||||
$body = @file_get_contents($url, false, $ctx, 0, $this->maxSize + 1);
|
||||
|
||||
if ($body === false || strlen($body) > $this->maxSize) {
|
||||
return null;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取上传目录配置
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function uploadDir(): string
|
||||
{
|
||||
$dir = 'uploads/docs';
|
||||
try {
|
||||
$config = get_addon_config('docs');
|
||||
if (is_array($config) && !empty($config['upload_dir'])) {
|
||||
$dir = (string) $config['upload_dir'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 配置异常时使用默认目录
|
||||
}
|
||||
|
||||
// 只允许相对路径,杜绝目录穿越
|
||||
$dir = str_replace('\\', '/', $dir);
|
||||
$dir = preg_replace('#\.\.+/#', '', $dir) ?? 'uploads/docs';
|
||||
return trim($dir, '/') ?: 'uploads/docs';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?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\backend;
|
||||
|
||||
use addon\docs\model\DocsDoc;
|
||||
use addon\docs\model\DocsProject;
|
||||
use addon\docs\model\DocsVersion;
|
||||
use addon\docs\service\DocSearchService;
|
||||
use addon\docs\service\DocTreeService;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
* 文档版本管理
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Version extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 版本列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$projectId = (int) $this->request->get('project_id', 0);
|
||||
$projects = DocsProject::order('sort', 'asc')->order('id', 'asc')->select()->toArray();
|
||||
|
||||
// 未指定项目时默认展示第一个项目,避免空白页
|
||||
if ($projectId <= 0 && !empty($projects)) {
|
||||
$projectId = (int) $projects[0]['id'];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
if ($projectId > 0) {
|
||||
$list = DocsVersion::where('project_id', $projectId)
|
||||
->order('sort', 'asc')->order('id', 'asc')
|
||||
->select()->toArray();
|
||||
|
||||
foreach ($list as &$item) {
|
||||
$item['doc_count'] = DocsDoc::where('version_id', $item['id'])->count();
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
$this->assign('list', $list);
|
||||
$this->assign('projects', $projects);
|
||||
$this->assign('projectId', $projectId);
|
||||
return $this->fetch('version/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增页面
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$this->assign('projects', DocsProject::order('sort', 'asc')->select()->toArray());
|
||||
$this->assign('projectId', (int) $this->request->get('project_id', 0));
|
||||
return $this->fetch('version/add');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新增
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$data = $this->collect();
|
||||
if (is_string($data)) {
|
||||
return $this->result->error($data);
|
||||
}
|
||||
|
||||
$exists = DocsVersion::where('project_id', $data['project_id'])
|
||||
->where('name', $data['name'])->find();
|
||||
if ($exists) {
|
||||
return $this->result->error('该项目下已存在同名版本');
|
||||
}
|
||||
|
||||
$version = DocsVersion::create($data);
|
||||
$this->syncDefault($version);
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '添加成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑页面
|
||||
*
|
||||
* @param int $id 版本ID
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
$version = DocsVersion::find((int) $id);
|
||||
if (!$version) {
|
||||
return $this->result->error('版本不存在');
|
||||
}
|
||||
|
||||
$this->assign('item', $version);
|
||||
$this->assign('projects', DocsProject::order('sort', 'asc')->select()->toArray());
|
||||
return $this->fetch('version/edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存编辑
|
||||
*
|
||||
* @param int $id 版本ID
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$version = DocsVersion::find((int) $id);
|
||||
if (!$version) {
|
||||
return $this->result->error('版本不存在');
|
||||
}
|
||||
|
||||
$data = $this->collect();
|
||||
if (is_string($data)) {
|
||||
return $this->result->error($data);
|
||||
}
|
||||
|
||||
$exists = DocsVersion::where('project_id', $data['project_id'])
|
||||
->where('name', $data['name'])
|
||||
->where('id', '<>', $version->id)
|
||||
->find();
|
||||
if ($exists) {
|
||||
return $this->result->error('该项目下已存在同名版本');
|
||||
}
|
||||
|
||||
$version->save($data);
|
||||
$this->syncDefault($version);
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除版本(连同其下文档)
|
||||
*
|
||||
* @param int $id 版本ID
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$id = (int) $id;
|
||||
$version = DocsVersion::find($id);
|
||||
if (!$version) {
|
||||
return $this->result->error('版本不存在');
|
||||
}
|
||||
|
||||
// 项目至少保留一个版本,否则文档将无处归属
|
||||
$left = DocsVersion::where('project_id', $version->project_id)->count();
|
||||
if ($left <= 1) {
|
||||
return $this->result->error('该项目仅剩一个版本,不能删除');
|
||||
}
|
||||
|
||||
$docIds = DocsDoc::where('version_id', $id)->column('id');
|
||||
foreach ($docIds as $docId) {
|
||||
DocSearchService::remove((int) $docId);
|
||||
}
|
||||
|
||||
DocsDoc::where('version_id', $id)->delete();
|
||||
$version->delete();
|
||||
|
||||
DocTreeService::clearCache();
|
||||
return $this->result->success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集并校验表单数据
|
||||
*
|
||||
* @return array|string
|
||||
*/
|
||||
protected function collect()
|
||||
{
|
||||
$projectId = (int) $this->request->post('project_id', 0);
|
||||
$name = trim((string) $this->request->post('name', ''));
|
||||
$title = trim((string) $this->request->post('title', ''));
|
||||
|
||||
if ($projectId <= 0 || !DocsProject::find($projectId)) {
|
||||
return '请选择所属项目';
|
||||
}
|
||||
if ($name === '' || !preg_match('/^[A-Za-z0-9][A-Za-z0-9_\.\-]{0,19}$/', $name)) {
|
||||
return '版本标识只能包含字母数字与点划线,长度 1-20';
|
||||
}
|
||||
if ($title === '') {
|
||||
return '请填写版本名称';
|
||||
}
|
||||
|
||||
return [
|
||||
'project_id' => $projectId,
|
||||
'name' => $name,
|
||||
'title' => $title,
|
||||
'intro' => trim((string) $this->request->post('intro', '')),
|
||||
'is_default' => (int) $this->request->post('is_default', 0) === 1 ? 1 : 0,
|
||||
'sort' => (int) $this->request->post('sort', 0),
|
||||
'status' => (int) $this->request->post('status', 1) === 1 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证同项目下仅有一个默认版本,并同步项目的默认版本标识
|
||||
*
|
||||
* @param DocsVersion $version 当前版本
|
||||
* @return void
|
||||
*/
|
||||
protected function syncDefault(DocsVersion $version): void
|
||||
{
|
||||
if ((int) $version->is_default !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
DocsVersion::where('project_id', $version->project_id)
|
||||
->where('id', '<>', $version->id)
|
||||
->update(['is_default' => 0]);
|
||||
|
||||
DocsProject::where('id', $version->project_id)
|
||||
->update(['default_version' => $version->name]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'docs',
|
||||
'title' => '文档中心',
|
||||
'intro' => '多项目 / 多版本在线文档系统,支持 UEditor 可视化编辑、Word 与 Markdown 导入、三栏阅读与全文搜索',
|
||||
'author' => 'ywxapp',
|
||||
'website' => 'https://www.ywxapp.cn',
|
||||
'version' => '1.0.1',
|
||||
'state' => 1,
|
||||
'url' => '/docs',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
'config' => [
|
||||
],
|
||||
'events' => [
|
||||
],
|
||||
'middleware' => [
|
||||
],
|
||||
'services' => [
|
||||
],
|
||||
'install_time' => 1785598672,
|
||||
'update_time' => 1786365810,
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
-- ============================================================
|
||||
-- addon/docs/install.sql 文档中心插件数据表
|
||||
-- 框架约定:插件安装时由 ywxapp\service\AddonService 执行本文件
|
||||
-- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。
|
||||
-- 表名须为 __PREFIX__docs_*,与 __PREFIX__addon 等核心表命名一致。
|
||||
-- 说明:owner_uid / uid / audit_status 为多用户预留字段,
|
||||
-- 当前版本仅站长后台管理,全部保持默认值即可。
|
||||
-- ============================================================
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__docs_project` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '英文标识,用于URL',
|
||||
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '项目名称',
|
||||
`intro` varchar(255) NOT NULL DEFAULT '' COMMENT '项目简介',
|
||||
`logo` varchar(255) NOT NULL DEFAULT '' COMMENT '项目图标',
|
||||
`addon` varchar(50) NOT NULL DEFAULT '' COMMENT '归属插件标识,空表示框架自身',
|
||||
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '归属用户,0表示官方',
|
||||
`default_version` varchar(20) NOT NULL DEFAULT '' COMMENT '默认版本标识',
|
||||
`sort` int NOT NULL DEFAULT '0' COMMENT '排序,越小越前',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用',
|
||||
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_name` (`name`),
|
||||
KEY `idx_addon` (`addon`),
|
||||
KEY `idx_owner_uid` (`owner_uid`),
|
||||
KEY `idx_status_sort` (`status`,`sort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文档项目表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__docs_version` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`project_id` int unsigned NOT NULL DEFAULT '0' COMMENT '所属项目ID',
|
||||
`name` varchar(20) NOT NULL DEFAULT '' COMMENT '版本标识,用于URL',
|
||||
`title` varchar(50) NOT NULL DEFAULT '' COMMENT '版本显示名',
|
||||
`intro` varchar(255) NOT NULL DEFAULT '' COMMENT '版本说明',
|
||||
`is_default` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否默认版本',
|
||||
`sort` int NOT NULL DEFAULT '0' COMMENT '排序,越小越前',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用',
|
||||
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_project_name` (`project_id`,`name`),
|
||||
KEY `idx_project_status` (`project_id`,`status`,`sort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文档版本表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__docs_doc` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`project_id` int unsigned NOT NULL DEFAULT '0' COMMENT '所属项目ID',
|
||||
`version_id` int unsigned NOT NULL DEFAULT '0' COMMENT '所属版本ID',
|
||||
`pid` int unsigned NOT NULL DEFAULT '0' COMMENT '父级文档ID,0为顶级',
|
||||
`name` varchar(100) NOT NULL DEFAULT '' COMMENT 'URL标识',
|
||||
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '文档标题',
|
||||
`content` longtext COMMENT '正文HTML,UEditor产出',
|
||||
`content_md` longtext COMMENT 'Markdown源码,导入导出用',
|
||||
`editor_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '编辑器:1富文本 2Markdown',
|
||||
`is_dir` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否纯目录节点,1表示无正文仅分组',
|
||||
`seo_title` varchar(200) NOT NULL DEFAULT '' COMMENT 'SEO标题',
|
||||
`seo_keywords` varchar(255) NOT NULL DEFAULT '' COMMENT 'SEO关键词',
|
||||
`seo_desc` varchar(500) NOT NULL DEFAULT '' COMMENT 'SEO描述',
|
||||
`views` int unsigned NOT NULL DEFAULT '0' COMMENT '阅读量',
|
||||
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '创建者,0表示站长后台',
|
||||
`editor_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '最后编辑者',
|
||||
`audit_status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '审核状态:0待审 1通过 2驳回',
|
||||
`sort` int NOT NULL DEFAULT '0' COMMENT '排序,越小越前',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1显示 0隐藏',
|
||||
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_scope_name` (`project_id`,`version_id`,`name`),
|
||||
KEY `idx_tree` (`project_id`,`version_id`,`pid`,`sort`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_uid` (`uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文档正文表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__docs_search` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`doc_id` int unsigned NOT NULL DEFAULT '0' COMMENT '文档ID',
|
||||
`project_id` int unsigned NOT NULL DEFAULT '0' COMMENT '项目ID',
|
||||
`version_id` int unsigned NOT NULL DEFAULT '0' COMMENT '版本ID',
|
||||
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '文档标题副本',
|
||||
`keywords` varchar(255) NOT NULL DEFAULT '' COMMENT '关键词',
|
||||
`plain_text` longtext COMMENT '正文纯文本,供全文检索',
|
||||
`update_at` int DEFAULT NULL COMMENT '索引更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_doc` (`doc_id`),
|
||||
KEY `idx_project_version` (`project_id`,`version_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文档搜索索引表';
|
||||
|
||||
INSERT INTO `__PREFIX__docs_project` (`id`, `name`, `title`, `intro`, `addon`, `owner_uid`, `default_version`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 1, 'framework', 'YwxApp 框架文档', '框架安装、目录结构、插件机制与命令行工具的完整说明', '', 0, 'v1', 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__docs_project` WHERE `name` = 'framework');
|
||||
|
||||
INSERT INTO `__PREFIX__docs_version` (`id`, `project_id`, `name`, `title`, `intro`, `is_default`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 1, 1, 'v1', '1.0 版本', '当前稳定版本', 1, 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__docs_version` WHERE `project_id` = 1 AND `name` = 'v1');
|
||||
|
||||
INSERT INTO `__PREFIX__docs_doc` (`id`, `project_id`, `version_id`, `pid`, `name`, `title`, `content`, `editor_type`, `is_dir`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 1, 1, 1, 0, 'guide', '入门指南', '', 1, 1, 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__docs_doc` WHERE `project_id` = 1 AND `version_id` = 1 AND `name` = 'guide');
|
||||
|
||||
INSERT INTO `__PREFIX__docs_doc` (`id`, `project_id`, `version_id`, `pid`, `name`, `title`, `content`, `editor_type`, `is_dir`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 2, 1, 1, 1, 'introduction', '框架介绍', '<h2>YwxApp 是什么</h2><p>YwxApp 是一套基于 ThinkPHP 的事件驱动插件化开发框架,内置插件市场、会员体系、支付与模板机制。</p><h2>核心特性</h2><p>多应用架构、插件热插拔、事件钩子、可视化后台。</p>', 1, 0, 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__docs_doc` WHERE `project_id` = 1 AND `version_id` = 1 AND `name` = 'introduction');
|
||||
|
||||
INSERT INTO `__PREFIX__docs_doc` (`id`, `project_id`, `version_id`, `pid`, `name`, `title`, `content`, `editor_type`, `is_dir`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 3, 1, 1, 1, 'installation', '安装部署', '<h2>环境要求</h2><p>PHP 8.0 以上,MySQL 5.7 以上,开启 pdo_mysql 与 fileinfo 扩展。</p><h2>安装步骤</h2><p>上传源码后访问安装向导,按提示填写数据库信息即可完成部署。</p>', 1, 0, 2, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__docs_doc` WHERE `project_id` = 1 AND `version_id` = 1 AND `name` = 'installation');
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"backend": [
|
||||
{ "name": "project", "title": "文档项目", "icon": "fa fa-folder-open", "type": 2, "sort": 1, "status": 1, "route": "/docs/backend/project" },
|
||||
{ "name": "version", "title": "版本管理", "icon": "fa fa-code-fork", "type": 2, "sort": 2, "status": 1, "route": "/docs/backend/version" },
|
||||
{ "name": "doc", "title": "文档管理", "icon": "fa fa-file-text-o", "type": 2, "sort": 3, "status": 1, "route": "/docs/backend/doc" },
|
||||
{ "name": "import", "title": "导入文档", "icon": "fa fa-upload", "type": 2, "sort": 4, "status": 1, "route": "/docs/backend/doc/import" }
|
||||
],
|
||||
"frontend": [
|
||||
{ "name": "index", "title": "文档首页", "icon": "fa fa-book", "type": 2, "sort": 1, "status": 1, "route": "/docs" },
|
||||
{ "name": "search", "title": "文档搜索", "icon": "fa fa-search", "type": 2, "sort": 2, "status": 1, "route": "/docs/search" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 文档正文模型
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class DocsDoc extends Model
|
||||
{
|
||||
protected $name = 'docs_doc';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
protected $type = [
|
||||
'id' => 'integer',
|
||||
'project_id' => 'integer',
|
||||
'version_id' => 'integer',
|
||||
'pid' => 'integer',
|
||||
'editor_type' => 'integer',
|
||||
'is_dir' => 'integer',
|
||||
'views' => 'integer',
|
||||
'uid' => 'integer',
|
||||
'editor_uid' => 'integer',
|
||||
'audit_status' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联所属项目
|
||||
*/
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(DocsProject::class, 'project_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联所属版本
|
||||
*/
|
||||
public function version()
|
||||
{
|
||||
return $this->belongsTo(DocsVersion::class, 'version_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按项目 + 版本 + 标识查找已发布文档
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @param int $versionId 版本ID
|
||||
* @param string $name 文档标识
|
||||
* @return static|null
|
||||
*/
|
||||
public static function findByName(int $projectId, int $versionId, string $name)
|
||||
{
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
return static::where('project_id', $projectId)
|
||||
->where('version_id', $versionId)
|
||||
->where('name', $name)
|
||||
->where('status', 1)
|
||||
->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读量自增
|
||||
*
|
||||
* @param int $id 文档ID
|
||||
* @return void
|
||||
*/
|
||||
public static function addViews(int $id): void
|
||||
{
|
||||
if ($id > 0) {
|
||||
static::where('id', $id)->inc('views')->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 文档项目模型
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class DocsProject extends Model
|
||||
{
|
||||
protected $name = 'docs_project';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
protected $type = [
|
||||
'id' => 'integer',
|
||||
'owner_uid' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联版本列表
|
||||
*/
|
||||
public function versions()
|
||||
{
|
||||
return $this->hasMany(DocsVersion::class, 'project_id', 'id')
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 URL 标识获取启用中的项目
|
||||
*
|
||||
* @param string $name 项目标识
|
||||
* @return static|null
|
||||
*/
|
||||
public static function findByName(string $name)
|
||||
{
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
return static::where('name', $name)->where('status', 1)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部启用项目(按排序)
|
||||
*
|
||||
* @return \think\Collection
|
||||
*/
|
||||
public static function enabledList()
|
||||
{
|
||||
return static::where('status', 1)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 文档搜索索引模型
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class DocsSearch extends Model
|
||||
{
|
||||
protected $name = 'docs_search';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = false;
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
protected $type = [
|
||||
'id' => 'integer',
|
||||
'doc_id' => 'integer',
|
||||
'project_id' => 'integer',
|
||||
'version_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联文档
|
||||
*/
|
||||
public function doc()
|
||||
{
|
||||
return $this->belongsTo(DocsDoc::class, 'doc_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 文档版本模型
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class DocsVersion extends Model
|
||||
{
|
||||
protected $name = 'docs_version';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
protected $type = [
|
||||
'id' => 'integer',
|
||||
'project_id' => 'integer',
|
||||
'is_default' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联所属项目
|
||||
*/
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(DocsProject::class, 'project_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目下的启用版本列表
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return \think\Collection
|
||||
*/
|
||||
public static function listByProject(int $projectId)
|
||||
{
|
||||
return static::where('project_id', $projectId)
|
||||
->where('status', 1)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析项目的目标版本
|
||||
*
|
||||
* 优先按名称精确匹配,其次取默认版本,最后退回第一个版本。
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @param string $name 版本标识,为空表示取默认
|
||||
* @return static|null
|
||||
*/
|
||||
public static function resolve(int $projectId, string $name = '')
|
||||
{
|
||||
if ($name !== '') {
|
||||
$version = static::where('project_id', $projectId)
|
||||
->where('name', $name)
|
||||
->where('status', 1)
|
||||
->find();
|
||||
if ($version) {
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
|
||||
$default = static::where('project_id', $projectId)
|
||||
->where('status', 1)
|
||||
->where('is_default', 1)
|
||||
->find();
|
||||
if ($default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return static::where('project_id', $projectId)
|
||||
->where('status', 1)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// 文档中心插件路由
|
||||
// 注意:本文件由 AddonService::loadAddonRoutes() 包裹在 Route::group('docs') 内,
|
||||
// 因此这里一律写相对路径,不要再加 docs/ 前缀。
|
||||
|
||||
use think\facade\Route;
|
||||
|
||||
// ==================== 后台管理 ====================
|
||||
Route::group('backend', function () {
|
||||
|
||||
// UEditor 服务端统一入口(config / 上传 / 图片管理 / 远程抓图)
|
||||
Route::rule('ueditor', 'backend.Ueditor/index', 'GET|POST');
|
||||
|
||||
// ---------- 文档项目 ----------
|
||||
Route::get('project/add', 'backend.Project/add');
|
||||
Route::post('project/save', 'backend.Project/save');
|
||||
Route::get('project/edit/:id', 'backend.Project/edit');
|
||||
Route::post('project/update/:id', 'backend.Project/update');
|
||||
Route::post('project/delete/:id', 'backend.Project/delete');
|
||||
Route::get('project', 'backend.Project/index');
|
||||
|
||||
// ---------- 文档版本 ----------
|
||||
Route::get('version/add', 'backend.Version/add');
|
||||
Route::post('version/save', 'backend.Version/save');
|
||||
Route::get('version/edit/:id', 'backend.Version/edit');
|
||||
Route::post('version/update/:id', 'backend.Version/update');
|
||||
Route::post('version/delete/:id', 'backend.Version/delete');
|
||||
Route::get('version', 'backend.Version/index');
|
||||
|
||||
// ---------- 文档正文 ----------
|
||||
// 具体路径必须写在 :id 通配之前,否则会被参数规则吞掉
|
||||
Route::get('doc/import', 'backend.Doc/import');
|
||||
Route::post('doc/import', 'backend.Doc/doImport');
|
||||
Route::post('doc/sort', 'backend.Doc/sort');
|
||||
Route::get('doc/tree', 'backend.Doc/tree');
|
||||
Route::get('doc/add', 'backend.Doc/add');
|
||||
Route::post('doc/save', 'backend.Doc/save');
|
||||
Route::post('doc/rebuild-index', 'backend.Doc/rebuildIndex');
|
||||
Route::get('doc/edit/:id', 'backend.Doc/edit');
|
||||
Route::post('doc/update/:id', 'backend.Doc/update');
|
||||
Route::post('doc/delete/:id', 'backend.Doc/delete');
|
||||
Route::get('doc', 'backend.Doc/index');
|
||||
|
||||
Route::get('/', 'backend.Index/index');
|
||||
});
|
||||
|
||||
// ==================== 前台阅读 ====================
|
||||
// 顺序敏感:固定路径 -> 三段 -> 两段 -> 一段 -> 首页
|
||||
Route::get('search', 'Search/index');
|
||||
|
||||
// /docs/<项目>/<版本>/<文档>
|
||||
Route::get(':project/:version/:name', 'Read/index')
|
||||
->pattern(['project' => '[A-Za-z0-9_\-]+', 'version' => '[A-Za-z0-9_\.\-]+', 'name' => '[A-Za-z0-9_\-]+']);
|
||||
|
||||
// /docs/<项目>/<文档>
|
||||
Route::get(':project/:name', 'Read/index')
|
||||
->pattern(['project' => '[A-Za-z0-9_\-]+', 'name' => '[A-Za-z0-9_\-]+']);
|
||||
|
||||
// /docs/<项目> 跳转到该项目第一篇
|
||||
Route::get(':project', 'Read/entry')
|
||||
->pattern(['project' => '[A-Za-z0-9_\-]+']);
|
||||
|
||||
// /docs 文档中心首页
|
||||
Route::get('/', 'Index/index');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
|
||||
<style>
|
||||
.doc-editor-wrap { border: 1px solid #e6e6e6; }
|
||||
.doc-form-side { background: #f8f9fb; padding: 15px; border: 1px solid #eee; border-radius: 4px; }
|
||||
.doc-form-side .layui-form-label { width: 82px; padding: 9px 6px; }
|
||||
.doc-form-side .layui-input-block { margin-left: 96px; }
|
||||
</style>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>新增文档 —— {$project.title} / {$version.title}</span>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" style="float:right;"
|
||||
href="/docs/backend/doc?project_id={$project.id}&version_id={$version.id}">返回列表</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" id="docForm">
|
||||
<input type="hidden" name="project_id" value="{$project.id}">
|
||||
<input type="hidden" name="version_id" value="{$version.id}">
|
||||
<input type="hidden" name="editor_type" value="1">
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md9">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block" style="margin-left:0;">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="请输入文档标题"
|
||||
autocomplete="off" class="layui-input" style="height:42px;font-size:16px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item" id="editorBox">
|
||||
<div class="doc-editor-wrap">
|
||||
<script id="docContent" name="content" type="text/plain"></script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-col-md3">
|
||||
<div class="doc-form-side">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">父级</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="pid">
|
||||
<option value="0">顶级文档</option>
|
||||
{volist name="parentOptions" id="opt"}
|
||||
<option value="{$opt.id}" {if condition="$item.pid eq $opt.id"}selected{/if}>{$opt.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" placeholder="留空自动生成" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="0" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">纯目录</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="is_dir" value="1" lay-skin="switch" lay-text="是|否" lay-filter="isDirSwitch">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">显示</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" checked lay-skin="switch" lay-text="显示|隐藏">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">SEO标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="seo_title" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">关键词</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="seo_keywords" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">描述</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="seo_desc" class="layui-textarea" style="min-height:70px;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item" style="margin-bottom:0;">
|
||||
<button class="layui-btn layui-btn-fluid" lay-submit lay-filter="docSubmit">保存文档</button>
|
||||
</div>
|
||||
<div class="docs-tip" style="margin-top:8px;">
|
||||
开启「纯目录」后该节点仅用于分组,不保存正文
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/library/ueditor/ueditor.config.js"></script>
|
||||
<script src="/assets/library/ueditor/ueditor.all.js"></script>
|
||||
<script>
|
||||
var docEditor = UE.getEditor('docContent', {
|
||||
serverUrl: '/docs/backend/ueditor',
|
||||
initialFrameHeight: 520,
|
||||
autoHeightEnabled: false,
|
||||
elementPathEnabled: false,
|
||||
catchRemoteImageEnable: true,
|
||||
// 文档站需要保留代码块结构,关闭 div 转 p
|
||||
allowDivTransToP: false,
|
||||
toolbars: [[
|
||||
'undo', 'redo', '|', 'bold', 'italic', 'underline', 'strikethrough', 'removeformat', '|',
|
||||
'paragraph', 'fontsize', '|', 'forecolor', 'backcolor', '|',
|
||||
'insertorderedlist', 'insertunorderedlist', 'blockquote', '|',
|
||||
'justifyleft', 'justifycenter', 'justifyright', '|',
|
||||
'link', 'unlink', 'anchor', '|',
|
||||
'simpleupload', 'insertimage', 'attachment', 'insertvideo', '|',
|
||||
'inserttable', 'deletetable', 'insertrow', 'deleterow', 'insertcol', 'deletecol', 'mergecells', '|',
|
||||
'horizontal', 'insertcode', 'pasteplain', '|',
|
||||
'searchreplace', 'preview', 'fullscreen', 'source'
|
||||
]]
|
||||
});
|
||||
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
// 纯目录节点不需要正文编辑器
|
||||
form.on('switch(isDirSwitch)', function (data) {
|
||||
$('#editorBox').toggle(!data.elem.checked);
|
||||
});
|
||||
|
||||
form.on('submit(docSubmit)', function (data) {
|
||||
var post = $.extend({}, data.field);
|
||||
post.status = data.field.status ? 1 : 0;
|
||||
post.is_dir = data.field.is_dir ? 1 : 0;
|
||||
post.content = post.is_dir === 1 ? '' : docEditor.getContent();
|
||||
|
||||
var loading = layer.load(1);
|
||||
$.post('/docs/backend/doc/save', post, function (res) {
|
||||
layer.close(loading);
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () {
|
||||
location.href = '/docs/backend/doc/edit/' + res.data.id;
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.close(loading);
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
|
||||
<style>
|
||||
.doc-editor-wrap { border: 1px solid #e6e6e6; }
|
||||
.doc-form-side { background: #f8f9fb; padding: 15px; border: 1px solid #eee; border-radius: 4px; }
|
||||
.doc-form-side .layui-form-label { width: 82px; padding: 9px 6px; }
|
||||
.doc-form-side .layui-input-block { margin-left: 96px; }
|
||||
</style>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>编辑文档:{$item.title}</span>
|
||||
<div class="layui-inline" style="float:right;">
|
||||
{if condition="$project && $item['is_dir'] neq 1"}
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" href="/docs/{$project.name}/{$item.name}" target="_blank">预览</a>
|
||||
{/if}
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
href="/docs/backend/doc?project_id={$item.project_id}&version_id={$item.version_id}">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" id="docForm">
|
||||
<input type="hidden" name="project_id" value="{$item.project_id}">
|
||||
<input type="hidden" name="version_id" value="{$item.version_id}">
|
||||
<input type="hidden" name="editor_type" value="{$item.editor_type}">
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md9">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block" style="margin-left:0;">
|
||||
<input type="text" name="title" value="{$item.title}" required lay-verify="required"
|
||||
autocomplete="off" class="layui-input" style="height:42px;font-size:16px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item" id="editorBox" {if condition="$item.is_dir eq 1"}style="display:none;"{/if}>
|
||||
<div class="doc-editor-wrap">
|
||||
<script id="docContent" name="content" type="text/plain">{$item.content|raw}</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-col-md3">
|
||||
<div class="doc-form-side">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">父级</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="pid">
|
||||
<option value="0">顶级文档</option>
|
||||
{volist name="parentOptions" id="opt"}
|
||||
<option value="{$opt.id}" {if condition="$item.pid eq $opt.id"}selected{/if}>{$opt.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" value="{$item.name}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{$item.sort}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">纯目录</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="is_dir" value="1" {if condition="$item.is_dir eq 1"}checked{/if}
|
||||
lay-skin="switch" lay-text="是|否" lay-filter="isDirSwitch">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">显示</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" {if condition="$item.status eq 1"}checked{/if}
|
||||
lay-skin="switch" lay-text="显示|隐藏">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">SEO标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="seo_title" value="{$item.seo_title}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">关键词</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="seo_keywords" value="{$item.seo_keywords}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">描述</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="seo_desc" class="layui-textarea" style="min-height:70px;">{$item.seo_desc}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item" style="margin-bottom:0;">
|
||||
<button class="layui-btn layui-btn-fluid" lay-submit lay-filter="docSubmit">保存文档</button>
|
||||
</div>
|
||||
<div class="docs-tip" style="margin-top:8px;">
|
||||
阅读量 {$item.views} 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/library/ueditor/ueditor.config.js"></script>
|
||||
<script src="/assets/library/ueditor/ueditor.all.js"></script>
|
||||
<script>
|
||||
var docEditor = UE.getEditor('docContent', {
|
||||
serverUrl: '/docs/backend/ueditor',
|
||||
initialFrameHeight: 520,
|
||||
autoHeightEnabled: false,
|
||||
elementPathEnabled: false,
|
||||
catchRemoteImageEnable: true,
|
||||
allowDivTransToP: false,
|
||||
toolbars: [[
|
||||
'undo', 'redo', '|', 'bold', 'italic', 'underline', 'strikethrough', 'removeformat', '|',
|
||||
'paragraph', 'fontsize', '|', 'forecolor', 'backcolor', '|',
|
||||
'insertorderedlist', 'insertunorderedlist', 'blockquote', '|',
|
||||
'justifyleft', 'justifycenter', 'justifyright', '|',
|
||||
'link', 'unlink', 'anchor', '|',
|
||||
'simpleupload', 'insertimage', 'attachment', 'insertvideo', '|',
|
||||
'inserttable', 'deletetable', 'insertrow', 'deleterow', 'insertcol', 'deletecol', 'mergecells', '|',
|
||||
'horizontal', 'insertcode', 'pasteplain', '|',
|
||||
'searchreplace', 'preview', 'fullscreen', 'source'
|
||||
]]
|
||||
});
|
||||
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('switch(isDirSwitch)', function (data) {
|
||||
$('#editorBox').toggle(!data.elem.checked);
|
||||
});
|
||||
|
||||
form.on('submit(docSubmit)', function (data) {
|
||||
var post = $.extend({}, data.field);
|
||||
post.status = data.field.status ? 1 : 0;
|
||||
post.is_dir = data.field.is_dir ? 1 : 0;
|
||||
post.content = post.is_dir === 1 ? '' : docEditor.getContent();
|
||||
|
||||
var loading = layer.load(1);
|
||||
$.post('/docs/backend/doc/update/{$item.id}', post, function (res) {
|
||||
layer.close(loading);
|
||||
layer.msg(res.message || (res.code === 0 ? '保存成功' : '保存失败'), { icon: res.code === 0 ? 1 : 2 });
|
||||
}, 'json').fail(function () {
|
||||
layer.close(loading);
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
|
||||
<style>
|
||||
.import-drop { border: 2px dashed #d2d6de; border-radius: 6px; padding: 40px 20px; text-align: center; background: #fafbfc; cursor: pointer; }
|
||||
.import-drop:hover { border-color: #1e9fff; background: #f4f9ff; }
|
||||
.import-drop .layui-icon { font-size: 44px; color: #c2c6cc; display: block; margin-bottom: 10px; }
|
||||
.import-file { margin-top: 12px; color: #1e9fff; }
|
||||
.import-help { background: #f8f9fb; border: 1px solid #eee; border-radius: 4px; padding: 15px; }
|
||||
.import-help h3 { font-size: 14px; margin-bottom: 8px; }
|
||||
.import-help ul { padding-left: 18px; color: #666; line-height: 2; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>导入文档</span>
|
||||
{if condition="$project"}
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" style="float:right;"
|
||||
href="/docs/backend/doc?project_id={$project.id}&version_id={$version.id}">返回列表</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
{if condition="empty($projects)"}
|
||||
<div class="docs-empty">
|
||||
还没有文档项目,请先 <a href="/docs/backend/project/add" class="layui-btn layui-btn-sm">创建项目</a>
|
||||
</div>
|
||||
{else/}
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md8">
|
||||
<form class="layui-form" id="importForm">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">目标项目</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="project_id" lay-filter="projectPick">
|
||||
{volist name="projects" id="p"}
|
||||
<option value="{$p.id}" {if condition="$project && $project.id eq $p.id"}selected{/if}>{$p.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">目标版本</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="version_id">
|
||||
{volist name="versions" id="v"}
|
||||
<option value="{$v.id}" {if condition="$version && $version.id eq $v.id"}selected{/if}>{$v.title}({$v.name})</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">父级文档</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="pid">
|
||||
<option value="0">顶级文档</option>
|
||||
{volist name="parentOptions" id="opt"}
|
||||
<option value="{$opt.id}">{$opt.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">文档标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" placeholder="留空则自动从文件内容提取" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">文档标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" placeholder="留空自动生成,重名会自动加后缀" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="0" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">选择文件</label>
|
||||
<div class="layui-input-block">
|
||||
<div class="import-drop" id="dropZone">
|
||||
<i class="layui-icon layui-icon-upload-drag"></i>
|
||||
<div>点击选择文件,或将文件拖拽到此处</div>
|
||||
<div class="docs-tip" style="margin-top:6px;">支持 docx / md / html / txt,单个文件不超过 20MB</div>
|
||||
<div class="import-file" id="fileName"></div>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept=".docx,.md,.markdown,.html,.htm,.txt" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button type="button" class="layui-btn" id="btnImport">开始导入</button>
|
||||
{if condition="$project"}
|
||||
<a class="layui-btn layui-btn-primary" href="/docs/backend/doc?project_id={$project.id}">取消</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="layui-col-md4">
|
||||
<div class="import-help">
|
||||
<h3>导入说明</h3>
|
||||
<ul>
|
||||
<li><b>Word(.docx)</b>:自动识别标题层级与段落
|
||||
{if condition="$hasPhpWord"}
|
||||
<span class="layui-badge layui-bg-green">已装 PhpWord,解析更完整</span>
|
||||
{else/}
|
||||
<span class="layui-badge layui-bg-orange">当前使用内置解析</span>
|
||||
{/if}
|
||||
</li>
|
||||
<li><b>Markdown(.md)</b>:支持标题、代码块、列表、表格、引用、链接与图片</li>
|
||||
<li><b>HTML</b>:自动提取 body 内容并清洗脚本</li>
|
||||
<li><b>纯文本</b>:按行转换为段落</li>
|
||||
</ul>
|
||||
|
||||
<h3 style="margin-top:14px;">提升 Word 解析效果</h3>
|
||||
<ul>
|
||||
<li>执行 <code>composer require phpoffice/phpword</code> 后可完整还原表格与样式</li>
|
||||
<li>Word 中的图片建议先上传到编辑器,或导入后在编辑页粘贴</li>
|
||||
</ul>
|
||||
|
||||
<h3 style="margin-top:14px;">另一种方式</h3>
|
||||
<ul>
|
||||
<li>直接在编辑器中从 Word 复制粘贴,外链图片会自动抓取并保存到本地</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
// 切换项目需要重载版本与父级下拉
|
||||
form.on('select(projectPick)', function (data) {
|
||||
location.href = '/docs/backend/doc/import?project_id=' + data.value;
|
||||
});
|
||||
|
||||
var $input = $('#fileInput');
|
||||
var $zone = $('#dropZone');
|
||||
|
||||
$zone.on('click', function () { $input.trigger('click'); });
|
||||
|
||||
$input.on('change', function () {
|
||||
var file = this.files && this.files[0];
|
||||
$('#fileName').text(file ? '已选择:' + file.name : '');
|
||||
});
|
||||
|
||||
// 拖拽上传
|
||||
$zone.on('dragover', function (e) { e.preventDefault(); e.stopPropagation(); });
|
||||
$zone.on('drop', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var files = e.originalEvent.dataTransfer.files;
|
||||
if (files && files.length) {
|
||||
$input[0].files = files;
|
||||
$('#fileName').text('已选择:' + files[0].name);
|
||||
}
|
||||
});
|
||||
|
||||
$('#btnImport').on('click', function () {
|
||||
var file = $input[0].files && $input[0].files[0];
|
||||
if (!file) {
|
||||
layer.msg('请先选择要导入的文件', { icon: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
$('#importForm').find('select, input[type=text], input[type=number]').each(function () {
|
||||
fd.append(this.name, this.value);
|
||||
});
|
||||
|
||||
var loading = layer.load(1);
|
||||
$.ajax({
|
||||
url: '/docs/backend/doc/import',
|
||||
type: 'POST',
|
||||
data: fd,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
dataType: 'json',
|
||||
success: function (res) {
|
||||
layer.close(loading);
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () {
|
||||
location.href = '/docs/backend/doc/edit/' + res.data.id;
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.message || '导入失败', { icon: 2 });
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
layer.close(loading);
|
||||
layer.msg('请求失败,请检查文件大小与服务器配置', { icon: 2 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
|
||||
<style>
|
||||
.doc-tree { list-style: none; margin: 0; padding: 0; }
|
||||
.doc-tree ul { list-style: none; margin: 0; padding-left: 22px; }
|
||||
.doc-tree li { line-height: 34px; }
|
||||
.doc-node { display: flex; align-items: center; padding: 0 8px; border-radius: 3px; }
|
||||
.doc-node:hover { background: #f2f5fa; }
|
||||
.doc-node .doc-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.doc-node .doc-name i { margin-right: 6px; color: #999; }
|
||||
.doc-node .doc-ops { flex-shrink: 0; visibility: hidden; }
|
||||
.doc-node:hover .doc-ops { visibility: visible; }
|
||||
.doc-node .layui-btn-xs { margin-left: 4px; }
|
||||
.doc-badge { margin-left: 6px; }
|
||||
.doc-sortbox { width: 54px; height: 26px; line-height: 26px; padding: 0 4px; margin-right: 8px; border: 1px solid #e6e6e6; border-radius: 3px; text-align: center; }
|
||||
</style>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>文档管理</span>
|
||||
<div class="layui-inline" style="float:right;">
|
||||
{if condition="$project"}
|
||||
<a class="layui-btn layui-btn-sm" href="/docs/backend/doc/add?project_id={$project.id}&version_id={$version.id}">新增文档</a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-normal" href="/docs/backend/doc/import?project_id={$project.id}&version_id={$version.id}">导入文档</a>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-primary" id="btnSaveSort">保存排序</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-primary" id="btnRebuild">重建索引</button>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" href="/docs/{$project.name}" target="_blank">预览</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
{if condition="empty($projects)"}
|
||||
<div class="docs-empty">
|
||||
还没有文档项目,请先 <a href="/docs/backend/project/add" class="layui-btn layui-btn-sm">创建项目</a>
|
||||
</div>
|
||||
{else/}
|
||||
<form class="layui-form docs-scope-bar">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px;">项目</label>
|
||||
<div class="layui-input-inline">
|
||||
<select name="project_id" lay-filter="projectPick">
|
||||
{volist name="projects" id="p"}
|
||||
<option value="{$p.id}" {if condition="$project && $project.id eq $p.id"}selected{/if}>{$p.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px;">版本</label>
|
||||
<div class="layui-input-inline">
|
||||
<select name="version_id" lay-filter="versionPick">
|
||||
{volist name="versions" id="v"}
|
||||
<option value="{$v.id}" {if condition="$version && $version.id eq $v.id"}selected{/if}>{$v.title}({$v.name})</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<span class="docs-tip">修改左侧排序框数值后点击「保存排序」生效;序号越小越靠前</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{if condition="empty($tree)"}
|
||||
<div class="docs-empty">
|
||||
当前版本下还没有文档,点击右上角「新增文档」或「导入文档」开始创建
|
||||
</div>
|
||||
{else/}
|
||||
<ul class="doc-tree" id="docTree">
|
||||
{php}
|
||||
/**
|
||||
* 递归渲染文档目录树
|
||||
* 模板引擎不支持递归 include,这里用函数递归输出
|
||||
*/
|
||||
if (!function_exists('docs_render_tree')) {
|
||||
function docs_render_tree(array $nodes)
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$id = (int) $node['id'];
|
||||
$pid = (int) $node['pid'];
|
||||
$sort = (int) $node['sort'];
|
||||
$isDir = (int) $node['is_dir'] === 1;
|
||||
$title = htmlspecialchars((string) $node['title'], ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars((string) $node['name'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
echo '<li>';
|
||||
echo '<div class="doc-node" data-id="' . $id . '" data-pid="' . $pid . '">';
|
||||
echo '<input type="number" class="doc-sortbox" value="' . $sort . '" title="排序值">';
|
||||
echo '<span class="doc-name">';
|
||||
echo '<i class="layui-icon ' . ($isDir ? 'layui-icon-folder' : 'layui-icon-file') . '"></i>';
|
||||
echo $title . ' <code style="color:#bbb;font-size:12px;">' . $name . '</code>';
|
||||
if ($isDir) {
|
||||
echo '<span class="layui-badge layui-bg-gray doc-badge">目录</span>';
|
||||
}
|
||||
if ((int) $node['status'] !== 1) {
|
||||
echo '<span class="layui-badge layui-bg-orange doc-badge">隐藏</span>';
|
||||
}
|
||||
echo '</span>';
|
||||
echo '<span class="doc-ops">';
|
||||
echo '<a class="layui-btn layui-btn-xs" href="/docs/backend/doc/edit/' . $id . '">编辑</a>';
|
||||
echo '<a class="layui-btn layui-btn-xs layui-btn-normal" href="/docs/backend/doc/add?project_id='
|
||||
. (int) $node['project_id'] . '&version_id=' . (int) $node['version_id']
|
||||
. '&pid=' . $id . '">加子级</a>';
|
||||
echo '<a class="layui-btn layui-btn-xs layui-btn-danger doc-del" href="javascript:;" data-id="'
|
||||
. $id . '" data-title="' . $title . '">删除</a>';
|
||||
echo '</span>';
|
||||
echo '</div>';
|
||||
|
||||
if (!empty($node['children'])) {
|
||||
echo '<ul>';
|
||||
docs_render_tree($node['children']);
|
||||
echo '</ul>';
|
||||
}
|
||||
echo '</li>';
|
||||
}
|
||||
}
|
||||
}
|
||||
docs_render_tree($tree);
|
||||
{/php}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
var projectId = {if condition="$project"}{$project.id}{else/}0{/if};
|
||||
var versionId = {if condition="$version"}{$version.id}{else/}0{/if};
|
||||
|
||||
// 切换项目时版本需要重新加载,因此直接整页跳转
|
||||
form.on('select(projectPick)', function (data) {
|
||||
location.href = '/docs/backend/doc?project_id=' + data.value;
|
||||
});
|
||||
|
||||
form.on('select(versionPick)', function (data) {
|
||||
location.href = '/docs/backend/doc?project_id=' + projectId + '&version_id=' + data.value;
|
||||
});
|
||||
|
||||
// 删除文档
|
||||
$('#docTree').on('click', '.doc-del', function () {
|
||||
var id = $(this).data('id');
|
||||
var title = $(this).data('title');
|
||||
layer.confirm('删除「' + title + '」将同时删除其所有下级文档,确定继续?', {
|
||||
icon: 3, title: '确认删除'
|
||||
}, function (index) {
|
||||
layer.close(index);
|
||||
$.post('/docs/backend/doc/delete/' + id, {}, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () { location.reload(); });
|
||||
} else {
|
||||
layer.msg(res.message || '删除失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 保存排序:收集所有节点的 id / pid / sort
|
||||
$('#btnSaveSort').on('click', function () {
|
||||
var nodes = [];
|
||||
$('#docTree .doc-node').each(function () {
|
||||
var $node = $(this);
|
||||
nodes.push({
|
||||
id: parseInt($node.data('id'), 10),
|
||||
pid: parseInt($node.data('pid'), 10),
|
||||
sort: parseInt($node.find('.doc-sortbox').val(), 10) || 0
|
||||
});
|
||||
});
|
||||
|
||||
if (!nodes.length) {
|
||||
layer.msg('没有可排序的文档', { icon: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
var loading = layer.load(1);
|
||||
$.post('/docs/backend/doc/sort', { nodes: JSON.stringify(nodes) }, function (res) {
|
||||
layer.close(loading);
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () { location.reload(); });
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.close(loading);
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
// 重建搜索索引
|
||||
$('#btnRebuild').on('click', function () {
|
||||
var loading = layer.load(1);
|
||||
$.post('/docs/backend/doc/rebuild-index', { project_id: projectId }, function (res) {
|
||||
layer.close(loading);
|
||||
layer.msg(res.message || '完成', { icon: res.code === 0 ? 1 : 2 });
|
||||
}, 'json').fail(function () {
|
||||
layer.close(loading);
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>新增文档项目</span>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" style="float:right;" href="/docs/backend/project">返回列表</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="projectForm" style="max-width:760px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" required lay-verify="required" placeholder="英文标识,用于 URL,如 framework" autocomplete="off" class="layui-input">
|
||||
<div class="docs-tip">前台访问地址为 /docs/项目标识,仅支持字母开头的字母数字与下划线中划线</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="显示名称,如 框架开发文档" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目简介</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="intro" placeholder="一句话介绍该文档项目" class="layui-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目图标</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="logo" placeholder="图标地址,可留空" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">归属插件</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="addon">
|
||||
<option value="">框架自身(不归属插件)</option>
|
||||
{volist name="addonList" id="addon"}
|
||||
<option value="{$addon.name}">{$addon.title}({$addon.name})</option>
|
||||
{/volist}
|
||||
</select>
|
||||
<div class="docs-tip">选择后可在插件详情页关联展示该文档</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="0" placeholder="数值越小越靠前" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" checked lay-skin="switch" lay-text="启用|禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="projectSubmit">保存</button>
|
||||
<a class="layui-btn layui-btn-primary" href="/docs/backend/project">取消</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(projectSubmit)', function (data) {
|
||||
var post = $.extend({}, data.field);
|
||||
post.status = data.field.status ? 1 : 0;
|
||||
|
||||
$.post('/docs/backend/project/save', post, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () {
|
||||
location.href = '/docs/backend/project';
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>编辑文档项目:{$item.title}</span>
|
||||
<div class="layui-inline" style="float:right;">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-normal" href="/docs/backend/doc?project_id={$item.id}">管理文档</a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" href="/docs/backend/project">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="projectForm" style="max-width:760px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" value="{$item.name}" required lay-verify="required" autocomplete="off" class="layui-input">
|
||||
<div class="docs-tip">修改标识会导致原有前台链接失效,请谨慎操作</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" value="{$item.title}" required lay-verify="required" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目简介</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="intro" class="layui-textarea">{$item.intro}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">项目图标</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="logo" value="{$item.logo}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">归属插件</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="addon">
|
||||
<option value="">框架自身(不归属插件)</option>
|
||||
{volist name="addonList" id="addon"}
|
||||
<option value="{$addon.name}" {if condition="$item.addon eq $addon.name"}selected{/if}>{$addon.title}({$addon.name})</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">默认版本</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="default_version">
|
||||
<option value="">自动(取默认版本)</option>
|
||||
{volist name="versionList" id="ver"}
|
||||
<option value="{$ver.name}" {if condition="$item.default_version eq $ver.name"}selected{/if}>{$ver.title}({$ver.name})</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{$item.sort}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" {if condition="$item.status eq 1"}checked{/if} lay-skin="switch" lay-text="启用|禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="projectSubmit">保存</button>
|
||||
<a class="layui-btn layui-btn-primary" href="/docs/backend/project">取消</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(projectSubmit)', function (data) {
|
||||
var post = $.extend({}, data.field);
|
||||
post.status = data.field.status ? 1 : 0;
|
||||
|
||||
$.post('/docs/backend/project/update/{$item.id}', post, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 });
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>文档项目</span>
|
||||
<div class="layui-inline" style="float:right;">
|
||||
<a class="layui-btn layui-btn-sm" href="/docs/backend/project/add">新增项目</a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" href="/docs" target="_blank">查看前台</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form docs-scope-bar" method="get" action="/docs/backend/project">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="keyword" value="{$keyword|default=''}" placeholder="按项目名称或标识搜索" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" type="submit">搜索</button>
|
||||
<a class="layui-btn layui-btn-primary" href="/docs/backend/project">重置</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="layui-table">
|
||||
<colgroup>
|
||||
<col width="70"><col width="150"><col><col width="130"><col width="90"><col width="90"><col width="80"><col width="90"><col width="200">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>标识</th><th>项目名称</th><th>归属插件</th>
|
||||
<th>版本数</th><th>文档数</th><th>排序</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{volist name="list" id="item" empty="<tr><td colspan='9' class='docs-empty'>暂无文档项目,点击右上角新增</td></tr>"}
|
||||
<tr>
|
||||
<td>{$item.id}</td>
|
||||
<td><code>{$item.name}</code></td>
|
||||
<td>
|
||||
{$item.title}
|
||||
{if condition="$item.intro neq ''"}<div class="docs-tip">{$item.intro}</div>{/if}
|
||||
</td>
|
||||
<td>{if condition="$item.addon neq ''"}{$item.addon}{else/}<span class="docs-tip">框架自身</span>{/if}</td>
|
||||
<td>{$item.version_count}</td>
|
||||
<td>{$item.doc_count}</td>
|
||||
<td>{$item.sort}</td>
|
||||
<td>
|
||||
{if condition="$item.status eq 1"}
|
||||
<span class="layui-badge layui-bg-blue">启用</span>
|
||||
{else/}
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a class="layui-btn layui-btn-xs" href="/docs/backend/project/edit/{$item.id}">编辑</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" href="/docs/backend/doc?project_id={$item.id}">管理文档</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger docs-del" href="javascript:;" data-id="{$item.id}" data-title="{$item.title}">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['layer', 'jquery'], function () {
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
$('.docs-del').on('click', function () {
|
||||
var id = $(this).data('id');
|
||||
var title = $(this).data('title');
|
||||
layer.confirm('删除项目「' + title + '」将同时删除其下所有版本与文档,确定继续?', {
|
||||
icon: 3, title: '确认删除'
|
||||
}, function (index) {
|
||||
layer.close(index);
|
||||
$.post('/docs/backend/project/delete/' + id, {}, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () { location.reload(); });
|
||||
} else {
|
||||
layer.msg(res.message || '删除失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>新增版本</span>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" style="float:right;" href="/docs/backend/version?project_id={$projectId}">返回列表</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" style="max-width:760px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">所属项目</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="project_id" required lay-verify="required">
|
||||
{volist name="projects" id="p"}
|
||||
<option value="{$p.id}" {if condition="$projectId eq $p.id"}selected{/if}>{$p.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" required lay-verify="required" placeholder="如 v1 / v2.0" autocomplete="off" class="layui-input">
|
||||
<div class="docs-tip">非默认版本的访问地址为 /docs/项目标识/版本标识/文档标识</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="如 1.0 稳定版" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本说明</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="intro" class="layui-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="0" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">设为默认</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="is_default" value="1" lay-skin="switch" lay-text="是|否">
|
||||
<div class="docs-tip">默认版本的文档使用短地址访问,同一项目只能有一个默认版本</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" checked lay-skin="switch" lay-text="启用|禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="versionSubmit">保存</button>
|
||||
<a class="layui-btn layui-btn-primary" href="/docs/backend/version?project_id={$projectId}">取消</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(versionSubmit)', function (data) {
|
||||
var post = $.extend({}, data.field);
|
||||
post.status = data.field.status ? 1 : 0;
|
||||
post.is_default = data.field.is_default ? 1 : 0;
|
||||
|
||||
$.post('/docs/backend/version/save', post, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () {
|
||||
location.href = '/docs/backend/version?project_id=' + post.project_id;
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>编辑版本:{$item.title}</span>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" style="float:right;" href="/docs/backend/version?project_id={$item.project_id}">返回列表</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" style="max-width:760px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">所属项目</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="project_id" required lay-verify="required">
|
||||
{volist name="projects" id="p"}
|
||||
<option value="{$p.id}" {if condition="$item.project_id eq $p.id"}selected{/if}>{$p.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本标识</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" value="{$item.name}" required lay-verify="required" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" value="{$item.title}" required lay-verify="required" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本说明</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="intro" class="layui-textarea">{$item.intro}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{$item.sort}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">设为默认</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="is_default" value="1" {if condition="$item.is_default eq 1"}checked{/if} lay-skin="switch" lay-text="是|否">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" {if condition="$item.status eq 1"}checked{/if} lay-skin="switch" lay-text="启用|禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="versionSubmit">保存</button>
|
||||
<a class="layui-btn layui-btn-primary" href="/docs/backend/version?project_id={$item.project_id}">取消</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(versionSubmit)', function (data) {
|
||||
var post = $.extend({}, data.field);
|
||||
post.status = data.field.status ? 1 : 0;
|
||||
post.is_default = data.field.is_default ? 1 : 0;
|
||||
|
||||
$.post('/docs/backend/version/update/{$item.id}', post, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 });
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>版本管理</span>
|
||||
<a class="layui-btn layui-btn-sm" style="float:right;" href="/docs/backend/version/add?project_id={$projectId}">新增版本</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form docs-scope-bar" method="get" action="/docs/backend/version">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px;">所属项目</label>
|
||||
<div class="layui-input-inline">
|
||||
<select name="project_id" lay-filter="projectPick">
|
||||
{volist name="projects" id="p"}
|
||||
<option value="{$p.id}" {if condition="$projectId eq $p.id"}selected{/if}>{$p.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="layui-table">
|
||||
<colgroup>
|
||||
<col width="70"><col width="140"><col><col width="90"><col width="90"><col width="80"><col width="90"><col width="150">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>版本标识</th><th>版本名称</th><th>默认</th>
|
||||
<th>文档数</th><th>排序</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{volist name="list" id="item" empty="<tr><td colspan='8' class='docs-empty'>该项目暂无版本</td></tr>"}
|
||||
<tr>
|
||||
<td>{$item.id}</td>
|
||||
<td><code>{$item.name}</code></td>
|
||||
<td>
|
||||
{$item.title}
|
||||
{if condition="$item.intro neq ''"}<div class="docs-tip">{$item.intro}</div>{/if}
|
||||
</td>
|
||||
<td>
|
||||
{if condition="$item.is_default eq 1"}
|
||||
<span class="layui-badge layui-bg-orange">默认</span>
|
||||
{else/}
|
||||
<span class="docs-tip">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>{$item.doc_count}</td>
|
||||
<td>{$item.sort}</td>
|
||||
<td>
|
||||
{if condition="$item.status eq 1"}
|
||||
<span class="layui-badge layui-bg-blue">启用</span>
|
||||
{else/}
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a class="layui-btn layui-btn-xs" href="/docs/backend/version/edit/{$item.id}">编辑</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" href="/docs/backend/doc?project_id={$projectId}&version_id={$item.id}">管理文档</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger docs-del" href="javascript:;" data-id="{$item.id}" data-title="{$item.title}">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('select(projectPick)', function (data) {
|
||||
location.href = '/docs/backend/version?project_id=' + data.value;
|
||||
});
|
||||
|
||||
$('.docs-del').on('click', function () {
|
||||
var id = $(this).data('id');
|
||||
var title = $(this).data('title');
|
||||
layer.confirm('删除版本「' + title + '」将同时删除其下所有文档,确定继续?', {
|
||||
icon: 3, title: '确认删除'
|
||||
}, function (index) {
|
||||
layer.close(index);
|
||||
$.post('/docs/backend/version/delete/' + id, {}, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.message, { icon: 1 }, function () { location.reload(); });
|
||||
} else {
|
||||
layer.msg(res.message || '删除失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败,请重试', { icon: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
{extend name="layout"}
|
||||
{block name="title"}{$docsTitle}{/block}
|
||||
{block name="description"}{$docsTitle} - 框架、插件与开发指南的完整在线文档{/block}
|
||||
|
||||
{block name="body"}
|
||||
<section class="dc-hero">
|
||||
<div class="dc-hero-inner">
|
||||
<h1>{$docsTitle}</h1>
|
||||
<p>框架架构、插件开发、会员支付与命令行工具的完整说明,共收录 {$totalDocs} 篇文档</p>
|
||||
<form class="dc-hero-search" action="/docs/search" method="get">
|
||||
<input type="text" name="kw" placeholder="搜索你想了解的内容..." autocomplete="off">
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dc-projects">
|
||||
{if condition="empty($projects)"}
|
||||
<div class="dc-empty">
|
||||
<p>还没有发布任何文档项目</p>
|
||||
<p class="dc-empty-tip">请到后台「文档中心 - 文档项目」创建</p>
|
||||
</div>
|
||||
{else/}
|
||||
<div class="dc-project-grid">
|
||||
{volist name="projects" id="p"}
|
||||
<div class="dc-project-card">
|
||||
<div class="dc-project-head">
|
||||
<div class="dc-project-icon">
|
||||
{if condition="$p.logo neq ''"}
|
||||
<img src="{$p.logo}" alt="{$p.title}">
|
||||
{else/}
|
||||
<span>{$p.title|mb_substr=0,1,'UTF-8'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dc-project-info">
|
||||
<h2><a href="/docs/{$p.name}">{$p.title}</a></h2>
|
||||
<p>{$p.intro|default='暂无简介'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dc-project-meta">
|
||||
<span>{$p.doc_count} 篇文档</span>
|
||||
{if condition="$p.addon neq ''"}
|
||||
<span class="dc-tag">插件 {$p.addon}</span>
|
||||
{else/}
|
||||
<span class="dc-tag">框架核心</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{if condition="!empty($p.recent)"}
|
||||
<ul class="dc-project-recent">
|
||||
{volist name="p.recent" id="r"}
|
||||
<li><a href="/docs/{$p.name}/{$r.name}">{$r.title}</a></li>
|
||||
{/volist}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<a class="dc-project-enter" href="/docs/{$p.name}">进入文档 →</a>
|
||||
</div>
|
||||
{/volist}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<footer class="dc-footer">
|
||||
<p>© {$currentYear} {$docsTitle} · Powered by YwxApp</p>
|
||||
</footer>
|
||||
{/block}
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{block name="title"}{$docsTitle}{/block}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="keywords" content="{block name="keywords"}YwxApp,文档中心{/block}">
|
||||
<meta name="description" content="{block name="description"}{$docsTitle}{/block}">
|
||||
<link rel="stylesheet" href="/static/docs/css/docs.css">
|
||||
{block name="css"}{/block}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="dc-header">
|
||||
<div class="dc-header-inner">
|
||||
<a class="dc-logo" href="/docs">
|
||||
<span class="dc-logo-mark">Y</span>
|
||||
<span class="dc-logo-text">{$docsTitle}</span>
|
||||
</a>
|
||||
|
||||
<nav class="dc-nav">
|
||||
<a href="/docs">文档首页</a>
|
||||
{volist name="projectList" id="p" offset="0" length="5"}
|
||||
<a href="/docs/{$p.name}">{$p.title}</a>
|
||||
{/volist}
|
||||
<a href="/" target="_blank">返回官网</a>
|
||||
</nav>
|
||||
|
||||
<form class="dc-search" action="/docs/search" method="get">
|
||||
<input type="text" name="kw" value="{$keyword|default=''}" placeholder="搜索文档..." autocomplete="off">
|
||||
<button type="submit" aria-label="搜索">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14Z"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button class="dc-menu-toggle" id="dcMenuToggle" aria-label="目录">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{block name="body"}{/block}
|
||||
|
||||
<script src="/static/docs/js/docs.js"></script>
|
||||
{block name="js"}{/block}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
{extend name="layout"}
|
||||
{block name="title"}{$pageTitle}{/block}
|
||||
{block name="keywords"}{$seoKeywords}{/block}
|
||||
{block name="description"}{$seoDesc}{/block}
|
||||
|
||||
{block name="body"}
|
||||
<div class="dc-layout">
|
||||
|
||||
<aside class="dc-sidebar" id="dcSidebar">
|
||||
<div class="dc-sidebar-head">
|
||||
<div class="dc-project-title">{$project.title}</div>
|
||||
{if condition="count($versionList) gt 1"}
|
||||
<select class="dc-version-select" id="dcVersionSelect">
|
||||
{volist name="versionList" id="v"}
|
||||
<option value="{$v.name}" data-default="{$v.is_default}" {if condition="$version.id eq $v.id"}selected{/if}>{$v.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="dc-tree">
|
||||
{$treeHtml|raw}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="dc-main">
|
||||
<div class="dc-crumbs">
|
||||
<a href="/docs">文档中心</a>
|
||||
<span class="dc-crumb-sep">/</span>
|
||||
<a href="/docs/{$project.name}">{$project.title}</a>
|
||||
{volist name="crumbs" id="crumb"}
|
||||
<span class="dc-crumb-sep">/</span>
|
||||
{if condition="$crumb.id eq $activeId"}
|
||||
<span class="dc-crumb-current">{$crumb.title}</span>
|
||||
{else/}
|
||||
<span>{$crumb.title}</span>
|
||||
{/if}
|
||||
{/volist}
|
||||
</div>
|
||||
|
||||
<article class="dc-article">
|
||||
<h1 class="dc-article-title">{$doc.title}</h1>
|
||||
<div class="dc-article-meta">
|
||||
<span>更新于 {$updateDate}</span>
|
||||
<span class="dc-dot">·</span>
|
||||
<span>阅读 {$doc.views}</span>
|
||||
</div>
|
||||
|
||||
<div class="dc-content" id="dcContent">
|
||||
{$content|raw}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<nav class="dc-pager">
|
||||
{if condition="$prev"}
|
||||
<a class="dc-pager-item dc-pager-prev" href="{$baseUrl}/{$prev.name}">
|
||||
<span class="dc-pager-label">上一篇</span>
|
||||
<span class="dc-pager-title">{$prev.title}</span>
|
||||
</a>
|
||||
{else/}
|
||||
<span class="dc-pager-item is-disabled"></span>
|
||||
{/if}
|
||||
|
||||
{if condition="$next"}
|
||||
<a class="dc-pager-item dc-pager-next" href="{$baseUrl}/{$next.name}">
|
||||
<span class="dc-pager-label">下一篇</span>
|
||||
<span class="dc-pager-title">{$next.title}</span>
|
||||
</a>
|
||||
{else/}
|
||||
<span class="dc-pager-item is-disabled"></span>
|
||||
{/if}
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<aside class="dc-toc">
|
||||
{if condition="!empty($toc)"}
|
||||
<div class="dc-toc-title">本页目录</div>
|
||||
<ul class="dc-toc-list" id="dcToc">
|
||||
{volist name="toc" id="t"}
|
||||
<li class="dc-toc-h{$t.level}"><a href="#{$t.anchor}" data-anchor="{$t.anchor}">{$t.text}</a></li>
|
||||
{/volist}
|
||||
</ul>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="dc-mask" id="dcMask"></div>
|
||||
{/block}
|
||||
@@ -0,0 +1,18 @@
|
||||
{extend name="layout"}
|
||||
{block name="title"}文档未找到 - {$docsTitle}{/block}
|
||||
|
||||
{block name="body"}
|
||||
<section class="dc-notfound">
|
||||
<div class="dc-notfound-code">404</div>
|
||||
<h1>{$message}</h1>
|
||||
<p>该地址可能已变更或对应内容已下线</p>
|
||||
<div class="dc-notfound-ops">
|
||||
<a class="dc-btn dc-btn-primary" href="/docs">返回文档中心</a>
|
||||
<a class="dc-btn" href="/">返回官网首页</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="dc-footer">
|
||||
<p>© {$currentYear} {$docsTitle} · Powered by YwxApp</p>
|
||||
</footer>
|
||||
{/block}
|
||||
@@ -0,0 +1,49 @@
|
||||
{extend name="layout"}
|
||||
{block name="title"}{$pageTitle} - {$docsTitle}{/block}
|
||||
|
||||
{block name="body"}
|
||||
<section class="dc-search-page">
|
||||
<form class="dc-search-form" action="/docs/search" method="get">
|
||||
<input type="text" name="kw" value="{$keyword}" placeholder="输入关键词搜索文档..." autocomplete="off" autofocus>
|
||||
<select name="project_id">
|
||||
<option value="0">全部项目</option>
|
||||
{volist name="projects" id="p"}
|
||||
<option value="{$p.id}" {if condition="$projectId eq $p.id"}selected{/if}>{$p.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
|
||||
{if condition="$keyword eq ''"}
|
||||
<div class="dc-empty">
|
||||
<p>请输入关键词开始搜索</p>
|
||||
<p class="dc-empty-tip">支持搜索文档标题、关键词与正文内容</p>
|
||||
</div>
|
||||
{else/}
|
||||
<div class="dc-search-summary">
|
||||
找到 <b>{$total}</b> 条与「{$keyword}」相关的结果
|
||||
</div>
|
||||
|
||||
{if condition="empty($list)"}
|
||||
<div class="dc-empty">
|
||||
<p>没有找到匹配的文档</p>
|
||||
<p class="dc-empty-tip">试试更换关键词,或到后台点击「重建索引」</p>
|
||||
</div>
|
||||
{else/}
|
||||
<ul class="dc-search-list">
|
||||
{volist name="list" id="r"}
|
||||
<li>
|
||||
<a class="dc-search-title" href="/docs/{$r.project_name}/{$r.doc_name}">{$r.title}</a>
|
||||
<div class="dc-search-path">{$r.project_title} / {$r.version_name}</div>
|
||||
<p class="dc-search-snippet">{$r.snippet}</p>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<footer class="dc-footer">
|
||||
<p>© {$currentYear} {$docsTitle} · Powered by YwxApp</p>
|
||||
</footer>
|
||||
{/block}
|
||||
Reference in New Issue
Block a user