大改,整理项目结构,完成rust ffi调用,写好tcp接口
This commit is contained in:
1 parent
df6d348995
commit
9758439b82
118 files changed
+8103
-6321
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -1,9 +1,9 @@
|
||||
// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置默认值。
|
||||
// 不碰 DOM、不发请求,方便单独阅读与替换。
|
||||
// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置。
|
||||
// 不碰 DOM、不发请求。
|
||||
|
||||
// ---------------------------------------------------------------- 转义
|
||||
|
||||
// 所有拼进 innerHTML 的文本都要先过这里(表名、列名、单元格值都是用户数据)。
|
||||
// 转义 HTML 特殊字符。
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -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,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,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;
|
||||
|
||||
Reference in new issue
Block a user