@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Редактор серверной SQLite: дерево bind → таблицы, сетка с сохранением через /api/admin/db/*.
|
||||
*/
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user