feat: add rich text help article editor

This commit is contained in:
wushumin
2026-05-27 14:41:28 +08:00
parent 2ccbd0ffe4
commit 7d901fb435
17 changed files with 1311 additions and 114 deletions

View File

@@ -118,6 +118,7 @@ class ContentsController
'title' => trim((string)$request->input('title', '')),
'summary' => trim((string)$request->input('summary', '')),
'keywords' => (array)$request->input('keywords', []),
'content_html' => (string)$request->input('content_html', ''),
'content_blocks' => (array)$request->input('content_blocks', []),
'is_recommended' => (bool)$request->input('is_recommended', false),
'is_enabled' => (bool)$request->input('is_enabled', true),

View File

@@ -160,6 +160,17 @@ class ContentService
}
return array_map(function (array $item) {
$contentBlocks = $this->decodeJsonConfig($item['content_blocks_json'] ?? '', []);
$contentHtml = trim((string)($item['content_html'] ?? ''));
if ($contentHtml === '') {
$contentHtml = $this->contentBlocksToHtml($contentBlocks);
} else {
$contentHtml = $this->sanitizeHelpArticleHtml($contentHtml);
}
if (!$contentBlocks && $contentHtml !== '') {
$contentBlocks = $this->contentBlocksFromHtml($contentHtml);
}
return [
'id' => (int)$item['id'],
'category' => (string)$item['category'],
@@ -171,7 +182,8 @@ class ContentService
'is_recommended' => (bool)$item['is_recommended'],
'is_enabled' => (bool)$item['is_enabled'],
'sort_order' => (int)$item['sort_order'],
'content_blocks' => $this->decodeJsonConfig($item['content_blocks_json'] ?? '', []),
'content_html' => $contentHtml,
'content_blocks' => $contentBlocks,
];
}, $query->select()->toArray());
}
@@ -227,11 +239,19 @@ class ContentService
$title = trim((string)($payload['title'] ?? ''));
$summary = trim((string)($payload['summary'] ?? ''));
$keywords = $this->normalizeStringList($payload['keywords'] ?? [], []);
$contentHtml = $this->sanitizeHelpArticleHtml((string)($payload['content_html'] ?? ''));
$contentBlocks = $this->normalizeStringList($payload['content_blocks'] ?? [], []);
$isRecommended = !empty($payload['is_recommended']) ? 1 : 0;
$isEnabled = array_key_exists('is_enabled', $payload) ? (!empty($payload['is_enabled']) ? 1 : 0) : 1;
$sortOrder = (int)($payload['sort_order'] ?? 0);
if ($contentHtml !== '') {
$contentBlocks = $this->contentBlocksFromHtml($contentHtml);
}
if ($contentHtml === '' && $contentBlocks) {
$contentHtml = $this->contentBlocksToHtml($contentBlocks);
}
if ($title === '' || $summary === '') {
throw new \RuntimeException('文章标题和摘要不能为空');
}
@@ -248,6 +268,7 @@ class ContentService
'title' => $title,
'summary' => $summary,
'keywords_json' => json_encode($keywords, JSON_UNESCAPED_UNICODE),
'content_html' => $contentHtml,
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
'is_recommended' => $isRecommended,
'is_enabled' => $isEnabled,
@@ -402,6 +423,7 @@ class ContentService
{
$exists = Db::query(sprintf("SHOW TABLES LIKE '%s'", self::HELP_TABLE));
if ($exists) {
$this->ensureHelpArticlesContentHtmlColumn();
return;
}
@@ -412,6 +434,7 @@ class ContentService
title VARCHAR(255) NOT NULL DEFAULT '',
summary VARCHAR(500) NOT NULL DEFAULT '',
keywords_json LONGTEXT NULL,
content_html LONGTEXT NULL,
content_blocks_json LONGTEXT NULL,
is_recommended TINYINT(1) NOT NULL DEFAULT 0,
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
@@ -497,12 +520,14 @@ class ContentService
$now = date('Y-m-d H:i:s');
foreach ($this->defaultHelpArticles() as $index => $item) {
$contentBlocks = $item['content_blocks'];
Db::name(self::HELP_TABLE)->insert([
'category' => $item['category'],
'title' => $item['title'],
'summary' => $item['summary'],
'keywords_json' => json_encode($item['keywords'], JSON_UNESCAPED_UNICODE),
'content_blocks_json' => json_encode($item['content_blocks'], JSON_UNESCAPED_UNICODE),
'content_html' => $this->contentBlocksToHtml($contentBlocks),
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
'is_recommended' => !empty($item['is_recommended']) ? 1 : 0,
'is_enabled' => 1,
'sort_order' => $item['sort_order'] ?? ($index + 1) * 10,
@@ -728,6 +753,166 @@ class ContentService
return $normalized ?: $default;
}
private function ensureHelpArticlesContentHtmlColumn(): void
{
$exists = Db::query(sprintf("SHOW COLUMNS FROM %s LIKE 'content_html'", self::HELP_TABLE));
if ($exists) {
return;
}
Db::execute(sprintf(
"ALTER TABLE %s ADD COLUMN content_html LONGTEXT NULL AFTER keywords_json",
self::HELP_TABLE
));
}
private function sanitizeHelpArticleHtml(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
if (!class_exists(\DOMDocument::class)) {
return $this->sanitizeHelpArticleHtmlFallback($html);
}
$document = new \DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
$loaded = $document->loadHTML('<?xml encoding="UTF-8"><div>' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded || !$document->documentElement) {
return '';
}
$this->sanitizeHelpArticleNode($document->documentElement);
$output = '';
foreach ($document->documentElement->childNodes as $childNode) {
$output .= $document->saveHTML($childNode);
}
return trim($output);
}
private function sanitizeHelpArticleNode(\DOMNode $node): void
{
$allowedTags = ['p', 'br', 'strong', 'em', 'u', 's', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'a', 'img'];
for ($index = $node->childNodes->length - 1; $index >= 0; $index--) {
$child = $node->childNodes->item($index);
if (!$child) {
continue;
}
if ($child instanceof \DOMElement) {
$tagName = strtolower($child->tagName);
if (!in_array($tagName, $allowedTags, true)) {
if (in_array($tagName, ['script', 'style', 'iframe', 'object', 'embed'], true)) {
$node->removeChild($child);
continue;
}
$this->sanitizeHelpArticleNode($child);
while ($child->firstChild) {
$node->insertBefore($child->firstChild, $child);
}
$node->removeChild($child);
continue;
}
if ($tagName === 'img' && !$this->isSafeHelpArticleUrl(trim((string)$child->getAttribute('src')))) {
$node->removeChild($child);
continue;
}
$this->sanitizeHelpArticleAttributes($child, $tagName);
$this->sanitizeHelpArticleNode($child);
} elseif ($child->nodeType !== XML_TEXT_NODE) {
$node->removeChild($child);
}
}
}
private function sanitizeHelpArticleAttributes(\DOMElement $node, string $tagName): void
{
$href = trim((string)$node->getAttribute('href'));
$src = trim((string)$node->getAttribute('src'));
$alt = trim((string)$node->getAttribute('alt'));
$title = trim((string)$node->getAttribute('title'));
while ($node->attributes->length > 0) {
$node->removeAttribute($node->attributes->item(0)->name);
}
if ($tagName === 'a') {
if ($this->isSafeHelpArticleUrl($href)) {
$node->setAttribute('href', $href);
$node->setAttribute('target', '_blank');
$node->setAttribute('rel', 'noopener noreferrer');
}
return;
}
if ($tagName !== 'img') {
return;
}
if ($this->isSafeHelpArticleUrl($src)) {
$node->setAttribute('src', $src);
}
if ($alt !== '') {
$node->setAttribute('alt', $this->limitHelpArticleAttribute($alt));
}
if ($title !== '') {
$node->setAttribute('title', $this->limitHelpArticleAttribute($title));
}
$node->setAttribute('loading', 'lazy');
}
private function limitHelpArticleAttribute(string $value): string
{
return function_exists('mb_substr') ? mb_substr($value, 0, 120) : substr($value, 0, 120);
}
private function sanitizeHelpArticleHtmlFallback(string $html): string
{
$html = strip_tags($html, '<p><br><strong><em><u><s><h2><h3><h4><ul><ol><li><blockquote><a><img>');
$html = preg_replace('/\s+on[a-z]+\s*=\s*(["\']).*?\1/i', '', $html) ?? '';
$html = preg_replace('/\s+(href|src)\s*=\s*(["\'])\s*(javascript|data):.*?\2/i', '', $html) ?? '';
return trim($html);
}
private function isSafeHelpArticleUrl(string $url): bool
{
if ($url === '') {
return false;
}
return (bool)preg_match('/^(https?:\/\/|\/(?!\/)|#)/i', $url);
}
private function contentBlocksFromHtml(string $html): array
{
$html = preg_replace('/<\/(h2|h3|h4|p|li|blockquote)>/i', "\n", $html) ?? $html;
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return array_values(array_filter(array_map(
fn ($item) => trim((string)preg_replace('/\s+/u', ' ', $item)),
preg_split('/\R/u', $text) ?: []
), fn ($item) => $item !== ''));
}
private function contentBlocksToHtml(array $blocks): string
{
return implode('', array_map(
fn ($item) => '<p>' . htmlspecialchars((string)$item, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>',
$this->normalizeStringList($blocks, [])
));
}
private function defaultHomeConfig(): array
{
return [
@@ -1093,12 +1278,14 @@ class ContentService
}
$now = date('Y-m-d H:i:s');
$contentBlocks = $definition['content_blocks'];
return (int)Db::name(self::HELP_TABLE)->insertGetId([
'category' => $definition['category'],
'title' => $definition['title'],
'summary' => $definition['summary'],
'keywords_json' => json_encode($definition['keywords'], JSON_UNESCAPED_UNICODE),
'content_blocks_json' => json_encode($definition['content_blocks'], JSON_UNESCAPED_UNICODE),
'content_html' => $this->contentBlocksToHtml($contentBlocks),
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
'is_recommended' => !empty($definition['is_recommended']) ? 1 : 0,
'is_enabled' => 1,
'sort_order' => (int)$definition['sort_order'],

View File

@@ -1391,6 +1391,7 @@ CREATE TABLE help_articles (
title VARCHAR(255) NOT NULL DEFAULT '',
summary VARCHAR(500) NOT NULL DEFAULT '',
keywords_json LONGTEXT NULL,
content_html LONGTEXT NULL,
content_blocks_json LONGTEXT NULL,
is_recommended TINYINT(1) NOT NULL DEFAULT 0,
is_enabled TINYINT(1) NOT NULL DEFAULT 1,