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)(]*>.*?)", html) + i = 1 + while i < len(parts): + heading = re.sub(r"<[^>]+>", "", parts[i]) + body = parts[i + 1] if i + 1 < len(parts) else "" + lower = heading.lower() + if any(p.lower() in lower for p in title_parts): + return html_to_plain(body) + i += 2 + return "" + + def content_extra_context(doc_type: DocumentType, html_or_plain: str) -> dict[str, str]: - plain = html_or_plain or "" - if "<" in plain: - plain = html_to_plain(plain) - extra = {"DOC_CONTENT": plain} + html = html_or_plain or "" + plain = html_to_plain(html) if "<" in html else html + extra: dict[str, str] = {"DOC_CONTENT": plain} key = CONTENT_CONTEXT_KEYS.get(doc_type) if key: extra[key] = plain + + # Sectional placeholders used by existing rp.docx / pmi.docx templates + if doc_type == DocumentType.RP: + purpose = _section_plain(html, "назначение") + conditions = _section_plain(html, "условия выполнения", "условия") + extra["PURPOSE"] = purpose or plain + extra["EXECUTION_CONDITIONS"] = conditions or "" + elif doc_type == DocumentType.PMI: + obj = _section_plain(html, "объект") + methods = _section_plain(html, "методы") + extra["TEST_OBJECT"] = obj or plain + extra["TEST_METHODS"] = methods or "" + return extra diff --git a/web/backend/app/services/espd/skeletons.py b/web/backend/app/services/espd/skeletons.py new file mode 100644 index 0000000..3381f06 --- /dev/null +++ b/web/backend/app/services/espd/skeletons.py @@ -0,0 +1,110 @@ +"""Default HTML skeletons for ESPD rich-text editors (visible outlines).""" + +from __future__ import annotations + +from app.services.espd.models import DocumentType + +# Placeholder paragraph shown under each heading so the outline is obvious in TipTap. +_HINT = "

Заполните раздел…

" + + +def _h2(title: str) -> str: + return f"

{title}

{_HINT}" + + +def _h3(title: str) -> str: + return f"

{title}

{_HINT}" + + +SKELETONS: dict[str, str] = { + DocumentType.RO.value: "\n".join( + [ + _h2("1. Назначение программы"), + _h3("1.1. Функциональное назначение"), + _h3("1.2. Эксплуатационное назначение"), + _h2("2. Условия выполнения программы"), + _h3("2.1. Требования к техническим средствам"), + _h3("2.2. Требования к программным средствам"), + _h3("2.3. Требования к персоналу"), + _h2("3. Установка программы"), + _h3("3.1. Состав дистрибутива"), + _h3("3.2. Порядок установки"), + _h2("4. Работа с программой"), + _h3("4.1. Запуск программы"), + _h3("4.2. Основные операции"), + _h3("4.3. Завершение работы"), + _h2("5. Сообщения оператору"), + ] + ), + DocumentType.RP.value: "\n".join( + [ + _h2("1. Назначение программы"), + _h2("2. Условия выполнения программы"), + _h2("3. Характеристики программы"), + _h2("4. Обращение к программе"), + _h2("5. Входные и выходные данные"), + _h2("6. Сообщения"), + ] + ), + DocumentType.RSP.value: "\n".join( + [ + _h2("1. Общие сведения о программе"), + _h2("2. Структура программы"), + _h2("3. Настройка программы"), + _h2("4. Проверка программы"), + _h2("5. Дополнительные возможности"), + _h2("6. Сообщения системному программисту"), + ] + ), + DocumentType.PMI.value: "\n".join( + [ + _h2("1. Объект испытаний"), + _h2("2. Цель испытаний"), + _h2("3. Требования к программе"), + _h2("4. Требования к программной документации"), + _h2("5. Средства и порядок испытаний"), + _h2("6. Методы испытаний"), + ] + ), + DocumentType.TEXT.value: "\n".join( + [ + _h2("1. Общие сведения"), + "

Краткое описание состава текста программы.

", + _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 ( +
+ {blocks.map((b, idx) => { + if (b.type === "table") { + return ( +
+ + + + {b.header.map((c, ci) => ( + + + + {b.rows.map((row, ri) => ( + + {b.header.map((_, ci) => ( + + ))} + +
+ ))} +
+ ))} +
+
+ ); + } + if (b.type === "code") { + return ( +
+              {b.text}
+            
+ ); + } + if (b.type === "heading") { + const Tag = b.level === 1 ? "h3" : b.level === 2 ? "h4" : "h5"; + return ; + } + if (b.type === "ul") { + return ( +
    + {b.items.map((it, ii) => ( +
  • + ))} +
+ ); + } + if (b.type === "ol") { + return ( +
    + {b.items.map((it, ii) => ( +
  1. + ))} +
+ ); + } + return ( +

+ ); + })} +

+ ); +} diff --git a/web/frontend/src/pages/EspdPanel.jsx b/web/frontend/src/pages/EspdPanel.jsx index 53c4690..e14d868 100644 --- a/web/frontend/src/pages/EspdPanel.jsx +++ b/web/frontend/src/pages/EspdPanel.jsx @@ -6,6 +6,7 @@ import RichTextEditor from "../components/RichTextEditor"; import EspdRagPanel from "../components/EspdRagPanel"; import SplitPane from "../components/SplitPane"; import { openEspdPreviewPopup, syncEspdPreviewPopup } from "../components/espdPreviewPopup"; +import ChatMarkdown from "../components/ChatMarkdown"; function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); @@ -82,8 +83,19 @@ function htmlFromContents(docContents, id, fallbackPlain) { return ""; } +function isBlankHtml(html) { + if (!html || !String(html).trim()) return true; + const text = String(html) + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + return text === ""; +} + export default function EspdPanel({ projectId }) { const [docTypes, setDocTypes] = useState([]); + const [skeletons, setSkeletons] = useState({}); const [common, setCommon] = useState({ decimal: "", progname: "", @@ -97,6 +109,7 @@ export default function EspdPanel({ projectId }) { const [roDescription, setRoDescription] = useState(""); const [docContents, setDocContents] = useState({}); const [activeTab, setActiveTab] = useState(""); + const [editorEpoch, setEditorEpoch] = useState(0); const [impZipInfo, setImpZipInfo] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -133,22 +146,31 @@ export default function EspdPanel({ projectId }) { const [data, me] = await Promise.all([api.getEspd(projectId), api.me().catch(() => null)]); setIsAdmin(Boolean(me?.is_admin)); setDocTypes(data.doc_types || []); + const skel = data.skeletons || {}; + setSkeletons(skel); const st = data.state || {}; if (st.common) setCommon((prev) => ({ ...prev, ...st.common })); - setSelectedDocs( - (st.selected_docs || []).map((d) => ({ - doc_type: d.doc_type, - with_dvd: Boolean(d.with_dvd ?? d.withDvd), - })) - ); + const selected = (st.selected_docs || []).map((d) => ({ + doc_type: d.doc_type, + with_dvd: Boolean(d.with_dvd ?? d.withDvd), + })); + setSelectedDocs(selected); setRoDescription(st.ro_description || ""); - setDocContents(st.doc_contents || {}); - if (st.ro_ai_content && !st.doc_contents?.ro?.html) { - setDocContents((prev) => ({ - ...prev, + let contents = { ...(st.doc_contents || {}) }; + if (st.ro_ai_content && isBlankHtml(contents.ro?.html)) { + contents = { + ...contents, ro: { html: `

${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 }) {

Содержимое документов

- {activeTab === "ro" && ( - - )} +
+ {activeTab && skeletons[activeTab] && ( + + )} + {activeTab === "ro" && ( + + )} +
{activeTab === "ro" && (