1567 lines
58 KiB
JavaScript
1567 lines
58 KiB
JavaScript
// 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();
|