// 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 = `
`;
}
// ---------------------------------------------------------------- 页面骨架
function renderShell() {
state.view = 'workbench';
root.innerHTML = `
ChuSQL数据库终端
`;
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 = `${escapeHtml(state.session.user)}${badge}
`;
}
// ---------------------------------------------------------------- 侧栏
function renderSidebar() {
const el = region('sidebar');
if (!el) return;
const options = state.databases
.map((name) => ``)
.join('');
const databaseOptions = state.database ? options : `${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' ? 'PK' : '',
column.indexed ? 'IX' : '',
column.nullable ? '' : 'NN',
].join('');
const drop = column.name === 'id'
? ''
: ``;
return `${escapeHtml(column.name)}
${escapeHtml(typeLabel(column.type))}
${marks}${drop}`;
}).join('');
const own = (table.indexes ?? []).filter((index) => !index.builtIn && index.column !== 'id');
const indexList = own.length
? `${own.map((index) => `- 索引 ${escapeHtml(index.column)}
`).join('')}
`
: '';
return `
${table.rowCount}
${hasId ? '' : '只读'}
${open ? `${indexList}
` : ''}
`;
}).join('');
const count = changeCount(state.changes);
el.innerHTML = `
资源管理器
数据库
表
${state.tables.length ? `` : `${state.database ? '这个库还没有表。' : '未选择数据库。'}
`}
`;
}
// ---------------------------------------------------------------- 标签栏
function renderTabs() {
const el = region('editor');
if (!el) return;
const tabs = state.tabs.map((tab) => `
`).join('');
el.innerHTML = `
${tabs || '没有打开的标签页'}
`;
renderEditor();
}
// ---------------------------------------------------------------- 编辑器区
function renderEditor() {
const el = region('tab-content');
if (!el) return;
const tab = activeTab();
if (!tab) {
el.innerHTML = `
ChuSQL 数据库终端
从左边选一张表,或者 。
`;
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 `${escapeHtml(nullText)}`;
return escapeHtml(cellText(value, nullText));
}
function renderTableTab(el, tab) {
const data = tab.data;
if (!data) {
el.innerHTML = '正在加载…
';
return;
}
if (data.error) {
el.innerHTML = `${escapeHtml(data.error)}
`;
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 `
${escapeHtml(typeLabel(column.type))} | `;
}).join('');
const filterRow = columns.map((column) => ` | `).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) => `${cellContent(insert.values[column.name], state.settings.nullText)} | `).join('');
return `
| + |
|
${cells}
`;
}).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 `${cellContent(value, state.settings.nullText)} | `;
}).join('');
const checkbox = editable
? ` | `
: ' | ';
return `
| ${data.offset + index + 1} |
${checkbox}
${cells}
`;
}).join('');
const pageCount = Math.max(1, Math.ceil(data.total / data.pageSize));
el.innerHTML = `
第 ${data.page + 1} / ${pageCount} 页 · 共 ${data.total} 行
${data.loading ? '
加载中…
' : ''}
${editable ? '' : '
这张表没有 id 列,只能浏览。
'}
`;
}
// ---- 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 '按 Ctrl+Enter 运行,或点上面的"运行"。
';
if (result.type === 'error') return `${escapeHtml(result.message ?? '执行失败')}
`;
if (result.type === 'affected') return `影响 ${result.affected ?? 0} 行 · ${result.durationMs.toFixed(1)} ms
`;
const head = result.columns.map((column) => `${escapeHtml(column)} | `).join('');
const body = result.rows
.map((row) => `${row.map((value) => `| ${cellContent(value, state.settings.nullText)} | `).join('')}
`)
.join('');
return `
${result.rows.length} 行${result.truncated ? '(已截断)' : ''} · ${result.durationMs.toFixed(1)} ms
`;
})();
el.innerHTML = `
库 ${escapeHtml(tab.database ?? state.database)}
${state.settings.sqlLineNumbers ? `
${lineNumbers(tab.sql)}` : ''}
${highlightHtml(tab.sql)}
${state.settings.minimap ? '
' : ''}
${resultHtml}
`;
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) => `
`;
const numberField = (key) => `
`;
const boolField = (key) => `
`;
const serverHtml = (() => {
const server = state.serverSettings;
if (!server) return '正在读取服务器设置…
';
if (!server.items.length) return `${escapeHtml(server.error ?? '没有可显示的设置。')}
`;
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) => `
`).join('');
})();
el.innerHTML = `
`;
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
? `${entries.map((entry) => `
-
${escapeHtml(entry.table)}
${escapeHtml(entry.sql)}
`).join('')}
预览生成的全部 SQL(${preview.length} 条)
${escapeHtml(preview.join('\n'))} `
: '没有待提交的变更。编辑单元格、插入或删除行都会先记在这里。
';
const messages = state.messages.length
? `${state.messages.slice(-100).map((message) => `
- ${escapeHtml(message.time)}${escapeHtml(message.text)}
`).join('')}
`
: '还没有消息。
';
el.innerHTML = `
${state.bottomTab === 'changes' ? changeList : messages}
`;
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 = `
`;
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) => ``)
.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();