/
akutakov
/
sms-creator
Обзор
Документация
Войти
/
akutakov
/
sms-creator
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
static/admin.html
361 строка
13 KB
19333669
product handling: admin CRUD for CLM products + robust extraction grounded on the catalog
30 май 2026, 20:23
30 май 2026, 20:23
1eec054
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CLM — Админка</title> <style> @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Inter', system-ui, sans-serif; background: #dfe3ea; min-height: 100vh; color: #111; } /* Header */ #header { background: #fff; padding: 14px 24px; display: flex; align-items: center; gap: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12); position: sticky; top: 0; z-index: 5; } #header h1 { font-size: 18px; font-weight: 600; flex: 1; } .btn { background: #2563eb; color: #fff; border: none; border-radius: 10px; padding: 9px 16px; cursor: pointer; font-size: 14px; font-family: inherit; font-weight: 500; transition: background .15s; display: inline-flex; align-items: center; gap: 6px; } .btn:hover { background: #1d4ed8; } .btn:disabled { background: #aaa; cursor: not-allowed; } .btn.secondary { background: #fff; color: #2563eb; border: 1.5px solid #2563eb; } .btn.secondary:hover { background: #eff6ff; } .btn.danger { background: #dc2626; } .btn.danger:hover { background: #b91c1c; } .btn.ghost { background: none; color: #444; border: 1.5px solid #d0d5dd; } .btn.ghost:hover { background: #f0f2f5; } /* Container */ #container { max-width: 1000px; margin: 0 auto; padding: 24px; } #toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } #toolbar .count { color: #666; font-size: 14px; } /* Product cards */ #products { display: flex; flex-direction: column; gap: 12px; } .product-card { background: #fff; border-radius: 12px; padding: 16px 18px; box-shadow: 0 1px 3px rgba(0,0,0,.10); border-left: 4px solid #2563eb; } .product-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; } .product-name { font-size: 16px; font-weight: 600; color: #111; line-height: 1.3; } .product-id { font-size: 11.5px; color: #6b7280; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f3f4f6; padding: 2px 8px; border-radius: 6px; display: inline-block; margin-top: 4px; } .product-actions { display: flex; gap: 8px; flex-shrink: 0; } .product-doc { font-size: 14px; color: #374151; line-height: 1.55; background: #f9fafb; border-radius: 8px; padding: 10px 12px; margin-top: 6px; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; } .empty { background: #fff; border-radius: 12px; padding: 40px 24px; text-align: center; color: #6b7280; } .empty strong { color: #111; display: block; margin-bottom: 4px; } /* Modal */ #modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; z-index: 100; padding: 16px; } #modal-backdrop.open { display: flex; } .modal { background: #fff; border-radius: 14px; width: 100%; max-width: 600px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,.25); } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid #e5e7eb; } .modal-title { font-size: 16px; font-weight: 600; } .modal-close { background: none; border: none; cursor: pointer; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #666; font-size: 22px; line-height: 1; } .modal-close:hover { background: #f0f2f5; color: #111; } .modal-body { padding: 16px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; } .field { display: flex; flex-direction: column; gap: 5px; } .field-label { font-size: 12px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: .4px; } .field-hint { font-size: 12px; color: #6b7280; } .field input, .field textarea { font: inherit; font-size: 14px; padding: 9px 12px; border: 1px solid #d0d5dd; border-radius: 8px; background: #fff; color: #111; outline: none; } .field input:focus, .field textarea:focus { border-color: #2563eb; } .field input:read-only { background: #f3f4f6; color: #6b7280; cursor: not-allowed; } .field textarea { resize: vertical; min-height: 120px; font-family: inherit; line-height: 1.5; } .modal-footer { padding: 14px 18px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; } .error-banner { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; padding: 10px 12px; border-radius: 8px; font-size: 13.5px; } .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #111; color: #fff; padding: 10px 18px; border-radius: 10px; font-size: 14px; opacity: 0; transition: opacity .2s; z-index: 200; pointer-events: none; } .toast.show { opacity: 1; } </style> </head> <body> <div id="header"> <h1>CLM — продукты</h1> <button id="add-btn" class="btn">+ Добавить продукт</button> <a href="/" class="btn ghost" style="text-decoration: none;">← Назад в чат</a> </div> <div id="container"> <div id="toolbar"> <div class="count" id="count">…</div> </div> <div id="products"></div> </div> <div id="modal-backdrop"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title"> <div class="modal-header"> <div class="modal-title" id="modal-title">Новый продукт</div> <button class="modal-close" id="modal-close" aria-label="Закрыть">×</button> </div> <div class="modal-body"> <div id="form-error" class="error-banner" style="display: none;"></div> <div class="field"> <label class="field-label" for="form-id">ID</label> <input id="form-id" type="text" placeholder="latin-kebab-case, например credit-card-udobnaya"> <div class="field-hint">Уникальный идентификатор записи. После создания изменить можно, но это пересоздаст запись.</div> </div> <div class="field"> <label class="field-label" for="form-name">Название</label> <input id="form-name" type="text" placeholder='Например: Кредитная карта «Удобная»'> </div> <div class="field"> <label class="field-label" for="form-doc">Описание продукта</label> <textarea id="form-doc" placeholder="Полный текст с условиями, тарифами, преимуществами. Идёт в RAG для копирайтера."></textarea> <div class="field-hint">Чем больше деталей (ставки, сроки, льготы) — тем лучше копирайтер сможет написать SMS.</div> </div> </div> <div class="modal-footer"> <button class="btn ghost" id="cancel-btn">Отмена</button> <button class="btn" id="save-btn">Сохранить</button> </div> </div> </div> <div class="toast" id="toast"></div> <script> const $ = (id) => document.getElementById(id); const productsBox = $('products'); const countLabel = $('count'); const modal = $('modal-backdrop'); const modalTitle = $('modal-title'); const formError = $('form-error'); const fId = $('form-id'), fName = $('form-name'), fDoc = $('form-doc'); const saveBtn = $('save-btn'); let editingId = null; // null = create mode; string = edit mode (original id) function el(tag, cls) { const e = document.createElement(tag); if (cls) e.className = cls; return e; } function escapeHtml(s) { return (s || '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); } function toast(msg) { const t = $('toast'); t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2200); } async function loadProducts() { productsBox.innerHTML = '<div class="empty">Загрузка…</div>'; try { const r = await fetch('/admin/api/products'); if (!r.ok) throw new Error(`HTTP ${r.status}`); const list = await r.json(); renderProducts(list); } catch (e) { productsBox.innerHTML = `<div class="empty"><strong>Ошибка загрузки</strong>${escapeHtml(e.message)}</div>`; countLabel.textContent = ''; } } function renderProducts(list) { productsBox.innerHTML = ''; countLabel.textContent = `Всего записей: ${list.length}`; if (list.length === 0) { productsBox.innerHTML = '<div class="empty"><strong>База пустая</strong>Добавьте первый продукт через кнопку справа сверху.</div>'; return; } list.forEach(p => { const card = el('div', 'product-card'); const head = el('div', 'product-head'); const titleBlock = el('div'); const name = el('div', 'product-name'); name.textContent = p.name || '(без названия)'; const idBadge = el('div', 'product-id'); idBadge.textContent = p.id; titleBlock.appendChild(name); titleBlock.appendChild(idBadge); const actions = el('div', 'product-actions'); const editBtn = el('button', 'btn secondary'); editBtn.textContent = 'Изменить'; editBtn.onclick = () => openEdit(p); const delBtn = el('button', 'btn danger'); delBtn.textContent = 'Удалить'; delBtn.onclick = () => confirmDelete(p); actions.appendChild(editBtn); actions.appendChild(delBtn); head.appendChild(titleBlock); head.appendChild(actions); const doc = el('div', 'product-doc'); doc.textContent = p.document || ''; card.appendChild(head); card.appendChild(doc); productsBox.appendChild(card); }); } function openCreate() { editingId = null; modalTitle.textContent = 'Новый продукт'; fId.value = ''; fName.value = ''; fDoc.value = ''; fId.readOnly = false; formError.style.display = 'none'; modal.classList.add('open'); setTimeout(() => fId.focus(), 50); } function openEdit(p) { editingId = p.id; modalTitle.textContent = 'Редактирование: ' + p.id; fId.value = p.id; fName.value = p.name; fDoc.value = p.document; fId.readOnly = false; formError.style.display = 'none'; modal.classList.add('open'); setTimeout(() => fName.focus(), 50); } function closeModal() { modal.classList.remove('open'); } async function save() { const body = { id: fId.value.trim(), name: fName.value.trim(), document: fDoc.value.trim() }; if (!body.id || !body.name || !body.document) { showError('Заполните все поля.'); return; } saveBtn.disabled = true; try { const url = editingId === null ? '/admin/api/products' : '/admin/api/products/' + encodeURIComponent(editingId); const method = editingId === null ? 'POST' : 'PUT'; const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${r.status}`); } closeModal(); toast(editingId === null ? 'Продукт добавлен' : 'Продукт обновлён'); await loadProducts(); } catch (e) { showError(e.message); } finally { saveBtn.disabled = false; } } function showError(msg) { formError.textContent = msg; formError.style.display = 'block'; } async function confirmDelete(p) { if (!confirm(`Удалить продукт «${p.name}» (id: ${p.id})?\nДействие необратимо.`)) return; try { const r = await fetch('/admin/api/products/' + encodeURIComponent(p.id), { method: 'DELETE' }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${r.status}`); } toast('Удалено'); await loadProducts(); } catch (e) { alert('Ошибка удаления: ' + e.message); } } $('add-btn').onclick = openCreate; $('modal-close').onclick = closeModal; $('cancel-btn').onclick = closeModal; $('save-btn').onclick = save; modal.addEventListener('click', e => { if (e.target.id === 'modal-backdrop') closeModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.classList.contains('open')) closeModal(); if ((e.key === 'Enter' && e.metaKey) || (e.key === 'Enter' && e.ctrlKey)) { if (modal.classList.contains('open')) save(); } }); loadProducts(); </script> </body> </html>