add admin data mutations and load check
Some checks failed
ci / test (pull_request) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-18 18:37:19 +09:00
parent 59bc6ebd4f
commit 8982299e71
9 changed files with 650 additions and 44 deletions

View File

@@ -206,6 +206,7 @@
<button type="button" class="ghost-btn" id="exportCsvBtn">CSV export</button>
</div>
</form>
<div id="sourceHint" class="admin-source-hint"></div>
<div id="dataResult" class="admin-table-wrap"></div>
</section>

View File

@@ -3,6 +3,7 @@ const AdminPage = (() => {
const state = {
active: "dashboard",
sources: [],
sourcesByName: {},
sorts: [],
lastDataPayload: null,
};
@@ -61,16 +62,97 @@ const AdminPage = (() => {
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
}
function renderTable(root, rows, preferredColumns = []) {
function sourceConfig(source) {
return source ? state.sourcesByName[source] : null;
}
function editableValues(row, source) {
const config = sourceConfig(source);
const fields = config?.editable || [];
return fields.reduce((payload, field) => {
if (Object.prototype.hasOwnProperty.call(row, field)) payload[field] = row[field];
return payload;
}, {});
}
function renderSourceHint(source) {
const hint = qs("#sourceHint");
if (!hint) return;
const config = sourceConfig(source);
if (!config) {
hint.textContent = "";
return;
}
const editable = config.editable?.length ? config.editable.join(", ") : "нет";
const deleteMode = config.deletable ? config.delete_mode : "нет";
hint.textContent = `Редактируемые поля: ${editable}. Удаление: ${deleteMode}. Все изменения требуют reason и пишутся в Audit Log.`;
}
async function mutateRow(action, source, row) {
const config = sourceConfig(source);
if (!config || !row?.id) return;
if (action === "edit") {
const draft = editableValues(row, source);
const raw = window.prompt("JSON с изменяемыми полями", JSON.stringify(draft, null, 2));
if (!raw) return;
let values;
try {
values = JSON.parse(raw);
} catch {
toast("Некорректный JSON", "error");
return;
}
const reason = window.prompt("Причина изменения") || "";
if (!reason.trim()) return;
await api(`/admin/data/${source}/${row.id}`, {
method: "PATCH",
body: JSON.stringify({ values, reason }),
});
toast("Запись обновлена");
} else {
const reason = window.prompt(`Причина удаления ${source} #${row.id}`) || "";
if (!reason.trim()) return;
if (!window.confirm(`Удалить ${source} #${row.id}?`)) return;
await api(`/admin/data/${source}/${row.id}`, {
method: "DELETE",
body: JSON.stringify({ reason }),
});
toast(config.delete_mode === "hard" ? "Запись удалена" : "Запись скрыта/отключена");
}
await loadActiveSection();
if (state.active === "data" && state.lastDataPayload) {
await submitDataQuery();
}
}
function bindTableActions(root, source, rows) {
root.querySelectorAll("[data-admin-row-action]").forEach((button) => {
button.addEventListener("click", async () => {
const row = rows[Number(button.dataset.rowIndex)];
try {
await mutateRow(button.dataset.adminRowAction, source, row);
} catch (error) {
toast(error.message || "Ошибка", "error");
}
});
});
}
function renderTable(root, rows, preferredColumns = [], source = null) {
if (!rows?.length) {
renderEmpty(root);
return;
}
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]);
const config = sourceConfig(source);
const hasActions = Boolean(config && rows.some((row) => row.id) && (config.editable?.length || config.deletable));
root.innerHTML = `
<table class="admin-table">
<thead>
<tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr>
<tr>
${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}
${hasActions ? "<th class=\"admin-actions-head\">Действия</th>" : ""}
</tr>
</thead>
<tbody>
${rows
@@ -78,6 +160,22 @@ const AdminPage = (() => {
(row) => `
<tr>
${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")}
${
hasActions
? `<td class="admin-action-cell">
${
config.editable?.length
? `<button type="button" class="ghost-btn compact-btn" data-admin-row-action="edit" data-row-index="${rows.indexOf(row)}">Edit</button>`
: ""
}
${
config.deletable
? `<button type="button" class="danger-btn compact-btn" data-admin-row-action="delete" data-row-index="${rows.indexOf(row)}">Delete</button>`
: ""
}
</td>`
: ""
}
</tr>
`,
)
@@ -85,6 +183,7 @@ const AdminPage = (() => {
</tbody>
</table>
`;
if (hasActions) bindTableActions(root, source, rows);
}
function badge(value) {
@@ -174,7 +273,7 @@ const AdminPage = (() => {
const query = new URLSearchParams();
if (search) query.set("search", search);
const data = await api(`/admin/users?${query.toString()}`);
renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"]);
renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"], "users");
}
async function loadSto(filters = {}) {
@@ -183,7 +282,7 @@ const AdminPage = (() => {
if (value) query.set(key, value);
});
const data = await api(`/admin/sto?${query.toString()}`);
renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"]);
renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"], "sto_profiles");
}
async function loadApplications() {
@@ -233,7 +332,7 @@ const AdminPage = (() => {
method: "POST",
body: JSON.stringify({ source, limit: 100 }),
});
renderTable(root, data.rows, columns);
renderTable(root, data.rows, columns, source);
} catch (error) {
renderError(root, error);
}
@@ -258,6 +357,7 @@ const AdminPage = (() => {
async function submitDataQuery(format = null) {
const payload = cleanPayload(formData(qs("#dataForm")));
state.lastDataPayload = payload;
renderSourceHint(payload.source);
if (format) {
const result = await api("/admin/data/export", {
method: "POST",
@@ -271,7 +371,7 @@ const AdminPage = (() => {
method: "POST",
body: JSON.stringify(payload),
});
renderTable(qs("#dataResult"), data.rows);
renderTable(qs("#dataResult"), data.rows, [], payload.source);
}
async function loadAudit(params = {}) {
@@ -280,12 +380,12 @@ const AdminPage = (() => {
if (value) query.set(key, value);
});
const rows = await api(`/admin/audit-log?${query.toString()}`);
renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"]);
renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"], "audit_logs");
}
async function loadExports() {
const data = await api("/admin/exports");
renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"]);
renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"], "imports_exports");
}
async function loadActiveSection() {
@@ -349,6 +449,7 @@ const AdminPage = (() => {
async function initSources() {
const data = await api("/admin/data/sources");
state.sources = data.sources || [];
state.sourcesByName = Object.fromEntries(state.sources.map((source) => [source.name, source]));
state.sorts = data.sorts || [];
qs("#sourceSelect").innerHTML = state.sources
.filter((source) => source.available && source.allowed)
@@ -357,6 +458,8 @@ const AdminPage = (() => {
qs("#sortSelect").innerHTML = state.sorts
.map((sort) => `<option value="${sort}">${sort}</option>`)
.join("");
qs("#sourceSelect")?.addEventListener("change", (event) => renderSourceHint(event.target.value));
renderSourceHint(qs("#sourceSelect")?.value);
}
async function init() {

View File

@@ -2156,6 +2156,7 @@ select {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 30px rgba(17, 36, 30, 0.05);
}
.admin-table {
@@ -2167,7 +2168,7 @@ select {
.admin-table th,
.admin-table td {
padding: 10px;
padding: 9px 10px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
@@ -2188,6 +2189,41 @@ select {
border-bottom: 0;
}
.admin-table tbody tr {
transition: background 140ms ease;
}
.admin-table tbody tr:hover {
background: #f7fbf8;
}
.admin-actions-head,
.admin-action-cell {
position: sticky;
right: 0;
min-width: 128px;
background: #fff;
box-shadow: -10px 0 18px rgba(255, 255, 255, 0.78);
}
.admin-table tbody tr:hover .admin-action-cell {
background: #f7fbf8;
}
.admin-action-cell {
display: flex;
gap: 6px;
align-items: center;
border-left: 1px solid var(--line);
}
.compact-btn {
min-height: 30px;
padding: 0 8px;
font-size: 12px;
box-shadow: none;
}
.admin-badge {
display: inline-flex;
min-height: 22px;
@@ -2217,6 +2253,17 @@ select {
gap: 8px;
}
.admin-source-hint {
margin: -2px 0 10px;
padding: 10px 12px;
border: 1px solid rgba(22, 128, 106, 0.16);
border-radius: 8px;
background: #f2faf6;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.error-state {
color: var(--danger);
background: #fff4f2;