502 lines
19 KiB
HTML
502 lines
19 KiB
HTML
{% extends "_shell.html" %}
|
||
{% block title %}數據管理:{{ table }} — alterdb{% endblock %}
|
||
{% block style %}
|
||
<style>
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.toolbar .title { display: flex; align-items: baseline; gap: .7rem; margin: 0; min-width: 0; }
|
||
.toolbar h2 { margin: 0; overflow-wrap: anywhere; }
|
||
.toolbar .type-badge {
|
||
font-size: .75rem;
|
||
font-weight: 600;
|
||
letter-spacing: .05em;
|
||
text-transform: uppercase;
|
||
padding: .1rem .5rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||
border-radius: 999px;
|
||
white-space: nowrap;
|
||
}
|
||
.toolbar .actions { display: flex; gap: .6rem; }
|
||
.toolbar .actions a {
|
||
font: inherit;
|
||
padding: .35rem .9rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
}
|
||
.toolbar .actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||
.crumb { margin: 0 0 1.8rem; font-size: .9rem; opacity: .8; }
|
||
.crumb code { opacity: 1; }
|
||
section h3 { margin: 0 0 .7rem; }
|
||
.error {
|
||
margin: 0 0 1rem;
|
||
padding: .5rem .75rem;
|
||
border-radius: 6px;
|
||
color: light-dark(#b3261e, #ffb4ab);
|
||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.note { margin: 0 0 .8rem; font-size: .9rem; opacity: .75; }
|
||
/* 儲存格過長時橫向滾動,不撐破版面 */
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||
border-radius: 10px;
|
||
}
|
||
.db-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: .95rem;
|
||
}
|
||
.db-table th, .db-table td {
|
||
padding: .5rem .6rem;
|
||
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
}
|
||
.db-table tr:last-child td { border-bottom: none; }
|
||
.db-table th { font-weight: 600; }
|
||
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||
/* 資料儲存格過長時截斷,完整內容以 title 提示呈現 */
|
||
.db-table td.cell {
|
||
max-width: 24rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.db-table td.null { opacity: .55; font-style: italic; }
|
||
.db-table .actions { white-space: nowrap; }
|
||
.db-table .actions button {
|
||
font: inherit;
|
||
padding: .2rem .6rem;
|
||
font-size: .85rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.db-table .actions button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||
.data-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .6rem;
|
||
margin-bottom: .8rem;
|
||
}
|
||
.data-toolbar select, .data-toolbar button, .pager button, #row-form button {
|
||
font: inherit;
|
||
font-size: .9rem;
|
||
padding: .3rem .9rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.data-toolbar button:hover, .pager button:hover, #row-form button:hover {
|
||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||
}
|
||
.data-toolbar button:disabled, .pager button:disabled, #row-form button:disabled {
|
||
opacity: .5;
|
||
cursor: not-allowed;
|
||
}
|
||
.pager {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
margin-top: .8rem;
|
||
font-size: .9rem;
|
||
}
|
||
.pager span { opacity: .8; }
|
||
/* 編輯資料列的對話框:逐欄輸入 + 可空欄位的 NULL 勾選 */
|
||
#row-dialog {
|
||
width: min(44rem, 92vw);
|
||
padding: 1.2rem 1.4rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||
border-radius: 12px;
|
||
}
|
||
#row-dialog::backdrop { background: rgb(0 0 0 / .45); }
|
||
#row-dialog h3 { margin: 0 0 .8rem; }
|
||
#row-fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||
gap: .8rem 1.2rem;
|
||
margin: .8rem 0;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
#row-fields .field { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
|
||
#row-fields label { font-size: .85rem; opacity: .75; overflow-wrap: anywhere; }
|
||
#row-fields input[type="text"] {
|
||
font: inherit;
|
||
padding: .35rem .6rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
}
|
||
#row-fields input[type="text"]:disabled { opacity: .5; }
|
||
#row-fields .null-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .4rem;
|
||
font-size: .85rem;
|
||
}
|
||
#row-fields .null-line input[type="checkbox"] { font: inherit; }
|
||
#row-form .hint { margin: 0 0 .8rem; font-size: .8rem; opacity: .65; }
|
||
#row-form .form-actions { display: flex; gap: .6rem; }
|
||
</style>
|
||
{% endblock %}
|
||
{% block page %}
|
||
<div id="table-data" data-database-id="{{ database.id }}" data-table="{{ table }}">
|
||
<div class="toolbar">
|
||
<div class="title">
|
||
<h2>{{ table }}</h2>
|
||
<span class="type-badge">數據管理</span>
|
||
<span class="type-badge">{{ database.db_type.as_str() }}</span>
|
||
</div>
|
||
<div class="actions">
|
||
<a href="/databases/{{ database.id }}/tables/{{ table|urlencode_strict }}">結構管理</a>
|
||
<a href="/databases/{{ database.id }}">返回連接詳情</a>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="crumb">
|
||
連接 <a href="/databases/{{ database.id }}">{{ database.name }}</a> ·
|
||
<code>{{ database.host }}:{{ database.port }}</code> ·
|
||
資料庫 <code>{{ database.database_name }}</code>
|
||
</p>
|
||
|
||
<section aria-label="資料">
|
||
<h3>資料</h3>
|
||
<p id="no-pk-note" class="note" hidden>此表沒有主鍵,僅能瀏覽資料,無法編輯。</p>
|
||
<div class="data-toolbar">
|
||
<select id="page-size" aria-label="每頁列數">
|
||
<option value="25">25 列 / 頁</option>
|
||
<option value="50" selected>50 列 / 頁</option>
|
||
<option value="100">100 列 / 頁</option>
|
||
<option value="200">200 列 / 頁</option>
|
||
</select>
|
||
<button type="button" id="refresh">刷新</button>
|
||
</div>
|
||
<p id="rows-error" class="error" role="alert" hidden></p>
|
||
<div class="table-wrap">
|
||
<table class="db-table">
|
||
<thead id="row-head"></thead>
|
||
<tbody id="row-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pager">
|
||
<button type="button" id="prev" disabled>上一頁</button>
|
||
<span id="page-info"></span>
|
||
<button type="button" id="next" disabled>下一頁</button>
|
||
</div>
|
||
</section>
|
||
|
||
<dialog id="row-dialog" aria-labelledby="row-dialog-title">
|
||
<form id="row-form" method="dialog" novalidate>
|
||
<h3 id="row-dialog-title">編輯資料列</h3>
|
||
<p id="row-form-error" class="error" role="alert" hidden></p>
|
||
<div id="row-fields"></div>
|
||
<p class="hint">僅送出有變更的欄位;勾選 NULL 會將該欄設為 NULL(不可空的欄位無此選項)。</p>
|
||
<div class="form-actions">
|
||
<button type="submit" id="row-submit">儲存</button>
|
||
<button type="button" id="row-cancel">取消</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
</div>
|
||
{% endblock %}
|
||
{% block script %}
|
||
<script>
|
||
(() => {
|
||
'use strict';
|
||
|
||
const root = document.getElementById('table-data');
|
||
const databaseId = root.dataset.databaseId;
|
||
const tableName = root.dataset.table;
|
||
const tableApi = '/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName);
|
||
|
||
const rowsError = document.getElementById('rows-error');
|
||
const rowHead = document.getElementById('row-head');
|
||
const rowBody = document.getElementById('row-body');
|
||
const pageSize = document.getElementById('page-size');
|
||
const refreshButton = document.getElementById('refresh');
|
||
const prevButton = document.getElementById('prev');
|
||
const nextButton = document.getElementById('next');
|
||
const pageInfo = document.getElementById('page-info');
|
||
const noPkNote = document.getElementById('no-pk-note');
|
||
const rowDialog = document.getElementById('row-dialog');
|
||
const rowForm = document.getElementById('row-form');
|
||
const rowFields = document.getElementById('row-fields');
|
||
const rowFormError = document.getElementById('row-form-error');
|
||
const rowSubmit = document.getElementById('row-submit');
|
||
const rowCancel = document.getElementById('row-cancel');
|
||
|
||
let limit = Number(pageSize.value) || 50;
|
||
let offset = 0;
|
||
|
||
// 欄位結構 metadata(名稱 / 類型 / 可空 / 主鍵):編輯表單與主鍵判定都依賴它
|
||
let columnsMeta = [];
|
||
let pkColumns = [];
|
||
// 當前頁的原始資料:編輯時以其主鍵值組 where、以原值比對變更
|
||
let currentRows = null;
|
||
let editingIndex = -1;
|
||
|
||
async function api(path, options) {
|
||
const response = await fetch(path, options);
|
||
if (!response.ok) {
|
||
let message = '請求失敗(HTTP ' + response.status + ')';
|
||
try {
|
||
const body = await response.json();
|
||
if (body.error) { message = body.error; }
|
||
} catch (_) { /* 非 JSON 錯誤體,保留默認訊息 */ }
|
||
throw new Error(message);
|
||
}
|
||
return response.status === 204 ? null : response.json();
|
||
}
|
||
|
||
function messageRow(colspan, text) {
|
||
const tr = document.createElement('tr');
|
||
const td = document.createElement('td');
|
||
td.colSpan = colspan;
|
||
td.className = 'empty';
|
||
td.textContent = text;
|
||
tr.append(td);
|
||
return tr;
|
||
}
|
||
|
||
// 取得欄位結構 metadata;失敗不阻斷瀏覽,僅停用編輯能力
|
||
async function loadColumnsMeta() {
|
||
try {
|
||
columnsMeta = await api(tableApi + '/columns');
|
||
pkColumns = columnsMeta
|
||
.filter((column) => column.primary_key)
|
||
.map((column) => column.name);
|
||
} catch (_) {
|
||
columnsMeta = [];
|
||
pkColumns = [];
|
||
}
|
||
noPkNote.hidden = pkColumns.length > 0;
|
||
}
|
||
|
||
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||
function renderRows(data) {
|
||
currentRows = data;
|
||
const editable = pkColumns.length > 0;
|
||
|
||
const head = document.createElement('tr');
|
||
for (const name of data.columns) {
|
||
const th = document.createElement('th');
|
||
th.scope = 'col';
|
||
th.textContent = name;
|
||
head.append(th);
|
||
}
|
||
if (editable) {
|
||
const th = document.createElement('th');
|
||
th.scope = 'col';
|
||
th.textContent = '操作';
|
||
head.append(th);
|
||
}
|
||
rowHead.replaceChildren(head);
|
||
|
||
if (data.rows.length === 0) {
|
||
rowBody.replaceChildren(
|
||
messageRow(data.columns.length + (editable ? 1 : 0), '沒有資料。')
|
||
);
|
||
} else {
|
||
const rows = data.rows.map((row, index) => {
|
||
const tr = document.createElement('tr');
|
||
for (const cell of row) {
|
||
const td = document.createElement('td');
|
||
if (cell === null) {
|
||
td.className = 'null';
|
||
td.textContent = 'NULL';
|
||
} else {
|
||
td.className = 'cell';
|
||
td.textContent = cell;
|
||
td.setAttribute('title', cell);
|
||
}
|
||
tr.append(td);
|
||
}
|
||
if (editable) {
|
||
const actions = document.createElement('td');
|
||
actions.className = 'actions';
|
||
const editButton = document.createElement('button');
|
||
editButton.type = 'button';
|
||
editButton.textContent = '編輯';
|
||
editButton.addEventListener('click', () => openEditor(index));
|
||
actions.append(editButton);
|
||
tr.append(actions);
|
||
}
|
||
return tr;
|
||
});
|
||
rowBody.replaceChildren(...rows);
|
||
}
|
||
|
||
const to = Math.min(data.offset + data.limit, data.total);
|
||
pageInfo.textContent = data.total === 0
|
||
? '沒有資料'
|
||
: '顯示第 ' + (data.offset + 1) + '–' + to + ' 列,共 ' + data.total + ' 列';
|
||
prevButton.disabled = data.offset === 0;
|
||
nextButton.disabled = to >= data.total;
|
||
}
|
||
|
||
async function loadRows() {
|
||
rowsError.hidden = true;
|
||
rowHead.replaceChildren();
|
||
rowBody.replaceChildren(messageRow(1, '載入中…'));
|
||
try {
|
||
const data = await api(tableApi + '/rows?limit=' + limit + '&offset=' + offset);
|
||
// 目標頁已無資料(總列數變少)→ 跳到最後一頁重取一次
|
||
if (data.rows.length === 0 && data.offset > 0) {
|
||
const lastPage = Math.floor(Math.max(0, data.total - 1) / data.limit) * data.limit;
|
||
if (lastPage < data.offset) {
|
||
offset = lastPage;
|
||
await loadRows();
|
||
return;
|
||
}
|
||
}
|
||
renderRows(data);
|
||
} catch (err) {
|
||
rowBody.replaceChildren();
|
||
rowsError.textContent = err.message;
|
||
rowsError.hidden = false;
|
||
pageInfo.textContent = '';
|
||
prevButton.disabled = true;
|
||
nextButton.disabled = true;
|
||
}
|
||
}
|
||
|
||
function cellInputId(index) { return 'cell-input-' + index; }
|
||
function cellNullId(index) { return 'cell-null-' + index; }
|
||
|
||
// 以當前頁第 index 列的原值預填編輯表單:逐欄輸入框,
|
||
// 可空欄位附 NULL 勾選(勾選時停用輸入框,送出 null)
|
||
function openEditor(index) {
|
||
editingIndex = index;
|
||
const original = currentRows.rows[index];
|
||
rowFormError.hidden = true;
|
||
rowFields.replaceChildren();
|
||
|
||
columnsMeta.forEach((meta, i) => {
|
||
const value = original[i] === undefined ? null : original[i];
|
||
const field = document.createElement('div');
|
||
field.className = 'field';
|
||
|
||
const label = document.createElement('label');
|
||
label.textContent = meta.primary_key ? meta.name + '(主鍵)' : meta.name;
|
||
label.htmlFor = cellInputId(i);
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.id = cellInputId(i);
|
||
input.value = value ?? '';
|
||
input.autocomplete = 'off';
|
||
|
||
field.append(label, input);
|
||
|
||
if (meta.nullable) {
|
||
const nullLine = document.createElement('div');
|
||
nullLine.className = 'null-line';
|
||
const toggle = document.createElement('input');
|
||
toggle.type = 'checkbox';
|
||
toggle.id = cellNullId(i);
|
||
toggle.checked = value === null;
|
||
const nullLabel = document.createElement('label');
|
||
nullLabel.textContent = 'NULL';
|
||
nullLabel.htmlFor = cellNullId(i);
|
||
toggle.addEventListener('change', () => {
|
||
input.disabled = toggle.checked;
|
||
if (!toggle.checked) { input.focus(); }
|
||
});
|
||
input.disabled = toggle.checked;
|
||
nullLine.append(toggle, nullLabel);
|
||
field.append(nullLine);
|
||
}
|
||
|
||
rowFields.append(field);
|
||
});
|
||
|
||
rowDialog.showModal();
|
||
const first = rowFields.querySelector('input[type="text"]:not(:disabled)');
|
||
if (first) { first.focus(); }
|
||
}
|
||
|
||
rowForm.addEventListener('submit', async (event) => {
|
||
// 阻止 method="dialog" 的默認關閉行為:錯誤時保持對話框開啟
|
||
event.preventDefault();
|
||
const original = currentRows.rows[editingIndex];
|
||
const set = {};
|
||
columnsMeta.forEach((meta, i) => {
|
||
const input = document.getElementById(cellInputId(i));
|
||
const nullToggle = document.getElementById(cellNullId(i));
|
||
const isNull = nullToggle !== null && nullToggle.checked;
|
||
const newValue = isNull ? null : input.value;
|
||
const oldValue = original[i] === undefined ? null : original[i];
|
||
// 未變更的欄位不送出,避免對未觸碰的欄位做不必要的型別轉換
|
||
if (newValue === oldValue) { return; }
|
||
set[meta.name] = newValue;
|
||
});
|
||
if (Object.keys(set).length === 0) {
|
||
rowDialog.close();
|
||
return;
|
||
}
|
||
|
||
// 以編輯前的主鍵原值定位資料列(主鍵欄位本身被修改時亦然)
|
||
const where = {};
|
||
for (const name of pkColumns) {
|
||
const index = columnsMeta.findIndex((meta) => meta.name === name);
|
||
where[name] = index === -1 ? null : (original[index] ?? null);
|
||
}
|
||
|
||
rowSubmit.disabled = true;
|
||
try {
|
||
await api(tableApi + '/rows', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ where: where, set: set }),
|
||
});
|
||
rowDialog.close();
|
||
loadRows();
|
||
} catch (err) {
|
||
rowFormError.textContent = err.message;
|
||
rowFormError.hidden = false;
|
||
} finally {
|
||
rowSubmit.disabled = false;
|
||
}
|
||
});
|
||
|
||
rowCancel.addEventListener('click', () => rowDialog.close());
|
||
|
||
pageSize.addEventListener('change', () => {
|
||
limit = Number(pageSize.value) || 50;
|
||
offset = 0;
|
||
loadRows();
|
||
});
|
||
// 刷新同時重取欄位結構(他頁可能已變更結構 / 主鍵)
|
||
refreshButton.addEventListener('click', () => {
|
||
loadColumnsMeta().then(loadRows);
|
||
});
|
||
prevButton.addEventListener('click', () => {
|
||
offset = Math.max(0, offset - limit);
|
||
loadRows();
|
||
});
|
||
nextButton.addEventListener('click', () => {
|
||
offset += limit;
|
||
loadRows();
|
||
});
|
||
|
||
loadColumnsMeta().then(loadRows);
|
||
})();
|
||
</script>
|
||
{% endblock %}
|