Files
alterdb/templates/table_data.html
T
2026-10-02 13:12:49 +08:00

502 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}