大改,整理项目结构,完成rust ffi调用,写好tcp接口

This commit is contained in:
LIm1tZz7 committed 2026-10-02 15:07:38 +08:00
1 parent df6d348995
commit 9758439b82
118 files changed
+8103 -6321

No files matched your search

+32 -2
View File
@@ -1,12 +1,14 @@
// REST 数据源:与 chusql-web 的 /api/* 一一对应。
// 所有请求带 X-ChuSQL-Database 头(服务端不保存 USE 状态);401 时广播登录事件。
// 请求都带 X-ChuSQL-Database 头;401 时广播登录事件。
export const AUTH_EVENT = 'chusql-auth-required';
// 是否是普通对象。
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// 发一次请求并处理错误。
async function request(url, init, database) {
const headers = new Headers(init && init.headers ? init.headers : undefined);
if (database) headers.set('X-ChuSQL-Database', database);
@@ -34,6 +36,7 @@ async function request(url, init, database) {
return body;
}
// 组装 JSON 请求参数。
function jsonInit(method, body) {
return {
method,
@@ -44,12 +47,14 @@ function jsonInit(method, body) {
const identifier = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
// 校验数据库名格式。
function checkDatabaseName(name) {
if (!identifier.test(String(name))) throw new Error('数据库名只能是字母、数字与下划线,且以字母或下划线开头。');
}
// ---------------------------------------------------------------- 会话
// 读取当前会话。
export async function currentSession() {
const body = await request('/api/session');
if (!isObject(body) || typeof body.user !== 'string') throw new Error('服务器返回的会话信息无法识别。');
@@ -60,16 +65,19 @@ export async function currentSession() {
};
}
// 提交登录。
export async function login(user, password) {
await request('/api/login', jsonInit('POST', { user, password }));
}
// 退出登录。
export async function logout() {
await request('/api/logout', jsonInit('POST', {}));
}
// ---------------------------------------------------------------- 数据库
// 列出数据库。
export async function listDatabases() {
const body = await request('/api/databases');
if (!Array.isArray(body) || !body.every((name) => typeof name === 'string')) {
@@ -78,16 +86,18 @@ export async function listDatabases() {
return body;
}
// 新建数据库。
export async function createDatabase(name, database) {
checkDatabaseName(name);
await request('/api/databases', jsonInit('POST', { name }), database);
}
// 删除数据库。
export async function dropDatabase(name, database) {
await request(`/api/databases/${encodeURIComponent(name)}`, jsonInit('DELETE'), database);
}
// 切换当前库:走一条 USE 语句,服务端在响应里回带新的库名。
// 切换当前库(发 USE 语句)。
export async function useDatabase(name, database) {
checkDatabaseName(name);
return query(`USE ${name}`, database);
@@ -95,12 +105,14 @@ export async function useDatabase(name, database) {
// ---------------------------------------------------------------- 表结构
// 取类型基名(去掉参数)。
function columnKind(type) {
return String(type).split('(')[0].trim().toLowerCase();
}
const knownTypes = ['int', 'bigint', 'smallint', 'str', 'text', 'varchar', 'char', 'float', 'double', 'decimal', 'numeric', 'bool', 'date', 'timestamp', 'blob'];
// 校验并规整列描述。
function asColumns(value) {
if (!Array.isArray(value)) throw new Error('服务器返回的列清单无法识别。');
return value.map((entry) => {
@@ -112,6 +124,7 @@ function asColumns(value) {
});
}
// 校验并规整表描述。
function asTable(value) {
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
throw new Error('服务器返回的表描述无法识别。');
@@ -125,38 +138,46 @@ function asTable(value) {
};
}
// 列出表。
export async function listTables(database) {
const body = await request('/api/tables', undefined, database);
if (!Array.isArray(body)) throw new Error('服务器返回的表清单无法识别。');
return body.map(asTable);
}
// 取单表结构。
export async function getTable(database, table) {
return asTable(await request(`/api/tables/${encodeURIComponent(table)}`, undefined, database));
}
// 新建表。
export async function createTable(database, name, columns) {
await request('/api/tables', jsonInit('POST', { name, columns }), database);
}
// 删除表。
export async function dropTable(database, table) {
await request(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'), database);
}
// 建索引。
export async function createIndex(database, table, column) {
await request(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }), database);
}
// 删索引。
export async function dropIndex(database, table, column) {
await request(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'), database);
}
// 删列。
export async function dropColumn(database, table, column) {
await request(`/api/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`, jsonInit('DELETE'), database);
}
// ---------------------------------------------------------------- 行
// 分页取表数据。
export async function fetchRows({ database, table, limit, offset, sort, dir, filters }) {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (sort) {
@@ -179,14 +200,17 @@ export async function fetchRows({ database, table, limit, offset, sort, dir, fil
return { columns, rows, total: body.total };
}
// 插入一行。
export async function insertRow(database, table, values) {
await request(`/api/tables/${encodeURIComponent(table)}/rows`, jsonInit('POST', { values }), database);
}
// 更新一行。
export async function updateRow(database, table, id, values) {
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('PATCH', { values }), database);
}
// 删除一行。
export async function deleteRow(database, table, id) {
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('DELETE'), database);
}
@@ -209,6 +233,7 @@ export async function commitChanges(database, changes) {
// ---------------------------------------------------------------- 查询
// 执行 SQL 并规整结果。
export async function query(sql, database) {
const started = performance.now();
try {
@@ -231,27 +256,32 @@ export async function query(sql, database) {
}
}
// 灌演示数据。
export async function seedDemoData(database) {
return request('/api/demo-data', jsonInit('POST', {}), database);
}
// ---------------------------------------------------------------- 设置
// 读取服务器设置。
export async function loadServerSettings() {
const body = await request('/api/settings');
if (!isObject(body) || !Array.isArray(body.items)) throw new Error('服务器返回的设置清单无法识别。');
return body;
}
// 保存服务器设置。
export async function saveServerSettings(values) {
const body = await request('/api/settings', jsonInit('PUT', { values }));
return isObject(body) ? body : { ok: true };
}
// 读取 IDE 设置。
export async function loadUiSettings() {
return request('/api/ui-settings');
}
// 保存 IDE 设置。
export async function saveUiSettings(settings) {
await request('/api/ui-settings', jsonInit('PUT', settings));
}
+85 -5
View File
@@ -1,6 +1,3 @@
// ChuSQL Web IDE:纯 HTML + CSS + JS,无 React、无打包器、无 Node 依赖。
// 结构:全局状态 → 页面骨架 → 各区域渲染 → 事件代理 → 会话/数据/变更动作。
import * as api from './api.js';
import { AUTH_EVENT } from './api.js';
import {
@@ -35,6 +32,8 @@ import {
typeLabel,
} from './core.js';
// ChuSQL Web IDE 主逻辑:状态、渲染、事件代理与会话/数据/变更动作。
// ---------------------------------------------------------------- 状态
const state = {
@@ -63,30 +62,36 @@ const root = document.getElementById('root');
// ---------------------------------------------------------------- 小工具
// 按 id 取 DOM 元素。
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);
@@ -94,17 +99,20 @@ function fail(error) {
return message;
}
// 生成下一个标签 id。
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 = `
@@ -122,6 +130,7 @@ function renderLogin(message, level) {
// ---------------------------------------------------------------- 页面骨架
// 渲染工作台骨架与对话框。
function renderShell() {
state.view = 'workbench';
root.innerHTML = `
@@ -200,7 +209,7 @@ function renderShell() {
applyLayout();
}
// 侧栏宽度与下方面板高度用 CSSOM 写,避免 CSP 拦掉内联 style 属性。
// 应用侧栏宽度与下方面板高度。
function applyLayout() {
const workbench = region('workbench');
if (!workbench) return;
@@ -210,6 +219,7 @@ function applyLayout() {
// ---------------------------------------------------------------- 账号区
// 渲染右上角账号区。
function renderAccount() {
const el = region('account');
if (!el) return;
@@ -225,6 +235,7 @@ function renderAccount() {
// ---------------------------------------------------------------- 侧栏
// 渲染资源管理器侧栏。
function renderSidebar() {
const el = region('sidebar');
if (!el) return;
@@ -298,6 +309,7 @@ function renderSidebar() {
// ---------------------------------------------------------------- 标签栏
// 渲染标签栏并刷新内容区。
function renderTabs() {
const el = region('editor');
if (!el) return;
@@ -314,6 +326,7 @@ function renderTabs() {
// ---------------------------------------------------------------- 编辑器区
// 渲染当前标签的内容。
function renderEditor() {
const el = region('tab-content');
if (!el) return;
@@ -332,11 +345,13 @@ function renderEditor() {
// ---- 表格标签
// 单元格显示内容的 HTML。
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) {
@@ -429,13 +444,16 @@ function renderTableTab(el, tab) {
// ---- SQL 标签
// 生成行号文本。
function lineNumbers(sql) {
const count = String(sql ?? '').split('\n').length;
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
}
// 渲染 SQL 控制台标签。
function renderSqlTab(el, tab) {
const result = tab.result;
// 结果区 HTML。
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>`;
@@ -473,11 +491,13 @@ function renderSqlTab(el, tab) {
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;
@@ -496,11 +516,14 @@ function renderSqlTab(el, tab) {
}
};
// 输入时更新 SQL 并刷新高亮。
textarea.addEventListener('input', () => {
tab.sql = textarea.value;
refreshLayers();
});
// 滚动时同步各层。
textarea.addEventListener('scroll', sync);
// Tab 缩进,Ctrl+Enter 运行。
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
event.preventDefault();
@@ -523,17 +546,22 @@ function renderSqlTab(el, tab) {
// ---- 设置标签
// 渲染设置标签页。
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>';
@@ -595,6 +623,7 @@ function renderSettingsTab(el) {
// ---------------------------------------------------------------- 下方面板
// 渲染下方面板(变更/消息)。
function renderBottom() {
const el = region('bottom');
if (!el) return;
@@ -635,6 +664,7 @@ function renderBottom() {
// ---------------------------------------------------------------- 数据加载
// 读取并缓存当前会话。
async function refreshSession() {
try {
state.session = await api.currentSession();
@@ -645,6 +675,7 @@ async function refreshSession() {
}
}
// 加载数据库列表与当前库的表。
async function loadWorkspace() {
try {
state.databases = await api.listDatabases();
@@ -661,6 +692,7 @@ async function loadWorkspace() {
}
}
// 切换当前数据库。
async function switchDatabase(name) {
if (name === state.database) return;
if (changeCount(state.changes) > 0) {
@@ -680,6 +712,7 @@ async function switchDatabase(name) {
renderEditor();
}
// 打开表标签并加载数据。
async function openTable(database, name) {
const opened = openTableTab(state.tabs, database, name, nextTabId('table'));
state.tabs = opened.tabs;
@@ -697,6 +730,7 @@ async function openTable(database, name) {
await loadTableData(tab);
}
// 加载表标签的一页数据。
async function loadTableData(tab) {
const data = tab.data;
data.loading = true;
@@ -736,6 +770,7 @@ async function loadTableData(tab) {
}
}
// 重新加载所有表标签。
async function reloadOpenTables() {
for (const tab of state.tabs) {
if (tab.kind === 'table') await loadTableData(tab);
@@ -744,6 +779,7 @@ async function reloadOpenTables() {
// ---------------------------------------------------------------- 变更动作
// 撤下某行的全部变更。
function unstageRow(table, pk) {
const prefix = `${table}:${JSON.stringify(pk)}:`;
for (const key of [...state.changes.updates.keys()]) {
@@ -752,12 +788,14 @@ function unstageRow(table, pk) {
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}`;
@@ -765,18 +803,21 @@ function stageInsert(table, database, 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);
@@ -784,6 +825,7 @@ function removeStaged(key) {
afterChange();
}
// 撤销全部暂存变更。
function rollbackAll() {
state.changes = createEmptyChangeSet();
for (const tab of state.tabs) if (tab.selected) tab.selected.clear();
@@ -791,6 +833,7 @@ function rollbackAll() {
afterChange();
}
// 提交全部暂存变更。
async function commitAll() {
const payload = toCommitPayload(state.changes);
if (!payload.length) {
@@ -832,6 +875,7 @@ async function commitAll() {
// ---------------------------------------------------------------- SQL 运行
// 运行 SQL 并展示结果。
async function runSql(tab) {
const sql = String(tab.sql ?? '').trim();
if (!sql) return;
@@ -862,6 +906,7 @@ async function runSql(tab) {
// ---------------------------------------------------------------- 标签动作
// 激活指定标签。
function activateTab(id) {
state.activeId = id;
const tab = activeTab();
@@ -870,6 +915,7 @@ function activateTab(id) {
renderTabs();
}
// 关闭指定标签。
function closeTabById(id) {
const closed = closeTab(state.tabs, id, state.activeId);
state.tabs = closed.tabs;
@@ -877,6 +923,7 @@ function closeTabById(id) {
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 ?? '');
@@ -885,6 +932,7 @@ function openQuery(sql) {
renderTabs();
}
// 打开设置标签。
function openSettings() {
const opened = openSettingsTab(state.tabs, '设置', nextTabId('settings'));
state.tabs = opened.tabs;
@@ -895,6 +943,7 @@ function openSettings() {
// ---------------------------------------------------------------- 设置动作
// 把 IDE 设置写到 CSS 变量。
function applySettingsToDom() {
const style = document.documentElement.style;
style.setProperty('--ui-font', fontFamilyValue(state.settings.uiFonts));
@@ -909,6 +958,7 @@ function applySettingsToDom() {
}
let saveTimer;
// 保存 IDE 设置(本地+服务器)。
function persistSettings() {
try {
localStorage.setItem(IDE_SETTINGS_KEY, JSON.stringify(state.settings));
@@ -921,6 +971,7 @@ function persistSettings() {
}, 400);
}
// 更新一项 IDE 设置。
function updateSetting(key, value) {
state.settings = { ...state.settings, [key]: value };
applySettingsToDom();
@@ -928,6 +979,7 @@ function updateSetting(key, value) {
renderEditor();
}
// 从本地存储读 IDE 设置。
function loadLocalSettings() {
try {
const raw = localStorage.getItem(IDE_SETTINGS_KEY);
@@ -938,6 +990,7 @@ function loadLocalSettings() {
}
}
// 从服务器读 IDE 设置。
async function loadRemoteSettings() {
try {
state.settings = { ...state.settings, ...parseUiSettings(await api.loadUiSettings()) };
@@ -947,6 +1000,7 @@ async function loadRemoteSettings() {
}
}
// 读取服务器设置。
async function loadServerSettings() {
try {
state.serverSettings = await api.loadServerSettings();
@@ -956,6 +1010,7 @@ async function loadServerSettings() {
renderEditor();
}
// 保存改过的服务器设置。
async function saveServerSettings() {
const inputs = [...document.querySelectorAll('[data-server-setting]')];
const values = {};
@@ -986,6 +1041,7 @@ async function saveServerSettings() {
// ---------------------------------------------------------------- 表格动作
// 切换该列的排序。
function sortColumn(tab, column) {
const data = tab.data;
if (data.sort === column) data.dir = data.dir === 'asc' ? 'desc' : 'asc';
@@ -997,6 +1053,7 @@ function sortColumn(tab, column) {
void loadTableData(tab);
}
// 暂存删除选中的行。
function deleteSelected(tab) {
const keyColumn = tab.data.columns.find((column) => column.name === 'id');
if (!keyColumn || !tab.selected) return;
@@ -1007,7 +1064,7 @@ function deleteSelected(tab) {
afterChange();
}
// 点一下就地把单元格变成输入框;Enter/失焦提交,Esc 放弃。
// 就地编辑单元格:提交或放弃。
function beginCellEdit(cell, options) {
if (cell.querySelector('input')) return;
const input = document.createElement('input');
@@ -1018,6 +1075,7 @@ function beginCellEdit(cell, options) {
input.focus();
input.select();
let done = false;
// 结束编辑并按需提交。
const finish = (save) => {
if (done) return;
done = true;
@@ -1025,6 +1083,7 @@ function beginCellEdit(cell, options) {
input.remove();
options.commit(save ? parseEdited(raw, options.type) : undefined);
};
// Enter 提交,Esc 放弃。
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -1034,9 +1093,11 @@ function beginCellEdit(cell, options) {
finish(false);
}
});
// 失焦时提交。
input.addEventListener('blur', () => finish(true));
}
// 在新建表对话框加一列。
function addColumnRow(name, type) {
const container = region('dlg-table-columns');
if (!container) return;
@@ -1049,6 +1110,7 @@ function addColumnRow(name, type) {
container.appendChild(row);
}
// 打开新建表对话框。
function openCreateTableDialog() {
region('dlg-table-name').value = '';
region('dlg-table-error').textContent = '';
@@ -1058,6 +1120,7 @@ function openCreateTableDialog() {
region('dlg-table').showModal();
}
// 打开建索引对话框。
function openIndexDialog(table) {
const meta = state.tables.find((entry) => entry.name === table);
const select = region('dlg-index-column');
@@ -1070,6 +1133,7 @@ function openIndexDialog(table) {
region('dlg-index').showModal();
}
// 提交新建数据库。
async function createDatabase() {
const name = region('dlg-db-name').value.trim();
try {
@@ -1082,6 +1146,7 @@ async function createDatabase() {
}
}
// 提交新建表。
async function createTable() {
const name = region('dlg-table-name').value.trim();
const rows = [...region('dlg-table-columns').querySelectorAll('.column-row')];
@@ -1107,6 +1172,7 @@ async function createTable() {
}
}
// 提交建索引。
async function createIndexFor(table) {
const dialog = region('dlg-index');
const column = region('dlg-index-column').value;
@@ -1121,6 +1187,7 @@ async function createIndexFor(table) {
}
}
// 删除当前数据库。
async function dropCurrentDatabase() {
const name = state.database;
if (!window.confirm(`删除数据库 ${name}?此操作不可撤销。`)) return;
@@ -1136,6 +1203,7 @@ async function dropCurrentDatabase() {
}
}
// 删除指定表。
async function dropTableByName(table) {
if (!window.confirm(`删除表 ${table}?`)) return;
try {
@@ -1150,6 +1218,7 @@ async function dropTableByName(table) {
}
}
// 删除指定列。
async function dropColumnByName(table, column) {
if (!window.confirm(`删除 ${table}.${column}?`)) return;
try {
@@ -1162,6 +1231,7 @@ async function dropColumnByName(table, column) {
}
}
// 删除指定索引。
async function dropIndexByName(table, column) {
if (!window.confirm(`删除 ${table}.${column} 上的索引?`)) return;
try {
@@ -1175,9 +1245,11 @@ async function dropIndexByName(table, column) {
// ---------------------------------------------------------------- 事件代理
// 拖动分隔条调整尺寸。
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));
@@ -1189,6 +1261,7 @@ function startSplitterDrag(kind, event) {
workbench.style.setProperty('--dock-height', `${height}px`);
}
};
// 移除拖动监听。
const stop = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', stop);
@@ -1204,10 +1277,12 @@ const DATABASE_ACTIONS = new Set([
'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) {
@@ -1451,6 +1526,7 @@ function onClick(event) {
}
}
// 输入与选择事件代理。
function onChange(event) {
const target = event.target;
if (target.dataset.action === 'switch-database') {
@@ -1495,6 +1571,7 @@ function onChange(event) {
else updateSetting(key, target.value);
}
// 提交事件代理(登录表单)。
function onSubmit(event) {
if (event.target.id !== 'login-form') return;
event.preventDefault();
@@ -1524,6 +1601,7 @@ function onSubmit(event) {
})();
}
// 键盘事件代理。
function onKeydown(event) {
if (event.key === 'Enter' && event.target.id === 'dlg-db-name') {
event.preventDefault();
@@ -1533,6 +1611,7 @@ function onKeydown(event) {
// ---------------------------------------------------------------- 启动
// 会话过期时回到登录页。
window.addEventListener(AUTH_EVENT, () => {
if (state.view === 'login') return;
state.session = null;
@@ -1546,6 +1625,7 @@ root.addEventListener('change', onChange);
root.addEventListener('submit', onSubmit);
root.addEventListener('keydown', onKeydown);
// 启动:读设置、恢复会话、渲染。
async function boot() {
loadLocalSettings();
applySettingsToDom();
+22 -12
View File
@@ -1,9 +1,9 @@
// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置默认值。
// 不碰 DOM、不发请求,方便单独阅读与替换。
// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置。
// 不碰 DOM、不发请求。
// ---------------------------------------------------------------- 转义
// 所有拼进 innerHTML 的文本都要先过这里(表名、列名、单元格值都是用户数据)。
// 转义 HTML 特殊字符。
export function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
@@ -20,7 +20,7 @@ const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 账号表:在 system 库里像普通表一样浏览,写入会翻译成账号命令。
export const accountsTable = '__chusql_users';
// 字面量:NULL / 数字 / 布尔原样,其它按字符串加单引号并把单引号翻倍。
// 值转 SQL 字面量(字符串加引号转义)。
export function sqlLiteral(value) {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
@@ -70,11 +70,12 @@ export function changeSql(change) {
// ---------------------------------------------------------------- 变更集
// 空变更集:updates 按"表:主键:列"记,inserts/deletes 按各自的键记。
// 建一个空变更集。
export function createEmptyChangeSet() {
return { updates: new Map(), inserts: new Map(), deletes: new Map() };
}
// 复制变更集。
export function cloneChangeSet(changes) {
return {
updates: new Map(changes.updates),
@@ -83,6 +84,7 @@ export function cloneChangeSet(changes) {
};
}
// 变更总条数。
export function changeCount(changes) {
return changes.updates.size + changes.inserts.size + changes.deletes.size;
}
@@ -92,7 +94,7 @@ export function changeKey(table, pk, column) {
return `${table}:${JSON.stringify(pk)}:${column}`;
}
// 插入行的键(临时 id,不落库)。
// 插入行的键。
export function insertKey(table, tempId) {
return `${table}:${tempId}`;
}
@@ -121,7 +123,7 @@ export function toCommitPayload(changes) {
];
}
// 面板里要显示的一条条变更:[{ key, kind, table, sql, … }]
// 面板展示用的变更条目列表。
export function changeEntries(changes) {
const updates = [...changes.updates.entries()].map(([key, update]) => ({
key,
@@ -155,16 +157,18 @@ export function changesSql(changes) {
// ---------------------------------------------------------------- 筛选
// 转义正则元字符。
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// like 通配转正则。
function likePattern(raw) {
const escaped = escapeRegExp(raw).replaceAll('%', '.*').replaceAll('_', '.');
return new RegExp(`^${escaped}$`, 'i');
}
// 表格筛选表达式:null / true / false / like 通配 / 数值比较 / 纯文本包含。
// 判断单元格值是否命中筛选表达式。
export function matchesFilter(value, rawExpression) {
const expression = String(rawExpression ?? '').trim();
if (!expression) return true;
@@ -209,23 +213,27 @@ export const creatableTypes = [
'float', 'double', 'decimal(10,2)', 'date', 'timestamp', 'blob',
];
// 取类型基名(去掉参数)。
export function columnKind(type) {
return String(type).split('(')[0].trim().toLowerCase();
}
// 是否整数类型。
export function isIntegerType(type) {
return ['int', 'bigint', 'smallint'].includes(columnKind(type));
}
// 是否数值类型。
export function isNumericType(type) {
return ['int', 'bigint', 'smallint', 'float', 'double', 'decimal', 'numeric'].includes(columnKind(type));
}
// 是否布尔类型。
export function isBooleanType(type) {
return ['bool', 'boolean'].includes(columnKind(type));
}
// 显示名:按基名查标签,参数原样跟在后面(varchar(20) → VARCHAR(20))。
// 类型的显示名。
export function typeLabel(type) {
const kind = columnKind(type);
const label = typeLabels[kind];
@@ -236,7 +244,7 @@ export function typeLabel(type) {
// ---------------------------------------------------------------- 值渲染
// 单元格显示的文本;null/undefined 用设置里的占位文本。
// 单元格显示文本(空值用占位文本)。
export function cellText(value, nullText) {
if (value === null || value === undefined) return nullText;
if (typeof value === 'boolean') return value ? 'true' : 'false';
@@ -249,7 +257,7 @@ export function editText(value) {
return String(value);
}
// 把编辑框里的文本变回提交给服务的值(空串按 null 处理,与界面上的"清空"一致)。
// 编辑框文本转提交值(空串当 null)。
export function parseEdited(raw, type) {
const text = String(raw ?? '').trim();
if (text === '') return null;
@@ -296,6 +304,7 @@ export function tokenizeSql(sql) {
let plain = '';
let index = 0;
const text = String(sql ?? '');
// 收尾当前普通片段。
const flush = () => {
if (plain) {
tokens.push({ text: plain, kind: 'plain' });
@@ -352,7 +361,7 @@ export function tokenizeSql(sql) {
return tokens;
}
// 高亮片段转 HTML(词法切分的结果,文本仍然要转义)。
// 高亮 SQL 片段转 HTML。
export function highlightHtml(sql) {
return tokenizeSql(sql)
.map((token) => (token.kind === 'plain' ? escapeHtml(token.text) : `<span class="tok-${token.kind}">${escapeHtml(token.text)}</span>`))
@@ -492,6 +501,7 @@ export function fontsFromText(text) {
.slice(0, 10);
}
// 字体链转逗号分隔文本。
export function fontsToText(chain) {
return chain.join(', ');
}
+1 -3
View File
@@ -1,8 +1,5 @@
<!doctype html>
<!-- ChuSQL Web IDE 页面壳:纯 HTML + CSS + JS,无打包器、无 Node 依赖。
这里不要写内联样式表或内联脚本:CSP 只给带 nonce 的样式放行。 -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
@@ -11,6 +8,7 @@
<title>ChuSQL 数据库终端</title>
<link rel="stylesheet" href="/static/style.css" />
<script type="module" src="/static/app.js"></script>
<!-- ChuSQL Web IDE 页面壳:挂载 #root,加载样式与 app.js。 -->
</head>
<body>
<div id="root"></div>
+1 -2
View File
@@ -1,5 +1,4 @@
/* ChuSQL Web IDE 样式:VS Code Dark+ 风格,纯手写,无框架。
字体与字号由 app.js 通过 CSS 变量(--ui-font 等)按 IDE 设置覆盖。 */
/* ChuSQL Web IDE 样式:VS Code Dark+ 风格,变量由 app.js 覆盖。 */
:root {
--bg-base: #1e1e1e;