chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+81
View File
@@ -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 [];
}
}
}
+56
View File
@@ -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');
}
}
+280
View File
@@ -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');
}
}
+52
View File
@@ -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');
}
}
+528
View File
@@ -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;
}
}
+29
View File
@@ -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');
}
}
+227
View File
@@ -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;
}
}
+483
View File
@@ -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';
}
}
+222
View File
@@ -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]);
}
}