353253
This commit is contained in:
@@ -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);
|
||||
|
||||
const columnSql = payload.columns
|
||||
.map(
|
||||
(column: { name: string; type: string; nullable?: boolean }) =>
|
||||
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||
(column: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }) =>
|
||||
`${quoteIdentifier(column.name)} ${column.type}${column.primaryKey ? " PRIMARY KEY" : column.nullable ? "" : " NOT NULL"}`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ export const createTableSchema = z.object({
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
nullable: z.boolean().optional()
|
||||
nullable: z.boolean().optional(),
|
||||
primaryKey: z.boolean().optional()
|
||||
})
|
||||
).min(1)
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export const api = {
|
||||
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
|
||||
logout: () => request("/auth/logout", { method: "POST" }),
|
||||
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`),
|
||||
rows: (tableName, query = {}) => {
|
||||
const params = new URLSearchParams(query).toString();
|
||||
|
||||
@@ -66,6 +66,7 @@ export function renderAppShell(state) {
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
${renderCreateTableModal(state)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -201,6 +202,7 @@ function renderActiveTab(state, activeTableMeta) {
|
||||
}
|
||||
|
||||
function renderDataTab(state) {
|
||||
const filteredRows = applyColumnFilters(state.rows.rows || [], state.columnFilters);
|
||||
return `
|
||||
<div class="section-header">
|
||||
<div>
|
||||
@@ -214,10 +216,41 @@ function renderDataTab(state) {
|
||||
<option value="asc" ${state.sortDirection === "asc" ? "selected" : ""}>ASC</option>
|
||||
<option value="desc" ${state.sortDirection === "desc" ? "selected" : ""}>DESC</option>
|
||||
</select>
|
||||
<button class="secondary-button" data-action="toggle-column-filters">Колонковые фильтры</button>
|
||||
<button class="secondary-button" data-action="search">Применить</button>
|
||||
</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">
|
||||
<section class="subpanel">
|
||||
<div class="subpanel-header">
|
||||
@@ -254,7 +287,7 @@ function renderDataTab(state) {
|
||||
</div>
|
||||
|
||||
<div class="table-toolbar">
|
||||
<div class="muted">Всего строк: ${state.rows.total}</div>
|
||||
<div class="muted">Всего строк: ${state.rows.total} · После фильтров: ${filteredRows.length}</div>
|
||||
<div class="toolbar">
|
||||
<button class="secondary-button" data-action="prev-page" ${state.pageNumber <= 1 ? "disabled" : ""}>Назад</button>
|
||||
<div class="page-pill">Страница ${state.pageNumber}</div>
|
||||
@@ -262,7 +295,7 @@ function renderDataTab(state) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${renderRowsTable(state.rows.rows || [], {
|
||||
${renderRowsTable(filteredRows, {
|
||||
selectable: true,
|
||||
selectedRowId: state.selectedRow?.id,
|
||||
allowDelete: true
|
||||
@@ -452,6 +485,14 @@ function renderSidePanel(state, activeTableMeta) {
|
||||
</div>
|
||||
</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">
|
||||
<div class="eyebrow">Quick SQL</div>
|
||||
<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) {
|
||||
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) {
|
||||
if (value === null || value === undefined) {
|
||||
return `<span class="cell-null">null</span>`;
|
||||
|
||||
@@ -27,7 +27,11 @@ const state = {
|
||||
sortDirection: "asc",
|
||||
pageNumber: 1,
|
||||
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");
|
||||
@@ -112,6 +116,7 @@ function bindEvents() {
|
||||
document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn));
|
||||
document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn));
|
||||
document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex));
|
||||
document.querySelector("#createTableForm")?.addEventListener("submit", wrapAction(handleCreateTable));
|
||||
|
||||
document.querySelectorAll("[data-page]").forEach((element) => {
|
||||
element.addEventListener(
|
||||
@@ -149,6 +154,10 @@ function bindEvents() {
|
||||
document.querySelectorAll("[data-action]").forEach((element) => {
|
||||
element.addEventListener("click", wrapAction(handleAction));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-input-action='set-column-filter']").forEach((element) => {
|
||||
element.addEventListener("input", wrapAction(handleAction));
|
||||
});
|
||||
}
|
||||
|
||||
function wrapAction(handler) {
|
||||
@@ -209,6 +218,11 @@ async function handleAction(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "toggle-column-filters") {
|
||||
state.showColumnFilters = !state.showColumnFilters;
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "run-sql") {
|
||||
state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
|
||||
const result = await api.executeSql(state.sqlDraft);
|
||||
@@ -287,6 +301,62 @@ async function handleAction(event) {
|
||||
const payload = await api.postgresLogs(state.logsSearch);
|
||||
state.postgresLogs = payload.logs;
|
||||
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("Индекс создан");
|
||||
}
|
||||
|
||||
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) {
|
||||
const formData = new FormData(form);
|
||||
const payload = {};
|
||||
|
||||
@@ -451,6 +451,16 @@ textarea {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.subpanel {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -473,6 +483,10 @@ textarea {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inline-check {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-field input {
|
||||
width: auto;
|
||||
}
|
||||
@@ -567,6 +581,48 @@ td {
|
||||
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 {
|
||||
justify-content: space-between;
|
||||
margin: 18px 0;
|
||||
@@ -614,7 +670,8 @@ button:disabled {
|
||||
.hero-card,
|
||||
.section-header,
|
||||
.subpanel-header,
|
||||
.table-toolbar {
|
||||
.table-toolbar,
|
||||
.modal-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -633,4 +690,8 @@ button:disabled {
|
||||
.login-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.builder-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
1452
index.html
Normal file
1452
index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user