// +---------------------------------------------------------------------- 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 */ 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; } }