Compare commits

...
3 Commits
Author SHA1 Message Date
Andrey Ermolaev fc370fafe1 Merge pull request #3 from dartdavros/codex/help-center-navigation-related
Навигация и связанные статьи портала помощи
2026-09-24 14:09:33 +03:00
Andrey df9c6ad6fd 🚨 fix(lint): порядок импортов портала 2026-09-24 14:08:04 +03:00
Andrey 8c618691f6 ✨ feat(portals): навигация и связанные статьи 2026-09-24 14:05:21 +03:00
26 changed files with 920 additions and 16 deletions

No files matched your search

@@ -0,0 +1,31 @@
from django.core.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.statuses import ArticleStatus
def apply_article_curation(article: PortalArticle, data: dict) -> None:
if "sortOrder" in data:
article.sort_order = int(data["sortOrder"])
if "relatedArticleIds" not in data:
return
related_ids = data["relatedArticleIds"]
if (
not isinstance(related_ids, list)
or len(related_ids) > 5
or any(type(item) is not int for item in related_ids)
or len(set(related_ids)) != len(related_ids)
or article.id in related_ids
):
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
valid_count = PortalArticle.objects.filter(
id__in=related_ids,
portal=article.portal,
locale=article.locale,
status=ArticleStatus.PUBLISHED,
published_revision__isnull=False,
).count()
if valid_count != len(related_ids):
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
article.related_article_ids = related_ids
@@ -9,6 +9,7 @@ from django.utils.text import slugify
from chatballs.ai.indexing import reindex_portal_article
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.i18n import t
from chatballs.support_portals.article_curation import apply_article_curation
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
@@ -104,6 +105,7 @@ def create_article(
category=category,
slug=str(data.get("slug", "")).strip().lower(),
locale=str(data.get("locale", portal.default_locale)).strip().lower(),
sort_order=1000,
)
article.full_clean()
article.save()
@@ -143,8 +145,11 @@ def update_article(
)
article.slug = str(data.get("slug", article.slug)).strip().lower()
article.locale = str(data.get("locale", article.locale)).strip().lower()
apply_article_curation(article, data)
article.full_clean()
article.save(update_fields=["category", "slug", "locale", "updated_at"])
article.save(update_fields=[
"category", "slug", "locale", "sort_order", "related_article_ids", "updated_at",
])
return article
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,96 @@
import json
import sys
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from chatballs.support_portals.models import PortalArticle, SupportPortal
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import support_portal_route
from chatballs.tenancy.lookup import load_organization
def checked_changes(portal: SupportPortal, plan: dict) -> list[PortalArticle]:
if plan.get("portal") != portal.slug:
raise CommandError("Portal slug differs from the curation plan")
locale = plan.get("locale")
order = plan.get("order")
related = plan.get("related")
if not isinstance(locale, str) or not isinstance(order, dict) or not isinstance(related, dict):
raise CommandError("Invalid curation plan")
articles = list(
portal.articles.filter(
locale=locale, status="PUBLISHED", published_revision__isnull=False
).select_related("category")
)
by_slug = {item.slug: item for item in articles}
ordered = [slug for slugs in order.values() for slug in slugs]
if (
len(ordered) != len(by_slug)
or len(set(ordered)) != len(ordered)
or set(ordered) != set(by_slug)
or set(related) != set(by_slug)
):
raise CommandError("Plan must cover every published article exactly once")
for category_slug, slugs in order.items():
if not isinstance(slugs, list) or any(
by_slug[slug].category.slug != category_slug for slug in slugs
):
raise CommandError(f"Wrong article category in {category_slug}")
for slug, targets in related.items():
if (
not isinstance(targets, list)
or len(targets) > 5
or len(set(targets)) != len(targets)
or slug in targets
or not set(targets) <= set(by_slug)
):
raise CommandError(f"Invalid related articles for {slug}")
changed = []
for slugs in order.values():
for position, slug in enumerate(slugs, start=1):
article = by_slug[slug]
target_ids = [by_slug[target].id for target in related[slug]]
if article.sort_order != position or article.related_article_ids != target_ids:
article.sort_order = position
article.related_article_ids = target_ids
changed.append(article)
return changed
class Command(BaseCommand):
help = "Validate or apply an editorial order and related articles to a public portal"
def add_arguments(self, parser):
parser.add_argument("--host", required=True)
parser.add_argument("--file", required=True, help="JSON plan path or - for stdin")
parser.add_argument("--apply", action="store_true")
def handle(self, *args, **options):
hostname = options["host"].strip().lower().rstrip(".")
route = support_portal_route(hostname)
organization = load_organization(route.organization_id) if route else None
if organization is None:
raise CommandError("Public portal host not found")
source = sys.stdin.read() if options["file"] == "-" else Path(options["file"]).read_text(encoding="utf-8")
try:
plan = json.loads(source)
except json.JSONDecodeError as error:
raise CommandError("Invalid JSON plan") from error
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
portal = SupportPortal.objects.filter(
id=route.resource_id,
organization=organization,
status="PUBLISHED",
).first()
if portal is None:
raise CommandError("Published portal not found")
changed = checked_changes(portal, plan)
if options["apply"] and changed:
PortalArticle.objects.bulk_update(
changed, ["sort_order", "related_article_ids"]
)
action = "Updated" if options["apply"] else "Would update"
self.stdout.write(f"{action} {len(changed)} articles")
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("support_portals", "0011_hosted_domains_follow_installation"),
]
operations = [
migrations.AddField(
model_name="portalarticle",
name="sort_order",
field=models.PositiveIntegerField(default=1000),
),
migrations.AddField(
model_name="portalarticle",
name="related_article_ids",
field=models.JSONField(blank=True, default=list),
),
]
@@ -153,6 +153,8 @@ class PortalArticle(TenantRelationModel):
)
slug = models.SlugField(max_length=96)
locale = models.CharField(max_length=16, default="ru")
sort_order = models.PositiveIntegerField(default=1000)
related_article_ids = models.JSONField(default=list, blank=True)
status = models.CharField(
max_length=16,
choices=ArticleStatus.choices,
@@ -58,7 +58,9 @@ class PublicPortalDetailView(PublicPortalView):
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
with tenant_atomic(context):
counts = category_article_counts(portal, published_only=True)
counts = category_article_counts(
portal, published_only=True, locale=portal.default_locale
)
return Response(
{
"portal": public_portal_payload(portal),
@@ -85,6 +87,7 @@ class PublicArticleListView(PublicPortalView):
locale=locale,
category=str(request.query_params.get("category", "")),
query=str(request.query_params.get("q", "")).strip(),
direct_category=request.query_params.get("direct") == "1",
)
try:
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
@@ -122,7 +125,17 @@ class PublicArticleDetailView(PublicPortalView):
).first()
if article is None:
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": public_article_payload(article)})
payload = public_article_payload(article)
related = public_articles(portal, locale=locale).filter(
id__in=article.related_article_ids
)
by_id = {item.id: item for item in related}
payload["relatedArticles"] = [
public_article_payload(by_id[item_id], content=False)
for item_id in article.related_article_ids
if item_id in by_id
]
return Response({"article": payload})
class PublicArticleFeedbackView(PublicPortalView):
@@ -108,6 +108,7 @@ def public_articles(
locale: str,
category: str = "",
query: str = "",
direct_category: bool = False,
) -> QuerySet[PortalArticle]:
articles = (
PortalArticle.objects.filter(
@@ -118,13 +119,13 @@ def public_articles(
)
.select_related("category", "published_revision")
.prefetch_related("files")
.order_by("category__sort_order", "published_revision__title")
.order_by("category__sort_order", "sort_order", "published_revision__title", "id")
)
if category:
root = portal.categories.filter(slug=category).first()
if root is None:
return articles.none()
category_ids = descendant_category_ids(portal, root.id)
category_ids = {root.id} if direct_category else descendant_category_ids(portal, root.id)
articles = articles.filter(category_id__in=category_ids)
if query:
articles = articles.filter(
@@ -151,10 +152,12 @@ def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]:
def category_article_counts(
portal: SupportPortal, *, published_only: bool = False
portal: SupportPortal, *, published_only: bool = False, locale: str | None = None
) -> dict[int, int]:
categories = list(portal.categories.all())
direct_query = PortalArticle.objects.filter(portal=portal)
if locale is not None:
direct_query = direct_query.filter(locale=locale)
if published_only:
direct_query = direct_query.filter(
status="PUBLISHED", published_revision__isnull=False
@@ -107,6 +107,8 @@ def article_payload(
"id": article.id,
"slug": article.slug,
"locale": article.locale,
"sortOrder": article.sort_order,
"relatedArticleIds": article.related_article_ids,
"status": article.status,
"category": category_payload(article.category),
"publishedRevision": (
@@ -0,0 +1,139 @@
import json
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from chatballs.support_portals.tests.base import SupportPortalTestCase
class ArticleCurationTests(SupportPortalTestCase):
def setUp(self) -> None:
super().setUp()
portal = self.create_portal().json()["portal"]
self.portal_id = portal["id"]
self.portal_host = portal["hostedDomain"]
self.root = self._category("Начало", "start")
self.child = self._category("Продолжение", "next", parent_id=self.root)
def _category(self, name: str, slug: str, *, parent_id: int | None = None) -> int:
payload = {"name": name, "slug": slug}
if parent_id is not None:
payload["parentId"] = parent_id
response = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/categories/",
payload,
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
return response.json()["category"]["id"]
def _article(self, slug: str, title: str, category_id: int) -> int:
response = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/",
{
"categoryId": category_id,
"slug": slug,
"title": title,
"summary": title,
"content": title,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
article = response.json()["article"]
published = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
{"revisionId": article["revisions"][0]["id"]},
format="json",
)
self.assertEqual(published.status_code, 200, published.content)
return article["id"]
def test_order_related_articles_and_direct_category(self) -> None:
first = self._article("first", "Альфа", self.root)
second = self._article("second", "Бета", self.root)
child = self._article("child", "Гамма", self.child)
base = f"/api/v1/support/portals/{self.portal_id}/articles/"
for article_id, data in (
(first, {"sortOrder": 2, "relatedArticleIds": [child, second]}),
(second, {"sortOrder": 1}),
):
response = self.client.patch(
f"{base}{article_id}/", data, format="json"
)
self.assertEqual(response.status_code, 200, response.content)
invalid = self.client.patch(
f"{base}{first}/",
{"relatedArticleIds": [first]},
format="json",
)
self.assertEqual(invalid.status_code, 400)
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
self.client.logout()
listing = self.client.get(
"/api/v1/help/articles/?category=start&direct=1",
HTTP_HOST=self.portal_host,
)
self.assertEqual(listing.status_code, 200, listing.content)
self.assertEqual(
[item["slug"] for item in listing.json()["items"]],
["second", "first"],
)
detail = self.client.get(
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
)
self.assertEqual(detail.status_code, 200, detail.content)
self.assertEqual(
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
["child", "second"],
)
def test_curation_command_dry_run_and_apply(self) -> None:
first = self._article("first", "Альфа", self.root)
second = self._article("second", "Бета", self.root)
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
plan = json.dumps({
"portal": "app-help",
"locale": "ru",
"order": {"start": ["second", "first"]},
"related": {"first": ["second"], "second": ["first"]},
})
output = StringIO()
with patch("sys.stdin", StringIO(plan)):
call_command(
"apply_portal_curation", host=self.portal_host, file="-", stdout=output
)
self.assertIn("Would update 2 articles", output.getvalue())
output = StringIO()
with patch("sys.stdin", StringIO(plan)):
call_command(
"apply_portal_curation", host=self.portal_host, file="-", apply=True,
stdout=output,
)
self.assertIn("Updated 2 articles", output.getvalue())
self.client.logout()
listing = self.client.get(
"/api/v1/help/articles/?category=start&direct=1",
HTTP_HOST=self.portal_host,
)
self.assertEqual(
[item["slug"] for item in listing.json()["items"]],
["second", "first"],
)
detail = self.client.get(
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
)
self.assertEqual(
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
["second"],
)
self.assertNotEqual(first, second)
@@ -4,6 +4,7 @@ import { fetchHelpArticle, sendArticleFeedback } from "./api";
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
import { HelpLayout } from "./HelpLayout";
import { HelpAttachments } from "./HelpAttachments";
import { HelpRelatedArticles } from "./HelpRelatedArticles";
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
import { t } from "../../i18n";
@@ -63,7 +64,9 @@ export function HelpArticle({
return (
<HelpLayout
compactHeader
portal={manifest.portal}
manifest={manifest}
activeCategoryId={article?.category.id}
activeArticleSlug={article?.slug}
search={search}
onSearchChange={setSearch}
onSearchSubmit={searchSubmit}
@@ -128,6 +131,7 @@ export function HelpArticle({
</>
)}
</section>
<HelpRelatedArticles articles={article.relatedArticles ?? []} />
</article>
{headings.length > 0 && (
<aside className="help-toc" aria-label={t("portals.article_contents")}>
@@ -12,6 +12,8 @@ import type { HelpManifest } from "./types";
import "./styles-layout.css";
import "./styles-home.css";
import "./styles-article.css";
import "./styles-navigation.css";
import "./styles-related.css";
import "./styles-responsive.css";
import { t } from "../../i18n";
@@ -80,7 +80,8 @@ export function HelpHome({ manifest }: { manifest: HelpManifest }) {
return (
<HelpLayout
portal={manifest.portal}
manifest={manifest}
activeCategoryId={selectedCategory?.id}
search={search}
onSearchChange={setSearch}
>
@@ -2,26 +2,32 @@ import type { ReactNode } from "react";
import { LogoIcon } from "../../shared/icons";
import { HelpSearch } from "./HelpSearch";
import { HelpNavigation } from "./HelpNavigation";
import { PortalWebWidget } from "./PortalWebWidget";
import type { HelpPortal } from "./types";
import type { HelpManifest } from "./types";
import { t } from "../../i18n";
export function HelpLayout({
portal,
manifest,
children,
search,
onSearchChange,
onSearchSubmit,
compactHeader = false,
activeCategoryId = null,
activeArticleSlug = null,
}: {
portal: HelpPortal;
manifest: HelpManifest;
children: ReactNode;
search: string;
onSearchChange: (value: string) => void;
onSearchSubmit?: (value: string) => void;
compactHeader?: boolean;
activeCategoryId?: number | null;
activeArticleSlug?: string | null;
}) {
const homeHref = "/";
const portal = manifest.portal;
return (
<div className={`help-center ${compactHeader ? "has-compact-header" : ""}`}>
<header className="help-header">
@@ -40,7 +46,14 @@ export function HelpLayout({
/>
)}
</header>
<main>{children}</main>
<main className="help-page-grid">
<HelpNavigation
manifest={manifest}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
/>
<div className="help-page-content">{children}</div>
</main>
<footer className="help-footer">
<div className="help-footer-row">
<a className="help-footer-brand" href={homeHref}>
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from "react";
import { fetchHelpArticles } from "./api";
import { HelpChevronIcon } from "./HelpIcons";
import type { HelpArticle, HelpCategory, HelpManifest } from "./types";
import { t } from "../../i18n";
function articleHref(slug: string): string {
return `/articles/${encodeURIComponent(slug)}/`;
}
type BranchProps = {
category: HelpCategory;
childrenByParent: Map<number | null, HelpCategory[]>;
activePath: Set<number>;
activeCategoryId: number | null;
activeArticleSlug: string | null;
locale: string;
};
function CategoryBranch({
category, childrenByParent, activePath, activeCategoryId, activeArticleSlug, locale,
}: BranchProps) {
const [expanded, setExpanded] = useState(activePath.has(category.id));
const [articles, setArticles] = useState<HelpArticle[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const children = childrenByParent.get(category.id) ?? [];
useEffect(() => {
if (activePath.has(category.id)) setExpanded(true);
}, [activePath, category.id]);
useEffect(() => {
if (!expanded || articles !== null || category.articleCount === 0) return;
let cancelled = false;
setLoading(true);
fetchHelpArticles({ locale, category: category.slug, direct: true, limit: 50 })
.then((payload) => {
if (cancelled) return;
setArticles(payload.items);
setHasMore(payload.pagination.hasMore);
setFailed(false);
})
.catch(() => { if (!cancelled) setFailed(true); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [articles, category.articleCount, category.slug, expanded, locale]);
async function loadMore() {
if (loading || !articles) return;
setLoading(true);
try {
const payload = await fetchHelpArticles({
locale, category: category.slug, direct: true, limit: 50, offset: articles.length,
});
setArticles((current) => [...(current ?? []), ...payload.items]);
setHasMore(payload.pagination.hasMore);
setFailed(false);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}
const canExpand = category.articleCount > 0 || children.length > 0;
return (
<li className="help-nav-branch">
<div className="help-nav-row">
<a
aria-current={activeCategoryId === category.id && !activeArticleSlug ? "page" : undefined}
className={activeCategoryId === category.id ? "is-current" : ""}
href={`/?category=${encodeURIComponent(category.slug)}`}
>{category.name}</a>
{canExpand && (
<button
aria-label={t(expanded ? "portals.collapse_section" : "portals.expand_section", { name: category.name })}
aria-expanded={expanded}
className={expanded ? "is-expanded" : ""}
type="button"
onClick={() => setExpanded((value) => !value)}
><HelpChevronIcon /></button>
)}
</div>
{canExpand && expanded && (
<ul className="help-nav-children">
{articles?.map((article) => (
<li key={article.slug}>
<a
aria-current={activeArticleSlug === article.slug ? "page" : undefined}
className={`help-nav-article${activeArticleSlug === article.slug ? " is-current" : ""}`}
href={articleHref(article.slug)}
>{article.revision.title}</a>
</li>
))}
{children.map((child) => (
<CategoryBranch
key={child.id}
category={child}
childrenByParent={childrenByParent}
activePath={activePath}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
locale={locale}
/>
))}
{loading && <li className="help-nav-status" aria-live="polite">…</li>}
{failed && <li className="help-nav-status">{t("portals.could_not_load_articles")}</li>}
{hasMore && !loading && (
<li><button className="help-nav-more" type="button" onClick={() => void loadMore()}>{t("portals.show_more")}</button></li>
)}
</ul>
)}
</li>
);
}
export function HelpNavigation({
manifest, activeCategoryId, activeArticleSlug,
}: {
manifest: HelpManifest;
activeCategoryId: number | null;
activeArticleSlug: string | null;
}) {
const [mobileOpen, setMobileOpen] = useState(false);
const childrenByParent = useMemo(() => {
const result = new Map<number | null, HelpCategory[]>();
for (const category of manifest.categories) {
const siblings = result.get(category.parentId) ?? [];
siblings.push(category);
result.set(category.parentId, siblings);
}
for (const siblings of result.values()) {
siblings.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
}
return result;
}, [manifest.categories]);
const activePath = useMemo(() => {
const byId = new Map(manifest.categories.map((category) => [category.id, category]));
const path = new Set<number>();
let current = activeCategoryId ? byId.get(activeCategoryId) : undefined;
while (current && !path.has(current.id)) {
path.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path;
}, [activeCategoryId, manifest.categories]);
return (
<aside className="help-navigation">
<button
aria-controls="help-navigation-tree"
aria-expanded={mobileOpen}
className="help-navigation-mobile-toggle"
type="button"
onClick={() => setMobileOpen((value) => !value)}
>{t("portals.all_sections")}<HelpChevronIcon /></button>
<nav aria-label={t("portals.navigation")} className={mobileOpen ? "is-open" : ""} id="help-navigation-tree">
<a className="help-nav-all" href="/">{t("portals.all_sections")}</a>
<ul>
{(childrenByParent.get(null) ?? []).map((category) => (
<CategoryBranch
key={category.id}
category={category}
childrenByParent={childrenByParent}
activePath={activePath}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
locale={manifest.portal.defaultLocale}
/>
))}
</ul>
</nav>
</aside>
);
}
@@ -0,0 +1,22 @@
import { HelpArrowIcon } from "./HelpIcons";
import type { HelpArticle } from "./types";
import { t } from "../../i18n";
export function HelpRelatedArticles({ articles }: { articles: HelpArticle[] }) {
if (articles.length === 0) return null;
return (
<section className="help-related" aria-labelledby="help-related-title">
<h2 id="help-related-title">{t("portals.related_articles")}</h2>
<ul>
{articles.map((article) => (
<li key={article.slug}>
<a href={`/articles/${encodeURIComponent(article.slug)}/`}>
<span>{article.revision.title}</span>
<HelpArrowIcon />
</a>
</li>
))}
</ul>
</section>
);
}
@@ -6,13 +6,15 @@ export function fetchHelpManifest(): Promise<HelpManifest> {
}
export function fetchHelpArticles(
options: { locale?: string; category?: string; query?: string; offset?: number } = {},
options: { locale?: string; category?: string; query?: string; offset?: number; limit?: number; direct?: boolean } = {},
): Promise<{ items: HelpArticle[]; pagination: { hasMore: boolean; limit: number; offset: number; total: number } }> {
const search = new URLSearchParams();
if (options.locale) search.set("locale", options.locale);
if (options.category) search.set("category", options.category);
if (options.query) search.set("q", options.query);
if (options.offset) search.set("offset", String(options.offset));
if (options.limit) search.set("limit", String(options.limit));
if (options.direct) search.set("direct", "1");
const suffix = search.size ? `?${search.toString()}` : "";
return api(`/api/v1/help/articles/${suffix}`);
}
@@ -26,7 +26,7 @@ html.help-center-document body {
}
.help-header {
width: min(1024px, calc(100% - 48px));
width: min(1280px, calc(100% - 48px));
margin: 0 auto;
padding: 34px 0 0;
}
@@ -154,7 +154,7 @@ html.help-center-document body {
.help-footer {
display: grid;
width: min(1024px, calc(100% - 48px));
width: min(1280px, calc(100% - 48px));
margin: 80px auto 0;
padding: 34px 0 42px;
gap: 18px;
@@ -293,4 +293,3 @@ html.help-center-document body {
.help-fatal span {
color: var(--help-text-muted);
}
@@ -0,0 +1,201 @@
.help-page-grid {
display: grid;
width: min(1280px, calc(100% - 48px));
margin: 0 auto;
grid-template-columns: 240px minmax(0, 1fr);
gap: 44px;
align-items: start;
}
.help-page-content {
min-width: 0;
}
.help-page-content .help-hero,
.help-page-content .help-home-content,
.help-page-content .help-article-shell {
width: 100%;
}
.help-page-content .help-article-grid {
grid-template-columns: minmax(0, 1fr) minmax(160px, 190px);
gap: 32px;
}
.help-navigation {
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
margin-top: 42px;
padding-right: 18px;
overflow: auto;
border-right: 1px solid var(--help-line-soft);
scrollbar-width: thin;
}
.help-navigation-mobile-toggle {
display: none;
}
.help-navigation nav > ul,
.help-nav-children {
margin: 0;
padding: 0;
list-style: none;
}
.help-nav-all,
.help-nav-row > a,
.help-nav-article,
.help-nav-more {
display: block;
padding: 9px 10px;
border-radius: var(--help-radius-sm);
color: var(--help-text);
font-size: 14px;
line-height: 1.35;
text-decoration: none;
}
.help-nav-all {
margin-bottom: 8px;
font-weight: var(--help-heading-weight);
}
.help-nav-row {
display: flex;
min-width: 0;
align-items: flex-start;
}
.help-nav-row > a {
min-width: 0;
flex: 1;
color: var(--help-ink);
font-weight: var(--help-heading-weight);
}
.help-nav-row > button {
display: grid;
width: 32px;
height: 36px;
flex: none;
place-items: center;
border: 0;
border-radius: var(--help-radius-sm);
background: transparent;
color: var(--help-text-muted);
cursor: pointer;
}
.help-nav-row > button svg {
transition: transform 150ms ease;
}
.help-nav-row > button.is-expanded svg {
transform: rotate(90deg);
}
.help-nav-children {
margin: 1px 0 7px 15px;
padding-left: 9px;
border-left: 1px solid var(--help-line-soft);
}
.help-nav-article {
color: var(--help-text-muted);
font-size: 13px;
}
.help-nav-all:hover,
.help-nav-row > a:hover,
.help-nav-article:hover,
.help-nav-more:hover,
.help-nav-row > button:hover {
background: var(--help-surface-soft);
color: var(--help-ink);
}
.help-nav-row > a.is-current,
.help-nav-article.is-current {
background: var(--help-surface-soft);
color: var(--help-accent);
font-weight: var(--help-heading-weight);
}
.help-nav-more {
width: 100%;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.help-nav-status {
padding: 9px 10px;
color: var(--help-text-subtle);
font-size: 13px;
}
@media (max-width: 1100px) {
.help-page-content .help-article-grid {
display: block;
}
.help-page-content .help-toc {
display: none;
}
}
@media (max-width: 860px) {
.help-page-grid {
display: block;
width: min(100% - 36px, 680px);
}
.help-navigation {
position: static;
max-height: none;
margin-top: 20px;
padding: 0;
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--help-line-soft);
}
.help-navigation-mobile-toggle {
display: flex;
width: 100%;
min-height: 44px;
padding: 0 4px;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
color: var(--help-ink);
font-family: var(--help-font-heading);
font-weight: var(--help-heading-weight);
cursor: pointer;
}
.help-navigation-mobile-toggle[aria-expanded="true"] svg {
transform: rotate(90deg);
}
.help-navigation nav {
display: none;
max-height: 60vh;
padding: 8px 0 14px;
overflow: auto;
}
.help-navigation nav.is-open {
display: block;
}
}
@media (max-width: 560px) {
.help-page-grid {
width: calc(100% - 28px);
}
}
@@ -0,0 +1,49 @@
.help-related {
margin-top: 46px;
}
.help-related h2 {
margin: 0 0 18px;
font-family: var(--help-font-heading);
font-size: 23px;
font-weight: var(--help-heading-weight);
letter-spacing: -0.03em;
}
.help-related ul {
margin: 0;
padding: 0;
border-top: 1px solid var(--help-line);
list-style: none;
}
.help-related li {
border-bottom: 1px solid var(--help-line-soft);
}
.help-related a {
display: flex;
min-height: 52px;
padding: 12px 2px;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--help-ink);
font-size: 15px;
font-weight: 600;
line-height: 1.4;
text-decoration: none;
}
.help-related a svg {
flex: none;
color: var(--help-text-muted);
}
.help-related a:hover {
color: var(--help-accent);
}
.help-related a:hover svg {
color: var(--help-accent);
}
@@ -35,6 +35,7 @@ export type HelpArticle = {
updatedAt: string;
// Файлы статьи: картинки вставлены в текст, остальное посетитель скачивает.
attachments?: HelpAttachment[];
relatedArticles?: HelpArticle[];
};
export type HelpPortal = {
+3
View File
@@ -824,6 +824,9 @@ export const en: Record<MessageKey, Message> = {
"portals.article_category_path_required": "Article #{index}: the required field “categoryPath” is a non-empty list of sections",
"portals.article_content_empty": "Article #{index}: the required field “content” is empty",
"portals.article_contents": "Article contents",
"portals.related_articles": "Related articles",
"portals.expand_section": "Expand section {name}",
"portals.collapse_section": "Collapse section {name}",
"portals.article_files": "ARTICLE FILES",
"portals.article_import": "Article import",
"portals.article_must_be_object": "Article #{index}: must be an object",
+3
View File
@@ -825,6 +825,9 @@ export const ru = {
"portals.article_category_path_required": "Статья #{index}: обязательное поле «categoryPath» — непустой список разделов",
"portals.article_content_empty": "Статья #{index}: обязательное поле «content» пустое",
"portals.article_contents": "Содержание статьи",
"portals.related_articles": "Связанные статьи",
"portals.expand_section": "Раскрыть раздел {name}",
"portals.collapse_section": "Свернуть раздел {name}",
"portals.article_files": "ФАЙЛЫ СТАТЬИ",
"portals.article_import": "Импорт статей",
"portals.article_must_be_object": "Статья #{index}: должна быть объектом",
+23
View File
@@ -0,0 +1,23 @@
# Редакционный порядок центра помощи Chatballs
`chatballs.json` задаёт порядок опубликованных статей внутри каждого раздела и
направленные связи между статьями. Карта охватывает 72 русскоязычные статьи и
170 переходов. Она не меняет тексты, категории и статусы публикации.
Команда `apply_portal_curation` сначала проверяет, что карта полностью совпадает
с опубликованными статьями выбранного портала: состав, разделы, язык и ссылки.
Без `--apply` она только показывает число записей, которые были бы изменены.
После штатной выкладки версии с миграцией `0012_article_curation`:
```sh
cat chatballs.json | ssh ubuntu@SERVER \
'cd /home/ubuntu && docker compose exec -T backend-admin \
python /app/apps/backend/manage.py apply_portal_curation \
--host chatballs.com.edevs.tech --file -'
```
После сверки результата тот же запуск с `--apply` сохраняет порядок и связи.
Повторный запуск идемпотентен: при уже применённой карте число изменений равно
нулю. Файл передаётся через stdin и не сохраняется на сервере, поскольку
production-образ не содержит этот каталог документации.
+92
View File
@@ -0,0 +1,92 @@
{
"portal": "chatballs",
"locale": "ru",
"order": {
"start": ["chto-takoe-chatballs", "pervyj-den", "demo-dannye", "roli-i-prava"],
"installation": ["pervyj-zapusk", "adres-i-https", "pochta-smtp", "yazyk-ustanovki", "hranilishche-fajlov", "rezervnoe-kopirovanie", "obnovlenie", "diagnostika", "zvonki-relay"],
"organization": ["organizaciya-nastrojki", "ai-provajder", "gruppy", "kogda-zvat-na-pomoshch", "neskolko-organizacij"],
"entry-points": ["tochki-vhoda-obzor", "telegram", "token-telegram", "max", "token-max", "veb-vidzhet", "pochtovyj-yashchik", "vkontakte", "golosovye-i-zvonki", "bot-uvedomlenij"],
"agents": ["kartochka-agenta", "model-i-provajder", "instrukcii-agenta", "znaniya-agenta", "zapusk-i-ostanovka", "agent-ne-znaet-otveta", "yazyk-otveta"],
"knowledge": ["material-i-vlozheniya", "kategorii-znanij", "kak-agent-nahodit-otvet", "import-yaml-znaniya", "massovye-dejstviya"],
"portals": ["sozdanie-portala", "kategorii-portala", "statya-portala", "revizii-i-publikaciya", "oformlenie-portala", "svoj-domen-portala", "import-statej", "portal-i-agent"],
"conversations": ["ekran-chat", "rezhimy-dialoga", "razbor-dialoga", "kto-vidit-dialogi", "shablony-otvetov", "fajly-golosovye-zvonki", "zakrytie-spam-udalenie"],
"contacts": ["spisok-kontaktov", "kartochka-kontakta", "zapros-kontakta", "obedinenie-dublej"],
"team": ["roli-sotrudnikov", "priglashenie-sotrudnika", "gruppy-sotrudnika", "bezopasnost-sotrudnika", "zhurnal-audita", "peredacha-vladeniya"],
"profile": ["profil-dannye", "profil-bezopasnost", "profil-uvedomleniya"],
"reference": ["chto-delat-esli", "glossarij", "formaty-i-razmery-fajlov", "chto-uhodit-v-model"]
},
"related": {
"chto-takoe-chatballs": ["pervyj-den", "roli-i-prava", "demo-dannye"],
"pervyj-den": ["demo-dannye", "pervyj-zapusk", "tochki-vhoda-obzor"],
"demo-dannye": ["pervyj-den", "ai-provajder"],
"roli-i-prava": ["roli-sotrudnikov", "priglashenie-sotrudnika"],
"pervyj-zapusk": ["adres-i-https", "pochta-smtp", "rezervnoe-kopirovanie"],
"adres-i-https": ["svoj-domen-portala", "diagnostika"],
"pochta-smtp": ["priglashenie-sotrudnika", "pochtovyj-yashchik"],
"yazyk-ustanovki": ["organizaciya-nastrojki", "profil-dannye", "yazyk-otveta"],
"hranilishche-fajlov": ["rezervnoe-kopirovanie", "formaty-i-razmery-fajlov"],
"rezervnoe-kopirovanie": ["obnovlenie", "diagnostika"],
"obnovlenie": ["rezervnoe-kopirovanie", "diagnostika"],
"diagnostika": ["chto-delat-esli", "adres-i-https", "rezervnoe-kopirovanie"],
"zvonki-relay": ["golosovye-i-zvonki", "fajly-golosovye-zvonki"],
"organizaciya-nastrojki": ["ai-provajder", "gruppy", "kogda-zvat-na-pomoshch"],
"ai-provajder": ["model-i-provajder", "kartochka-agenta"],
"gruppy": ["gruppy-sotrudnika", "kto-vidit-dialogi"],
"kogda-zvat-na-pomoshch": ["agent-ne-znaet-otveta", "razbor-dialoga"],
"neskolko-organizacij": ["organizaciya-nastrojki", "roli-i-prava"],
"tochki-vhoda-obzor": ["telegram", "max", "veb-vidzhet"],
"telegram": ["token-telegram", "kartochka-agenta", "diagnostika"],
"token-telegram": ["telegram", "bot-uvedomlenij"],
"max": ["token-max", "telegram"],
"token-max": ["max", "token-telegram"],
"veb-vidzhet": ["golosovye-i-zvonki", "oformlenie-portala", "tochki-vhoda-obzor"],
"pochtovyj-yashchik": ["pochta-smtp", "tochki-vhoda-obzor"],
"vkontakte": ["tochki-vhoda-obzor", "kartochka-agenta"],
"golosovye-i-zvonki": ["fajly-golosovye-zvonki", "zvonki-relay"],
"bot-uvedomlenij": ["profil-uvedomleniya", "token-telegram", "token-max"],
"kartochka-agenta": ["model-i-provajder", "instrukcii-agenta", "znaniya-agenta"],
"model-i-provajder": ["ai-provajder", "yazyk-otveta"],
"instrukcii-agenta": ["znaniya-agenta", "agent-ne-znaet-otveta"],
"znaniya-agenta": ["kategorii-znanij", "material-i-vlozheniya", "kak-agent-nahodit-otvet"],
"zapusk-i-ostanovka": ["kartochka-agenta", "tochki-vhoda-obzor"],
"agent-ne-znaet-otveta": ["znaniya-agenta", "kogda-zvat-na-pomoshch", "rezhimy-dialoga"],
"yazyk-otveta": ["yazyk-ustanovki", "instrukcii-agenta"],
"material-i-vlozheniya": ["kategorii-znanij", "kak-agent-nahodit-otvet", "statya-portala"],
"kategorii-znanij": ["massovye-dejstviya", "znaniya-agenta"],
"kak-agent-nahodit-otvet": ["znaniya-agenta", "material-i-vlozheniya", "agent-ne-znaet-otveta"],
"import-yaml-znaniya": ["kategorii-znanij", "massovye-dejstviya"],
"massovye-dejstviya": ["kategorii-znanij", "import-yaml-znaniya"],
"sozdanie-portala": ["kategorii-portala", "statya-portala", "oformlenie-portala"],
"kategorii-portala": ["statya-portala", "import-statej"],
"statya-portala": ["revizii-i-publikaciya", "portal-i-agent", "material-i-vlozheniya"],
"revizii-i-publikaciya": ["statya-portala", "oformlenie-portala"],
"oformlenie-portala": ["svoj-domen-portala", "veb-vidzhet"],
"svoj-domen-portala": ["adres-i-https", "sozdanie-portala"],
"import-statej": ["kategorii-portala", "statya-portala"],
"portal-i-agent": ["znaniya-agenta", "material-i-vlozheniya", "statya-portala"],
"ekran-chat": ["rezhimy-dialoga", "razbor-dialoga", "kto-vidit-dialogi"],
"rezhimy-dialoga": ["agent-ne-znaet-otveta", "razbor-dialoga", "kto-vidit-dialogi"],
"razbor-dialoga": ["gruppy", "kto-vidit-dialogi", "ekran-chat"],
"kto-vidit-dialogi": ["gruppy", "gruppy-sotrudnika"],
"shablony-otvetov": ["ekran-chat", "razbor-dialoga"],
"fajly-golosovye-zvonki": ["golosovye-i-zvonki", "hranilishche-fajlov"],
"zakrytie-spam-udalenie": ["rezhimy-dialoga", "razbor-dialoga"],
"spisok-kontaktov": ["kartochka-kontakta", "obedinenie-dublej"],
"kartochka-kontakta": ["spisok-kontaktov", "zapros-kontakta", "obedinenie-dublej"],
"zapros-kontakta": ["kartochka-kontakta", "telegram"],
"obedinenie-dublej": ["kartochka-kontakta", "spisok-kontaktov"],
"roli-sotrudnikov": ["roli-i-prava", "priglashenie-sotrudnika"],
"priglashenie-sotrudnika": ["pochta-smtp", "gruppy-sotrudnika", "roli-sotrudnikov"],
"gruppy-sotrudnika": ["gruppy", "kto-vidit-dialogi"],
"bezopasnost-sotrudnika": ["profil-bezopasnost", "zhurnal-audita"],
"zhurnal-audita": ["bezopasnost-sotrudnika", "peredacha-vladeniya"],
"peredacha-vladeniya": ["roli-sotrudnikov", "bezopasnost-sotrudnika"],
"profil-dannye": ["yazyk-ustanovki", "profil-uvedomleniya"],
"profil-bezopasnost": ["bezopasnost-sotrudnika", "profil-dannye"],
"profil-uvedomleniya": ["bot-uvedomlenij", "profil-dannye"],
"chto-delat-esli": ["diagnostika", "agent-ne-znaet-otveta", "pochta-smtp"],
"glossarij": ["chto-takoe-chatballs", "tochki-vhoda-obzor"],
"formaty-i-razmery-fajlov": ["hranilishche-fajlov", "material-i-vlozheniya", "statya-portala"],
"chto-uhodit-v-model": ["ai-provajder", "profil-bezopasnost", "znaniya-agenta"]
}
}