mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(knowledge): article YAML import in support portals + template download link
Add a standalone backend endpoint POST /api/v1/support/portals/<id>/articles/import/ that upserts articles by (portal, locale, slug): creates a new article or appends a revision, resolving category by categoryPath. No DB migrations, reuses existing create_article/add_revision services. Frontend: new portal article import modal + parseArticleYaml validator + article template (knowledge-template.yml / portal-articles-template.yml). Add a ".link has-icon" "Скачать шаблон" link to both the AI knowledge import modal and the new portal import modal, using the existing shared links.css standard and the existing download icon.
This commit is contained in:
1 parent
3e0e5f4af9
commit
e697807811
12 files changed
+604
-3
No files matched your search
@@ -0,0 +1,173 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from hub_platform.support_portals.content_services import (
|
||||
add_revision,
|
||||
create_article,
|
||||
ensure_portal_editable,
|
||||
update_article,
|
||||
)
|
||||
from hub_platform.support_portals.models import PortalCategory
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ArticleImportResult:
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
unchanged: int = 0
|
||||
failed: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"created": self.created,
|
||||
"updated": self.updated,
|
||||
"unchanged": self.unchanged,
|
||||
"failed": self.failed,
|
||||
}
|
||||
|
||||
|
||||
def _required(document: dict[str, object], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValidationError({key: f"{key} is required"})
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _content(document: dict[str, object]) -> str:
|
||||
value = document.get("content")
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"content": "Content string is required"})
|
||||
return value
|
||||
|
||||
|
||||
def _locale(document: dict[str, object], *, default: str) -> str:
|
||||
if "locale" not in document or document["locale"] in (None, ""):
|
||||
return default
|
||||
value = document["locale"]
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"locale": "Locale must be a string"})
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def _summary(document: dict[str, object], *, default: str) -> str:
|
||||
if "summary" not in document or document["summary"] is None:
|
||||
return default
|
||||
value = document["summary"]
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"summary": "Summary must be a string"})
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _category_for_path(*, portal, raw_path: object) -> PortalCategory:
|
||||
if not isinstance(raw_path, list) or not raw_path:
|
||||
raise ValidationError({"categoryPath": "Non-empty category path required"})
|
||||
parent_id = None
|
||||
category = None
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": "Category names must be non-empty strings"})
|
||||
name = raw_name.strip()
|
||||
try:
|
||||
category = portal.categories.get(parent_id=parent_id, name=name)
|
||||
except (
|
||||
PortalCategory.DoesNotExist,
|
||||
PortalCategory.MultipleObjectsReturned,
|
||||
) as error:
|
||||
raise ValidationError({"categoryPath": "Category path not found"}) from error
|
||||
parent_id = category.id
|
||||
assert category is not None
|
||||
return category
|
||||
|
||||
|
||||
def _validation_detail(error: ValidationError) -> str:
|
||||
if hasattr(error, "message_dict"):
|
||||
return "; ".join(
|
||||
message for messages in error.message_dict.values() for message in messages
|
||||
)
|
||||
return "; ".join(error.messages)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _import_document(
|
||||
*, context: TenantContext, portal, document: dict[str, object]
|
||||
) -> str:
|
||||
ensure_portal_editable(portal)
|
||||
slug = _required(document, "slug").lower()
|
||||
title = _required(document, "title")
|
||||
content = _content(document)
|
||||
summary = _summary(document, default="")
|
||||
locale = _locale(document, default=portal.default_locale)
|
||||
category = _category_for_path(portal=portal, raw_path=document.get("categoryPath"))
|
||||
|
||||
existing = portal.articles.filter(locale=locale, slug=slug).first()
|
||||
if existing is None:
|
||||
create_article(
|
||||
context=context,
|
||||
portal=portal,
|
||||
data={
|
||||
"categoryId": category.id,
|
||||
"slug": slug,
|
||||
"locale": locale,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
return "created"
|
||||
|
||||
latest = existing.revisions.order_by("-revision").first()
|
||||
same_category = existing.category_id == category.id
|
||||
same_content = (
|
||||
latest is not None
|
||||
and latest.title == title
|
||||
and latest.summary == summary
|
||||
and latest.content == content
|
||||
)
|
||||
if same_content and same_category:
|
||||
return "unchanged"
|
||||
if not same_category:
|
||||
update_article(
|
||||
article=existing,
|
||||
data={
|
||||
"categoryId": category.id,
|
||||
"slug": existing.slug,
|
||||
"locale": existing.locale,
|
||||
},
|
||||
)
|
||||
if not same_content:
|
||||
add_revision(
|
||||
context=context,
|
||||
article=existing,
|
||||
data={"title": title, "summary": summary, "content": content},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
|
||||
def import_articles(
|
||||
*, context: TenantContext, portal, documents: list[object]
|
||||
) -> ArticleImportResult:
|
||||
result = ArticleImportResult()
|
||||
for raw_document in documents:
|
||||
slug = ""
|
||||
if isinstance(raw_document, dict):
|
||||
raw_slug = raw_document.get("slug")
|
||||
slug = raw_slug.strip() if isinstance(raw_slug, str) else ""
|
||||
try:
|
||||
if not isinstance(raw_document, dict):
|
||||
raise ValidationError({"article": "Article must be an object"})
|
||||
outcome = _import_document(
|
||||
context=context, portal=portal, document=raw_document
|
||||
)
|
||||
except ValidationError as error:
|
||||
result.failed.append({"slug": slug, "detail": _validation_detail(error)})
|
||||
continue
|
||||
if outcome == "created":
|
||||
result.created += 1
|
||||
elif outcome == "updated":
|
||||
result.updated += 1
|
||||
else:
|
||||
result.unchanged += 1
|
||||
return result
|
||||
@@ -0,0 +1,32 @@
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.support_portals.article_import import import_articles
|
||||
from hub_platform.support_portals.portal_views import PortalBaseView
|
||||
|
||||
|
||||
class ArticleImportView(PortalBaseView):
|
||||
def post(self, request: Request, portal_id: int) -> Response:
|
||||
portal = self.portal(request, portal_id)
|
||||
if portal is None:
|
||||
return Response({"detail": "Портал не найден"}, status=404)
|
||||
articles = request.data.get("articles")
|
||||
if not isinstance(articles, list) or not articles:
|
||||
return Response({"detail": "articles must be a non-empty list"}, status=400)
|
||||
result = import_articles(
|
||||
context=request.tenant_context,
|
||||
portal=portal,
|
||||
documents=articles,
|
||||
)
|
||||
payload = result.payload()
|
||||
record_audit_event(
|
||||
action="support_portal.articles_imported",
|
||||
actor=request.user,
|
||||
organization=request.tenant_context.organization,
|
||||
object_type="PortalArticle",
|
||||
object_id="",
|
||||
payload=payload,
|
||||
request=request,
|
||||
)
|
||||
return Response(payload, status=201)
|
||||
@@ -0,0 +1,136 @@
|
||||
from hub_platform.support_portals.models import PortalArticle
|
||||
from hub_platform.support_portals.tests.base import SupportPortalTestCase
|
||||
|
||||
|
||||
class SupportPortalArticleImportTests(SupportPortalTestCase):
|
||||
def _category(
|
||||
self,
|
||||
portal_id: int,
|
||||
name: str,
|
||||
slug: str,
|
||||
parent_id: int | None = None,
|
||||
) -> int:
|
||||
payload = {"slug": slug, "name": name}
|
||||
if parent_id is not None:
|
||||
payload["parentId"] = parent_id
|
||||
response = self.client.post(
|
||||
f"/api/v1/support/portals/{portal_id}/categories/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
return response.json()["category"]["id"]
|
||||
|
||||
def _import(self, portal_id: int, articles: list[object]):
|
||||
return self.client.post(
|
||||
f"/api/v1/support/portals/{portal_id}/articles/import/",
|
||||
data={"articles": articles},
|
||||
format="json",
|
||||
)
|
||||
|
||||
def test_import_creates_new_article_from_category_path(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
parent = self._category(portal_id, "Возврат", "returns")
|
||||
self._category(portal_id, "Оформление", "checkout", parent_id=parent)
|
||||
|
||||
response = self._import(
|
||||
portal_id,
|
||||
[
|
||||
{
|
||||
"slug": "vozvrat-tovara",
|
||||
"categoryPath": ["Возврат", "Оформление"],
|
||||
"title": "Как оформить возврат",
|
||||
"summary": "Короткое описание",
|
||||
"content": "Шаги оформления возврата.",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertEqual(response.json()["created"], 1)
|
||||
article = PortalArticle.objects.get(slug="vozvrat-tovara")
|
||||
self.assertEqual(article.category.name, "Оформление")
|
||||
self.assertEqual(article.revisions.count(), 1)
|
||||
self.assertEqual(article.revisions.first().title, "Как оформить возврат")
|
||||
|
||||
def test_import_updates_existing_article_by_slug_with_new_revision(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
self._category(portal_id, "Возврат", "returns")
|
||||
|
||||
self._import(
|
||||
portal_id,
|
||||
[
|
||||
{
|
||||
"slug": "garantiya",
|
||||
"categoryPath": ["Возврат"],
|
||||
"title": "Гарантия",
|
||||
"content": "Версия 1",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
response = self._import(
|
||||
portal_id,
|
||||
[
|
||||
{
|
||||
"slug": "garantiya",
|
||||
"categoryPath": ["Возврат"],
|
||||
"title": "Гарантия",
|
||||
"content": "Версия 2",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
body = response.json()
|
||||
self.assertEqual(body["updated"], 1)
|
||||
self.assertEqual(body["created"], 0)
|
||||
article = PortalArticle.objects.get(slug="garantiya")
|
||||
self.assertEqual(article.revisions.count(), 2)
|
||||
self.assertEqual(article.revisions.order_by("-revision").first().content, "Версия 2")
|
||||
|
||||
def test_import_marks_unchanged_article_when_content_matches(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
self._category(portal_id, "Возврат", "returns")
|
||||
|
||||
document = {
|
||||
"slug": "neizmennyj",
|
||||
"categoryPath": ["Возврат"],
|
||||
"title": "Без изменений",
|
||||
"content": "Текст",
|
||||
}
|
||||
self._import(portal_id, [document])
|
||||
response = self._import(portal_id, [document])
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertEqual(response.json()["unchanged"], 1)
|
||||
article = PortalArticle.objects.get(slug="neizmennyj")
|
||||
self.assertEqual(article.revisions.count(), 1)
|
||||
|
||||
def test_import_reports_failed_document_for_unknown_category_path(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
self._category(portal_id, "Возврат", "returns")
|
||||
|
||||
response = self._import(
|
||||
portal_id,
|
||||
[
|
||||
{
|
||||
"slug": "problemnyj",
|
||||
"categoryPath": ["Не существует"],
|
||||
"title": "Проблема",
|
||||
"content": "Текст",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
body = response.json()
|
||||
self.assertEqual(body["created"], 0)
|
||||
self.assertEqual(len(body["failed"]), 1)
|
||||
self.assertEqual(body["failed"][0]["slug"], "problemnyj")
|
||||
self.assertFalse(PortalArticle.objects.filter(slug="problemnyj").exists())
|
||||
|
||||
def test_import_rejects_empty_articles_list(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
response = self._import(portal_id, [])
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from hub_platform.support_portals import content_views, portal_views
|
||||
from hub_platform.support_portals import article_import_views, content_views, portal_views
|
||||
|
||||
urlpatterns = [
|
||||
path("", portal_views.PortalListView.as_view(), name="support-portal-list"),
|
||||
@@ -13,6 +13,7 @@ urlpatterns = [
|
||||
path("<int:portal_id>/categories/", content_views.CategoryListView.as_view(), name="support-portal-categories"),
|
||||
path("<int:portal_id>/categories/<int:category_id>/", content_views.CategoryDetailView.as_view(), name="support-portal-category-detail"),
|
||||
path("<int:portal_id>/articles/", content_views.ArticleListView.as_view(), name="support-portal-articles"),
|
||||
path("<int:portal_id>/articles/import/", article_import_views.ArticleImportView.as_view(), name="support-portal-article-import"),
|
||||
path("<int:portal_id>/articles/<int:article_id>/", content_views.ArticleDetailView.as_view(), name="support-portal-article"),
|
||||
path("<int:portal_id>/articles/<int:article_id>/revisions/", content_views.ArticleRevisionListView.as_view(), name="support-portal-article-revisions"),
|
||||
path("<int:portal_id>/articles/<int:article_id>/publish/", content_views.ArticlePublishView.as_view(), name="support-portal-article-publish"),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import { downloadKnowledgeTemplate } from "./downloadKnowledgeTemplate";
|
||||
import { importKnowledge, type KnowledgeImportReport } from "./model";
|
||||
import { parseKnowledgeYaml, type ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
|
||||
@@ -48,7 +50,12 @@ export function KnowledgeImportModal({ onClose, onImported }: KnowledgeImportMod
|
||||
<Modal open title="Импорт знаний из YAML" onCancel={onClose} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<label className="knowledge-import-file">
|
||||
<span>Файл YAML · формат: documents: [{"{"} title, description?, content {"}"}]</span>
|
||||
<span>
|
||||
Файл YAML · формат: documents: [{"{"} title, description?, content {"}"}]
|
||||
<button className="link has-icon" type="button" onClick={downloadKnowledgeTemplate}>
|
||||
<Icon name="download" size={14} />Скачать шаблон
|
||||
</button>
|
||||
</span>
|
||||
<input type="file" accept=".yaml,.yml" onChange={(event) => void onFile(event)} />
|
||||
</label>
|
||||
{parsed && (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Каркас YAML-файла для импорта знаний (формат parseKnowledgeYaml):
|
||||
// documents: [{ title, description?, content }]. description необязателен —
|
||||
// шаблон показывает оба варианта.
|
||||
const KNOWLEDGE_TEMPLATE = `documents:
|
||||
- title: "Название знания"
|
||||
description: "Короткое описание (необязательно)"
|
||||
content: |
|
||||
Основной текст знания.
|
||||
|
||||
- title: "Название знания без описания"
|
||||
content: |
|
||||
Основной текст знания.
|
||||
`;
|
||||
|
||||
export function downloadKnowledgeTemplate(): void {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`\uFEFF${KNOWLEDGE_TEMPLATE}`], { type: "text/yaml;charset=utf-8" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "knowledge-template.yml";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import type { ArticleImportReport } from "./api";
|
||||
import { downloadArticleTemplate } from "./downloadArticleTemplate";
|
||||
import { importPortalArticles, portalErrorMessage } from "./model";
|
||||
import { parseArticleYaml, type ParsedArticleYaml } from "./parseArticleYaml";
|
||||
|
||||
type PortalArticleImportModalProps = {
|
||||
portalId: number;
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
};
|
||||
|
||||
export function PortalArticleImportModal({ portalId, onClose, onImported }: PortalArticleImportModalProps) {
|
||||
const [parsed, setParsed] = useState<ParsedArticleYaml | null>(null);
|
||||
const [result, setResult] = useState<ArticleImportReport | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setParsed(parseArticleYaml(await file.text()));
|
||||
} catch (caught) {
|
||||
setParsed(null);
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось прочитать файл");
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!parsed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await importPortalArticles(portalId, parsed.articles));
|
||||
setParsed(null);
|
||||
onImported();
|
||||
} catch (caught) {
|
||||
setError(portalErrorMessage(caught, "Не удалось импортировать статьи"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open title="Импорт статей из YAML" onCancel={onClose} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<label className="knowledge-import-file">
|
||||
<span>
|
||||
Файл YAML · формат: articles: [{"{"} slug, categoryPath, locale?, title, summary?, content {"}"}]
|
||||
<button className="link has-icon" type="button" onClick={downloadArticleTemplate}>
|
||||
<Icon name="download" size={14} />Скачать шаблон
|
||||
</button>
|
||||
</span>
|
||||
<input type="file" accept=".yaml,.yml" onChange={(event) => void onFile(event)} />
|
||||
</label>
|
||||
{parsed && (
|
||||
<div className="ai-doc-import-preview">
|
||||
К импорту: <b>{parsed.articles.length}</b> статей. Совпадение по адресу создаст новую версию.
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="ai-doc-import-report">
|
||||
<div className="ai-doc-import-summary">
|
||||
Создано: <b>{result.created}</b> · Обновлено: <b>{result.updated}</b> · Без изменений: <b>{result.unchanged}</b>
|
||||
</div>
|
||||
{result.failed.length > 0 && (
|
||||
<ul className="ai-doc-import-failed">
|
||||
{result.failed.map((item, index) => (
|
||||
<li key={index}>{item.slug ? `«${item.slug}»: ` : ""}{item.detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="ai-doc-error">{error}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={onClose}>Закрыть</Button>
|
||||
<Button variant="primary" icon="download" disabled={!parsed || busy} onClick={submit}>
|
||||
{busy ? "Импорт…" : "Импорт"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibra
|
||||
import { DecisionDialog } from "../../shared/DecisionDialog";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { PortalArticleEditor } from "./PortalArticleEditor";
|
||||
import { PortalArticleImportModal } from "./PortalArticleImportModal";
|
||||
import { PortalArticleTable } from "./PortalArticleTable";
|
||||
import { PortalCategoryManagement } from "./PortalCategoryManagement";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ export function PortalContent({
|
||||
const [status, setStatus] = useState("");
|
||||
const [editing, setEditing] = useState<PortalArticle | null | undefined>(undefined);
|
||||
const [managingCategories, setManagingCategories] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [decision, setDecision] = useState<Decision | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -128,7 +130,12 @@ export function PortalContent({
|
||||
<ContentLibraryToolbar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
action={canManage && categories.length > 0 && <Button variant="primary" icon="plus" onClick={() => setEditing(null)}>Новая статья</Button>}
|
||||
action={canManage && categories.length > 0 && (
|
||||
<>
|
||||
<Button variant="secondary" icon="download" onClick={() => setImportOpen(true)}>Импорт YAML</Button>
|
||||
<Button variant="primary" icon="plus" onClick={() => setEditing(null)}>Новая статья</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<label className="knowledge-filter-select"><span>Язык:</span><select value={language} onChange={(event) => setLanguage(event.target.value)}><option value="">Все</option><option value="ru">Русский</option><option value="en">English</option></select></label>
|
||||
<label className="knowledge-filter-select"><span>Статус:</span><select value={status} onChange={(event) => setStatus(event.target.value)}><option value="">Все</option><option value="DRAFT">Черновик</option><option value="PUBLISHED">Опубликована</option><option value="ARCHIVED">Архив</option></select></label>
|
||||
@@ -157,6 +164,13 @@ export function PortalContent({
|
||||
</main>
|
||||
</div>
|
||||
{managingCategories && <PortalCategoryManagement categories={categories} portalId={portalId} onChanged={reload} onClose={() => setManagingCategories(false)} />}
|
||||
{importOpen && (
|
||||
<PortalArticleImportModal
|
||||
portalId={portalId}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={() => void reload()}
|
||||
/>
|
||||
)}
|
||||
<DecisionDialog
|
||||
open={Boolean(decision)}
|
||||
onClose={() => setDecision(null)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "../../api/client";
|
||||
import type { Channel } from "../channels/types";
|
||||
import type { ArticleImportDocument } from "./parseArticleYaml";
|
||||
import type {
|
||||
ArticleRevision,
|
||||
PortalArticle,
|
||||
@@ -182,3 +183,20 @@ export function archivePortalArticle(
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export type ArticleImportReport = {
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
failed: Array<{ slug?: string; detail: string }>;
|
||||
};
|
||||
|
||||
export function importPortalArticles(
|
||||
portalId: number,
|
||||
articles: ArticleImportDocument[],
|
||||
): Promise<ArticleImportReport> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/import/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ articles }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Каркас YAML-файла для импорта статей портала (формат parseArticleYaml):
|
||||
// articles: [{ slug, categoryPath: [..], locale?, title, summary?, content }].
|
||||
// summary необязателен — шаблон показывает оба варианта.
|
||||
const ARTICLE_TEMPLATE = `articles:
|
||||
- slug: "kak-oformit-vozvrat"
|
||||
categoryPath:
|
||||
- "Возврат"
|
||||
- "Оформление"
|
||||
title: "Как оформить возврат"
|
||||
summary: "Короткое описание (необязательно)"
|
||||
content: |
|
||||
Текст статьи в Markdown.
|
||||
|
||||
- slug: "garantiya"
|
||||
categoryPath:
|
||||
- "Возврат"
|
||||
title: "Гарантия"
|
||||
content: |
|
||||
Текст статьи без описания.
|
||||
`;
|
||||
|
||||
export function downloadArticleTemplate(): void {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`\uFEFF${ARTICLE_TEMPLATE}`], { type: "text/yaml;charset=utf-8" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "portal-articles-template.yml";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -109,6 +109,7 @@ export {
|
||||
createPortalCategory,
|
||||
createSupportPortal,
|
||||
deletePortalCategory,
|
||||
importPortalArticles,
|
||||
listPortalArticles,
|
||||
listPortalCategories,
|
||||
listPortalSupportChannels,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { load as parseYaml } from "js-yaml";
|
||||
|
||||
export type ArticleImportDocument = {
|
||||
slug: string;
|
||||
categoryPath: string[];
|
||||
locale?: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ParsedArticleYaml = {
|
||||
articles: ArticleImportDocument[];
|
||||
};
|
||||
|
||||
// Парсит YAML импорта статей портала:
|
||||
// { articles: [{ slug, categoryPath: [..], locale?, title, summary?, content }] }.
|
||||
// Ключ совпадания — (slug, locale). Бросает Error с человекочитаемым сообщением
|
||||
// при невалидном YAML или структуре.
|
||||
export function parseArticleYaml(text: string): ParsedArticleYaml {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = parseYaml(text);
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? `Невалидный YAML: ${error.message}` : "Невалидный YAML");
|
||||
}
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Ожидается объект верхнего уровня с ключом articles");
|
||||
}
|
||||
const root = data as { articles?: unknown };
|
||||
if (!Array.isArray(root.articles) || root.articles.length === 0) {
|
||||
throw new Error("articles должен быть непустым списком");
|
||||
}
|
||||
const articles: ArticleImportDocument[] = [];
|
||||
root.articles.forEach((item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new Error(`Статья #${index + 1}: должна быть объектом`);
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const slug = record.slug === undefined || record.slug === null ? "" : String(record.slug).trim().toLowerCase();
|
||||
if (!slug) {
|
||||
throw new Error(`Статья #${index + 1}: обязательное поле «slug» пустое`);
|
||||
}
|
||||
const title = record.title === undefined || record.title === null ? "" : String(record.title).trim();
|
||||
if (!title) {
|
||||
throw new Error(`Статья #${index + 1}: обязательное поле «title» пустое`);
|
||||
}
|
||||
const content = record.content === undefined || record.content === null ? "" : String(record.content);
|
||||
if (!content.trim()) {
|
||||
throw new Error(`Статья #${index + 1}: обязательное поле «content» пустое`);
|
||||
}
|
||||
if (!Array.isArray(record.categoryPath) || record.categoryPath.length === 0) {
|
||||
throw new Error(`Статья #${index + 1}: обязательное поле «categoryPath» — непустой список разделов`);
|
||||
}
|
||||
const categoryPath: string[] = [];
|
||||
record.categoryPath.forEach((segment, segmentIndex) => {
|
||||
if (segment === undefined || segment === null || !String(segment).trim()) {
|
||||
throw new Error(`Статья #${index + 1}: раздел #${segmentIndex + 1} пустой`);
|
||||
}
|
||||
categoryPath.push(String(segment).trim());
|
||||
});
|
||||
const doc: ArticleImportDocument = { slug, categoryPath, title, content };
|
||||
if (record.locale !== undefined && record.locale !== null && String(record.locale).trim()) {
|
||||
doc.locale = String(record.locale).trim().toLowerCase();
|
||||
}
|
||||
if (record.summary !== undefined && record.summary !== null && String(record.summary).trim()) {
|
||||
doc.summary = String(record.summary).trim();
|
||||
}
|
||||
articles.push(doc);
|
||||
});
|
||||
return { articles };
|
||||
}
|
||||
Reference in new issue
Block a user