Files
ChuSQL/chusql-web/static/app.js
T
LIm1tZz7 df6d348995 大更新:
1、完善SQL语法
2、移除node前端,只保留静态资源
3、写好安装脚本
4、做好linux适配
2026-10-01 20:48:38 +08:00

1568 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ChuSQL Web IDE:纯 HTML + CSS + JS,无 React、无打包器、无 Node 依赖。
// 结构:全局状态 → 页面骨架 → 各区域渲染 → 事件代理 → 会话/数据/变更动作。
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';
// ---------------------------------------------------------------- 状态
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');
// ---------------------------------------------------------------- 小工具
function region(id) {
return document.getElementById(id);
}
function now() {
return new Date().toLocaleTimeString('zh-CN', { hour12: false });
}
function log(level, text) {
state.messages.push({ level, text, time: now() });
if (state.messages.length > 200) state.messages.shift();
renderBottom();
}
function setStatus(text) {
state.status = text;
const el = region('status-message');
if (el) el.textContent = text;
}
function describe(error) {
return error instanceof Error ? error.message : String(error);
}
function fail(error) {
const message = describe(error);
log('error', message);
setStatus(message);
return message;
}
function nextTabId(prefix) {
state.tabSeq += 1;
return `${prefix}-${state.tabSeq}`;
}
function activeTab() {
return state.tabs.find((tab) => tab.id === state.activeId);
}
// ---------------------------------------------------------------- 登录页
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>`;
}
// ---------------------------------------------------------------- 页面骨架
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();
}
// 侧栏宽度与下方面板高度用 CSSOM 写,避免 CSP 拦掉内联 style 属性。
function applyLayout() {
const workbench = region('workbench');
if (!workbench) return;
workbench.style.setProperty('--sidebar-width', `${state.sidebarWidth}px`);
workbench.style.setProperty('--dock-height', state.dockHeight);
}
// ---------------------------------------------------------------- 账号区
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>`;
}
// ---------------------------------------------------------------- 侧栏
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>`;
}
// ---------------------------------------------------------------- 标签栏
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();
}
// ---------------------------------------------------------------- 编辑器区
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);
}
// ---- 表格标签
function cellContent(value, nullText) {
if (value === null || value === undefined) return `<span class="null-value">${escapeHtml(nullText)}</span>`;
return escapeHtml(cellText(value, nullText));
}
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 标签
function lineNumbers(sql) {
const count = String(sql ?? '').split('\n').length;
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
}
function renderSqlTab(el, tab) {
const result = tab.result;
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;
const refreshLayers = () => {
if (highlight) highlight.innerHTML = highlightHtml(tab.sql);
if (gutter) gutter.textContent = lineNumbers(tab.sql);
sync();
};
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))}%`;
}
};
textarea.addEventListener('input', () => {
tab.sql = textarea.value;
refreshLayers();
});
textarea.addEventListener('scroll', sync);
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();
}
// ---- 设置标签
function renderSettingsTab(el) {
const settings = state.settings;
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>`;
const numberField = (key) => `
<label class="field"><span>${escapeHtml(settingLabels[key])}</span>
<input type="number" data-setting="number" data-key="${key}" value="${settings[key]}" /></label>`;
const boolField = (key) => `
<label class="toggle"><input type="checkbox" data-setting="boolean" data-key="${key}"${settings[key] ? ' checked' : ''} /><span>${escapeHtml(settingLabels[key])}</span></label>`;
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,服务器设置写回 chusql.toml 的 [web] 分区。</p></header>
<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`;
}
}
// ---------------------------------------------------------------- 下方面板
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}`;
}
// ---------------------------------------------------------------- 数据加载
async function refreshSession() {
try {
state.session = await api.currentSession();
return true;
} catch {
state.session = null;
return false;
}
}
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);
}
}
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();
}
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);
}
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();
}
}
async function reloadOpenTables() {
for (const tab of state.tabs) {
if (tab.kind === 'table') await loadTableData(tab);
}
}
// ---------------------------------------------------------------- 变更动作
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));
}
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();
}
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();
}
function stageDelete(table, database, pk, row) {
unstageRow(table, pk);
state.changes.deletes.set(deleteKey(table, pk), { table, database, pk, row });
afterChange();
}
function afterChange() {
renderSidebar();
renderEditor();
renderBottom();
}
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();
}
function rollbackAll() {
state.changes = createEmptyChangeSet();
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
log('info', '已撤销全部待提交变更。');
afterChange();
}
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 运行
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();
}
// ---------------------------------------------------------------- 标签动作
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();
}
function closeTabById(id) {
const closed = closeTab(state.tabs, id, state.activeId);
state.tabs = closed.tabs;
state.activeId = closed.activeId;
renderTabs();
}
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();
}
function openSettings() {
const opened = openSettingsTab(state.tabs, '设置', nextTabId('settings'));
state.tabs = opened.tabs;
state.activeId = opened.activeId;
renderTabs();
if (!state.serverSettings) void loadServerSettings();
}
// ---------------------------------------------------------------- 设置动作
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;
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);
}
function updateSetting(key, value) {
state.settings = { ...state.settings, [key]: value };
applySettingsToDom();
persistSettings();
renderEditor();
}
function loadLocalSettings() {
try {
const raw = localStorage.getItem(IDE_SETTINGS_KEY);
if (!raw) return;
state.settings = { ...state.settings, ...parseUiSettings(JSON.parse(raw)) };
} catch {
// 本地设置坏了就用默认值
}
}
async function loadRemoteSettings() {
try {
state.settings = { ...state.settings, ...parseUiSettings(await api.loadUiSettings()) };
applySettingsToDom();
} catch (error) {
log('warn', `IDE 设置没能从服务器读取:${describe(error)}`);
}
}
async function loadServerSettings() {
try {
state.serverSettings = await api.loadServerSettings();
} catch (error) {
state.serverSettings = { error: describe(error), items: [] };
}
renderEditor();
}
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);
}
}
// ---------------------------------------------------------------- 表格动作
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);
}
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();
}
// 点一下就地把单元格变成输入框;Enter/失焦提交,Esc 放弃。
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;
const finish = (save) => {
if (done) return;
done = true;
const raw = input.value;
input.remove();
options.commit(save ? parseEdited(raw, options.type) : undefined);
};
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
finish(false);
}
});
input.addEventListener('blur', () => finish(true));
}
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);
}
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();
}
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();
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
// ---------------------------------------------------------------- 事件代理
function startSplitterDrag(kind, event) {
event.preventDefault();
const workbench = region('workbench');
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`);
}
};
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',
]);
function needsDatabase(action) {
return DATABASE_ACTIONS.has(action);
}
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:
}
}
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);
}
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');
}
})();
}
function onKeydown(event) {
if (event.key === 'Enter' && event.target.id === 'dlg-db-name') {
event.preventDefault();
void createDatabase();
}
}
// ---------------------------------------------------------------- 启动
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);
async function boot() {
loadLocalSettings();
applySettingsToDom();
if (!(await refreshSession())) {
renderLogin('', 'info');
return;
}
renderShell();
renderSidebar();
renderTabs();
renderBottom();
applySettingsToDom();
await loadWorkspace();
renderEditor();
await loadRemoteSettings();
renderEditor();
}
void boot();