diff --git a/web/backend/app/api/routes.py b/web/backend/app/api/routes.py index 2d186fe..2626d3c 100644 --- a/web/backend/app/api/routes.py +++ b/web/backend/app/api/routes.py @@ -239,6 +239,7 @@ async def reupload_zip( @secured.get("/projects/{project_id}/espd") def get_espd(project_id: int, db: Session = Depends(get_db)): from app.services.espd import DOC_TYPE_CONFIG, get_espd_state + from app.services.espd.skeletons import all_skeletons project = _get_project(db, project_id) _require_espd(project) @@ -252,7 +253,11 @@ def get_espd(project_id: int, db: Session = Depends(get_db)): } for dt, cfg in sorted(DOC_TYPE_CONFIG.items(), key=lambda x: x[1]["order"]) ] - return {"state": state.model_dump(by_alias=True, mode="json"), "doc_types": doc_types} + return { + "state": state.model_dump(by_alias=True, mode="json"), + "doc_types": doc_types, + "skeletons": all_skeletons(), + } @secured.patch("/projects/{project_id}/espd") diff --git a/web/backend/app/services/espd/content_ctx.py b/web/backend/app/services/espd/content_ctx.py index dac8b67..2de0a07 100644 --- a/web/backend/app/services/espd/content_ctx.py +++ b/web/backend/app/services/espd/content_ctx.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import Any from app.services.espd.html_util import html_to_plain @@ -34,14 +35,42 @@ def resolve_doc_html_map( return out +def _section_plain(html: str, *title_parts: str) -> str: + """Extract plain text of first h2 whose title contains any of title_parts.""" + if not html: + return "" + parts = re.split(r"(?i)(
Заполните раздел…
" + + +def _h2(title: str) -> str: + return f"Краткое описание состава текста программы.
", + _h2("2. Текст программы"), + "// Вставьте листинг или фрагменты исходного кода…\n",
+ _h2("3. Примечания"),
+ ]
+ ),
+ DocumentType.IMP.value: "\n".join(
+ [
+ _h2("1. Перечень исполняемых модулей"),
+ "Таблица файлов формируется из загруженного ZIP при генерации DOCX. " + "Здесь можно указать пояснения к составу поставки.
", + _h2("2. Инструкция по установке / размещению"), + _h2("3. Проверка контрольных сумм"), + "Контрольная сумма ZIP подставляется автоматически при генерации.
", + ] + ), +} + + +def get_skeleton(doc_type: str | DocumentType) -> str: + key = doc_type.value if isinstance(doc_type, DocumentType) else str(doc_type) + return SKELETONS.get(key, "") + + +def all_skeletons() -> dict[str, str]: + return dict(SKELETONS) + + +def is_blank_html(html: str | None) -> bool: + """True if editor content is empty / only whitespace or empty tags.""" + if not html or not str(html).strip(): + return True + import re + from html import unescape + + text = re.sub(r"<[^>]+>", " ", str(html)) + text = unescape(text) + text = re.sub(r"\s+", " ", text).strip() + return text == "" diff --git a/web/frontend/src/components/ChatMarkdown.jsx b/web/frontend/src/components/ChatMarkdown.jsx new file mode 100644 index 0000000..411e212 --- /dev/null +++ b/web/frontend/src/components/ChatMarkdown.jsx @@ -0,0 +1,197 @@ +/** + * Lightweight chat markdown → React nodes. + * Focus: pipe tables, headings, lists, bold/italic, code, paragraphs. + */ + +function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function inlineFormat(text) { + let s = escapeHtml(text); + s = s.replace(/`([^`]+)`/g, "$1");
+ s = s.replace(/\*\*([^*]+)\*\*/g, "$1");
+ s = s.replace(/(?$1");
+ s = s.replace(/__([^_]+)__/g, "$1");
+ return s;
+}
+
+function isTableSeparator(line) {
+ return /^\s*\|?[\s-:|]+\|[\s-:|]*\|?\s*$/.test(line) && line.includes("-");
+}
+
+function isTableRow(line) {
+ return /^\s*\|(.+)\|\s*$/.test(line) || (/\|/.test(line) && line.trim().startsWith("|"));
+}
+
+function splitCells(line) {
+ let s = line.trim();
+ if (s.startsWith("|")) s = s.slice(1);
+ if (s.endsWith("|")) s = s.slice(0, -1);
+ return s.split("|").map((c) => c.trim());
+}
+
+function parseBlocks(text) {
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
+ const blocks = [];
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // fenced code
+ if (line.trim().startsWith("```")) {
+ const lang = line.trim().slice(3).trim();
+ i += 1;
+ const code = [];
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
+ code.push(lines[i]);
+ i += 1;
+ }
+ if (i < lines.length) i += 1;
+ blocks.push({ type: "code", lang, text: code.join("\n") });
+ continue;
+ }
+
+ // markdown table
+ if (isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
+ const header = splitCells(line);
+ i += 2;
+ const rows = [];
+ while (i < lines.length && isTableRow(lines[i]) && !isTableSeparator(lines[i])) {
+ rows.push(splitCells(lines[i]));
+ i += 1;
+ }
+ blocks.push({ type: "table", header, rows });
+ continue;
+ }
+
+ // blank
+ if (!line.trim()) {
+ i += 1;
+ continue;
+ }
+
+ // heading
+ const hm = line.match(/^(#{1,3})\s+(.+)$/);
+ if (hm) {
+ blocks.push({ type: "heading", level: hm[1].length, text: hm[2] });
+ i += 1;
+ continue;
+ }
+
+ // unordered list
+ if (/^\s*[-*•]\s+/.test(line)) {
+ const items = [];
+ while (i < lines.length && /^\s*[-*•]\s+/.test(lines[i])) {
+ items.push(lines[i].replace(/^\s*[-*•]\s+/, ""));
+ i += 1;
+ }
+ blocks.push({ type: "ul", items });
+ continue;
+ }
+
+ // ordered list
+ if (/^\s*\d+[.)]\s+/.test(line)) {
+ const items = [];
+ while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
+ items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ""));
+ i += 1;
+ }
+ blocks.push({ type: "ol", items });
+ continue;
+ }
+
+ // paragraph (merge consecutive non-special lines)
+ const para = [line];
+ i += 1;
+ while (
+ i < lines.length &&
+ lines[i].trim() &&
+ !lines[i].trim().startsWith("```") &&
+ !isTableRow(lines[i]) &&
+ !lines[i].match(/^(#{1,3})\s+/) &&
+ !/^\s*[-*•]\s+/.test(lines[i]) &&
+ !/^\s*\d+[.)]\s+/.test(lines[i])
+ ) {
+ para.push(lines[i]);
+ i += 1;
+ }
+ blocks.push({ type: "p", text: para.join("\n") });
+ }
+ return blocks;
+}
+
+export default function ChatMarkdown({ text, className = "" }) {
+ const blocks = parseBlocks(text);
+
+ return (
+ | + ))} + |
|---|
| + ))} + |
+ {b.text}
+
+ );
+ }
+ if (b.type === "heading") {
+ const Tag = b.level === 1 ? "h3" : b.level === 2 ? "h4" : "h5";
+ return ${String(st.ro_ai_content).replace(/\n/g, "
")}
` }, - })); + }; } + for (const sel of selected) { + const id = sel.doc_type; + if (id === "spec") continue; + if (isBlankHtml(contents[id]?.html) && skel[id]) { + contents[id] = { html: skel[id] }; + } + } + setDocContents(contents); setImpZipInfo(st.imp_zip_info || null); }, [projectId]); @@ -171,15 +193,49 @@ export default function EspdPanel({ projectId }) { } }, [contentDocTypes, activeTab]); + function applySkeleton(id, { force = false } = {}) { + const html = skeletons[id]; + if (!html) return false; + let changed = false; + setDocContents((prev) => { + if (!force && !isBlankHtml(prev[id]?.html)) return prev; + changed = true; + return { ...prev, [id]: { ...(prev[id] || {}), html } }; + }); + if (force) setEditorEpoch((n) => n + 1); + return changed; + } + + useEffect(() => { + if (activeTab && activeTab !== "spec" && skeletons[activeTab]) { + applySkeleton(activeTab); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, skeletons]); + function toggleDoc(id) { setSelectedDocs((prev) => { if (prev.some((d) => d.doc_type === id)) { return prev.filter((d) => d.doc_type !== id); } + if (id !== "spec") { + queueMicrotask(() => { + applySkeleton(id); + setActiveTab(id); + }); + } return [...prev, { doc_type: id, with_dvd: false }]; }); } + function insertTemplate() { + if (!activeTab || !skeletons[activeTab]) return; + const hasContent = !isBlankHtml(docContents[activeTab]?.html); + if (hasContent && !window.confirm("Заменить текущий текст шаблоном разделов?")) return; + applySkeleton(activeTab, { force: true }); + setMessage("Шаблон вставлен"); + } + function toggleDvd(id) { setSelectedDocs((prev) => prev.map((d) => (d.doc_type === id ? { ...d, with_dvd: !d.with_dvd } : d)) @@ -508,11 +564,18 @@ export default function EspdPanel({ projectId }) {