This commit is contained in:
2026-03-19 17:23:29 +07:00
parent 9dddc3f377
commit 7950b401dc
7 changed files with 1735 additions and 8 deletions

View File

@@ -80,13 +80,16 @@ export class MetadataService {
}; };
} }
async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) { async createTable(
userId: string,
payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }[] }
) {
await this.assertSchemaPermission(userId, payload.tableName); await this.assertSchemaPermission(userId, payload.tableName);
const columnSql = payload.columns const columnSql = payload.columns
.map( .map(
(column: { name: string; type: string; nullable?: boolean }) => (column: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }) =>
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}` `${quoteIdentifier(column.name)} ${column.type}${column.primaryKey ? " PRIMARY KEY" : column.nullable ? "" : " NOT NULL"}`
) )
.join(", "); .join(", ");

View File

@@ -6,7 +6,8 @@ export const createTableSchema = z.object({
z.object({ z.object({
name: z.string().min(1), name: z.string().min(1),
type: z.string().min(1), type: z.string().min(1),
nullable: z.boolean().optional() nullable: z.boolean().optional(),
primaryKey: z.boolean().optional()
}) })
).min(1) ).min(1)
}); });

View File

@@ -28,6 +28,8 @@ export const api = {
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }), login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
logout: () => request("/auth/logout", { method: "POST" }), logout: () => request("/auth/logout", { method: "POST" }),
tables: () => request("/db/tables"), tables: () => request("/db/tables"),
createTable: (body) => request("/db/tables", { method: "POST", body: JSON.stringify(body) }),
deleteTable: (tableName) => request(`/db/tables/${tableName}`, { method: "DELETE" }),
tableDetails: (tableName) => request(`/db/tables/${tableName}/details`), tableDetails: (tableName) => request(`/db/tables/${tableName}/details`),
rows: (tableName, query = {}) => { rows: (tableName, query = {}) => {
const params = new URLSearchParams(query).toString(); const params = new URLSearchParams(query).toString();

View File

@@ -66,6 +66,7 @@ export function renderAppShell(state) {
</section> </section>
</main> </main>
</div> </div>
${renderCreateTableModal(state)}
`; `;
} }
@@ -201,6 +202,7 @@ function renderActiveTab(state, activeTableMeta) {
} }
function renderDataTab(state) { function renderDataTab(state) {
const filteredRows = applyColumnFilters(state.rows.rows || [], state.columnFilters);
return ` return `
<div class="section-header"> <div class="section-header">
<div> <div>
@@ -214,10 +216,41 @@ function renderDataTab(state) {
<option value="asc" ${state.sortDirection === "asc" ? "selected" : ""}>ASC</option> <option value="asc" ${state.sortDirection === "asc" ? "selected" : ""}>ASC</option>
<option value="desc" ${state.sortDirection === "desc" ? "selected" : ""}>DESC</option> <option value="desc" ${state.sortDirection === "desc" ? "selected" : ""}>DESC</option>
</select> </select>
<button class="secondary-button" data-action="toggle-column-filters">Колонковые фильтры</button>
<button class="secondary-button" data-action="search">Применить</button> <button class="secondary-button" data-action="search">Применить</button>
</div> </div>
</div> </div>
${
state.showColumnFilters
? `
<section class="subpanel filter-panel">
<div class="subpanel-header">
<h4>Фильтры по колонкам</h4>
</div>
<div class="filter-grid">
${state.tableDetails.columns
.map(
(column) => `
<label>
<span>${column.column_name}</span>
<input
data-input-action="set-column-filter"
data-action="set-column-filter"
data-column="${column.column_name}"
value="${escapeHtml(state.columnFilters[column.column_name] || "")}"
placeholder="Фильтр"
/>
</label>
`
)
.join("")}
</div>
</section>
`
: ""
}
<div class="panel-grid"> <div class="panel-grid">
<section class="subpanel"> <section class="subpanel">
<div class="subpanel-header"> <div class="subpanel-header">
@@ -254,7 +287,7 @@ function renderDataTab(state) {
</div> </div>
<div class="table-toolbar"> <div class="table-toolbar">
<div class="muted">Всего строк: ${state.rows.total}</div> <div class="muted">Всего строк: ${state.rows.total} · После фильтров: ${filteredRows.length}</div>
<div class="toolbar"> <div class="toolbar">
<button class="secondary-button" data-action="prev-page" ${state.pageNumber <= 1 ? "disabled" : ""}>Назад</button> <button class="secondary-button" data-action="prev-page" ${state.pageNumber <= 1 ? "disabled" : ""}>Назад</button>
<div class="page-pill">Страница ${state.pageNumber}</div> <div class="page-pill">Страница ${state.pageNumber}</div>
@@ -262,7 +295,7 @@ function renderDataTab(state) {
</div> </div>
</div> </div>
${renderRowsTable(state.rows.rows || [], { ${renderRowsTable(filteredRows, {
selectable: true, selectable: true,
selectedRowId: state.selectedRow?.id, selectedRowId: state.selectedRow?.id,
allowDelete: true allowDelete: true
@@ -452,6 +485,14 @@ function renderSidePanel(state, activeTableMeta) {
</div> </div>
</section> </section>
<section class="side-card">
<div class="eyebrow">Schema Actions</div>
<div class="quick-sql-list">
<button class="primary-button" data-action="open-create-table-modal">Создать таблицу</button>
<button class="danger-button" data-action="delete-table" ${activeTableMeta ? "" : "disabled"}>Удалить таблицу</button>
</div>
</section>
<section class="side-card"> <section class="side-card">
<div class="eyebrow">Quick SQL</div> <div class="eyebrow">Quick SQL</div>
<div class="quick-sql-list"> <div class="quick-sql-list">
@@ -484,6 +525,60 @@ function renderSidePanel(state, activeTableMeta) {
`; `;
} }
function renderCreateTableModal(state) {
if (!state.isCreateTableModalOpen) {
return "";
}
return `
<div class="modal-backdrop">
<div class="modal-card">
<div class="modal-header">
<div>
<div class="hero-kicker">Create Table</div>
<h3>Создание таблицы</h3>
</div>
<button class="ghost-button" data-action="close-create-table-modal">Закрыть</button>
</div>
<form id="createTableForm" class="form-grid">
<label>
<span>Название таблицы</span>
<input name="tableName" placeholder="finance_entries_archive" required />
</label>
<div class="subpanel">
<div class="subpanel-header">
<h4>Колонки</h4>
<button class="secondary-button" type="button" data-action="add-table-column">Добавить колонку</button>
</div>
<div class="table-column-builder">
${state.newTableColumns
.map(
(column) => `
<div class="builder-row">
<input name="column-name-${column.id}" value="${escapeHtml(column.name)}" placeholder="name" required />
<input name="column-type-${column.id}" value="${escapeHtml(column.type)}" placeholder="varchar(255)" required />
<label class="checkbox-field inline-check"><input type="checkbox" name="column-primary-${column.id}" ${column.primaryKey ? "checked" : ""} /><span>PK</span></label>
<label class="checkbox-field inline-check"><input type="checkbox" name="column-nullable-${column.id}" ${column.nullable ? "checked" : ""} /><span>NULL</span></label>
<button class="danger-button small-button" type="button" data-action="remove-table-column" data-column-id="${column.id}">Удалить</button>
</div>
`
)
.join("")}
</div>
</div>
<div class="form-actions split">
<button class="secondary-button" type="button" data-action="close-create-table-modal">Отмена</button>
<button class="primary-button" type="submit">Создать таблицу</button>
</div>
</form>
</div>
</div>
`;
}
function renderSortOptions(state) { function renderSortOptions(state) {
const columns = state.tableDetails.columns || []; const columns = state.tableDetails.columns || [];
@@ -564,6 +659,20 @@ function renderRowsTable(rows, options = {}) {
`; `;
} }
function applyColumnFilters(rows, columnFilters) {
if (!Object.keys(columnFilters).length) {
return rows;
}
return rows.filter((row) =>
Object.entries(columnFilters).every(([column, value]) =>
String(row[column] ?? "")
.toLowerCase()
.includes(String(value).toLowerCase())
)
);
}
function formatCell(value) { function formatCell(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return `<span class="cell-null">null</span>`; return `<span class="cell-null">null</span>`;

View File

@@ -27,7 +27,11 @@ const state = {
sortDirection: "asc", sortDirection: "asc",
pageNumber: 1, pageNumber: 1,
pageSize: 25, pageSize: 25,
selectedRow: null selectedRow: null,
columnFilters: {},
showColumnFilters: false,
isCreateTableModalOpen: false,
newTableColumns: [{ id: 1, name: "", type: "varchar(255)", nullable: true, primaryKey: false }]
}; };
const app = document.querySelector("#app"); const app = document.querySelector("#app");
@@ -112,6 +116,7 @@ function bindEvents() {
document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn)); document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn));
document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn)); document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn));
document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex)); document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex));
document.querySelector("#createTableForm")?.addEventListener("submit", wrapAction(handleCreateTable));
document.querySelectorAll("[data-page]").forEach((element) => { document.querySelectorAll("[data-page]").forEach((element) => {
element.addEventListener( element.addEventListener(
@@ -149,6 +154,10 @@ function bindEvents() {
document.querySelectorAll("[data-action]").forEach((element) => { document.querySelectorAll("[data-action]").forEach((element) => {
element.addEventListener("click", wrapAction(handleAction)); element.addEventListener("click", wrapAction(handleAction));
}); });
document.querySelectorAll("[data-input-action='set-column-filter']").forEach((element) => {
element.addEventListener("input", wrapAction(handleAction));
});
} }
function wrapAction(handler) { function wrapAction(handler) {
@@ -209,6 +218,11 @@ async function handleAction(event) {
return; return;
} }
if (action === "toggle-column-filters") {
state.showColumnFilters = !state.showColumnFilters;
return;
}
if (action === "run-sql") { if (action === "run-sql") {
state.sqlDraft = document.querySelector("#sqlEditor")?.value || ""; state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
const result = await api.executeSql(state.sqlDraft); const result = await api.executeSql(state.sqlDraft);
@@ -287,6 +301,62 @@ async function handleAction(event) {
const payload = await api.postgresLogs(state.logsSearch); const payload = await api.postgresLogs(state.logsSearch);
state.postgresLogs = payload.logs; state.postgresLogs = payload.logs;
setNotice("Логи отфильтрованы"); setNotice("Логи отфильтрованы");
return;
}
if (action === "set-column-filter") {
const column = event.currentTarget.dataset.column;
const value = event.currentTarget.value.trim();
if (value) {
state.columnFilters[column] = value;
} else {
delete state.columnFilters[column];
}
return;
}
if (action === "open-create-table-modal") {
state.isCreateTableModalOpen = true;
return;
}
if (action === "close-create-table-modal") {
state.isCreateTableModalOpen = false;
return;
}
if (action === "add-table-column") {
state.newTableColumns.push({
id: Date.now(),
name: "",
type: "varchar(255)",
nullable: true,
primaryKey: false
});
return;
}
if (action === "remove-table-column") {
const columnId = Number(event.currentTarget.dataset.columnId);
state.newTableColumns = state.newTableColumns.filter((column) => column.id !== columnId);
if (!state.newTableColumns.length) {
state.newTableColumns = [{ id: Date.now(), name: "", type: "varchar(255)", nullable: true, primaryKey: false }];
}
return;
}
if (action === "delete-table") {
if (!state.activeTable) {
throw new Error("Сначала выберите таблицу");
}
await api.deleteTable(state.activeTable);
state.activeTable = "";
state.selectedRow = null;
state.tableDetails = { columns: [], foreignKeys: [], indexes: [] };
state.rows = { rows: [], total: 0 };
await hydrateDashboard();
setNotice("Таблица удалена");
} }
} }
@@ -352,6 +422,35 @@ async function handleCreateIndex(event) {
setNotice("Индекс создан"); setNotice("Индекс создан");
} }
async function handleCreateTable(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const tableName = String(formData.get("tableName") || "").trim();
if (!tableName) {
throw new Error("Введите название таблицы");
}
const columns = state.newTableColumns.map((column) => ({
name: String(formData.get(`column-name-${column.id}`) || "").trim(),
type: String(formData.get(`column-type-${column.id}`) || "").trim(),
nullable: formData.get(`column-nullable-${column.id}`) === "on",
primaryKey: formData.get(`column-primary-${column.id}`) === "on"
}));
if (columns.some((column) => !column.name || !column.type)) {
throw new Error("У каждой колонки должны быть имя и тип");
}
await api.createTable({ tableName, columns });
state.isCreateTableModalOpen = false;
state.newTableColumns = [{ id: Date.now(), name: "", type: "varchar(255)", nullable: true, primaryKey: false }];
await hydrateDashboard();
state.activeTable = tableName;
await refreshTableState();
setNotice("Таблица создана");
}
function formToPayload(form) { function formToPayload(form) {
const formData = new FormData(form); const formData = new FormData(form);
const payload = {}; const payload = {};

View File

@@ -451,6 +451,16 @@ textarea {
margin-bottom: 20px; margin-bottom: 20px;
} }
.filter-panel {
margin-bottom: 20px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.subpanel { .subpanel {
padding: 20px; padding: 20px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -473,6 +483,10 @@ textarea {
gap: 10px; gap: 10px;
} }
.inline-check {
white-space: nowrap;
}
.checkbox-field input { .checkbox-field input {
width: auto; width: auto;
} }
@@ -567,6 +581,48 @@ td {
word-break: break-word; word-break: break-word;
} }
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(2, 9, 19, 0.72);
backdrop-filter: blur(8px);
}
.modal-card {
width: min(980px, 100%);
max-height: 88vh;
overflow: auto;
padding: 24px;
border-radius: 28px;
border: 1px solid rgba(137, 181, 255, 0.16);
background: rgba(10, 21, 38, 0.98);
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 18px;
}
.table-column-builder {
display: grid;
gap: 12px;
}
.builder-row {
display: grid;
grid-template-columns: 1.3fr 1.2fr auto auto auto;
gap: 10px;
align-items: center;
}
.table-toolbar { .table-toolbar {
justify-content: space-between; justify-content: space-between;
margin: 18px 0; margin: 18px 0;
@@ -614,7 +670,8 @@ button:disabled {
.hero-card, .hero-card,
.section-header, .section-header,
.subpanel-header, .subpanel-header,
.table-toolbar { .table-toolbar,
.modal-header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
@@ -633,4 +690,8 @@ button:disabled {
.login-form { .login-form {
padding: 20px; padding: 20px;
} }
.builder-row {
grid-template-columns: 1fr;
}
} }

1452
index.html Normal file

File diff suppressed because it is too large Load Diff