356 lines
13 KiB
HTML
356 lines
13 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; }
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
margin-bottom: .7rem;
|
||
}
|
||
.section-head h3 { margin: 0; }
|
||
.section-head button, #column-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;
|
||
}
|
||
.section-head button:hover, #column-form button:hover {
|
||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||
}
|
||
.section-head button:disabled, #column-form button:disabled {
|
||
opacity: .5;
|
||
cursor: not-allowed;
|
||
}
|
||
.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;
|
||
}
|
||
/* 新增欄位的內嵌表單面板 */
|
||
#column-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||
gap: .8rem 1.2rem;
|
||
align-items: end;
|
||
margin-bottom: 1rem;
|
||
padding: 1rem 1.2rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||
border-radius: 10px;
|
||
}
|
||
#column-form[hidden] { display: none; }
|
||
#column-form .field { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
|
||
#column-form label { font-size: .85rem; opacity: .75; }
|
||
#column-form input[type="text"], #column-form input[type="checkbox"] { font: inherit; }
|
||
#column-form input[type="text"] {
|
||
padding: .35rem .6rem;
|
||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
}
|
||
#column-form .form-actions {
|
||
display: flex;
|
||
gap: .6rem;
|
||
grid-column: 1 / -1;
|
||
}
|
||
#column-form .hint { font-size: .8rem; opacity: .65; }
|
||
/* 欄位過多時橫向滾動,不撐破版面 */
|
||
.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; }
|
||
.db-table td.mono {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: .85em;
|
||
}
|
||
.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); }
|
||
.db-table .actions .danger { color: light-dark(#b3261e, #ffb4ab); }
|
||
</style>
|
||
{% endblock %}
|
||
{% block page %}
|
||
<div id="table-detail" data-database-id="{{ database.id }}" data-table="{{ table }}">
|
||
<div class="toolbar">
|
||
<div class="title">
|
||
<h2>{{ table }}</h2>
|
||
<span class="type-badge">{{ database.db_type.as_str() }}</span>
|
||
</div>
|
||
<div class="actions">
|
||
<a href="/databases/{{ database.id }}/tables/{{ table|urlencode_strict }}/data">數據管理</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="欄位結構">
|
||
<div class="section-head">
|
||
<h3>欄位結構</h3>
|
||
<button type="button" id="add-column">新增欄位</button>
|
||
</div>
|
||
<form id="column-form" hidden>
|
||
<div class="field">
|
||
<label for="column-name">名稱</label>
|
||
<input type="text" id="column-name" maxlength="64" required autocomplete="off">
|
||
</div>
|
||
<div class="field">
|
||
<label for="column-type">類型</label>
|
||
<input type="text" id="column-type" maxlength="128" required autocomplete="off"
|
||
placeholder="varchar(255)" list="type-suggestions">
|
||
<datalist id="type-suggestions">
|
||
<option value="int"></option>
|
||
<option value="bigint"></option>
|
||
<option value="varchar(255)"></option>
|
||
<option value="text"></option>
|
||
<option value="decimal(10,2)"></option>
|
||
<option value="double"></option>
|
||
<option value="boolean"></option>
|
||
<option value="date"></option>
|
||
<option value="datetime"></option>
|
||
<option value="timestamp"></option>
|
||
</datalist>
|
||
</div>
|
||
<div class="field">
|
||
<label for="column-nullable">可空</label>
|
||
<input type="checkbox" id="column-nullable" checked>
|
||
</div>
|
||
<div class="field">
|
||
<label for="column-default">默認值(可留空)</label>
|
||
<input type="text" id="column-default" maxlength="256" autocomplete="off"
|
||
placeholder="0、'文字'、CURRENT_TIMESTAMP">
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="submit" id="column-submit">新增</button>
|
||
<button type="button" id="column-cancel">取消</button>
|
||
</div>
|
||
<p class="hint">類型與默認值以原生 SQL 片段提交;新增會直接修改目標資料庫。</p>
|
||
</form>
|
||
<p id="columns-error" class="error" role="alert" hidden></p>
|
||
<div class="table-wrap">
|
||
<table class="db-table">
|
||
<thead>
|
||
<tr><th>欄位</th><th>類型</th><th>可空</th><th>默認值</th><th>操作</th></tr>
|
||
</thead>
|
||
<tbody id="column-rows"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
{% endblock %}
|
||
{% block script %}
|
||
<script>
|
||
(() => {
|
||
'use strict';
|
||
|
||
const root = document.getElementById('table-detail');
|
||
const databaseId = root.dataset.databaseId;
|
||
const tableName = root.dataset.table;
|
||
const tableApi = '/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName);
|
||
|
||
const columnsError = document.getElementById('columns-error');
|
||
const columnRows = document.getElementById('column-rows');
|
||
const addColumnButton = document.getElementById('add-column');
|
||
const columnForm = document.getElementById('column-form');
|
||
const columnName = document.getElementById('column-name');
|
||
const columnType = document.getElementById('column-type');
|
||
const columnNullable = document.getElementById('column-nullable');
|
||
const columnDefault = document.getElementById('column-default');
|
||
const columnSubmit = document.getElementById('column-submit');
|
||
const columnCancel = document.getElementById('column-cancel');
|
||
|
||
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;
|
||
}
|
||
|
||
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||
function renderColumns(columns) {
|
||
if (columns.length === 0) {
|
||
columnRows.replaceChildren(messageRow(5, '找不到此資料表。'));
|
||
return;
|
||
}
|
||
const rows = columns.map((column) => {
|
||
const tr = document.createElement('tr');
|
||
const name = document.createElement('td');
|
||
name.className = 'mono';
|
||
name.textContent = column.name;
|
||
const type = document.createElement('td');
|
||
type.className = 'mono';
|
||
type.textContent = column.type;
|
||
const nullable = document.createElement('td');
|
||
nullable.textContent = column.nullable ? '是' : '否';
|
||
const def = document.createElement('td');
|
||
def.className = 'mono';
|
||
def.textContent = column.default ?? '—';
|
||
const actions = document.createElement('td');
|
||
actions.className = 'actions';
|
||
const dropButton = document.createElement('button');
|
||
dropButton.type = 'button';
|
||
dropButton.className = 'danger';
|
||
dropButton.textContent = '刪除';
|
||
dropButton.addEventListener('click', () => dropColumn(column.name));
|
||
actions.append(dropButton);
|
||
tr.append(name, type, nullable, def, actions);
|
||
return tr;
|
||
});
|
||
columnRows.replaceChildren(...rows);
|
||
}
|
||
|
||
async function loadColumns() {
|
||
columnsError.hidden = true;
|
||
columnRows.replaceChildren(messageRow(5, '載入中…'));
|
||
try {
|
||
renderColumns(await api(tableApi + '/columns'));
|
||
} catch (err) {
|
||
columnRows.replaceChildren();
|
||
columnsError.textContent = err.message;
|
||
columnsError.hidden = false;
|
||
}
|
||
}
|
||
|
||
addColumnButton.addEventListener('click', () => {
|
||
columnsError.hidden = true;
|
||
columnForm.hidden = false;
|
||
columnName.focus();
|
||
});
|
||
|
||
columnCancel.addEventListener('click', () => {
|
||
columnForm.hidden = true;
|
||
columnForm.reset();
|
||
});
|
||
|
||
columnForm.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const payload = {
|
||
name: columnName.value,
|
||
type: columnType.value,
|
||
nullable: columnNullable.checked,
|
||
};
|
||
const defaultValue = columnDefault.value.trim();
|
||
if (defaultValue !== '') { payload.default = defaultValue; }
|
||
|
||
columnSubmit.disabled = true;
|
||
try {
|
||
await api(tableApi + '/columns', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
columnForm.hidden = true;
|
||
columnForm.reset();
|
||
loadColumns();
|
||
} catch (err) {
|
||
columnsError.textContent = err.message;
|
||
columnsError.hidden = false;
|
||
} finally {
|
||
columnSubmit.disabled = false;
|
||
}
|
||
});
|
||
|
||
async function dropColumn(name) {
|
||
if (!window.confirm('確定刪除欄位「' + name + '」?該欄位的資料將一併刪除且無法復原。')) { return; }
|
||
try {
|
||
await api(tableApi + '/columns/' + encodeURIComponent(name), { method: 'DELETE' });
|
||
loadColumns();
|
||
} catch (err) {
|
||
columnsError.textContent = err.message;
|
||
columnsError.hidden = false;
|
||
}
|
||
}
|
||
|
||
loadColumns();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|