Files
ChuSQL/chusql-web/static/app.js
T

1647 lines
60 KiB
JavaScript
Raw Normal View History

2026-10-01 20:48:38 +08:00
import * as api from './api.js';
import { AUTH_EVENT } from './api.js';
import {
DEFAULT_SETTINGS,
IDE_SETTINGS_KEY,
accountsTable,
cellText,
changeCount,
changeEntries,
changeKey,
changesSql,
clampSetting,
closeTab,
createEmptyChangeSet,
creatableTypes,
deleteKey,
editText,
escapeHtml,
fontFamilyValue,
fontsFromText,
fontsToText,
highlightHtml,
insertKey,
matchesFilter,
openQueryTab,
openSettingsTab,
openTableTab,
parseEdited,
parseUiSettings,
settingLabels,
toCommitPayload,
typeLabel,
} from './core.js';
// ChuSQL Web IDE 主逻辑:状态、渲染、事件代理与会话/数据/变更动作。
2026-10-01 20:48:38 +08:00
// ---------------------------------------------------------------- 状态
const state = {
view: 'loading', // loading | login | workbench
session: null,
databases: [],
database: '',
tables: [],
expanded: new Set(),
tabs: [],
activeId: undefined,
changes: createEmptyChangeSet(),
insertSeq: 0,
messages: [],
tabSeq: 0,
settings: { ...DEFAULT_SETTINGS },
serverSettings: null,
bottomTab: 'changes',
status: '',
loading: false,
sidebarWidth: 250,
dockHeight: '22vh',
};
const root = document.getElementById('root');
// ---------------------------------------------------------------- 小工具
// 按 id 取 DOM 元素。
2026-10-01 20:48:38 +08:00
function region(id) {
return document.getElementById(id);
}
// 当前时间(时分秒)。
2026-10-01 20:48:38 +08:00
function now() {
return new Date().toLocaleTimeString('zh-CN', { hour12: false });
}
// 记一条消息并刷新下方面板。
2026-10-01 20:48:38 +08:00
function log(level, text) {
state.messages.push({ level, text, time: now() });
if (state.messages.length > 200) state.messages.shift();
renderBottom();
}
// 设置状态栏文案。
2026-10-01 20:48:38 +08:00
function setStatus(text) {
state.status = text;
const el = region('status-message');
if (el) el.textContent = text;
}
// 取错误的文字描述。
2026-10-01 20:48:38 +08:00
function describe(error) {
return error instanceof Error ? error.message : String(error);
}
// 记录错误并写到状态栏。
2026-10-01 20:48:38 +08:00
function fail(error) {
const message = describe(error);
log('error', message);
setStatus(message);
return message;
}
// 生成下一个标签 id。
2026-10-01 20:48:38 +08:00
function nextTabId(prefix) {
state.tabSeq += 1;
return `${prefix}-${state.tabSeq}`;
}
// 当前激活的标签。
2026-10-01 20:48:38 +08:00
function activeTab() {
return state.tabs.find((tab) => tab.id === state.activeId);
}
// ---------------------------------------------------------------- 登录页
// 渲染登录页。
2026-10-01 20:48:38 +08:00
function renderLogin(message, level) {
state.view = 'login';
root.innerHTML = `
<div class="auth-view">
<form class="auth-card" id="login-form">
<header><span class="brand-mark">ChuSQL</span><h1>数据库终端</h1></header>
<label><span>账号</span><input id="login-user" name="user" autocomplete="username" autofocus /></label>
<label><span>口令</span><input id="login-password" name="password" type="password" autocomplete="current-password" /></label>
<p class="auth-hint">配置里口令留空时,管理员直接留空口令登录。</p>
${message ? `<p class="alert alert-${level ?? 'error'}">${escapeHtml(message)}</p>` : ''}
<button type="submit" class="primary"${state.loading ? ' disabled' : ''}>${state.loading ? '登录中…' : '登录'}</button>
</form>
</div>`;
}
// ---------------------------------------------------------------- 页面骨架
// 渲染工作台骨架与对话框。
2026-10-01 20:48:38 +08:00
function renderShell() {
state.view = 'workbench';
root.innerHTML = `
<div class="workbench" id="workbench">
<header class="titlebar">
<div class="brand"><span class="brand-mark">ChuSQL</span><span class="brand-name">数据库终端</span></div>
<nav class="titlebar-actions">
<button data-action="new-query" title="新建查询">新建查询</button>
<button data-action="demo-data" title="灌一份演示数据">演示数据</button>
<button data-action="refresh" title="重新加载当前库">刷新</button>
<button data-action="settings" title="IDE 与服务器设置">设置</button>
</nav>
<div class="account" id="account"></div>
</header>
<div class="activitybar">
<button data-action="focus-explorer" title="资源管理器">▤</button>
<button data-action="settings" title="设置">⚙</button>
</div>
<aside class="sidebar" id="sidebar"></aside>
<main class="editor" id="editor"></main>
<section class="bottom" id="bottom"></section>
<footer class="statusbar">
<span id="status-database"></span>
<span id="status-message"></span>
<span class="flex-spacer"></span>
<span id="status-changes"></span>
</footer>
<button class="splitter-v" data-split="v" title="调整侧栏宽度"></button>
<button class="splitter-h" data-split="h" title="调整下方面板高度"></button>
</div>
<dialog id="dlg-db" class="dialog">
<div class="dialog-body">
<header class="dialog-header"><h2>新建数据库</h2></header>
<div class="dialog-content">
<label class="field"><span>数据库名</span><input id="dlg-db-name" placeholder="字母或下划线开头" /></label>
<p class="alert" id="dlg-db-error"></p>
</div>
<footer class="dialog-footer">
<button type="button" data-action="close-dialog">取消</button>
<button type="button" class="primary" data-action="create-database">创建</button>
</footer>
</div>
</dialog>
<dialog id="dlg-table" class="dialog">
<div class="dialog-body">
<header class="dialog-header"><h2>新建表</h2></header>
<div class="dialog-content">
<label class="field"><span>表名</span><input id="dlg-table-name" placeholder="字母或下划线开头" /></label>
<div class="field"><span>列</span><div id="dlg-table-columns" class="column-rows"></div></div>
<button type="button" class="plain" data-action="add-column">+ 增加一列</button>
<p class="alert" id="dlg-table-error"></p>
</div>
<footer class="dialog-footer">
<button type="button" data-action="close-dialog">取消</button>
<button type="button" class="primary" data-action="create-table">创建</button>
</footer>
</div>
</dialog>
<dialog id="dlg-index" class="dialog">
<div class="dialog-body">
<header class="dialog-header"><h2>建索引</h2></header>
<div class="dialog-content">
<label class="field"><span>列</span><select id="dlg-index-column"></select></label>
<p class="alert" id="dlg-index-error"></p>
</div>
<footer class="dialog-footer">
<button type="button" data-action="close-dialog">取消</button>
<button type="button" class="primary" data-action="create-index">创建</button>
</footer>
</div>
</dialog>`;
renderAccount();
applyLayout();
}
// 应用侧栏宽度与下方面板高度。
2026-10-01 20:48:38 +08:00
function applyLayout() {
const workbench = region('workbench');
if (!workbench) return;
workbench.style.setProperty('--sidebar-width', `${state.sidebarWidth}px`);
workbench.style.setProperty('--dock-height', state.dockHeight);
}
// ---------------------------------------------------------------- 账号区
// 渲染右上角账号区。
2026-10-01 20:48:38 +08:00
function renderAccount() {
const el = region('account');
if (!el) return;
if (!state.session) {
el.innerHTML = '';
return;
}
const title = state.session.administrator ? '管理员' : '普通账号';
const badge = state.session.administrator ? ' · 管理员' : '';
el.innerHTML = `<span class="account-name" title="${title}">${escapeHtml(state.session.user)}${badge}</span>
<button data-action="logout">退出</button>`;
}
// ---------------------------------------------------------------- 侧栏
// 渲染资源管理器侧栏。
2026-10-01 20:48:38 +08:00
function renderSidebar() {
const el = region('sidebar');
if (!el) return;
const options = state.databases
.map((name) => `<option value="${escapeHtml(name)}"${name === state.database ? ' selected' : ''}>${escapeHtml(name)}</option>`)
.join('');
const databaseOptions = state.database ? options : `<option value="" selected>(未选择)</option>${options}`;
const rows = state.tables.map((table) => {
const open = state.expanded.has(table.name);
const hasId = table.columns.some((column) => column.name === 'id');
const columns = table.columns.map((column) => {
const marks = [
column.primaryKey || column.name === 'id' ? '<span class="mark-pk" title="主键">PK</span>' : '',
column.indexed ? '<span class="mark-ix" title="有索引">IX</span>' : '',
column.nullable ? '' : '<span class="mark-nn" title="非空">NN</span>',
].join('');
const drop = column.name === 'id'
? ''
: `<button class="mini danger" data-action="drop-column" data-table="${escapeHtml(table.name)}" data-column="${escapeHtml(column.name)}" title="删除列">✕</button>`;
return `<li><span class="col-name">${escapeHtml(column.name)}</span>
<span class="col-type">${escapeHtml(typeLabel(column.type))}</span>
<span class="col-marks">${marks}</span>${drop}</li>`;
}).join('');
const own = (table.indexes ?? []).filter((index) => !index.builtIn && index.column !== 'id');
const indexList = own.length
? `<ul class="index-list">${own.map((index) => `<li><span>索引 ${escapeHtml(index.column)}</span>
<button class="mini danger" data-action="drop-index" data-table="${escapeHtml(table.name)}" data-column="${escapeHtml(index.column)}" title="删除索引">✕</button></li>`).join('')}</ul>`
: '';
return `<li class="tree-table">
<div class="tree-row">
<button class="mini toggle" data-action="expand-table" data-table="${escapeHtml(table.name)}" title="${open ? '收起' : '展开'}">${open ? '▾' : '▸'}</button>
<button class="tree-label" data-action="open-table" data-table="${escapeHtml(table.name)}" title="${escapeHtml(table.name)}">${escapeHtml(table.name)}</button>
<small>${table.rowCount}</small>
${hasId ? '' : '<small class="warn" title="没有 id 列,无法编辑或删除">只读</small>'}
<button class="mini danger" data-action="drop-table" data-table="${escapeHtml(table.name)}" title="删除表">✕</button>
</div>
${open ? `<ul class="column-list">${columns}</ul>${indexList}
<div class="tree-actions"><button class="mini" data-action="open-index-dialog" data-table="${escapeHtml(table.name)}">+ 索引</button></div>` : ''}
</li>`;
}).join('');
const count = changeCount(state.changes);
el.innerHTML = `
<h2>资源管理器</h2>
<section class="side-section">
<div class="side-title">数据库</div>
<div class="side-row">
<select id="database-select" data-action="switch-database">${databaseOptions}</select>
<button class="mini" data-action="open-db-dialog" title="新建数据库">+</button>
<button class="mini danger" data-action="drop-database" title="删除当前数据库">✕</button>
</div>
</section>
<section class="side-section">
<div class="side-title">表<span class="side-actions">
<button class="mini" data-action="open-table-dialog" title="新建表">+</button>
<button class="mini" data-action="refresh" title="刷新">⟳</button>
</span></div>
${state.tables.length ? `<ul class="table-list">${rows}</ul>` : `<p class="muted">${state.database ? '这个库还没有表。' : '未选择数据库。'}</p>`}
</section>
<section class="side-section">
<div class="side-title">变更</div>
<div class="side-row">
<button data-action="commit"${count ? '' : ' disabled'}>提交 ${count}</button>
<button data-action="rollback"${count ? '' : ' disabled'}>撤销</button>
</div>
</section>`;
}
// ---------------------------------------------------------------- 标签栏
// 渲染标签栏并刷新内容区。
2026-10-01 20:48:38 +08:00
function renderTabs() {
const el = region('editor');
if (!el) return;
const tabs = state.tabs.map((tab) => `
<span class="tab${tab.id === state.activeId ? ' tab-active' : ''}">
<button class="tab-label" data-action="activate-tab" data-tab="${tab.id}" title="${escapeHtml(tab.title)}">${escapeHtml(tab.title)}</button>
<button class="mini" data-action="close-tab" data-tab="${tab.id}" title="关闭">✕</button>
</span>`).join('');
el.innerHTML = `
<div class="tabbar">${tabs || '<span class="muted tab-empty">没有打开的标签页</span>'}</div>
<div class="tab-content" id="tab-content"></div>`;
renderEditor();
}
// ---------------------------------------------------------------- 编辑器区
// 渲染当前标签的内容。
2026-10-01 20:48:38 +08:00
function renderEditor() {
const el = region('tab-content');
if (!el) return;
const tab = activeTab();
if (!tab) {
el.innerHTML = `<div class="welcome"><div>
<h1>ChuSQL 数据库终端</h1>
<p>从左边选一张表,或者 <button class="link" data-action="new-query">新建查询</button>。</p>
</div></div>`;
return;
}
if (tab.kind === 'table') renderTableTab(el, tab);
else if (tab.kind === 'sql') renderSqlTab(el, tab);
else renderSettingsTab(el, tab);
}
// ---- 表格标签
// 单元格显示内容的 HTML。
2026-10-01 20:48:38 +08:00
function cellContent(value, nullText) {
if (value === null || value === undefined) return `<span class="null-value">${escapeHtml(nullText)}</span>`;
return escapeHtml(cellText(value, nullText));
}
// 渲染表格标签(工具栏、表头、数据行)。
2026-10-01 20:48:38 +08:00
function renderTableTab(el, tab) {
const data = tab.data;
if (!data) {
el.innerHTML = '<p class="notice">正在加载…</p>';
return;
}
if (data.error) {
el.innerHTML = `<p class="notice alert alert-error">${escapeHtml(data.error)}</p>`;
return;
}
const columns = data.columns;
const keyColumn = columns.find((column) => column.name === 'id');
const editable = Boolean(keyColumn);
const filters = data.filters ?? {};
const count = changeCount(state.changes);
const head = columns.map((column) => {
const arrow = data.sort === column.name ? (data.dir === 'desc' ? ' ▼' : ' ▲') : '';
return `<th><button class="column-head" data-action="sort-column" data-column="${escapeHtml(column.name)}" title="排序">${escapeHtml(column.name)}${arrow}</button>
<small>${escapeHtml(typeLabel(column.type))}</small></th>`;
}).join('');
const filterRow = columns.map((column) => `<th><input class="filter-input" data-action="filter-column" data-column="${escapeHtml(column.name)}" value="${escapeHtml(filters[column.name] ?? '')}" placeholder="筛选" /></th>`).join('');
const stagedInserts = [...state.changes.inserts.values()].filter((insert) => insert.table === tab.table && insert.database === tab.database);
const insertRows = stagedInserts.map((insert) => {
const cells = columns.map((column) => `<td class="inserted" data-action="edit-insert" data-temp="${escapeHtml(insert.tempId)}" data-column="${escapeHtml(column.name)}">${cellContent(insert.values[column.name], state.settings.nullText)}</td>`).join('');
return `<tr class="row-inserted">
<td class="row-number">+</td>
<td class="select-cell"><button class="mini danger" data-action="unstage-insert" data-temp="${escapeHtml(insert.tempId)}" title="取消这一行">✕</button></td>
${cells}</tr>`;
}).join('');
const body = data.rows.map((row, index) => {
const pk = keyColumn ? row[keyColumn.name] : undefined;
const removed = keyColumn ? state.changes.deletes.has(deleteKey(tab.table, pk)) : false;
const selected = tab.selected?.has(String(pk)) === true;
const cells = columns.map((column) => {
const staged = keyColumn ? state.changes.updates.get(changeKey(tab.table, pk, column.name)) : undefined;
const value = staged ? staged.newValue : row[column.name];
const action = editable ? ` data-action="edit-cell" data-pk="${escapeHtml(pk)}" data-column="${escapeHtml(column.name)}"` : '';
return `<td class="${staged ? 'dirty' : ''}"${action}>${cellContent(value, state.settings.nullText)}</td>`;
}).join('');
const checkbox = editable
? `<td class="select-cell"><input type="checkbox" data-action="select-row" data-pk="${escapeHtml(pk)}"${selected ? ' checked' : ''} /></td>`
: '<td class="select-cell"></td>';
return `<tr class="${removed ? 'row-deleted' : ''}">
<td class="row-number">${data.offset + index + 1}</td>
${checkbox}
${cells}</tr>`;
}).join('');
const pageCount = Math.max(1, Math.ceil(data.total / data.pageSize));
el.innerHTML = `
<div class="table-editor">
<div class="toolbar">
<button data-action="insert-row"${editable ? '' : ' disabled'}>+ 新增行</button>
<button data-action="delete-selected"${editable && (tab.selected?.size ?? 0) ? '' : ' disabled'}>删除选中</button>
<span class="sep"></span>
<button class="primary" data-action="commit"${count ? '' : ' disabled'}>提交 (${count})</button>
<button data-action="rollback"${count ? '' : ' disabled'}>撤销</button>
<span class="sep"></span>
<button data-action="reload-table">刷新</button>
<span class="flex-spacer"></span>
<label class="inline">每页
<select data-action="change-page-size">
${[50, 100, 200, 500].map((size) => `<option value="${size}"${size === data.pageSize ? ' selected' : ''}>${size}</option>`).join('')}
</select>
</label>
<button data-action="prev-page"${data.page <= 0 ? ' disabled' : ''}>上一页</button>
<span class="page-info">第 ${data.page + 1} / ${pageCount} 页 · 共 ${data.total} 行</span>
<button data-action="next-page"${data.page + 1 >= pageCount ? ' disabled' : ''}>下一页</button>
</div>
<div class="grid-scroll">
<table class="data-grid">
<thead>
<tr><th class="row-number"></th><th class="select-cell"></th>${head}</tr>
<tr class="filter-row"><th class="row-number"></th><th class="select-cell"></th>${filterRow}</tr>
</thead>
<tbody>${insertRows}${body}</tbody>
</table>
${data.rows.length || stagedInserts.length ? '' : '<p class="empty">这张表没有数据。</p>'}
</div>
${data.loading ? '<div class="grid-loading">加载中…</div>' : ''}
${editable ? '' : '<p class="notice">这张表没有 id 列,只能浏览。</p>'}
</div>`;
}
// ---- SQL 标签
// 生成行号文本。
2026-10-01 20:48:38 +08:00
function lineNumbers(sql) {
const count = String(sql ?? '').split('\n').length;
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
}
// 渲染 SQL 控制台标签。
2026-10-01 20:48:38 +08:00
function renderSqlTab(el, tab) {
const result = tab.result;
// 结果区 HTML。
2026-10-01 20:48:38 +08:00
const resultHtml = (() => {
if (!result) return '<p class="muted">按 Ctrl+Enter 运行,或点上面的"运行"。</p>';
if (result.type === 'error') return `<p class="alert alert-error">${escapeHtml(result.message ?? '执行失败')}</p>`;
if (result.type === 'affected') return `<p class="muted">影响 ${result.affected ?? 0} 行 · ${result.durationMs.toFixed(1)} ms</p>`;
const head = result.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('');
const body = result.rows
.map((row) => `<tr>${row.map((value) => `<td>${cellContent(value, state.settings.nullText)}</td>`).join('')}</tr>`)
.join('');
return `<div class="result-scroll"><table class="result-grid"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>
<p class="muted">${result.rows.length} 行${result.truncated ? '(已截断)' : ''} · ${result.durationMs.toFixed(1)} ms</p>`;
})();
el.innerHTML = `
<div class="sql-console">
<div class="toolbar">
<button class="primary" data-action="run-sql">运行</button>
<button data-action="clear-sql">清空</button>
<span class="sep"></span>
<span class="muted">库 ${escapeHtml(tab.database ?? state.database)}</span>
</div>
<div class="sql-editor">
${state.settings.sqlLineNumbers ? `<pre class="sql-gutter" id="sql-gutter">${lineNumbers(tab.sql)}</pre>` : ''}
<div class="sql-layer">
<pre class="sql-highlight" id="sql-highlight" aria-hidden="true">${highlightHtml(tab.sql)}</pre>
<textarea class="sql-input" id="sql-input" spellcheck="false" wrap="off" placeholder="SELECT * FROM users">${escapeHtml(tab.sql)}</textarea>
</div>
${state.settings.minimap ? '<div class="sql-minimap" id="sql-minimap" title="缩略图"><div class="minimap-thumb"></div></div>' : ''}
</div>
<div class="sql-result" id="sql-result">${resultHtml}</div>
</div>`;
const textarea = region('sql-input');
const highlight = region('sql-highlight');
const gutter = region('sql-gutter');
const minimap = region('sql-minimap');
if (!textarea) return;
// 刷新高亮层与行号。
2026-10-01 20:48:38 +08:00
const refreshLayers = () => {
if (highlight) highlight.innerHTML = highlightHtml(tab.sql);
if (gutter) gutter.textContent = lineNumbers(tab.sql);
sync();
};
// 同步滚动位置与缩略图。
2026-10-01 20:48:38 +08:00
const sync = () => {
if (highlight) {
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
}
if (gutter) gutter.scrollTop = textarea.scrollTop;
if (minimap) {
const thumb = minimap.querySelector('.minimap-thumb');
if (!thumb) return;
const visible = Math.max(8, (textarea.clientHeight / Math.max(1, textarea.scrollHeight)) * 100);
const ratio = textarea.scrollHeight > textarea.clientHeight
? textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight)
: 0;
thumb.style.height = `${visible}%`;
thumb.style.top = `${Math.min(100 - visible, ratio * (100 - visible))}%`;
}
};
// 输入时更新 SQL 并刷新高亮。
2026-10-01 20:48:38 +08:00
textarea.addEventListener('input', () => {
tab.sql = textarea.value;
refreshLayers();
});
// 滚动时同步各层。
2026-10-01 20:48:38 +08:00
textarea.addEventListener('scroll', sync);
// Tab 缩进,Ctrl+Enter 运行。
2026-10-01 20:48:38 +08:00
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
event.preventDefault();
const spaces = ' '.repeat(state.settings.sqlTabSize);
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = `${textarea.value.slice(0, start)}${spaces}${textarea.value.slice(end)}`;
textarea.selectionStart = start + spaces.length;
textarea.selectionEnd = start + spaces.length;
tab.sql = textarea.value;
refreshLayers();
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void runSql(tab);
}
});
sync();
}
// ---- 设置标签
// 渲染设置标签页。
2026-10-01 20:48:38 +08:00
function renderSettingsTab(el) {
const settings = state.settings;
// 字体链输入框。
2026-10-01 20:48:38 +08:00
const fontField = (key) => `
<label class="field"><span>${escapeHtml(settingLabels[key])}</span>
<input data-setting="fonts" data-key="${key}" value="${escapeHtml(fontsToText(settings[key]))}" placeholder="字体名用逗号分隔" /></label>`;
// 数值输入框。
2026-10-01 20:48:38 +08:00
const numberField = (key) => `
<label class="field"><span>${escapeHtml(settingLabels[key])}</span>
<input type="number" data-setting="number" data-key="${key}" value="${settings[key]}" /></label>`;
// 布尔开关。
2026-10-01 20:48:38 +08:00
const boolField = (key) => `
<label class="toggle"><input type="checkbox" data-setting="boolean" data-key="${key}"${settings[key] ? ' checked' : ''} /><span>${escapeHtml(settingLabels[key])}</span></label>`;
// 服务器设置分组表单。
2026-10-01 20:48:38 +08:00
const serverHtml = (() => {
const server = state.serverSettings;
if (!server) return '<p class="muted">正在读取服务器设置…</p>';
if (!server.items.length) return `<p class="alert alert-error">${escapeHtml(server.error ?? '没有可显示的设置。')}</p>`;
const groups = [];
for (const item of server.items) {
let group = groups.find((entry) => entry.name === item.group);
if (!group) {
group = { name: item.group, items: [] };
groups.push(group);
}
group.items.push(item);
}
return groups.map((group) => `
<fieldset class="settings-group"><legend>${escapeHtml(group.name)}</legend>
${group.items.map((item) => `
<label class="field"><span>${escapeHtml(item.label)}${item.rootOnly ? ' <em>(管理员)</em>' : ''}</span>
<input data-server-setting="${escapeHtml(item.key)}" value="${escapeHtml(item.kind === 'secret' ? '' : item.value)}"${item.editable ? '' : ' disabled'} placeholder="${escapeHtml(item.default)}" />
<small class="muted">来源:${escapeHtml(item.source)}${item.restart ? ' · 需重启' : item.live ? ' · 立即生效' : ''}${item.locked ? ' · 文件锁定' : ''}</small>
</label>`).join('')}
</fieldset>`).join('');
})();
el.innerHTML = `
<div class="settings-view">
<header><h1>设置</h1><p class="muted">IDE 设置存在 chusql.ui.settings.json,服务器设置写回 settings.toml 的 [web] 分区。</p></header>
2026-10-01 20:48:38 +08:00
<fieldset class="settings-group"><legend>界面</legend>
${fontField('uiFonts')}${numberField('uiFontSize')}
</fieldset>
<fieldset class="settings-group"><legend>数据表格</legend>
${fontField('gridFonts')}${numberField('gridFontSize')}${numberField('gridRowHeight')}${numberField('pageSize')}
<label class="field"><span>${escapeHtml(settingLabels.nullText)}</span>
<input data-setting="text" data-key="nullText" value="${escapeHtml(settings.nullText)}" /></label>
</fieldset>
<fieldset class="settings-group"><legend>SQL 控制台</legend>
${fontField('sqlFonts')}${numberField('sqlFontSize')}${numberField('sqlLineHeight')}${numberField('sqlTabSize')}
${boolField('sqlLineNumbers')}${boolField('autocomplete')}${boolField('minimap')}
</fieldset>
<div class="preview" id="settings-preview">
<strong>预览</strong>
<div>SELECT * FROM users WHERE id = 1;</div>
</div>
<footer class="settings-footer"><button data-action="reset-settings">恢复默认</button></footer>
<section class="server-settings">${serverHtml}
<footer class="settings-footer">
<button class="primary" data-action="save-server-settings">保存服务器设置</button>
<span class="muted" id="server-settings-note"></span>
</footer>
</section>
</div>`;
const preview = region('settings-preview');
if (preview) {
preview.style.fontFamily = fontFamilyValue(settings.gridFonts);
preview.style.fontSize = `${settings.gridFontSize}px`;
preview.style.lineHeight = `${settings.gridRowHeight}px`;
}
}
// ---------------------------------------------------------------- 下方面板
// 渲染下方面板(变更/消息)。
2026-10-01 20:48:38 +08:00
function renderBottom() {
const el = region('bottom');
if (!el) return;
const count = changeCount(state.changes);
const entries = changeEntries(state.changes);
const preview = entries.map((entry) => entry.sql);
const changeList = entries.length
? `<ul class="change-list">${entries.map((entry) => `
<li>
<span class="change-table">${escapeHtml(entry.table)}</span>
<code title="${escapeHtml(entry.sql)}">${escapeHtml(entry.sql)}</code>
<button class="mini danger" data-action="unstage" data-key="${escapeHtml(entry.key)}" title="撤下这一条">✕</button>
</li>`).join('')}</ul>
<details class="sql-preview"><summary>预览生成的全部 SQL(${preview.length} 条)</summary>
<pre>${escapeHtml(preview.join('\n'))}</pre></details>`
: '<p class="muted">没有待提交的变更。编辑单元格、插入或删除行都会先记在这里。</p>';
const messages = state.messages.length
? `<ul class="message-list">${state.messages.slice(-100).map((message) => `
<li class="msg-${message.level}"><span class="msg-time">${escapeHtml(message.time)}</span>${escapeHtml(message.text)}</li>`).join('')}</ul>`
: '<p class="muted">还没有消息。</p>';
el.innerHTML = `
<div class="bottom-head">
<button class="${state.bottomTab === 'changes' ? 'active' : ''}" data-action="bottom-tab" data-tab="changes">待提交变更 (${count})</button>
<button class="${state.bottomTab === 'messages' ? 'active' : ''}" data-action="bottom-tab" data-tab="messages">消息 (${state.messages.length})</button>
<span class="flex-spacer"></span>
<button data-action="commit"${count ? '' : ' disabled'}>提交全部</button>
<button data-action="rollback"${count ? '' : ' disabled'}>撤销全部</button>
<button data-action="copy-sql"${count ? '' : ' disabled'}>复制 SQL</button>
</div>
<div class="bottom-body">${state.bottomTab === 'changes' ? changeList : messages}</div>`;
const dbEl = region('status-database');
if (dbEl) dbEl.textContent = state.database ? `库 ${state.database}` : '未选择数据库';
const changeEl = region('status-changes');
if (changeEl) changeEl.textContent = `待提交 ${count}`;
}
// ---------------------------------------------------------------- 数据加载
// 读取并缓存当前会话。
2026-10-01 20:48:38 +08:00
async function refreshSession() {
try {
state.session = await api.currentSession();
return true;
} catch {
state.session = null;
return false;
}
}
// 加载数据库列表与当前库的表。
2026-10-01 20:48:38 +08:00
async function loadWorkspace() {
try {
state.databases = await api.listDatabases();
if (!state.databases.includes(state.database)) {
// 没有默认库:优先留在原来的库,否则挑第一个非系统库,一个都没有就空着
state.database = state.databases.filter((name) => name !== 'system')[0] ?? '';
}
state.tables = state.database ? await api.listTables(state.database) : [];
renderSidebar();
renderBottom();
setStatus(state.database ? `已加载 ${state.tables.length} 张表` : '未选择数据库:选一个库,或新建一个');
} catch (error) {
fail(error);
}
}
// 切换当前数据库。
2026-10-01 20:48:38 +08:00
async function switchDatabase(name) {
if (name === state.database) return;
if (changeCount(state.changes) > 0) {
log('warn', '请先提交或撤销当前库的修改,再切换数据库。');
renderSidebar();
return;
}
const result = await api.query(`USE ${name}`, state.database);
if (result.type === 'error') {
fail(new Error(result.message));
renderSidebar();
return;
}
state.database = result.database ?? name;
log('info', `已切换到数据库 ${state.database}`);
await loadWorkspace();
renderEditor();
}
// 打开表标签并加载数据。
2026-10-01 20:48:38 +08:00
async function openTable(database, name) {
const opened = openTableTab(state.tabs, database, name, nextTabId('table'));
state.tabs = opened.tabs;
state.activeId = opened.activeId;
const tab = state.tabs.find((entry) => entry.id === opened.activeId);
if (!tab.data) {
tab.data = {
columns: [], rows: [], total: 0, page: 0, pageSize: state.settings.pageSize,
sort: null, dir: 'asc', filters: {}, offset: 0, loading: true,
};
tab.selected = new Set();
}
tab.database = database;
renderTabs();
await loadTableData(tab);
}
// 加载表标签的一页数据。
2026-10-01 20:48:38 +08:00
async function loadTableData(tab) {
const data = tab.data;
data.loading = true;
renderEditor();
try {
const meta = await api.getTable(tab.database, tab.table);
const filters = Object.entries(data.filters ?? {})
.filter(([, expression]) => String(expression).trim().length > 0)
.map(([column, expression]) => ({ column, expression }));
const pageSize = data.pageSize || state.settings.pageSize;
const offset = data.page * pageSize;
const page = await api.fetchRows({
database: tab.database,
table: tab.table,
limit: pageSize,
offset,
sort: data.sort,
dir: data.dir,
filters,
});
data.columns = meta.columns;
data.total = page.total;
data.offset = offset;
data.pageSize = pageSize;
data.error = null;
// 服务端做等值过滤,界面上的 like / 比较表达式再在本地收敛一次
data.rows = filters.length
? page.rows.filter((row) => filters.every((filter) => matchesFilter(row[filter.column], filter.expression)))
: page.rows;
if (!data.rows.length && data.page > 0) data.page -= 1;
} catch (error) {
data.error = describe(error);
} finally {
data.loading = false;
renderEditor();
renderBottom();
}
}
// 重新加载所有表标签。
2026-10-01 20:48:38 +08:00
async function reloadOpenTables() {
for (const tab of state.tabs) {
if (tab.kind === 'table') await loadTableData(tab);
}
}
// ---------------------------------------------------------------- 变更动作
// 撤下某行的全部变更。
2026-10-01 20:48:38 +08:00
function unstageRow(table, pk) {
const prefix = `${table}:${JSON.stringify(pk)}:`;
for (const key of [...state.changes.updates.keys()]) {
if (key.startsWith(prefix)) state.changes.updates.delete(key);
}
state.changes.deletes.delete(deleteKey(table, pk));
}
// 暂存单元格修改。
2026-10-01 20:48:38 +08:00
function stageUpdate(table, database, pk, column, oldValue, newValue) {
if (oldValue === newValue) state.changes.updates.delete(changeKey(table, pk, column));
else state.changes.updates.set(changeKey(table, pk, column), { table, database, pk, column, oldValue, newValue });
afterChange();
}
// 暂存新增行。
2026-10-01 20:48:38 +08:00
function stageInsert(table, database, values) {
state.insertSeq += 1;
const tempId = `new-${state.insertSeq}`;
state.changes.inserts.set(insertKey(table, tempId), { table, database, tempId, values });
afterChange();
}
// 暂存删除行。
2026-10-01 20:48:38 +08:00
function stageDelete(table, database, pk, row) {
unstageRow(table, pk);
state.changes.deletes.set(deleteKey(table, pk), { table, database, pk, row });
afterChange();
}
// 变更后重绘相关区域。
2026-10-01 20:48:38 +08:00
function afterChange() {
renderSidebar();
renderEditor();
renderBottom();
}
// 撤下一条暂存变更。
2026-10-01 20:48:38 +08:00
function removeStaged(key) {
if (state.changes.updates.has(key)) state.changes.updates.delete(key);
else if (state.changes.inserts.has(key)) state.changes.inserts.delete(key);
else if (state.changes.deletes.has(key)) state.changes.deletes.delete(key);
afterChange();
}
// 撤销全部暂存变更。
2026-10-01 20:48:38 +08:00
function rollbackAll() {
state.changes = createEmptyChangeSet();
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
log('info', '已撤销全部待提交变更。');
afterChange();
}
// 提交全部暂存变更。
2026-10-01 20:48:38 +08:00
async function commitAll() {
const payload = toCommitPayload(state.changes);
if (!payload.length) {
log('info', '没有待提交的变更。');
return;
}
const database = state.database;
const result = await api.commitChanges(database, payload);
if (result.ok) {
state.changes = createEmptyChangeSet();
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
log('info', `已提交 ${result.applied} 条变更。`);
setStatus(`已提交 ${result.applied} 条变更`);
} else {
const failed = result.errors?.[0];
// 成功的部分不再保留,失败的那条留在面板上等修正
state.changes = createEmptyChangeSet();
payload.slice(result.applied).forEach((change) => {
if (change.op === 'insert') {
state.insertSeq += 1;
const tempId = `new-${state.insertSeq}`;
state.changes.inserts.set(insertKey(change.table, tempId), { table: change.table, database, tempId, values: change.values });
} else if (change.op === 'delete') {
state.changes.deletes.set(deleteKey(change.table, change.pk), { table: change.table, database, pk: change.pk, row: {} });
} else {
for (const [column, value] of Object.entries(change.set)) {
state.changes.updates.set(changeKey(change.table, change.pk, column), {
table: change.table, database, pk: change.pk, column, oldValue: undefined, newValue: value,
});
}
}
});
log('error', `提交在第 ${(failed?.index ?? 0) + 1} 条失败:${failed?.message ?? '未知错误'}(已成功 ${result.applied} 条)`);
}
await loadWorkspace();
await reloadOpenTables();
afterChange();
}
// ---------------------------------------------------------------- SQL 运行
// 运行 SQL 并展示结果。
2026-10-01 20:48:38 +08:00
async function runSql(tab) {
const sql = String(tab.sql ?? '').trim();
if (!sql) return;
setStatus('执行中…');
const result = await api.query(sql, tab.database);
tab.result = result;
if (result.type === 'error') {
log('error', result.message ?? '执行失败');
setStatus(result.message ?? '执行失败');
} else {
if (result.database && result.database !== state.database) {
state.database = result.database;
tab.database = result.database;
await loadWorkspace();
}
if (result.type === 'rows') {
log('info', `查询返回 ${result.rows.length} 行`);
setStatus(`${result.rows.length} 行 · ${result.durationMs.toFixed(1)} ms`);
} else {
log('info', `语句影响 ${result.affected ?? 0} 行`);
setStatus(`影响 ${result.affected ?? 0} 行`);
await loadWorkspace();
await reloadOpenTables();
}
}
renderEditor();
}
// ---------------------------------------------------------------- 标签动作
// 激活指定标签。
2026-10-01 20:48:38 +08:00
function activateTab(id) {
state.activeId = id;
const tab = activeTab();
if (tab?.kind === 'sql') tab.database = state.database;
if (tab?.kind === 'settings' && !state.serverSettings) void loadServerSettings();
renderTabs();
}
// 关闭指定标签。
2026-10-01 20:48:38 +08:00
function closeTabById(id) {
const closed = closeTab(state.tabs, id, state.activeId);
state.tabs = closed.tabs;
state.activeId = closed.activeId;
renderTabs();
}
// 新开一个查询标签。
2026-10-01 20:48:38 +08:00
function openQuery(sql) {
const title = `查询 ${state.tabs.filter((tab) => tab.kind === 'sql').length + 1}`;
const opened = openQueryTab(state.tabs, title, nextTabId('sql'), state.database, sql ?? '');
state.tabs = opened.tabs;
state.activeId = opened.activeId;
renderTabs();
}
// 打开设置标签。
2026-10-01 20:48:38 +08:00
function openSettings() {
const opened = openSettingsTab(state.tabs, '设置', nextTabId('settings'));
state.tabs = opened.tabs;
state.activeId = opened.activeId;
renderTabs();
if (!state.serverSettings) void loadServerSettings();
}
// ---------------------------------------------------------------- 设置动作
// 把 IDE 设置写到 CSS 变量。
2026-10-01 20:48:38 +08:00
function applySettingsToDom() {
const style = document.documentElement.style;
style.setProperty('--ui-font', fontFamilyValue(state.settings.uiFonts));
style.setProperty('--ui-font-size', `${state.settings.uiFontSize}px`);
style.setProperty('--grid-font', fontFamilyValue(state.settings.gridFonts));
style.setProperty('--grid-font-size', `${state.settings.gridFontSize}px`);
style.setProperty('--grid-row-height', `${state.settings.gridRowHeight}px`);
style.setProperty('--sql-font', fontFamilyValue(state.settings.sqlFonts));
style.setProperty('--sql-font-size', `${state.settings.sqlFontSize}px`);
style.setProperty('--sql-line-height', `${state.settings.sqlLineHeight}px`);
style.setProperty('--sql-tab-size', String(state.settings.sqlTabSize));
}
let saveTimer;
// 保存 IDE 设置(本地+服务器)。
2026-10-01 20:48:38 +08:00
function persistSettings() {
try {
localStorage.setItem(IDE_SETTINGS_KEY, JSON.stringify(state.settings));
} catch {
// 本地存储不可用时忽略:服务器那边仍会保存
}
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
api.saveUiSettings(state.settings).catch((error) => log('warn', `IDE 设置没能保存到服务器:${describe(error)}`));
}, 400);
}
// 更新一项 IDE 设置。
2026-10-01 20:48:38 +08:00
function updateSetting(key, value) {
state.settings = { ...state.settings, [key]: value };
applySettingsToDom();
persistSettings();
renderEditor();
}
// 从本地存储读 IDE 设置。
2026-10-01 20:48:38 +08:00
function loadLocalSettings() {
try {
const raw = localStorage.getItem(IDE_SETTINGS_KEY);
if (!raw) return;
state.settings = { ...state.settings, ...parseUiSettings(JSON.parse(raw)) };
} catch {
// 本地设置坏了就用默认值
}
}
// 从服务器读 IDE 设置。
2026-10-01 20:48:38 +08:00
async function loadRemoteSettings() {
try {
state.settings = { ...state.settings, ...parseUiSettings(await api.loadUiSettings()) };
applySettingsToDom();
} catch (error) {
log('warn', `IDE 设置没能从服务器读取:${describe(error)}`);
}
}
// 读取服务器设置。
2026-10-01 20:48:38 +08:00
async function loadServerSettings() {
try {
state.serverSettings = await api.loadServerSettings();
} catch (error) {
state.serverSettings = { error: describe(error), items: [] };
}
renderEditor();
}
// 保存改过的服务器设置。
2026-10-01 20:48:38 +08:00
async function saveServerSettings() {
const inputs = [...document.querySelectorAll('[data-server-setting]')];
const values = {};
for (const input of inputs) {
if (input.disabled || !input.dataset.dirty) continue;
values[input.dataset.serverSetting] = input.value;
}
if (!Object.keys(values).length) {
const note = region('server-settings-note');
if (note) note.textContent = '没有需要保存的改动。';
return;
}
try {
const result = await api.saveServerSettings(values);
const applied = Array.isArray(result.applied) ? result.applied : [];
const restart = Array.isArray(result.restartRequired) ? result.restartRequired : [];
log('info', `服务器设置已保存:${Object.keys(values).join('、')}`);
state.serverSettings = await api.loadServerSettings();
renderEditor();
const note = region('server-settings-note');
if (note) {
note.textContent = `已应用 ${applied.length} 项${restart.length ? `,需重启生效 ${restart.length} 项:${restart.join('、')}` : ''}`;
}
} catch (error) {
fail(error);
}
}
// ---------------------------------------------------------------- 表格动作
// 切换该列的排序。
2026-10-01 20:48:38 +08:00
function sortColumn(tab, column) {
const data = tab.data;
if (data.sort === column) data.dir = data.dir === 'asc' ? 'desc' : 'asc';
else {
data.sort = column;
data.dir = 'asc';
}
data.page = 0;
void loadTableData(tab);
}
// 暂存删除选中的行。
2026-10-01 20:48:38 +08:00
function deleteSelected(tab) {
const keyColumn = tab.data.columns.find((column) => column.name === 'id');
if (!keyColumn || !tab.selected) return;
for (const row of tab.data.rows) {
if (tab.selected.has(String(row[keyColumn.name]))) stageDelete(tab.table, tab.database, row[keyColumn.name], row);
}
tab.selected.clear();
afterChange();
}
// 就地编辑单元格:提交或放弃。
2026-10-01 20:48:38 +08:00
function beginCellEdit(cell, options) {
if (cell.querySelector('input')) return;
const input = document.createElement('input');
input.className = 'cell-input';
input.value = editText(options.currentValue);
cell.textContent = '';
cell.appendChild(input);
input.focus();
input.select();
let done = false;
// 结束编辑并按需提交。
2026-10-01 20:48:38 +08:00
const finish = (save) => {
if (done) return;
done = true;
const raw = input.value;
input.remove();
options.commit(save ? parseEdited(raw, options.type) : undefined);
};
// Enter 提交,Esc 放弃。
2026-10-01 20:48:38 +08:00
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
finish(false);
}
});
// 失焦时提交。
2026-10-01 20:48:38 +08:00
input.addEventListener('blur', () => finish(true));
}
// 在新建表对话框加一列。
2026-10-01 20:48:38 +08:00
function addColumnRow(name, type) {
const container = region('dlg-table-columns');
if (!container) return;
const row = document.createElement('div');
row.className = 'column-row';
row.innerHTML = `
<input class="column-name" value="${escapeHtml(name)}" placeholder="列名" />
<select class="column-type">${creatableTypes.map((option) => `<option value="${option}"${option === type ? ' selected' : ''}>${escapeHtml(typeLabel(option))}</option>`).join('')}</select>
<button class="mini danger" data-action="remove-column" title="移除">✕</button>`;
container.appendChild(row);
}
// 打开新建表对话框。
2026-10-01 20:48:38 +08:00
function openCreateTableDialog() {
region('dlg-table-name').value = '';
region('dlg-table-error').textContent = '';
region('dlg-table-columns').innerHTML = '';
addColumnRow('id', 'int');
addColumnRow('', 'str');
region('dlg-table').showModal();
}
// 打开建索引对话框。
2026-10-01 20:48:38 +08:00
function openIndexDialog(table) {
const meta = state.tables.find((entry) => entry.name === table);
const select = region('dlg-index-column');
const candidates = (meta?.columns ?? []).filter((column) => column.name !== 'id' && !column.indexed);
select.innerHTML = candidates
.map((column) => `<option value="${escapeHtml(column.name)}">${escapeHtml(column.name)}(${escapeHtml(typeLabel(column.type))})</option>`)
.join('');
region('dlg-index-error').textContent = candidates.length ? '' : '没有可建索引的列。';
region('dlg-index').dataset.table = table;
region('dlg-index').showModal();
}
// 提交新建数据库。
2026-10-01 20:48:38 +08:00
async function createDatabase() {
const name = region('dlg-db-name').value.trim();
try {
await api.createDatabase(name, state.database);
region('dlg-db').close();
log('info', `已创建数据库 ${name}`);
await loadWorkspace();
} catch (error) {
region('dlg-db-error').textContent = describe(error);
}
}
// 提交新建表。
2026-10-01 20:48:38 +08:00
async function createTable() {
const name = region('dlg-table-name').value.trim();
const rows = [...region('dlg-table-columns').querySelectorAll('.column-row')];
const columns = rows
.map((row) => ({ name: row.querySelector('.column-name').value.trim(), type: row.querySelector('.column-type').value }))
.filter((column) => column.name);
if (!name) {
region('dlg-table-error').textContent = '表名不能为空。';
return;
}
if (!columns.length) {
region('dlg-table-error').textContent = '至少需要一列。';
return;
}
try {
await api.createTable(state.database, name, columns);
region('dlg-table').close();
log('info', `已创建表 ${name}`);
await loadWorkspace();
await openTable(state.database, name);
} catch (error) {
region('dlg-table-error').textContent = describe(error);
}
}
// 提交建索引。
2026-10-01 20:48:38 +08:00
async function createIndexFor(table) {
const dialog = region('dlg-index');
const column = region('dlg-index-column').value;
if (!column) return;
try {
await api.createIndex(state.database, table, column);
dialog.close();
log('info', `已在 ${table}.${column} 上建索引`);
await loadWorkspace();
} catch (error) {
region('dlg-index-error').textContent = describe(error);
}
}
// 删除当前数据库。
2026-10-01 20:48:38 +08:00
async function dropCurrentDatabase() {
const name = state.database;
if (!window.confirm(`删除数据库 ${name}?此操作不可撤销。`)) return;
try {
await api.dropDatabase(name, name);
log('info', `已删除数据库 ${name}`);
state.tabs = state.tabs.filter((tab) => tab.kind !== 'table' || tab.database !== name);
if (!state.tabs.some((tab) => tab.id === state.activeId)) state.activeId = state.tabs[0]?.id;
await loadWorkspace();
renderTabs();
} catch (error) {
fail(error);
}
}
// 删除指定表。
2026-10-01 20:48:38 +08:00
async function dropTableByName(table) {
if (!window.confirm(`删除表 ${table}?`)) return;
try {
await api.dropTable(state.database, table);
log('info', `已删除表 ${table}`);
state.tabs = state.tabs.filter((tab) => !(tab.kind === 'table' && tab.table === table));
if (!state.tabs.some((tab) => tab.id === state.activeId)) state.activeId = state.tabs[0]?.id;
await loadWorkspace();
renderTabs();
} catch (error) {
fail(error);
}
}
// 删除指定列。
2026-10-01 20:48:38 +08:00
async function dropColumnByName(table, column) {
if (!window.confirm(`删除 ${table}.${column}?`)) return;
try {
await api.dropColumn(state.database, table, column);
log('info', `已删除列 ${table}.${column}`);
await loadWorkspace();
await reloadOpenTables();
} catch (error) {
fail(error);
}
}
// 删除指定索引。
2026-10-01 20:48:38 +08:00
async function dropIndexByName(table, column) {
if (!window.confirm(`删除 ${table}.${column} 上的索引?`)) return;
try {
await api.dropIndex(state.database, table, column);
log('info', `已删除 ${table}.${column} 上的索引`);
await loadWorkspace();
} catch (error) {
fail(error);
}
}
// ---------------------------------------------------------------- 事件代理
// 拖动分隔条调整尺寸。
2026-10-01 20:48:38 +08:00
function startSplitterDrag(kind, event) {
event.preventDefault();
const workbench = region('workbench');
// 按鼠标位置调整宽度或高度。
2026-10-01 20:48:38 +08:00
const move = (moveEvent) => {
if (kind === 'v') {
const width = Math.min(560, Math.max(180, moveEvent.clientX - 48));
state.sidebarWidth = width;
workbench.style.setProperty('--sidebar-width', `${width}px`);
} else {
const height = Math.min(window.innerHeight - 160, Math.max(80, window.innerHeight - moveEvent.clientY - 22));
state.dockHeight = `${height}px`;
workbench.style.setProperty('--dock-height', `${height}px`);
}
};
// 移除拖动监听。
2026-10-01 20:48:38 +08:00
const stop = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', stop);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', stop);
}
// 这些动作都要求已经选中一个库(服务没有默认库)
const DATABASE_ACTIONS = new Set([
'open-table', 'demo-data', 'open-table-dialog', 'create-table',
'open-index-dialog', 'create-index', 'drop-table', 'drop-column', 'drop-index',
'drop-database', 'commit', 'rollback', 'copy-sql',
]);
// 该动作是否需要当前库。
2026-10-01 20:48:38 +08:00
function needsDatabase(action) {
return DATABASE_ACTIONS.has(action);
}
// 点击事件代理。
2026-10-01 20:48:38 +08:00
function onClick(event) {
const splitter = event.target.closest('[data-split]');
if (splitter) {
startSplitterDrag(splitter.dataset.split, event);
return;
}
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const tab = activeTab();
// 没有选库时,需要当前库的动作先拦下来(服务端也会拒,这里给人话)
if (!state.database && needsDatabase(action)) {
log('warn', '还没有选择数据库:先在左侧数据库下拉里选一个,或者新建一个库。');
return;
}
switch (action) {
case 'logout':
void (async () => {
try {
await api.logout();
} catch {
// 退出失败也要回到登录页
}
state.session = null;
state.tabs = [];
state.activeId = undefined;
state.changes = createEmptyChangeSet();
renderLogin('已退出登录。', 'info');
})();
return;
case 'new-query':
openQuery('');
return;
case 'focus-explorer': {
const sidebar = region('sidebar');
if (sidebar) sidebar.scrollTop = 0;
return;
}
case 'settings':
openSettings();
return;
case 'close-dialog': {
const dialog = target.closest('dialog');
if (dialog) dialog.close();
return;
}
case 'activate-tab':
activateTab(target.dataset.tab);
return;
case 'close-tab':
closeTabById(target.dataset.tab);
return;
case 'bottom-tab':
state.bottomTab = target.dataset.tab;
renderBottom();
return;
case 'expand-table': {
const name = target.dataset.table;
if (state.expanded.has(name)) state.expanded.delete(name);
else state.expanded.add(name);
renderSidebar();
return;
}
case 'open-table':
void openTable(state.database, target.dataset.table);
return;
case 'refresh':
void loadWorkspace().then(reloadOpenTables);
return;
case 'demo-data':
void (async () => {
try {
const report = await api.seedDemoData(state.database);
log('info', `演示数据:新建 ${(report.created ?? []).length} 张,保留 ${(report.skipped ?? []).length} 张。`);
await loadWorkspace();
await reloadOpenTables();
} catch (error) {
fail(error);
}
})();
return;
case 'open-db-dialog':
region('dlg-db-name').value = '';
region('dlg-db-error').textContent = '';
region('dlg-db').showModal();
return;
case 'open-table-dialog':
openCreateTableDialog();
return;
case 'add-column':
addColumnRow('', 'int');
return;
case 'remove-column': {
const row = target.closest('.column-row');
if (row) row.remove();
return;
}
case 'create-database':
void createDatabase();
return;
case 'create-table':
void createTable();
return;
case 'open-index-dialog':
openIndexDialog(target.dataset.table);
return;
case 'create-index':
void createIndexFor(region('dlg-index').dataset.table);
return;
case 'drop-database':
void dropCurrentDatabase();
return;
case 'drop-table':
void dropTableByName(target.dataset.table);
return;
case 'drop-column':
void dropColumnByName(target.dataset.table, target.dataset.column);
return;
case 'drop-index':
void dropIndexByName(target.dataset.table, target.dataset.column);
return;
case 'sort-column':
if (tab?.kind === 'table') sortColumn(tab, target.dataset.column);
return;
case 'prev-page':
if (tab?.kind === 'table' && tab.data.page > 0) {
tab.data.page -= 1;
void loadTableData(tab);
}
return;
case 'next-page':
if (tab?.kind === 'table') {
tab.data.page += 1;
void loadTableData(tab);
}
return;
case 'reload-table':
if (tab?.kind === 'table') void loadTableData(tab);
return;
case 'insert-row':
if (tab?.kind === 'table') {
const values = {};
for (const column of tab.data.columns) values[column.name] = null;
if (tab.table === accountsTable) {
values.user = '';
values.password = '';
}
stageInsert(tab.table, tab.database, values);
}
return;
case 'delete-selected':
if (tab?.kind === 'table') deleteSelected(tab);
return;
case 'unstage':
removeStaged(target.dataset.key);
return;
case 'unstage-insert': {
const owner = tab?.kind === 'table' ? tab : undefined;
if (owner) removeStaged(insertKey(owner.table, target.dataset.temp));
return;
}
case 'commit':
void commitAll();
return;
case 'rollback':
rollbackAll();
return;
case 'copy-sql': {
const text = changesSql(state.changes).join('\n');
const clipboard = navigator.clipboard;
if (!clipboard) {
log('warn', '这个浏览器不允许写剪贴板,请手动选中下面的 SQL。');
return;
}
clipboard.writeText(text).then(
() => log('info', 'SQL 已复制到剪贴板。'),
() => log('warn', '复制失败,请手动选中下面的 SQL。'),
);
return;
}
case 'run-sql':
if (tab?.kind === 'sql') void runSql(tab);
return;
case 'clear-sql':
if (tab?.kind === 'sql') {
tab.sql = '';
tab.result = undefined;
renderEditor();
}
return;
case 'reset-settings':
state.settings = { ...DEFAULT_SETTINGS };
applySettingsToDom();
persistSettings();
renderEditor();
return;
case 'save-server-settings':
void saveServerSettings();
return;
case 'edit-cell': {
if (tab?.kind !== 'table') return;
const pk = target.dataset.pk;
const column = target.dataset.column;
const meta = tab.data.columns.find((entry) => entry.name === column);
const row = tab.data.rows.find((entry) => String(entry.id) === String(pk));
if (!meta || !row) return;
const staged = state.changes.updates.get(changeKey(tab.table, pk, column));
const oldValue = staged ? staged.oldValue : row[column.name];
beginCellEdit(target, {
type: meta.type,
currentValue: staged ? staged.newValue : row[column.name],
commit: (value) => {
if (value === undefined) renderEditor();
else stageUpdate(tab.table, tab.database, pk, column, oldValue, value);
},
});
return;
}
case 'edit-insert': {
if (tab?.kind !== 'table') return;
const insert = state.changes.inserts.get(insertKey(tab.table, target.dataset.temp));
if (!insert) return;
const column = target.dataset.column;
const meta = tab.data.columns.find((entry) => entry.name === column);
beginCellEdit(target, {
type: meta ? meta.type : 'str',
currentValue: insert.values[column],
commit: (value) => {
if (value === undefined) renderEditor();
else {
insert.values[column] = value;
afterChange();
}
},
});
return;
}
default:
}
}
// 输入与选择事件代理。
2026-10-01 20:48:38 +08:00
function onChange(event) {
const target = event.target;
if (target.dataset.action === 'switch-database') {
void switchDatabase(target.value);
return;
}
if (target.dataset.action === 'filter-column') {
const tab = activeTab();
if (tab?.kind !== 'table') return;
tab.data.filters = { ...(tab.data.filters ?? {}), [target.dataset.column]: target.value };
tab.data.page = 0;
void loadTableData(tab);
return;
}
if (target.dataset.action === 'change-page-size') {
const tab = activeTab();
if (tab?.kind !== 'table') return;
tab.data.pageSize = Number(target.value);
tab.data.page = 0;
void loadTableData(tab);
return;
}
if (target.dataset.action === 'select-row') {
const tab = activeTab();
if (tab?.kind !== 'table') return;
tab.selected = tab.selected ?? new Set();
if (target.checked) tab.selected.add(String(target.dataset.pk));
else tab.selected.delete(String(target.dataset.pk));
renderEditor();
return;
}
if (target.dataset.serverSetting) {
target.dataset.dirty = '1';
return;
}
const setting = target.dataset.setting;
if (!setting) return;
const key = target.dataset.key;
if (setting === 'number') updateSetting(key, clampSetting(key, target.value));
else if (setting === 'boolean') updateSetting(key, target.checked);
else if (setting === 'fonts') updateSetting(key, fontsFromText(target.value));
else updateSetting(key, target.value);
}
// 提交事件代理(登录表单)。
2026-10-01 20:48:38 +08:00
function onSubmit(event) {
if (event.target.id !== 'login-form') return;
event.preventDefault();
const user = region('login-user').value.trim();
const password = region('login-password').value;
state.loading = true;
renderLogin('', 'info');
void (async () => {
try {
await api.login(user, password);
state.loading = false;
if (!(await refreshSession())) {
renderLogin('登录成功但会话读取失败,请重试。', 'error');
return;
}
renderShell();
renderSidebar();
renderTabs();
renderBottom();
applySettingsToDom();
await loadWorkspace();
log('info', `已登录:${state.session.user}`);
} catch (error) {
state.loading = false;
renderLogin(describe(error), 'error');
}
})();
}
// 键盘事件代理。
2026-10-01 20:48:38 +08:00
function onKeydown(event) {
if (event.key === 'Enter' && event.target.id === 'dlg-db-name') {
event.preventDefault();
void createDatabase();
}
}
// ---------------------------------------------------------------- 启动
// 会话过期时回到登录页。
2026-10-01 20:48:38 +08:00
window.addEventListener(AUTH_EVENT, () => {
if (state.view === 'login') return;
state.session = null;
state.tabs = [];
state.activeId = undefined;
renderLogin('会话已过期,请重新登录。', 'warn');
});
root.addEventListener('click', onClick);
root.addEventListener('change', onChange);
root.addEventListener('submit', onSubmit);
root.addEventListener('keydown', onKeydown);
// 启动:读设置、恢复会话、渲染。
2026-10-01 20:48:38 +08:00
async function boot() {
loadLocalSettings();
applySettingsToDom();
if (!(await refreshSession())) {
renderLogin('', 'info');
return;
}
renderShell();
renderSidebar();
renderTabs();
renderBottom();
applySettingsToDom();
await loadWorkspace();
renderEditor();
await loadRemoteSettings();
renderEditor();
}
void boot();