fixup
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404 找不到頁面 — alterdb{% endblock %}
|
||||
{% block style %}
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 2.5rem 2rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.not-found .status {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
opacity: .85;
|
||||
}
|
||||
.not-found h2 { margin: .5rem 0 0; }
|
||||
.not-found p { opacity: .8; margin: 1rem 0 0; }
|
||||
.not-found a {
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
padding: .5rem 1.2rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.not-found a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
<h1>alterdb</h1>
|
||||
<button type="button" class="theme-toggle" data-theme-toggle>深色模式</button>
|
||||
</header>
|
||||
<div class="not-found">
|
||||
<p class="status">404</p>
|
||||
<h2>找不到頁面</h2>
|
||||
<p>你訪問的頁面 <code>{{ path }}</code> 不存在或已被移動,請檢查網址是否正確。</p>
|
||||
<a href="/">返回首頁</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
{% block layout_style %}
|
||||
<style>
|
||||
/* 應用外殼:左側固定側邊欄 + 右側主內容區;窄屏折疊為頂欄 */
|
||||
.shell { display: flex; min-height: 100vh; min-height: 100dvh; }
|
||||
.sidebar {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: 15rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.4rem 1rem;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
}
|
||||
.sidebar .brand h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; }
|
||||
.sidebar .brand a { color: inherit; text-decoration: none; }
|
||||
.sidebar .brand p { margin: .25rem 0 0; font-size: .78rem; opacity: .65; }
|
||||
.sidebar nav { display: flex; flex-direction: column; gap: .25rem; }
|
||||
.sidebar nav a {
|
||||
padding: .5rem .8rem;
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar nav a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
.sidebar nav a[aria-current="page"] {
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||
font-size: .85rem;
|
||||
}
|
||||
.sidebar-footer form { margin: 0; }
|
||||
.sidebar-footer .footer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
}
|
||||
.sidebar-footer .footer-actions a {
|
||||
font: inherit;
|
||||
padding: .3rem .9rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-footer .footer-actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
.sidebar-footer button {
|
||||
font: inherit;
|
||||
padding: .3rem .9rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sidebar-footer button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
.main {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 56rem;
|
||||
padding: 2.5rem 3rem;
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.shell { display: block; min-height: auto; }
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
width: auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .75rem 1.25rem;
|
||||
padding: .8rem 1rem;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar .brand p { display: none; }
|
||||
.sidebar nav { flex-direction: row; gap: .25rem; }
|
||||
.sidebar-footer {
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: .8rem;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
font-size: .8rem;
|
||||
}
|
||||
.main { padding: 1.5rem 1rem; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1><a href="/">alterdb</a></h1>
|
||||
<p>以 SQLite 為底層存儲的自定義數據庫引擎</p>
|
||||
</div>
|
||||
<nav aria-label="主導航">
|
||||
<a href="/"{% if nav_active == "index" %} aria-current="page"{% endif %}>首頁</a>
|
||||
<a href="/databases"{% if nav_active == "databases" %} aria-current="page"{% endif %}>數據庫</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<span>已登入:<strong>{{ user_display_name }}</strong>{% if user_display_name != user_username %} <code>{{ user_username }}</code>{% endif %}</span>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="theme-toggle" data-theme-toggle>深色模式</button>
|
||||
<a href="/password">變更密碼</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
{% block page %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}alterdb{% endblock %}</title>
|
||||
<script>
|
||||
// 首幀繪製前套用手動選擇的主題,避免閃爍;無記錄時跟隨系統偏好。
|
||||
// 必須內聯且同步執行,置於樣式之後、body 之前。
|
||||
(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
} catch (_) { /* 存儲不可用(如隱私模式)時跟隨系統 */ }
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
/* 手動固定主題:覆蓋 color-scheme 後,預設畫布/文字色與 light-dark() 一併翻轉 */
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* 獨立版式頁(登入 / 404)用的置中容器 */
|
||||
.page {
|
||||
max-width: 40rem;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
header.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
header.site-header h1 { margin: 0; }
|
||||
code { background: color-mix(in srgb, currentColor 10%, transparent); padding: 0.1em 0.4em; border-radius: 4px; }
|
||||
/* 深淺色切換按鈕(各版式自行放置,樣式與側欄按鈕一致) */
|
||||
.theme-toggle {
|
||||
font: inherit;
|
||||
font-size: .85rem;
|
||||
padding: .3rem .9rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-toggle:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
</style>
|
||||
{% block layout_style %}{% endblock %}
|
||||
{% block style %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
{% block script %}{% endblock %}
|
||||
<script>
|
||||
// 深淺色切換:全站共用,綁定所有帶 data-theme-toggle 的按鈕。
|
||||
// 主題固定在 <html data-theme>;未設置時由 color-scheme: light dark 跟隨系統。
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
function effectiveTheme() {
|
||||
const pinned = document.documentElement.getAttribute('data-theme');
|
||||
if (pinned === 'light' || pinned === 'dark') { return pinned; }
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// 按鈕文字顯示點擊後將切換到的模式
|
||||
function refreshLabels() {
|
||||
const label = effectiveTheme() === 'dark' ? '淺色模式' : '深色模式';
|
||||
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
|
||||
button.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
|
||||
button.addEventListener('click', () => {
|
||||
const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('theme', next); } catch (_) { /* 存儲不可用時僅本次生效 */ }
|
||||
refreshLabels();
|
||||
});
|
||||
}
|
||||
// 未手動固定主題時,頁面配色由 CSS 自動跟隨系統,這裡僅同步按鈕文字
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', refreshLabels);
|
||||
refreshLabels();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,252 @@
|
||||
{% extends "_shell.html" %}
|
||||
{% block title %}連接詳情:{{ database.name }} — 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; }
|
||||
.toolbar h2 { margin: 0; }
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.toolbar .actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: .6rem 1.4rem;
|
||||
margin: 0 0 1.8rem;
|
||||
padding: 1rem 1.2rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.meta div { min-width: 0; }
|
||||
.meta dt {
|
||||
font-size: .8rem;
|
||||
opacity: .65;
|
||||
margin: 0;
|
||||
}
|
||||
.meta dd { margin: 0; overflow-wrap: anywhere; }
|
||||
section h3 { margin: 0 0 .7rem; }
|
||||
.status-line { display: flex; align-items: center; gap: .8rem; margin-bottom: 1.6rem; }
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .45rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status::before {
|
||||
content: "";
|
||||
width: .55rem;
|
||||
height: .55rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.status.pending { opacity: .65; }
|
||||
.status.ok { color: light-dark(#146c2e, #6ddb8c); }
|
||||
.status.bad { color: light-dark(#b3261e, #ffb4ab); }
|
||||
#retest {
|
||||
font: inherit;
|
||||
padding: .3rem .9rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
#retest:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
#retest:disabled { opacity: .5; cursor: wait; }
|
||||
.status-detail { opacity: .8; font-size: .9rem; overflow-wrap: anywhere; }
|
||||
.error {
|
||||
margin: 0 0 1rem;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: 6px;
|
||||
color: light-dark(#b3261e, #ffb4ab);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.db-table th { font-weight: 600; }
|
||||
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block page %}
|
||||
<div id="db-detail" data-database-id="{{ database.id }}">
|
||||
<div class="toolbar">
|
||||
<div class="title">
|
||||
<h2>{{ database.name }}</h2>
|
||||
<span class="type-badge">{{ database.db_type.as_str() }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/databases/{{ database.id }}/edit">編輯</a>
|
||||
<a href="/databases">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="meta">
|
||||
<div><dt>主機</dt><dd>{{ database.host }}:{{ database.port }}</dd></div>
|
||||
<div><dt>資料庫</dt><dd>{{ database.database_name }}</dd></div>
|
||||
<div><dt>用戶</dt><dd>{{ database.username }}</dd></div>
|
||||
<div><dt>建立於</dt><dd>{{ created_at_display }}</dd></div>
|
||||
<div><dt>更新於</dt><dd>{{ updated_at_display }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<section aria-label="連接狀態">
|
||||
<h3>連接狀態</h3>
|
||||
<div class="status-line">
|
||||
<span id="status" class="status pending">測試中…</span>
|
||||
<span id="status-detail" class="status-detail"></span>
|
||||
<button type="button" id="retest">重新測試</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="數據表">
|
||||
<h3>數據表</h3>
|
||||
<p id="tables-error" class="error" role="alert" hidden></p>
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr><th>名稱</th><th>類型</th></tr>
|
||||
</thead>
|
||||
<tbody id="table-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const root = document.getElementById('db-detail');
|
||||
const databaseId = root.dataset.databaseId;
|
||||
const statusBadge = document.getElementById('status');
|
||||
const statusDetail = document.getElementById('status-detail');
|
||||
const retestButton = document.getElementById('retest');
|
||||
const tablesError = document.getElementById('tables-error');
|
||||
const tbody = document.getElementById('table-rows');
|
||||
|
||||
// information_schema 的 TABLE_TYPE → 中文標籤;未知值原樣展示
|
||||
const KIND_LABELS = { 'BASE TABLE': '表', 'VIEW': '視圖' };
|
||||
|
||||
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(text) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.className = 'empty';
|
||||
td.textContent = text;
|
||||
tr.append(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
retestButton.disabled = true;
|
||||
statusBadge.textContent = '測試中…';
|
||||
statusBadge.className = 'status pending';
|
||||
statusDetail.textContent = '';
|
||||
try {
|
||||
const outcome = await api('/api/databases/' + databaseId + '/test', { method: 'POST' });
|
||||
if (outcome.ok) {
|
||||
statusBadge.textContent = '已連接';
|
||||
statusBadge.className = 'status ok';
|
||||
statusDetail.textContent =
|
||||
(outcome.server_version || '') + ' · ' + outcome.elapsed_ms + ' ms';
|
||||
} else {
|
||||
statusBadge.textContent = '無法連接';
|
||||
statusBadge.className = 'status bad';
|
||||
statusDetail.textContent = outcome.error || '未知錯誤';
|
||||
}
|
||||
} catch (err) {
|
||||
statusBadge.textContent = '測試失敗';
|
||||
statusBadge.className = 'status bad';
|
||||
statusDetail.textContent = err.message;
|
||||
} finally {
|
||||
retestButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||||
function renderTables(tables) {
|
||||
if (tables.length === 0) {
|
||||
tbody.replaceChildren(messageRow('此資料庫目前 schema 下沒有數據表。'));
|
||||
return;
|
||||
}
|
||||
const rows = tables.map((table) => {
|
||||
const tr = document.createElement('tr');
|
||||
const name = document.createElement('td');
|
||||
// 表名連到表管理頁(欄位結構管理,資料瀏覽在其「數據管理」頁);路徑段編碼後拼接
|
||||
const link = document.createElement('a');
|
||||
link.href = '/databases/' + databaseId + '/tables/' + encodeURIComponent(table.name);
|
||||
link.textContent = table.name;
|
||||
name.append(link);
|
||||
const kind = document.createElement('td');
|
||||
kind.textContent = KIND_LABELS[table.kind] || table.kind;
|
||||
tr.append(name, kind);
|
||||
return tr;
|
||||
});
|
||||
tbody.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
async function loadTables() {
|
||||
tablesError.hidden = true;
|
||||
tbody.replaceChildren(messageRow('載入中…'));
|
||||
try {
|
||||
renderTables(await api('/api/databases/' + databaseId + '/tables'));
|
||||
} catch (err) {
|
||||
tbody.replaceChildren();
|
||||
tablesError.textContent = err.message;
|
||||
tablesError.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
runTest();
|
||||
loadTables();
|
||||
}
|
||||
|
||||
retestButton.addEventListener('click', refresh);
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "_shell.html" %}
|
||||
{% block title %}{% if let Some(db) = editing %}編輯連接:{{ db.name }}{% else %}新增連接{% endif %} — alterdb{% endblock %}
|
||||
{% block style %}
|
||||
<style>
|
||||
.form-card {
|
||||
padding: 1.4rem 1.6rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.form-card h2 { margin: 0 0 1.1rem; }
|
||||
.error {
|
||||
margin: 0 0 1rem;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: 6px;
|
||||
color: light-dark(#b3261e, #ffb4ab);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
.db-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem 1.4rem;
|
||||
}
|
||||
.db-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.db-form input, .db-form select {
|
||||
font: inherit;
|
||||
padding: .45rem .65rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.db-form .form-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: .6rem;
|
||||
}
|
||||
.db-form .form-actions button,
|
||||
.db-form .form-actions a {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: .5rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
.db-form .form-actions a {
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
}
|
||||
.db-form .form-actions button:hover,
|
||||
.db-form .form-actions a:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
|
||||
.db-form .hint { font-size: .85rem; opacity: .7; font-weight: 400; }
|
||||
@media (max-width: 34rem) { .db-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block page %}
|
||||
<div class="form-card">
|
||||
<h2>{% if let Some(db) = editing %}編輯連接:{{ db.name }}{% else %}新增連接{% endif %}</h2>
|
||||
<p id="form-error" class="error" role="alert" hidden></p>
|
||||
<form id="db-form" class="db-form"
|
||||
data-mode="{% if editing.is_some() %}edit{% else %}create{% endif %}"
|
||||
{% if let Some(db) = editing %}data-id="{{ db.id }}"{% endif %}>
|
||||
<label>名稱
|
||||
<input name="name" required maxlength="128" autocomplete="off"
|
||||
value="{% if let Some(db) = editing %}{{ db.name }}{% endif %}">
|
||||
</label>
|
||||
<label>類型
|
||||
<select name="type">
|
||||
<option value="mysql"{% if let Some(db) = editing %}{% if db.db_type.as_str() == "mysql" %} selected{% endif %}{% endif %}>MySQL</option>
|
||||
<option value="postgres"{% if let Some(db) = editing %}{% if db.db_type.as_str() == "postgres" %} selected{% endif %}{% endif %}>PostgreSQL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>主機
|
||||
<input name="host" required maxlength="255" autocomplete="off" placeholder="如 10.0.0.5 或 db.example.com"
|
||||
value="{% if let Some(db) = editing %}{{ db.host }}{% endif %}">
|
||||
</label>
|
||||
<label>埠
|
||||
<input name="port" type="number" min="1" max="65535" inputmode="numeric"
|
||||
value="{% if let Some(db) = editing %}{{ db.port }}{% endif %}">
|
||||
</label>
|
||||
<label>用戶名
|
||||
<input name="username" required maxlength="64" autocomplete="off"
|
||||
value="{% if let Some(db) = editing %}{{ db.username }}{% endif %}">
|
||||
</label>
|
||||
<label>密碼
|
||||
<input name="password" type="password" maxlength="128" autocomplete="new-password"
|
||||
placeholder="{% if editing.is_some() %}留空保留原密碼{% else %}可留空{% endif %}">
|
||||
</label>
|
||||
<label>資料庫名
|
||||
<input name="database_name" required maxlength="64" autocomplete="off"
|
||||
value="{% if let Some(db) = editing %}{{ db.database_name }}{% endif %}">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit">儲存</button>
|
||||
<a href="/databases">取消</a>
|
||||
</div>
|
||||
<p class="hint" style="grid-column: 1 / -1; margin: 0;">埠留空按類型補默認(MySQL 3306 / PostgreSQL 5432);密碼不會顯示與回填。</p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const TYPE_PORTS = { mysql: 3306, postgres: 5432 };
|
||||
|
||||
const form = document.getElementById('db-form');
|
||||
const formError = document.getElementById('form-error');
|
||||
const typeSelect = form.elements['type'];
|
||||
const portInput = form.elements['port'];
|
||||
const passwordInput = form.elements['password'];
|
||||
const editing = form.dataset.mode === 'edit';
|
||||
const editingId = form.dataset.id || null;
|
||||
|
||||
function updatePortHint() {
|
||||
portInput.placeholder = '留空使用默認 ' + TYPE_PORTS[typeSelect.value];
|
||||
}
|
||||
updatePortHint();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
formError.hidden = true;
|
||||
|
||||
const payload = {
|
||||
name: form.elements['name'].value.trim(),
|
||||
type: typeSelect.value,
|
||||
host: form.elements['host'].value.trim(),
|
||||
username: form.elements['username'].value.trim(),
|
||||
database_name: form.elements['database_name'].value.trim(),
|
||||
};
|
||||
const port = portInput.value.trim();
|
||||
if (port !== '') { payload.port = Number(port); }
|
||||
// 密碼留空時省略該鍵:新增視為空密碼(後端默認)、編輯保留原值
|
||||
if (passwordInput.value !== '') { payload.password = passwordInput.value; }
|
||||
|
||||
const url = editing ? '/api/databases/' + editingId : '/api/databases';
|
||||
try {
|
||||
await api(url, {
|
||||
method: editing ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
window.location.assign('/databases');
|
||||
} catch (err) {
|
||||
formError.textContent = err.message;
|
||||
formError.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
typeSelect.addEventListener('change', updatePortHint);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,178 @@
|
||||
{% extends "_shell.html" %}
|
||||
{% block title %}數據庫連接 — alterdb{% endblock %}
|
||||
{% block style %}
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.toolbar h2 { margin: 0; }
|
||||
.toolbar a, .db-table .actions a, .db-table .actions button {
|
||||
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;
|
||||
}
|
||||
.toolbar a:hover, .db-table .actions a:hover, .db-table .actions button:hover {
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
.db-table .actions { white-space: nowrap; }
|
||||
.db-table .actions a, .db-table .actions button {
|
||||
padding: .2rem .6rem;
|
||||
font-size: .85rem;
|
||||
margin-left: .3rem;
|
||||
}
|
||||
.db-table .actions .danger { color: light-dark(#b3261e, #ffb4ab); }
|
||||
.db-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 2rem;
|
||||
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;
|
||||
vertical-align: top;
|
||||
}
|
||||
.db-table th { font-weight: 600; }
|
||||
.db-table td.type { text-transform: uppercase; font-size: .8rem; letter-spacing: .04em; }
|
||||
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||||
.error {
|
||||
margin: 0 0 1rem;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: 6px;
|
||||
color: light-dark(#b3261e, #ffb4ab);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block page %}
|
||||
<div class="toolbar">
|
||||
<h2>數據庫連接</h2>
|
||||
<a href="/databases/new">新增連接</a>
|
||||
</div>
|
||||
<p id="list-error" class="error" role="alert" hidden></p>
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名稱</th><th>類型</th><th>主機:埠</th><th>資料庫</th><th>用戶</th><th>更新時間</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="db-rows"></tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const listError = document.getElementById('list-error');
|
||||
const tbody = document.getElementById('db-rows');
|
||||
|
||||
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 formatTime(iso) {
|
||||
return iso.replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
||||
}
|
||||
|
||||
function renderRows(databases) {
|
||||
const rows = [];
|
||||
if (databases.length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 7;
|
||||
td.className = 'empty';
|
||||
td.textContent = '尚無連接,點擊「新增連接」建立第一個。';
|
||||
tr.append(td);
|
||||
rows.push(tr);
|
||||
} else {
|
||||
for (const item of databases) { rows.push(renderRow(item)); }
|
||||
}
|
||||
tbody.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||||
function renderRow(item) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// 名稱連到詳情頁(連接狀態與數據表瀏覽)
|
||||
const nameCell = document.createElement('td');
|
||||
const nameLink = document.createElement('a');
|
||||
nameLink.href = '/databases/' + item.id;
|
||||
nameLink.textContent = item.name;
|
||||
nameCell.append(nameLink);
|
||||
tr.append(nameCell);
|
||||
|
||||
const cells = [
|
||||
{ text: item.type, className: 'type' },
|
||||
{ text: item.host + ':' + item.port },
|
||||
{ text: item.database_name },
|
||||
{ text: item.username },
|
||||
{ text: formatTime(item.updated_at) },
|
||||
];
|
||||
for (const cell of cells) {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = cell.text;
|
||||
if (cell.className) { td.className = cell.className; }
|
||||
tr.append(td);
|
||||
}
|
||||
|
||||
const actions = document.createElement('td');
|
||||
actions.className = 'actions';
|
||||
const editLink = document.createElement('a');
|
||||
editLink.href = '/databases/' + item.id + '/edit';
|
||||
editLink.textContent = '編輯';
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.type = 'button';
|
||||
deleteButton.textContent = '刪除';
|
||||
deleteButton.className = 'danger';
|
||||
deleteButton.addEventListener('click', () => removeDatabase(item));
|
||||
actions.append(editLink, deleteButton);
|
||||
tr.append(actions);
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listError.hidden = true;
|
||||
try {
|
||||
renderRows(await api('/api/databases'));
|
||||
} catch (err) {
|
||||
listError.textContent = err.message;
|
||||
listError.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDatabase(item) {
|
||||
if (!window.confirm('確定刪除連接「' + item.name + '」?')) { return; }
|
||||
try {
|
||||
await api('/api/databases/' + item.id, { method: 'DELETE' });
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
listError.textContent = err.message;
|
||||
listError.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadList();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "_shell.html" %}
|
||||
{% block page %}
|
||||
<p>服務:{{ service }} <code>v{{ version }}</code></p>
|
||||
<p>管理介面:</p>
|
||||
<ul>
|
||||
<li><a href="/databases">數據庫連接</a> — 管理 MySQL / PostgreSQL 連接</li>
|
||||
<li><a href="/api/health"><code>GET /api/health</code></a> — 健康檢查(含資料庫狀態)</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登入 — alterdb{% endblock %}
|
||||
{% block style %}
|
||||
<style>
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
max-width: 22rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 2rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.login-card h2 { margin: 0; }
|
||||
.login-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.login-card input {
|
||||
font: inherit;
|
||||
padding: .5rem .65rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.login-card button {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: .55rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
.login-card button:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
|
||||
.login-card .error {
|
||||
margin: 0;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: 6px;
|
||||
color: light-dark(#b3261e, #ffb4ab);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
.login-card .notice {
|
||||
margin: 0;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: 6px;
|
||||
color: light-dark(#146c2e, #6fdd8b);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
.login-card .hint { font-size: .85rem; opacity: .7; margin: 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
<h1>alterdb</h1>
|
||||
<button type="button" class="theme-toggle" data-theme-toggle>深色模式</button>
|
||||
</header>
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<h2>登入管理介面</h2>
|
||||
{% if let Some(message) = notice %}
|
||||
<p class="notice" role="status">{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if let Some(message) = error %}
|
||||
<p class="error" role="alert">{{ message }}</p>
|
||||
{% endif %}
|
||||
<label>用戶名
|
||||
<input type="text" name="username" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>密碼
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">登入</button>
|
||||
<p class="hint">默認管理員帳號為 <code>admin</code> / <code>admin</code>,建議登入後儘早於側邊欄變更密碼。</p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "_shell.html" %}
|
||||
{% block title %}變更密碼 — alterdb{% endblock %}
|
||||
{% block style %}
|
||||
<style>
|
||||
.password-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
max-width: 22rem;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.password-card h2 { margin: 0; }
|
||||
.password-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.password-card input {
|
||||
font: inherit;
|
||||
padding: .5rem .65rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.password-card button {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: .55rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
.password-card button:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
|
||||
.password-card .error {
|
||||
margin: 0;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: 6px;
|
||||
color: light-dark(#b3261e, #ffb4ab);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
.password-card .hint { font-size: .85rem; opacity: .7; margin: 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block page %}
|
||||
<form class="password-card" method="post" action="/password">
|
||||
<h2>變更密碼</h2>
|
||||
{% if let Some(message) = error %}
|
||||
<p class="error" role="alert">{{ message }}</p>
|
||||
{% endif %}
|
||||
<label>原密碼
|
||||
<input type="password" name="current_password" required autocomplete="current-password">
|
||||
</label>
|
||||
<label>新密碼
|
||||
<input type="password" name="new_password" required minlength="8" maxlength="128" autocomplete="new-password">
|
||||
</label>
|
||||
<label>確認新密碼
|
||||
<input type="password" name="confirm_password" required minlength="8" maxlength="128" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">更新密碼</button>
|
||||
<p class="hint">新密碼至少 8 個字符;更新成功後所有會話會失效,需以新密碼重新登入。</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,501 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,355 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user