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