Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1,367 @@
/**
* Редактор серверной SQLite: дерево bind → таблицы, сетка с сохранением через /api/admin/db/*.
*/
function escHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function parseInputValue(raw, sampleOriginal) {
const t = raw.trim();
if (t === "" && (sampleOriginal === null || sampleOriginal === undefined)) return null;
if (t === "") return "";
if (/^-?\d+$/.test(t)) return Number(t);
if (/^-?\d+\.\d+$/.test(t)) return Number(t);
if (t === "true") return 1;
if (t === "false") return 0;
return raw;
}
function closeModal() {
const m = document.getElementById("adminSqliteModal");
if (m) m.hidden = true;
document.body.style.overflow = "";
}
let browseState = {
bind: "recipes",
table: null,
limit: 50,
offset: 0,
page: null,
};
/** Колбэк выбора таблицы (нужен для повторной отрисовки дерева). */
let dbTreePickCallback = null;
async function fetchJson(url, options) {
const r = await fetch(url, options);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(data.message || `HTTP ${r.status}`);
}
return data;
}
function renderTree(binds, preferredBind, onPickTable) {
dbTreePickCallback = onPickTable;
const root = document.getElementById("adminDbTree");
if (!root) return;
root.innerHTML = binds
.map((b) => {
const open = b.bind === preferredBind ? "is-open" : "";
const rows = (b.tables || [])
.map(
(t) =>
`<li class="wesp-db-tree-leaf"><button type="button" class="wesp-db-tree-table ant-btn ant-btn-sm" data-bind="${escHtml(b.bind)}" data-table="${escHtml(t.name)}">${escHtml(t.name)}</button></li>`,
)
.join("");
return `<li class="wesp-db-tree-node ${open}">
<button type="button" class="wesp-db-tree-caret" aria-expanded="${b.bind === preferredBind ? "true" : "false"}"><span class="wesp-db-tree-caret-icon">▶</span></button>
<span class="wesp-db-tree-folder">${escHtml(b.bind)}</span>
<ul class="wesp-db-tree-children">${rows}</ul>
</li>`;
})
.join("");
root.querySelectorAll(".wesp-db-tree-caret").forEach((btn) => {
btn.addEventListener("click", () => {
const li = btn.closest(".wesp-db-tree-node");
if (!li) return;
const open = li.classList.toggle("is-open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
});
root.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
btn.addEventListener("click", () => {
const bind = btn.getAttribute("data-bind");
const table = btn.getAttribute("data-table");
root.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
onPickTable(bind, table);
});
});
}
function renderGrid(data, setStatus) {
const meta = document.getElementById("adminDbGridMeta");
const wrap = document.getElementById("adminDbGridWrap");
const pager = document.getElementById("adminDbPager");
if (!meta || !wrap || !pager) return;
browseState.page = data;
const { columns, rows, total, has_rowid: hasRowid } = data;
meta.textContent = `${data.bind} / ${data.table} — строк ${total}, показано ${rows.length} (offset ${browseState.offset})`;
const colMetaByName = Object.fromEntries((columns || []).map((c) => [c.name, c]));
const displayCols = (data.column_names || []).filter((n) => n !== "_wesp_rowid");
let thead =
"<thead><tr>" +
(hasRowid ? "<th>rowid</th>" : "") +
displayCols.map((n) => `<th title="${escHtml(colMetaByName[n]?.type || "")}">${escHtml(n)}</th>`).join("") +
"<th class=\"wesp-db-grid-actions-col\">Действия</th></tr></thead>";
const body = rows
.map((row) => {
const rowKey = {};
if (hasRowid && row._wesp_rowid != null) rowKey._wesp_rowid = row._wesp_rowid;
(data.pk_names || []).forEach((pk) => {
if (pk in row) rowKey[pk] = row[pk];
});
const cells = displayCols
.map((name) => {
const cm = colMetaByName[name];
const val = row[name];
if (cm && !cm.editable) {
return `<td class="wesp-db-cell-readonly">(BLOB)</td>`;
}
const v = val == null ? "" : String(val);
return `<td><input type="text" class="ant-input wesp-db-cell" data-col="${escHtml(name)}" value="${escHtml(v)}"/></td>`;
})
.join("");
const ridCell = hasRowid
? `<td class="wesp-db-cell-readonly">${row._wesp_rowid != null ? escHtml(String(row._wesp_rowid)) : "—"}</td>`
: "";
const rowKeyEnc = encodeURIComponent(JSON.stringify(rowKey));
const rowJsonEnc = encodeURIComponent(JSON.stringify(row));
return `<tr data-row-key="${rowKeyEnc}" data-row-json="${rowJsonEnc}">
${ridCell}${cells}
<td class="wesp-db-row-actions">
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary wesp-db-row-save"><span>Сохранить</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger wesp-db-row-delete"><span>Удалить</span></button>
</td>
</tr>`;
})
.join("");
wrap.innerHTML = `<table class="ant-table wesp-db-grid-table">${thead}<tbody>${body}</tbody></table>`;
wrap.querySelectorAll(".wesp-db-row-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
const tr = btn.closest("tr");
if (!tr) return;
let rowKey;
try {
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
} catch (e) {
setStatus("Ошибка разбора строки", true);
return;
}
btn.disabled = true;
try {
const prev = await fetchJson("/api/admin/db/row/delete-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
}),
});
let msg = `Будет удалено строк: ${prev.total}\n\n${(prev.lines || []).join("\n")}`;
if (prev.truncated) msg += "\n\n… (в списке не все строки)";
msg += "\n\nУчитываются только связи, объявленные внешними ключами SQLite.\nПродолжить удаление?";
if (!window.confirm(msg)) {
setStatus("Удаление отменено");
return;
}
await fetchJson("/api/admin/db/row/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
}),
});
setStatus(`Удалено строк: ${prev.total}`);
await loadTablePage(setStatus);
} catch (e) {
setStatus(e.message, true, "Ошибка удаления");
} finally {
btn.disabled = false;
}
});
});
wrap.querySelectorAll(".wesp-db-row-save").forEach((btn) => {
btn.addEventListener("click", async () => {
const tr = btn.closest("tr");
if (!tr) return;
let rowKey;
let original;
try {
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
original = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-json") || "%7B%7D"));
} catch (e) {
setStatus("Ошибка разбора строки", true);
return;
}
const changes = {};
tr.querySelectorAll(".wesp-db-cell[data-col]").forEach((inp) => {
const col = inp.getAttribute("data-col");
if (!col || inp.readOnly) return;
const oldVal = Object.prototype.hasOwnProperty.call(original, col) ? original[col] : undefined;
const newVal = parseInputValue(inp.value, oldVal);
const same =
(oldVal == null && (newVal === "" || newVal === null)) ||
JSON.stringify(oldVal) === JSON.stringify(newVal === "" && oldVal == null ? null : newVal);
if (!same) {
changes[col] = newVal === "" && oldVal == null ? null : newVal;
}
});
if (!Object.keys(changes).length) {
setStatus("Нет изменений в строке");
return;
}
btn.disabled = true;
try {
await fetchJson("/api/admin/db/row", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
changes,
}),
});
setStatus("Строка сохранена на сервере");
await loadTablePage(setStatus);
} catch (e) {
setStatus(e.message, true, "Ошибка сохранения");
} finally {
btn.disabled = false;
}
});
});
const prev = browseState.offset > 0;
const next = browseState.offset + rows.length < total;
pager.innerHTML = `
<button type="button" class="ant-btn ant-btn-sm" id="adminDbPrev" ${prev ? "" : "disabled"}><span>Назад</span></button>
<button type="button" class="ant-btn ant-btn-sm" id="adminDbNext" ${next ? "" : "disabled"}><span>Вперёд</span></button>
`;
document.getElementById("adminDbPrev")?.addEventListener("click", async () => {
browseState.offset = Math.max(0, browseState.offset - browseState.limit);
await loadTablePage(setStatus);
});
document.getElementById("adminDbNext")?.addEventListener("click", async () => {
browseState.offset += browseState.limit;
await loadTablePage(setStatus);
});
}
async function loadTablePage(setStatus) {
const errEl = document.getElementById("adminSqliteError");
if (errEl) errEl.textContent = "";
if (!browseState.table) return;
const u = `/api/admin/db/table?bind=${encodeURIComponent(browseState.bind)}&table=${encodeURIComponent(browseState.table)}&limit=${browseState.limit}&offset=${browseState.offset}`;
const data = await fetchJson(u);
renderGrid(data, setStatus);
}
export async function refreshAdminDbTree(setStatus) {
const modal = document.getElementById("adminSqliteModal");
const errEl = document.getElementById("adminSqliteError");
if (!modal || modal.hidden) return;
try {
const tree = await fetchJson("/api/admin/db/tree");
const pref = browseState.bind || "recipes";
const cb = dbTreePickCallback;
if (typeof cb !== "function") {
if (typeof setStatus === "function") setStatus("Подождите, дерево ещё загружается");
return;
}
renderTree(tree.binds || [], pref, cb);
document.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
if (
btn.getAttribute("data-bind") === browseState.bind &&
btn.getAttribute("data-table") === browseState.table
) {
document.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
}
});
if (typeof setStatus === "function") setStatus("Каталог таблиц обновлён");
} catch (e) {
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export async function refreshAdminDbGrid(setStatus) {
const errEl = document.getElementById("adminSqliteError");
if (!browseState.table) {
if (typeof setStatus === "function") setStatus("Сначала выберите таблицу в дереве");
return;
}
if (errEl) errEl.textContent = "";
try {
await loadTablePage(setStatus);
if (typeof setStatus === "function") setStatus("Таблица обновлена с сервера");
} catch (e) {
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export async function openAdminDbBrowser(options = {}) {
const { preferredBind = "recipes", setStatus } = options;
const modal = document.getElementById("adminSqliteModal");
const errEl = document.getElementById("adminSqliteError");
if (!modal || !errEl) {
if (typeof setStatus === "function") setStatus("Нет разметки редактора БД", true);
return;
}
browseState = {
bind: preferredBind,
table: null,
limit: 50,
offset: 0,
page: null,
};
modal.hidden = false;
document.body.style.overflow = "hidden";
errEl.textContent = "";
document.getElementById("adminDbGridMeta").textContent = "";
document.getElementById("adminDbGridWrap").innerHTML =
'<p class="wesp-db-browser__muted">Выберите таблицу в дереве слева.</p>';
document.getElementById("adminDbPager").innerHTML = "";
try {
const tree = await fetchJson("/api/admin/db/tree");
renderTree(tree.binds || [], preferredBind, async (bind, table) => {
browseState.bind = bind;
browseState.table = table;
browseState.offset = 0;
errEl.textContent = "";
try {
await loadTablePage(setStatus);
} catch (e) {
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
});
if (typeof setStatus === "function") setStatus("Дерево БД загружено");
} catch (e) {
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export function wireAdminDbBrowserModal() {
document.getElementById("adminSqliteModalClose")?.addEventListener("click", closeModal);
document.getElementById("adminSqliteModalMask")?.addEventListener("click", closeModal);
}