/
ivan2mit
/
demo_script
Обзор
Документация
Войти
/
ivan2mit
/
demo_script
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
index.html
482 строки
19 KB
power_ivan
Update: index.html
02 июл 2026, 01:27
Верифицирован
02 июл 2026, 01:27
b3be211
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Скрипт обращения к родителю</title> <style> * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #f1f5f9; margin: 0; padding: 40px 16px; color: #0f172a; } .page { max-width: 720px; margin: 0 auto; } .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: #94a3b8; margin: 0 0 4px; } .page-title { font-size: 22px; font-weight: 700; margin: 0; } .page-desc { font-size: 13px; color: #64748b; line-height: 1.6; max-width: 560px; margin: 8px 0 24px; } .card { position: relative; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); } .toast { position: absolute; top: -12px; right: 16px; z-index: 20; display: flex; align-items: center; gap: 6px; background: #0f172a; color: #fff; font-size: 12px; font-weight: 500; padding: 6px 12px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.15); opacity: 0; transform: translateY(-4px); transition: opacity .25s, transform .25s; pointer-events: none; } .toast.show { opacity: 1; transform: translateY(0); } .toast svg { color: #34d399; } .card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #f1f5f9; } .card-title { font-size: 14px; font-weight: 600; margin: 0; } .card-sub { font-size: 12px; color: #94a3b8; margin: 3px 0 0; } .btn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; border-radius: 8px; padding: 6px 12px; border: 1px solid transparent; cursor: pointer; white-space: nowrap; background: none; font-family: inherit; } .btn-dark { background: #0f172a; color: #fff; } .btn-dark:hover { background: #334155; } .btn-ghost { border-color: #e2e8f0; color: #475569; } .btn-ghost:hover { background: #f8fafc; } .card-body { padding: 14px 16px; } .script-view { display: block; width: 100%; text-align: left; background: none; border: none; cursor: pointer; border-radius: 8px; padding: 6px; margin: -6px; position: relative; font: inherit; } .script-view:hover { background: #f8fafc; } .script-text { white-space: pre-wrap; font-size: 13.5px; line-height: 1.6; color: #334155; padding-right: 26px; margin: 0; } .copy-icon { position: absolute; top: 6px; right: 6px; color: #cbd5e1; } .script-view:hover .copy-icon { color: #64748b; } .edit-layout { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; } .edit-main { flex: 1 1 300px; min-width: 260px; position: relative; } textarea { width: 100%; resize: vertical; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 8px; padding: 9px 10px; font-size: 13px; line-height: 1.6; color: #1e293b; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; outline: none; } textarea:focus { border-color: #cbd5e1; box-shadow: 0 0 0 3px rgba(148,163,184,.25); } .hint { font-size: 11px; color: #94a3b8; margin: 5px 0 0; } .autocomplete { position: absolute; left: 8px; right: 8px; z-index: 10; margin-top: 4px; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; box-shadow: 0 8px 20px rgba(0,0,0,.12); } .autocomplete-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 7px 10px; font-size: 12px; border: none; background: none; cursor: pointer; font-family: inherit; color: #334155; } .autocomplete-item .tag { color: #94a3b8; } .autocomplete-item:hover, .autocomplete-item.active { background: #0f172a; color: #fff; } .autocomplete-item.active .tag { color: #cbd5e1; } .sidebar { width: 190px; flex-shrink: 0; } .sidebar-title { font-size: 11px; font-weight: 600; color: #94a3b8; margin: 0 0 6px; } .tag-list { display: flex; flex-direction: column; gap: 4px; max-height: 260px; overflow-y: auto; padding-right: 2px; } .tag-item { display: flex; align-items: flex-start; gap: 6px; text-align: left; border: 1px solid #e2e8f0; background: #f8fafc; border-radius: 6px; padding: 6px 8px; cursor: pointer; font-family: inherit; } .tag-item:hover { border-color: #94a3b8; background: #fff; } .tag-num { font-size: 11px; font-weight: 600; color: #64748b; flex-shrink: 0; } .tag-label { font-size: 11px; line-height: 1.4; color: #475569; } .reset-link { display: inline-flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 11px; font-weight: 500; color: #94a3b8; background: none; border: none; cursor: pointer; font-family: inherit; } .reset-link:hover { color: #334155; } .hidden { display: none !important; } .page-footer { font-size: 12px; color: #94a3b8; line-height: 1.6; margin-top: 20px; } </style> </head> <body> <div class="page"> <p class="eyebrow">Демо · для внутренней презентации</p> <h1 class="page-title">Скрипт обращения к родителю</h1> <p class="page-desc"> Текст подставляется из данных заявки автоматически. Педагог может отредактировать сам шаблон — вручную набрав нужный блок через «#» или выбрав его из списка справа. </p> <div class="card" id="card"> <div class="toast" id="toast"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg> Скрипт скопирован </div> <div class="card-header"> <div> <h3 class="card-title">Скрипт обращения к родителю</h3> <p class="card-sub" id="cardSub">нажмите на текст, чтобы скопировать</p> </div> <div id="headerActions"></div> </div> <div class="card-body"> <div id="viewMode"></div> <div id="editMode" class="hidden"> <div class="edit-layout"> <div class="edit-main"> <textarea id="draftInput" rows="9" spellcheck="false"></textarea> <div class="autocomplete hidden" id="autocomplete"></div> <p class="hint">Наберите «#», чтобы выбрать блок из списка</p> </div> <div class="sidebar"> <p class="sidebar-title">Доступные блоки</p> <div class="tag-list" id="tagList"></div> <button class="reset-link hidden" id="resetBtn"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> Сбросить к дефолту </button> </div> </div> </div> </div> </div> <p class="page-footer"> Данные ниже — заглушка (в проде поля подтягиваются из системы заявок). Изменённый шаблон сохраняется в localStorage браузера и переживает перезагрузку страницы. </p> </div> <script> (function () { const STORAGE_KEY = "applicationScriptTemplate"; const DEFAULT_TEMPLATE = `Добрый день, #1! Меня зовут #2, я педагог дополнительного образования гимназии Сириус. Вы подали заявку на программу "#3", в группе будет обучаться #4 Занятия будут проходить #5. Все летние программы проводятся на платной основе. Стоимость 1 академического часа (40 минут) #6 руб. Данный интенсив рассчитан на #7 часов. Суммарная стоимость составит #8 рублей. Скажите, вас устраивают расписание и стоимость?`; const FIELD_DEFS = [ { key: "1", label: "Имя отчество родителя" }, { key: "2", label: "ФИО педагога" }, { key: "3", label: "Название программы" }, { key: "4", label: "Фамилия Имя ученика" }, { key: "5", label: "Расписание занятий" }, { key: "6", label: "Стоимость 1 академ. часа" }, { key: "7", label: "Часов всего/в месяц" }, { key: "8", label: "Суммарная стоимость" }, ]; // ЗАГЛУШКА: при встраивании подставьте реальные данные заявки из БД const DATA = { 1: "Анна Ивановна", 2: "Петров Пётр Петрович", 3: "Робототехника для начинающих", 4: "Сидоров Иван", 5: "вторник, четверг 15:00–16:00", 6: "1500", 7: "32 / 8", 8: "48000", }; function renderTemplate(template, data) { return template.replace(/#(\d+)/g, (m, num) => { const v = data[num]; return v !== undefined && v !== "" ? String(v) : m; }); } // ---- состояние ---- let template = localStorage.getItem(STORAGE_KEY) || DEFAULT_TEMPLATE; let draft = template; let isEditing = false; let suggest = null; // { query, start, activeIndex } let toastTimer = null; // ---- элементы ---- const cardSub = document.getElementById("cardSub"); const headerActions = document.getElementById("headerActions"); const viewMode = document.getElementById("viewMode"); const editMode = document.getElementById("editMode"); const draftInput = document.getElementById("draftInput"); const autocompleteEl = document.getElementById("autocomplete"); const tagList = document.getElementById("tagList"); const resetBtn = document.getElementById("resetBtn"); const toastEl = document.getElementById("toast"); const copyIconSVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; const pencilSVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>'; const saveSVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>'; const xSVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'; // ---- рендер тегов в сайдбаре ---- function renderTagList() { tagList.innerHTML = ""; FIELD_DEFS.forEach((f) => { const btn = document.createElement("button"); btn.className = "tag-item"; btn.innerHTML = `<span class="tag-num">#${f.key}</span><span class="tag-label">${f.label}</span>`; btn.addEventListener("click", () => insertBlock(f.key)); tagList.appendChild(btn); }); } function renderHeaderActions() { if (!isEditing) { headerActions.innerHTML = `<button class="btn btn-dark" id="editBtn">${pencilSVG}Редактировать</button>`; document.getElementById("editBtn").addEventListener("click", startEdit); } else { headerActions.innerHTML = ` <div style="display:flex; gap:8px;"> <button class="btn btn-ghost" id="cancelBtn">${xSVG}Отмена</button> <button class="btn btn-dark" id="saveBtn">${saveSVG}Сохранить</button> </div>`; document.getElementById("cancelBtn").addEventListener("click", cancelEdit); document.getElementById("saveBtn").addEventListener("click", saveEdit); } } function renderView() { const filled = renderTemplate(template, DATA); viewMode.innerHTML = ` <button class="script-view" id="copyBtn"> <p class="script-text">${escapeHtml(filled)}</p> <span class="copy-icon">${copyIconSVG}</span> </button>`; document.getElementById("copyBtn").addEventListener("click", copyScript); } function escapeHtml(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } function render() { const isCustom = template !== DEFAULT_TEMPLATE; cardSub.classList.toggle("hidden", isEditing); cardSub.textContent = (isCustom ? "Изменённый шаблон · " : "") + "нажмите на текст, чтобы скопировать"; viewMode.classList.toggle("hidden", isEditing); editMode.classList.toggle("hidden", !isEditing); renderHeaderActions(); if (!isEditing) { renderView(); } else { draftInput.value = draft; resetBtn.classList.toggle("hidden", draft === DEFAULT_TEMPLATE); renderAutocomplete(); } } // ---- редактирование ---- function startEdit() { draft = template; isEditing = true; render(); draftInput.focus(); } function cancelEdit() { draft = template; suggest = null; isEditing = false; render(); } function saveEdit() { template = draft; if (draft === DEFAULT_TEMPLATE) { localStorage.removeItem(STORAGE_KEY); } else { localStorage.setItem(STORAGE_KEY, draft); } suggest = null; isEditing = false; render(); } resetBtn.addEventListener("click", () => { draft = DEFAULT_TEMPLATE; draftInput.value = draft; resetBtn.classList.add("hidden"); draftInput.focus(); }); function insertAt(text, start, end, block) { return { next: text.slice(0, start) + block + text.slice(end), caret: start + block.length }; } function insertBlock(key) { const el = draftInput; const block = "#" + key; const start = el.selectionStart ?? draft.length; const end = el.selectionEnd ?? draft.length; const scrollTop = el.scrollTop; const { next, caret } = insertAt(draft, start, end, block); draft = next; suggest = null; el.value = draft; requestAnimationFrame(() => { el.focus(); el.setSelectionRange(caret, caret); el.scrollTop = scrollTop; // не даём браузеру сбросить прокрутку }); resetBtn.classList.toggle("hidden", draft === DEFAULT_TEMPLATE); renderAutocomplete(); } draftInput.addEventListener("input", (e) => { draft = e.target.value; const pos = e.target.selectionStart; const before = draft.slice(0, pos); const match = before.match(/#([a-zA-Zа-яА-Я0-9]*)$/); suggest = match ? { query: match[1], start: pos - match[0].length, activeIndex: 0 } : null; resetBtn.classList.toggle("hidden", draft === DEFAULT_TEMPLATE); renderAutocomplete(); }); function currentSuggestions() { if (!suggest) return []; const q = suggest.query.toLowerCase(); return FIELD_DEFS.filter((f) => f.key.startsWith(q) || f.label.toLowerCase().includes(q)); } function applySuggestion(field) { const el = draftInput; const cursor = el.selectionStart ?? draft.length; const scrollTop = el.scrollTop; const { next, caret } = insertAt(draft, suggest.start, cursor, "#" + field.key); draft = next; suggest = null; el.value = draft; requestAnimationFrame(() => { el.focus(); el.setSelectionRange(caret, caret); el.scrollTop = scrollTop; }); resetBtn.classList.toggle("hidden", draft === DEFAULT_TEMPLATE); renderAutocomplete(); } function renderAutocomplete() { const items = currentSuggestions(); if (!suggest || items.length === 0) { autocompleteEl.classList.add("hidden"); autocompleteEl.innerHTML = ""; return; } autocompleteEl.classList.remove("hidden"); autocompleteEl.innerHTML = items .map( (f, i) => `<button type="button" class="autocomplete-item ${i === suggest.activeIndex ? "active" : ""}" data-key="${f.key}"> <span class="tag">#${f.key}</span>${f.label} </button>` ) .join(""); Array.from(autocompleteEl.querySelectorAll(".autocomplete-item")).forEach((btn) => { btn.addEventListener("mousedown", (e) => { e.preventDefault(); const key = btn.getAttribute("data-key"); applySuggestion(FIELD_DEFS.find((f) => f.key === key)); }); }); } draftInput.addEventListener("keydown", (e) => { const items = currentSuggestions(); if (!suggest || items.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); suggest.activeIndex = (suggest.activeIndex + 1) % items.length; renderAutocomplete(); } else if (e.key === "ArrowUp") { e.preventDefault(); suggest.activeIndex = (suggest.activeIndex - 1 + items.length) % items.length; renderAutocomplete(); } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); applySuggestion(items[suggest.activeIndex]); } else if (e.key === "Escape") { suggest = null; renderAutocomplete(); } }); draftInput.addEventListener("blur", () => { setTimeout(() => { suggest = null; renderAutocomplete(); }, 120); }); // ---- копирование ---- function showToast() { toastEl.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2000); } function legacyCopy(text) { const el = document.createElement("textarea"); el.value = text; el.style.position = "fixed"; el.style.left = "-9999px"; document.body.appendChild(el); el.focus(); el.select(); let ok = false; try { ok = document.execCommand("copy"); } catch (e) { ok = false; } document.body.removeChild(el); return ok; } async function copyScript() { const filled = renderTemplate(template, DATA); try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(filled); showToast(); return; } throw new Error("clipboard API недоступен"); } catch (e) { const ok = legacyCopy(filled); if (ok) showToast(); else console.error("Не удалось скопировать текст автоматически:", e); } } // ---- старт ---- renderTagList(); render(); })(); </script> </body> </html>