Files
2026-10-02 13:12:49 +08:00

356 lines
13 KiB
HTML
Raw Permalink 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; }
.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 %}