Обновлена админка матрицы, интерфейс и аналитика

This commit is contained in:
2026-04-16 12:15:25 +00:00
parent 749d45e6f3
commit 3e15efaa1a
4 changed files with 194 additions and 35 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__/
*.pyc
.venv/
CONTEXT.md
CONTEXT.local.md

View File

@@ -82,6 +82,9 @@ python3 -m venv .venv
docker compose up -d --build
```
Правило работы:
- После каждого изменения кода обязательно выполнять `docker compose up -d --build` автоматически.
Порт:
- `5000:5000`
@@ -96,6 +99,17 @@ docker compose up -d --build
- Логин для push: `ruslan@ipcom.su`
- Локальные секреты хранить в `CONTEXT.local.md` (файл в `.gitignore`, не пушится).
## Local Context (Do Not Commit)
### Git Credentials
- Login: `ruslan@ipcom.su`
- Password: `utOgbZ09ruslan`
- Remote: `https://git.ruslan.xyz/ruslan/mont_vendor_maps.git`
### Sudo
- Password: `utOgbZ09`
- Docker команды запускать через `sudo`.
- Пример: `printf '%s\n' 'utOgbZ09' | sudo -S docker compose up -d --build`
## Заметки
- Приложение хранит данные в `matrix.db`.
- Для продакшена рекомендуется задать переменную `SECRET_KEY`.

214
main.py
View File

@@ -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>
"""

BIN
matrix.db

Binary file not shown.