Обновлена админка матрицы, интерфейс и аналитика
This commit is contained in:
214
main.py
214
main.py
@@ -288,6 +288,7 @@ def init_db() -> None:
|
||||
FOREIGN KEY(product_id) REFERENCES ib_products(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
"""
|
||||
)
|
||||
try:
|
||||
@@ -321,7 +322,6 @@ def init_db() -> None:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_matrix() -> dict:
|
||||
conn = get_db()
|
||||
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM vendors ORDER BY lower(name)")]
|
||||
@@ -890,7 +890,8 @@ INDEX_HTML = """
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Z-карта вендоров</title>
|
||||
<title>Корзина МОНТ</title>
|
||||
<link rel="icon" type="image/png" href="/assets/mont-logo" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Manrope:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
@@ -1226,11 +1227,11 @@ INDEX_HTML = """
|
||||
}
|
||||
.credit .name {
|
||||
font-family: Caveat, cursive;
|
||||
font-size: 28px;
|
||||
font-size: 14px;
|
||||
color: #1c3f7c;
|
||||
}
|
||||
.credit a {
|
||||
font-size: 13px;
|
||||
font-size: 7px;
|
||||
color: #2f5fae;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
@@ -1241,8 +1242,8 @@ INDEX_HTML = """
|
||||
.board { grid-template-columns: 1fr; }
|
||||
.hero { padding: 20px; }
|
||||
.credit { right: 8px; bottom: 6px; }
|
||||
.credit .name { font-size: 10px; }
|
||||
.credit a { font-size: 8px; }
|
||||
.credit .name { font-size: 8px; }
|
||||
.credit a { font-size: 6px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -1298,6 +1299,20 @@ INDEX_HTML = """
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){
|
||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108577107', 'ym');
|
||||
|
||||
ym(108577107, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/108577107" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
|
||||
@@ -1683,8 +1698,23 @@ ADMIN_HTML = """
|
||||
border:1px solid #d4e3ff;
|
||||
border-radius:12px;
|
||||
padding:12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
form.inline { display:flex; gap:8px; flex-wrap: wrap; }
|
||||
.inline > * { min-width: 0; }
|
||||
.inline-product {
|
||||
display:grid;
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1fr) minmax(180px, 1.2fr) auto;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
}
|
||||
.inline-product button { white-space: nowrap; }
|
||||
@media (max-width: 1300px) {
|
||||
.inline-product { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.inline-product { grid-template-columns: 1fr; }
|
||||
}
|
||||
form.inline { display:flex; gap:8px; }
|
||||
input[type="text"] { flex:1; padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px; }
|
||||
button { border:0; border-radius:9px; padding:9px 11px; cursor:pointer; font-weight:700; }
|
||||
.pri { background:#1f4ea3; color:#fff; }
|
||||
@@ -1692,14 +1722,25 @@ ADMIN_HTML = """
|
||||
.danger { background:#ffefef; color:#8e1d1d; }
|
||||
|
||||
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
|
||||
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; }
|
||||
|
||||
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; overflow:auto; }
|
||||
table { border-collapse: collapse; min-width: 1200px; }
|
||||
.list-box { max-height: 430px; overflow-y: auto; padding-right: 4px; }
|
||||
.list-box::-webkit-scrollbar { width:12px; }
|
||||
.list-box::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
|
||||
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; min-height: 36px; }
|
||||
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; }
|
||||
.matrix-scroll { overflow:auto; max-height:72vh; border:1px solid #dce7ff; border-radius:10px; }
|
||||
.matrix-scroll::-webkit-scrollbar,
|
||||
.matrix-h-scroll::-webkit-scrollbar { height:24px; width:14px; }
|
||||
.matrix-scroll::-webkit-scrollbar-thumb,
|
||||
.matrix-h-scroll::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
|
||||
.matrix-h-scroll { overflow-x:auto; overflow-y:hidden; height:28px; margin:8px 0 10px; border:1px solid #dce7ff; border-radius:10px; background:#f6f9ff; }
|
||||
.matrix-h-scroll-inner { height:1px; }
|
||||
table { border-collapse: collapse; min-width: 1200px; width:max-content; }
|
||||
th, td { border:1px solid var(--line); padding:6px; font-size:12px; text-align:center; }
|
||||
th { position: sticky; top: 0; background:#eaf2ff; z-index: 2; }
|
||||
th:first-child, td:first-child { position: sticky; left:0; background:#eef5ff; z-index: 1; text-align:left; min-width: 280px; }
|
||||
th:first-child { z-index: 3; }
|
||||
td input { transform: scale(1.05); }
|
||||
.matrix-tip { margin:0 0 6px; font-size:12px; color:#37507d; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="{% if scope == 'ib' %}ib{% endif %}">
|
||||
@@ -1743,7 +1784,7 @@ ADMIN_HTML = """
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Добавить продукт</h3>
|
||||
<form method="post" class="inline">
|
||||
<form method="post" class="inline inline-product">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="add_product" />
|
||||
<select name="vendor_id" required style="padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px;">
|
||||
@@ -1761,6 +1802,7 @@ ADMIN_HTML = """
|
||||
<section class="lists">
|
||||
<div class="box">
|
||||
<h3>Удалить вендора</h3>
|
||||
<div class="list-box">
|
||||
{% for v in vendors %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
@@ -1770,9 +1812,11 @@ ADMIN_HTML = """
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Удалить категорию</h3>
|
||||
<div class="list-box">
|
||||
{% for c in categories %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
@@ -1782,9 +1826,11 @@ ADMIN_HTML = """
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Удалить продукт</h3>
|
||||
<div class="list-box">
|
||||
{% for p in products %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
@@ -1797,39 +1843,137 @@ ADMIN_HTML = """
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="matrix-wrap">
|
||||
<form method="post">
|
||||
<p class="matrix-tip">Прокрутка: колесо/тачпад вниз-вверх внутри таблицы, полосой ниже - влево-вправо.</p>
|
||||
<form method="post" id="matrixForm">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="save_matrix" />
|
||||
<button class="pri" type="submit" style="margin-bottom:10px;">Сохранить матрицу продуктов</button>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Вендор / Продукт</th>
|
||||
{% for c in categories %}
|
||||
<th>{{ c.name }}</th>
|
||||
<div id="matrixHScroll" class="matrix-h-scroll"><div id="matrixHScrollInner" class="matrix-h-scroll-inner"></div></div>
|
||||
<div id="matrixScroll" class="matrix-scroll">
|
||||
<table id="matrixTable">
|
||||
<tr>
|
||||
<th>Вендор / Продукт</th>
|
||||
{% for c in categories %}
|
||||
<th>{{ c.name }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for p in products %}
|
||||
<tr>
|
||||
<td><strong>{{ p.vendor_name }}</strong><br/>{{ p.name }}</td>
|
||||
{% for c in categories %}
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="pc_{{ p.id }}_{{ c.id }}"
|
||||
{% if (p.id, c.id) in links %}checked{% endif %}
|
||||
/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for p in products %}
|
||||
<tr>
|
||||
<td><strong>{{ p.vendor_name }}</strong><br/>{{ p.name }}</td>
|
||||
{% for c in categories %}
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="pc_{{ p.id }}_{{ c.id }}"
|
||||
{% if (p.id, c.id) in links %}checked{% endif %}
|
||||
/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
const matrixForm = document.getElementById("matrixForm");
|
||||
const matrixScroll = document.getElementById("matrixScroll");
|
||||
const matrixTable = document.getElementById("matrixTable");
|
||||
const topScroll = document.getElementById("matrixHScroll");
|
||||
const topScrollInner = document.getElementById("matrixHScrollInner");
|
||||
if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return;
|
||||
|
||||
let isDirty = false;
|
||||
let syncing = false;
|
||||
let saveTimer = null;
|
||||
let saveInFlight = false;
|
||||
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function updateTopScrollWidth() {
|
||||
topScrollInner.style.width = matrixTable.scrollWidth + "px";
|
||||
}
|
||||
|
||||
function syncScrollFromTop() {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
matrixScroll.scrollLeft = topScroll.scrollLeft;
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
function syncScrollFromMatrix() {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
topScroll.scrollLeft = matrixScroll.scrollLeft;
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
async function autoSaveMatrix() {
|
||||
if (saveInFlight) return;
|
||||
saveInFlight = true;
|
||||
try {
|
||||
const formData = new FormData(matrixForm);
|
||||
const response = await fetch(window.location.href, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) throw new Error("save failed");
|
||||
isDirty = false;
|
||||
} catch (error) {
|
||||
} finally {
|
||||
saveInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
matrixForm.addEventListener("change", (event) => {
|
||||
if (!(event.target && event.target.matches('input[type="checkbox"]'))) return;
|
||||
markDirty();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(autoSaveMatrix, 250);
|
||||
});
|
||||
|
||||
matrixForm.addEventListener("submit", () => {
|
||||
isDirty = false;
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!isDirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const anchor = event.target.closest("a");
|
||||
if (!anchor || !isDirty) return;
|
||||
const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить.");
|
||||
if (!ok) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!form || form === matrixForm || !isDirty) return;
|
||||
const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить.");
|
||||
if (!ok) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
topScroll.addEventListener("scroll", syncScrollFromTop);
|
||||
matrixScroll.addEventListener("scroll", syncScrollFromMatrix);
|
||||
window.addEventListener("resize", updateTopScrollWidth);
|
||||
updateTopScrollWidth();
|
||||
syncScrollFromMatrix();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user