Обновлена админка матрицы, интерфейс и аналитика
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
CONTEXT.md
|
||||||
CONTEXT.local.md
|
CONTEXT.local.md
|
||||||
|
|||||||
14
CONTEXT.md
14
CONTEXT.md
@@ -82,6 +82,9 @@ python3 -m venv .venv
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Правило работы:
|
||||||
|
- После каждого изменения кода обязательно выполнять `docker compose up -d --build` автоматически.
|
||||||
|
|
||||||
Порт:
|
Порт:
|
||||||
- `5000:5000`
|
- `5000:5000`
|
||||||
|
|
||||||
@@ -96,6 +99,17 @@ docker compose up -d --build
|
|||||||
- Логин для push: `ruslan@ipcom.su`
|
- Логин для push: `ruslan@ipcom.su`
|
||||||
- Локальные секреты хранить в `CONTEXT.local.md` (файл в `.gitignore`, не пушится).
|
- Локальные секреты хранить в `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`.
|
- Приложение хранит данные в `matrix.db`.
|
||||||
- Для продакшена рекомендуется задать переменную `SECRET_KEY`.
|
- Для продакшена рекомендуется задать переменную `SECRET_KEY`.
|
||||||
|
|||||||
174
main.py
174
main.py
@@ -288,6 +288,7 @@ def init_db() -> None:
|
|||||||
FOREIGN KEY(product_id) REFERENCES ib_products(id) ON DELETE CASCADE,
|
FOREIGN KEY(product_id) REFERENCES ib_products(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
|
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -321,7 +322,6 @@ def init_db() -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def fetch_matrix() -> dict:
|
def fetch_matrix() -> dict:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM vendors ORDER BY lower(name)")]
|
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM vendors ORDER BY lower(name)")]
|
||||||
@@ -890,7 +890,8 @@ INDEX_HTML = """
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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 {
|
.credit .name {
|
||||||
font-family: Caveat, cursive;
|
font-family: Caveat, cursive;
|
||||||
font-size: 28px;
|
font-size: 14px;
|
||||||
color: #1c3f7c;
|
color: #1c3f7c;
|
||||||
}
|
}
|
||||||
.credit a {
|
.credit a {
|
||||||
font-size: 13px;
|
font-size: 7px;
|
||||||
color: #2f5fae;
|
color: #2f5fae;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1241,8 +1242,8 @@ INDEX_HTML = """
|
|||||||
.board { grid-template-columns: 1fr; }
|
.board { grid-template-columns: 1fr; }
|
||||||
.hero { padding: 20px; }
|
.hero { padding: 20px; }
|
||||||
.credit { right: 8px; bottom: 6px; }
|
.credit { right: 8px; bottom: 6px; }
|
||||||
.credit .name { font-size: 10px; }
|
.credit .name { font-size: 8px; }
|
||||||
.credit a { font-size: 8px; }
|
.credit a { font-size: 6px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -1298,6 +1299,20 @@ INDEX_HTML = """
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
<script>
|
||||||
const state = {
|
const state = {
|
||||||
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
|
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
|
||||||
@@ -1683,8 +1698,23 @@ ADMIN_HTML = """
|
|||||||
border:1px solid #d4e3ff;
|
border:1px solid #d4e3ff;
|
||||||
border-radius:12px;
|
border-radius:12px;
|
||||||
padding: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; }
|
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; }
|
button { border:0; border-radius:9px; padding:9px 11px; cursor:pointer; font-weight:700; }
|
||||||
.pri { background:#1f4ea3; color:#fff; }
|
.pri { background:#1f4ea3; color:#fff; }
|
||||||
@@ -1692,14 +1722,25 @@ ADMIN_HTML = """
|
|||||||
.danger { background:#ffefef; color:#8e1d1d; }
|
.danger { background:#ffefef; color:#8e1d1d; }
|
||||||
|
|
||||||
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
|
.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; }
|
.list-box { max-height: 430px; overflow-y: auto; padding-right: 4px; }
|
||||||
|
.list-box::-webkit-scrollbar { width:12px; }
|
||||||
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; overflow:auto; }
|
.list-box::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
|
||||||
table { border-collapse: collapse; min-width: 1200px; }
|
.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, 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 { 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, 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); }
|
td input { transform: scale(1.05); }
|
||||||
|
.matrix-tip { margin:0 0 6px; font-size:12px; color:#37507d; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="{% if scope == 'ib' %}ib{% endif %}">
|
<body class="{% if scope == 'ib' %}ib{% endif %}">
|
||||||
@@ -1743,7 +1784,7 @@ ADMIN_HTML = """
|
|||||||
</div>
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Добавить продукт</h3>
|
<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="scope" value="{{ scope }}" />
|
||||||
<input type="hidden" name="action" value="add_product" />
|
<input type="hidden" name="action" value="add_product" />
|
||||||
<select name="vendor_id" required style="padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px;">
|
<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">
|
<section class="lists">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Удалить вендора</h3>
|
<h3>Удалить вендора</h3>
|
||||||
|
<div class="list-box">
|
||||||
{% for v in vendors %}
|
{% for v in vendors %}
|
||||||
<form class="list-item" method="post">
|
<form class="list-item" method="post">
|
||||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||||
@@ -1771,8 +1813,10 @@ ADMIN_HTML = """
|
|||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Удалить категорию</h3>
|
<h3>Удалить категорию</h3>
|
||||||
|
<div class="list-box">
|
||||||
{% for c in categories %}
|
{% for c in categories %}
|
||||||
<form class="list-item" method="post">
|
<form class="list-item" method="post">
|
||||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||||
@@ -1783,8 +1827,10 @@ ADMIN_HTML = """
|
|||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Удалить продукт</h3>
|
<h3>Удалить продукт</h3>
|
||||||
|
<div class="list-box">
|
||||||
{% for p in products %}
|
{% for p in products %}
|
||||||
<form class="list-item" method="post">
|
<form class="list-item" method="post">
|
||||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||||
@@ -1798,14 +1844,17 @@ ADMIN_HTML = """
|
|||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="matrix-wrap">
|
<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="scope" value="{{ scope }}" />
|
||||||
<input type="hidden" name="action" value="save_matrix" />
|
<input type="hidden" name="action" value="save_matrix" />
|
||||||
<button class="pri" type="submit" style="margin-bottom:10px;">Сохранить матрицу продуктов</button>
|
<div id="matrixHScroll" class="matrix-h-scroll"><div id="matrixHScrollInner" class="matrix-h-scroll-inner"></div></div>
|
||||||
<table>
|
<div id="matrixScroll" class="matrix-scroll">
|
||||||
|
<table id="matrixTable">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Вендор / Продукт</th>
|
<th>Вендор / Продукт</th>
|
||||||
{% for c in categories %}
|
{% for c in categories %}
|
||||||
@@ -1827,9 +1876,104 @@ ADMIN_HTML = """
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user