大更新:

1、完善SQL语法
2、移除node前端,只保留静态资源
3、写好安装脚本
4、做好linux适配
This commit is contained in:
LIm1tZz7 committed 2026-10-01 20:48:38 +08:00
1 parent 687c012841
commit df6d348995
166 files changed
+16317 -10763

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
// REST 数据源:与 chusql-web 的 /api/* 一一对应。
// 所有请求带 X-ChuSQL-Database 头(服务端不保存 USE 状态);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);
let response;
try {
response = await fetch(url, { credentials: 'same-origin', ...(init ?? {}), headers });
} catch (error) {
throw new Error(`无法连接服务器:${error && error.message ? error.message : String(error)}`);
}
let body;
try {
body = await response.json();
} catch {
body = undefined;
}
if (!response.ok) {
if (response.status === 401 || (isObject(body) && body.error === 'password_expired')) {
window.dispatchEvent(new Event(AUTH_EVENT));
}
const message = isObject(body) && typeof body.message === 'string'
? body.message
: `请求失败(HTTP ${response.status})。`;
throw new Error(message);
}
return body;
}
function jsonInit(method, body) {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(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('服务器返回的会话信息无法识别。');
return {
user: body.user,
administrator: body.administrator === true,
policy: isObject(body.policy) ? body.policy : { minLength: 0, classes: 0 },
};
}
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')) {
throw new Error('服务器返回的数据库列表无法识别。');
}
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 语句,服务端在响应里回带新的库名。
export async function useDatabase(name, database) {
checkDatabaseName(name);
return query(`USE ${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) => {
if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') {
throw new Error('服务器返回的列描述无法识别。');
}
if (!knownTypes.includes(columnKind(entry.type))) throw new Error(`不支持的列类型:${entry.type}`);
return { ...entry, name: entry.name, type: entry.type };
});
}
function asTable(value) {
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
throw new Error('服务器返回的表描述无法识别。');
}
return {
name: value.table,
rowCount: value.rowCount,
columns: asColumns(value.columns),
indexes: Array.isArray(value.indexes) ? value.indexes : [],
kind: value.kind === 'system' ? 'system' : 'table',
};
}
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) {
params.set('sort', sort);
params.set('dir', dir === 'desc' ? 'desc' : 'asc');
}
if (filters && filters.length) {
params.set('filter', JSON.stringify(filters.map((f) => ({ column: f.column, value: f.expression }))));
}
const body = await request(`/api/tables/${encodeURIComponent(table)}/rows?${params}`, undefined, database);
if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((c) => typeof c === 'string')
|| !Array.isArray(body.rows) || typeof body.total !== 'number') {
throw new Error('服务器返回的分页数据无法识别。');
}
const columns = body.columns;
const rows = body.rows.map((raw) => {
if (!Array.isArray(raw)) throw new Error('服务器返回的行数据无法识别。');
return Object.fromEntries(columns.map((column, index) => [column, raw[index]]));
});
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);
}
// 逐条提交,遇到第一条失败就停,返回已经成功了几条。
export async function commitChanges(database, changes) {
let applied = 0;
for (const [index, change] of changes.entries()) {
try {
if (change.op === 'insert') await insertRow(database, change.table, change.values);
else if (change.op === 'delete') await deleteRow(database, change.table, change.pk);
else await updateRow(database, change.table, change.pk, change.set);
applied += 1;
} catch (error) {
return { ok: false, applied, errors: [{ index, message: error instanceof Error ? error.message : String(error) }] };
}
}
return { ok: true, applied };
}
// ---------------------------------------------------------------- 查询
export async function query(sql, database) {
const started = performance.now();
try {
const body = await request('/api/query', jsonInit('POST', { sql }), database);
if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) {
throw new Error('服务器返回的查询结果无法识别。');
}
const durationMs = performance.now() - started;
return {
type: body.columns.length ? 'rows' : 'affected',
columns: body.columns,
rows: body.rows,
affected: typeof body.rowCount === 'number' ? body.rowCount : 0,
truncated: body.truncated === true,
database: typeof body.database === 'string' ? body.database : undefined,
durationMs,
};
} catch (error) {
return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) };
}
}
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 };
}
export async function loadUiSettings() {
return request('/api/ui-settings');
}
export async function saveUiSettings(settings) {
await request('/api/ui-settings', jsonInit('PUT', settings));
}
File diff suppressed because it is too large. Load diff
+497
View File
@@ -0,0 +1,497 @@
// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置默认值。
// 不碰 DOM、不发请求,方便单独阅读与替换。
// ---------------------------------------------------------------- 转义
// 所有拼进 innerHTML 的文本都要先过这里(表名、列名、单元格值都是用户数据)。
export function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
// ---------------------------------------------------------------- SQL 字面量
const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 账号表:在 system 库里像普通表一样浏览,写入会翻译成账号命令。
export const accountsTable = '__chusql_users';
// 字面量:NULL / 数字 / 布尔原样,其它按字符串加单引号并把单引号翻倍。
export function sqlLiteral(value) {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return `'${String(value).replaceAll("'", "''")}'`;
}
// 标识符:白名单外才加双引号。
export function sqlIdentifier(name) {
const text = String(name);
return identifierPattern.test(text) ? text : `"${text.replaceAll('"', '""')}"`;
}
// 单元格修改语句。
export function updateSql(table, pk, column, value) {
if (table === accountsTable) return `ALTER USER ${sqlIdentifier(pk)} IDENTIFIED BY ${sqlLiteral(value)};`;
return `UPDATE ${sqlIdentifier(table)} SET ${sqlIdentifier(column)} = ${sqlLiteral(value)} WHERE id = ${sqlLiteral(pk)};`;
}
// 插入语句。
export function insertSql(table, values) {
if (table === accountsTable) {
return `CREATE USER ${sqlIdentifier(values.user ?? '')} IDENTIFIED BY ${sqlLiteral(values.password)};`;
}
const columns = Object.keys(values);
const literals = columns.map((column) => sqlLiteral(values[column]));
return `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(', ')}) VALUES (${literals.join(', ')});`;
}
// 删除语句。
export function deleteSql(table, pk) {
if (table === accountsTable) return `DROP USER ${sqlIdentifier(pk)};`;
return `DELETE FROM ${sqlIdentifier(table)} WHERE id = ${sqlLiteral(pk)};`;
}
// 一条待提交变更对应的展示用 SQL。
export function changeSql(change) {
if (change.op === 'insert') return insertSql(change.table, change.values);
if (change.op === 'delete') return deleteSql(change.table, change.pk);
if (change.table === accountsTable) {
return updateSql(change.table, change.pk, 'password', change.set.password);
}
const assignments = Object.entries(change.set)
.map(([column, value]) => `${sqlIdentifier(column)} = ${sqlLiteral(value)}`)
.join(', ');
return `UPDATE ${sqlIdentifier(change.table)} SET ${assignments} WHERE id = ${sqlLiteral(change.pk)};`;
}
// ---------------------------------------------------------------- 变更集
// 空变更集: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),
inserts: new Map(changes.inserts),
deletes: new Map(changes.deletes),
};
}
export function changeCount(changes) {
return changes.updates.size + changes.inserts.size + changes.deletes.size;
}
// 单元格变更的键。
export function changeKey(table, pk, column) {
return `${table}:${JSON.stringify(pk)}:${column}`;
}
// 插入行的键(临时 id,不落库)。
export function insertKey(table, tempId) {
return `${table}:${tempId}`;
}
// 删除行的键。
export function deleteKey(table, pk) {
return `${table}:${JSON.stringify(pk)}`;
}
// 组装 DML 提交载荷:同一行的多列修改并成一条。
export function toCommitPayload(changes) {
const updates = new Map();
for (const update of changes.updates.values()) {
const key = `${update.table}:${JSON.stringify(update.pk)}`;
const existing = updates.get(key);
if (existing) {
existing.set[update.column] = update.newValue;
} else {
updates.set(key, { op: 'update', table: update.table, pk: update.pk, set: { [update.column]: update.newValue } });
}
}
return [
...updates.values(),
...[...changes.inserts.values()].map((insert) => ({ op: 'insert', table: insert.table, values: { ...insert.values } })),
...[...changes.deletes.values()].map((deleted) => ({ op: 'delete', table: deleted.table, pk: deleted.pk })),
];
}
// 面板里要显示的一条条变更:[{ key, kind, table, sql, … }]
export function changeEntries(changes) {
const updates = [...changes.updates.entries()].map(([key, update]) => ({
key,
kind: 'update',
table: update.table,
pk: update.pk,
column: update.column,
sql: changeSql({ op: 'update', table: update.table, pk: update.pk, set: { [update.column]: update.newValue } }),
}));
const inserts = [...changes.inserts.entries()].map(([key, insert]) => ({
key,
kind: 'insert',
table: insert.table,
tempId: insert.tempId,
sql: changeSql({ op: 'insert', table: insert.table, values: insert.values }),
}));
const deletes = [...changes.deletes.entries()].map(([key, deleted]) => ({
key,
kind: 'delete',
table: deleted.table,
pk: deleted.pk,
sql: changeSql({ op: 'delete', table: deleted.table, pk: deleted.pk }),
}));
return [...updates, ...inserts, ...deletes];
}
// 全部变更的 SQL 预览。
export function changesSql(changes) {
return toCommitPayload(changes).map(changeSql);
}
// ---------------------------------------------------------------- 筛选
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
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;
const normalized = expression.toLowerCase();
if (normalized === 'null') return value === null || value === undefined;
if (normalized === 'true' || normalized === 'false') return value === (normalized === 'true');
const like = expression.match(/^like\s+(.+)$/i);
if (like) return likePattern(like[1]).test(String(value ?? ''));
const comparison = expression.match(/^(>=|<=|>|<|=|!=)\s*(-?\d+(?:\.\d+)?)$/);
if (comparison) {
const left = Number(value);
const right = Number(comparison[2]);
if (!Number.isFinite(left)) return false;
switch (comparison[1]) {
case '>': return left > right;
case '<': return left < right;
case '>=': return left >= right;
case '<=': return left <= right;
case '!=': return left !== right;
default: return left === right;
}
}
return String(value ?? '').toLocaleLowerCase().includes(normalized);
}
// ---------------------------------------------------------------- 列类型
const typeLabels = {
int: 'INT', bigint: 'BIGINT', smallint: 'SMALLINT', str: 'STR', text: 'TEXT',
varchar: 'VARCHAR', char: 'CHAR', bool: 'BOOL', boolean: 'BOOL', float: 'FLOAT',
real: 'REAL', double: 'DOUBLE', decimal: 'DECIMAL', numeric: 'NUMERIC',
date: 'DATE', timestamp: 'TIMESTAMP', blob: 'BLOB',
};
// 建表对话框里可选的类型。
export const creatableTypes = [
'int', 'bigint', 'smallint', 'str', 'varchar(255)', 'char(1)', 'bool',
'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];
const args = String(type).includes('(') ? String(type).slice(String(type).indexOf('(')) : '';
if (label) return `${label}${args}`;
return String(type);
}
// ---------------------------------------------------------------- 值渲染
// 单元格显示的文本;null/undefined 用设置里的占位文本。
export function cellText(value, nullText) {
if (value === null || value === undefined) return nullText;
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
}
// 编辑框里回填的文本(null 给空串)。
export function editText(value) {
if (value === null || value === undefined) return '';
return String(value);
}
// 把编辑框里的文本变回提交给服务的值(空串按 null 处理,与界面上的"清空"一致)。
export function parseEdited(raw, type) {
const text = String(raw ?? '').trim();
if (text === '') return null;
if (isIntegerType(type)) {
const n = Number(text);
return Number.isFinite(n) ? Math.trunc(n) : text;
}
if (isNumericType(type)) {
const n = Number(text);
return Number.isFinite(n) ? n : text;
}
if (isBooleanType(type)) {
const lower = text.toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
return text;
}
return text;
}
// ---------------------------------------------------------------- SQL 高亮
export const SQL_TOKEN_COLORS = {
keyword: '#569CD6',
string: '#CE9178',
number: '#B5CEA8',
comment: '#6A9955',
};
const keywords = new Set([
'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete',
'create', 'table', 'index', 'on', 'drop', 'alter', 'and', 'or', 'not', 'null',
'order', 'by', 'group', 'having', 'limit', 'offset', 'join', 'left', 'right',
'inner', 'outer', 'as', 'distinct', 'primary', 'key', 'int', 'integer', 'str',
'text', 'bool', 'boolean', 'true', 'false', 'use', 'show', 'database', 'databases',
'user', 'identified', 'add', 'column', 'if', 'exists', 'begin', 'commit', 'rollback',
]);
const wordChar = /[A-Za-z0-9_]/;
// 切分 SQL 为关键字、字符串、数字、注释与普通片段。
export function tokenizeSql(sql) {
const tokens = [];
let plain = '';
let index = 0;
const text = String(sql ?? '');
const flush = () => {
if (plain) {
tokens.push({ text: plain, kind: 'plain' });
plain = '';
}
};
while (index < text.length) {
const char = text[index];
if (char === "'") {
flush();
let end = index + 1;
while (end < text.length) {
if (text[end] !== "'") { end += 1; continue; }
if (text[end + 1] === "'") { end += 2; continue; }
end += 1;
break;
}
tokens.push({ text: text.slice(index, end), kind: 'string' });
index = end;
continue;
}
if (char === '-' && text[index + 1] === '-') {
flush();
const end = text.indexOf('\n', index);
tokens.push({ text: end < 0 ? text.slice(index) : text.slice(index, end), kind: 'comment' });
index = end < 0 ? text.length : end;
continue;
}
if (char >= '0' && char <= '9') {
flush();
let end = index;
while (end < text.length && (wordChar.test(text[end]) || text[end] === '.')) end += 1;
tokens.push({ text: text.slice(index, end), kind: 'number' });
index = end;
continue;
}
if (wordChar.test(char)) {
let end = index;
while (end < text.length && wordChar.test(text[end])) end += 1;
const word = text.slice(index, end);
if (keywords.has(word.toLowerCase())) {
flush();
tokens.push({ text: word, kind: 'keyword' });
} else {
plain += word;
}
index = end;
continue;
}
plain += char;
index += 1;
}
flush();
return tokens;
}
// 高亮片段转 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>`))
.join('');
}
// ---------------------------------------------------------------- 标签页
// 打开表标签:同库同表已开则激活,否则追加。
export function openTableTab(tabs, database, name, id) {
const existing = tabs.find((tab) => tab.kind === 'table' && tab.table === name && tab.database === database);
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'table', title: name, table: name, database }], activeId: id };
}
// 打开查询标签:每次追加。
export function openQueryTab(tabs, title, id, database, sql) {
return { tabs: [...tabs, { id, kind: 'sql', title, sql, database }], activeId: id };
}
// 打开设置标签:只保留一个。
export function openSettingsTab(tabs, title, id) {
const existing = tabs.find((tab) => tab.kind === 'settings');
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'settings', title }], activeId: id };
}
// 关闭标签:关当前标签时激活右邻,无则左邻。
export function closeTab(tabs, id, activeId) {
const index = tabs.findIndex((tab) => tab.id === id);
if (index < 0) return { tabs, activeId };
const remaining = tabs.filter((tab) => tab.id !== id);
if (activeId !== id) return { tabs: remaining, activeId };
const next = remaining[index] ?? remaining[index - 1];
return { tabs: remaining, activeId: next ? next.id : undefined };
}
// ---------------------------------------------------------------- IDE 设置
// 默认 IDE 设置(与后端 /api/ui-settings 的键一一对应)。
export const DEFAULT_SETTINGS = Object.freeze({
uiFonts: ['JetBrains Mono', 'Consolas', 'Cascadia Code'],
uiFontSize: 13,
gridFonts: ['JetBrains Mono', 'Consolas'],
gridFontSize: 12,
gridRowHeight: 24,
sqlFonts: ['JetBrains Mono', 'Consolas'],
sqlFontSize: 13,
sqlLineHeight: 20,
sqlTabSize: 2,
sqlLineNumbers: true,
pageSize: 200,
nullText: 'NULL',
autocomplete: true,
minimap: false,
});
export const fontKeys = ['uiFonts', 'gridFonts', 'sqlFonts'];
export const booleanKeys = ['sqlLineNumbers', 'autocomplete', 'minimap'];
export const numberKeys = ['uiFontSize', 'gridFontSize', 'gridRowHeight', 'sqlFontSize', 'sqlLineHeight', 'sqlTabSize', 'pageSize'];
export const settingRanges = {
uiFontSize: [11, 16],
gridFontSize: [10, 18],
gridRowHeight: [18, 40],
sqlFontSize: [10, 20],
sqlLineHeight: [16, 40],
sqlTabSize: [1, 8],
pageSize: [10, 500],
};
export const settingLabels = {
uiFonts: '界面字体链',
uiFontSize: '界面字号',
gridFonts: '表格字体链',
gridFontSize: '表格字号',
gridRowHeight: '表格行高',
sqlFonts: 'SQL 字体链',
sqlFontSize: 'SQL 字号',
sqlLineHeight: 'SQL 行高',
sqlTabSize: 'SQL Tab 宽度',
sqlLineNumbers: '显示行号',
pageSize: '每页行数',
nullText: 'NULL 显示文本',
autocomplete: '自动补全',
minimap: '缩略图',
};
export const IDE_SETTINGS_KEY = 'chusql.ide.settings.v1';
// 数值收敛到区间并取整。
export function clampSetting(key, value) {
const range = settingRanges[key];
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_SETTINGS[key];
const rounded = Math.round(numeric);
return range ? Math.min(range[1], Math.max(range[0], rounded)) : rounded;
}
// 字体链 → CSS font-family 值。
export function fontFamilyValue(chain) {
const fonts = chain
.map((font) => String(font).trim())
.filter(Boolean)
.map((font) => `"${font.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`);
return [...fonts, 'monospace'].join(', ');
}
// 只取已知键、按类型收敛;坏值丢弃。
export function parseUiSettings(body) {
const parsed = {};
if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed;
for (const key of fontKeys) {
const value = body[key];
if (!Array.isArray(value)) continue;
const fonts = value.filter((font) => typeof font === 'string' && font.trim().length > 0).slice(0, 10);
if (fonts.length) parsed[key] = fonts;
}
for (const key of numberKeys) {
const value = body[key];
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
parsed[key] = clampSetting(key, value);
}
for (const key of booleanKeys) {
if (typeof body[key] === 'boolean') parsed[key] = body[key];
}
if (typeof body.nullText === 'string') parsed.nullText = body.nullText;
return parsed;
}
// 逗号分隔的字体链 ↔ 数组。
export function fontsFromText(text) {
return String(text ?? '')
.split(',')
.map((font) => font.trim())
.filter(Boolean)
.slice(0, 10);
}
export function fontsToText(chain) {
return chain.join(', ');
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<!-- ChuSQL Web IDE 页面壳:纯 HTML + CSS + JS,无打包器、无 Node 依赖。
这里不要写内联样式表或内联脚本:CSP 只给带 nonce 的样式放行。 -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ChuSQL 数据库终端</title>
<link rel="stylesheet" href="/static/style.css" />
<script type="module" src="/static/app.js"></script>
</head>
<body>
<div id="root"></div>
<noscript>ChuSQL 数据库终端需要启用 JavaScript。</noscript>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
/* ChuSQL Web IDE 样式:VS Code Dark+ 风格,纯手写,无框架。
字体与字号由 app.js 通过 CSS 变量(--ui-font 等)按 IDE 设置覆盖。 */
:root {
--bg-base: #1e1e1e;
--bg-elevated: #252526;
--bg-overlay: #2d2d30;
--bg-active: #37373d;
--bg-titlebar: #3c3c3c;
--bg-statusbar: #007acc;
--border: #3c3c3c;
--border-subtle: #2d2d2d;
--fg-primary: #cccccc;
--fg-muted: #858585;
--fg-disabled: #5a5a5a;
--accent: #007acc;
--accent-2: #0e639c;
--success: #4ec9b0;
--warning: #cca700;
--danger: #f14c4c;
--modified: #e2c08d;
--info: #9cdcfe;
--syn-keyword: #569cd6;
--syn-string: #ce9178;
--syn-number: #b5cea8;
--syn-comment: #6a9955;
--ui-font: "JetBrains Mono", Consolas, "Cascadia Code", monospace;
--ui-font-size: 13px;
--grid-font: "JetBrains Mono", Consolas, monospace;
--grid-font-size: 12px;
--grid-row-height: 24px;
--sql-font: "JetBrains Mono", Consolas, monospace;
--sql-font-size: 13px;
--sql-line-height: 20px;
--sql-tab-size: 2;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; overflow: hidden; }
body {
background: var(--bg-base);
color: var(--fg-primary);
font-family: var(--ui-font);
font-size: var(--ui-font-size);
}
button, input, select, textarea { font-family: inherit; font-size: inherit; }
button { border-radius: 2px; cursor: pointer; }
button:disabled { cursor: default; opacity: 0.45; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-active); }
.muted { color: var(--fg-muted); }
.flex-spacer { flex: 1; }
.sep { width: 1px; align-self: stretch; background: var(--border); margin: 3px 4px; }
.inline { display: inline-flex; align-items: center; gap: 5px; }
.alert { margin: 4px 0; }
.alert-error { color: var(--danger); }
.alert-warn { color: var(--warning); }
.alert-info { color: var(--info); }
/* ---------------------------------------------------------------- 按钮 / 表单 */
button:not(.mini):not(.tab-label):not(.tree-label):not(.column-head):not(.link):not(.splitter-v):not(.splitter-h) {
background: var(--bg-active);
border: 1px solid var(--border);
color: var(--fg-primary);
min-height: 24px;
padding: 0 8px;
}
button:not(.mini):not(.tab-label):not(.tree-label):not(.column-head):not(.link):not(.splitter-v):not(.splitter-h):hover:not(:disabled) {
background: #2f2f33;
}
button.primary { background: var(--accent-2); border-color: var(--accent); color: #ffffff; }
button.primary:hover:not(:disabled) { background: #1177bb; }
button.plain { background: transparent; border: 1px dashed var(--border); color: var(--fg-muted); }
button.link {
background: none;
border: 0;
color: var(--info);
padding: 0;
text-decoration: underline;
}
button.mini {
background: transparent;
border: 1px solid transparent;
color: var(--fg-muted);
height: 20px;
min-width: 20px;
padding: 0 3px;
line-height: 1;
}
button.mini:hover:not(:disabled) { background: var(--bg-active); color: var(--fg-primary); }
button.mini.danger:hover:not(:disabled) { color: var(--danger); }
input, select, textarea {
background: var(--bg-base);
border: 1px solid var(--border);
color: var(--fg-primary);
min-height: 22px;
padding: 1px 5px;
}
input[type="checkbox"] { min-height: 0; }
.field { display: grid; gap: 4px; margin: 6px 0; }
.field > span { color: var(--fg-muted); }
.field small { font-size: 11px; }
.toggle { display: inline-flex; align-items: center; gap: 6px; margin: 6px 12px 6px 0; }
/* ---------------------------------------------------------------- 登录页 */
.auth-view { height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-base); }
.auth-card {
width: 340px;
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 18px;
display: grid;
gap: 10px;
}
.auth-card header { display: flex; align-items: center; gap: 8px; }
.auth-card h1 { margin: 0; font-size: 14px; font-weight: 500; }
.auth-card label { display: grid; gap: 4px; }
.auth-card label span { color: var(--fg-muted); font-size: 11px; }
.auth-card input { height: 26px; }
.auth-hint { color: var(--fg-muted); font-size: 11px; margin: 0; }
.brand-mark { color: var(--accent); font-weight: 700; letter-spacing: 0.04em; }
/* ---------------------------------------------------------------- 工作台布局 */
.workbench {
--sidebar-width: 250px;
--dock-height: 22vh;
display: grid;
grid-template-columns: 48px var(--sidebar-width) minmax(0, 1fr);
grid-template-rows: 34px minmax(0, 1fr) var(--dock-height) 22px;
height: 100vh;
min-height: 360px;
position: relative;
}
.titlebar {
grid-column: 1 / -1;
grid-row: 1;
background: var(--bg-titlebar);
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 0 12px;
}
.brand { display: inline-flex; align-items: baseline; gap: 7px; }
.brand-name { color: var(--fg-muted); font-size: 11px; }
.titlebar-actions { display: flex; gap: 2px; margin-left: auto; }
.account { display: inline-flex; align-items: center; gap: 8px; margin-left: 10px; }
.account-name { color: var(--fg-muted); }
.activitybar {
grid-column: 1;
grid-row: 2 / 4;
background: #333333;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding-top: 12px;
}
.activitybar button { background: none; border: 0; color: var(--fg-muted); font-size: 16px; width: 100%; height: 28px; }
.activitybar button:hover { color: var(--fg-primary); }
.sidebar {
grid-column: 2;
grid-row: 2 / 4;
background: var(--bg-elevated);
border-right: 1px solid var(--border);
overflow: auto;
min-width: 0;
}
.sidebar h2 {
margin: 0;
padding: 11px 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.side-section { padding-bottom: 10px; }
.side-title {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.side-actions { margin-left: auto; display: flex; gap: 3px; }
.side-row { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
.side-row select { flex: 1; min-width: 0; }
.table-list, .column-list, .index-list { list-style: none; margin: 0; padding: 0; }
.tree-row { display: flex; align-items: center; gap: 4px; height: 22px; padding: 0 6px; }
.tree-row small { color: var(--fg-muted); }
.tree-row small.warn { color: var(--warning); }
.tree-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
background: none;
border: 0;
border-left: 2px solid transparent;
color: var(--fg-primary);
height: 22px;
}
.tree-label:hover { background: var(--bg-active); }
.column-list li, .index-list li { display: flex; align-items: center; gap: 6px; height: 22px; padding-left: 34px; }
.index-list li { padding-left: 34px; color: var(--fg-muted); }
.col-type { color: var(--info); font-size: 11px; }
.col-marks { display: inline-flex; gap: 3px; font-size: 9px; }
.mark-pk { color: var(--modified); }
.mark-ix { color: var(--success); }
.mark-nn { color: var(--fg-muted); }
.col-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.column-list li .mini, .index-list li .mini { margin-left: auto; }
.tree-actions { padding: 2px 6px 4px 34px; }
.editor {
grid-column: 3;
grid-row: 2;
display: grid;
grid-template-rows: 30px minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.tabbar {
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
display: flex;
align-items: stretch;
overflow-x: auto;
}
.tab { display: flex; align-items: center; background: var(--bg-base); border-right: 1px solid var(--border); border-top: 1px solid transparent; }
.tab-active { border-top-color: var(--accent); }
.tab-label {
background: none;
border: 0;
color: var(--fg-muted);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px 0 12px;
}
.tab-active .tab-label { color: var(--fg-primary); }
.tab-empty { padding: 6px 10px; }
.tab-content { min-height: 0; overflow: hidden; }
.welcome { height: 100%; display: flex; align-items: center; justify-content: center; color: var(--fg-muted); text-align: center; }
.welcome h1 { font-size: 16px; font-weight: 500; color: var(--fg-primary); }
.notice { padding: 16px; color: var(--fg-muted); }
.empty { text-align: center; color: var(--fg-muted); padding: 28px; }
.bottom {
grid-column: 3;
grid-row: 3;
background: var(--bg-elevated);
border-top: 1px solid var(--border);
display: grid;
grid-template-rows: 28px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.bottom-head { display: flex; align-items: center; gap: 3px; border-bottom: 1px solid var(--border); padding: 0 4px; }
.bottom-head > button:not(.mini) { background: none; border: 0; color: var(--fg-primary); height: 100%; padding: 0 9px; }
.bottom-head > button.active { border-bottom: 1px solid var(--accent); }
.bottom-body { overflow: auto; padding: 4px 8px; }
.change-list, .message-list { list-style: none; margin: 0; padding: 0; }
.change-list li {
display: grid;
grid-template-columns: 160px minmax(0, 1fr) 24px;
align-items: center;
gap: 10px;
min-height: 26px;
border-bottom: 1px solid var(--border-subtle);
}
.change-list code { font-family: var(--ui-font); color: #c8c8c8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.change-table { color: var(--info); }
.message-list li { min-height: 22px; border-bottom: 1px solid var(--border-subtle); }
.msg-time { color: var(--fg-muted); margin-right: 8px; }
.msg-error { color: var(--danger); }
.msg-warn { color: var(--warning); }
.sql-preview { margin-top: 6px; }
.sql-preview pre { white-space: pre-wrap; color: #c8c8c8; margin: 4px 0; }
.statusbar {
grid-column: 1 / -1;
grid-row: 4;
background: var(--bg-statusbar);
color: #ffffff;
display: flex;
align-items: center;
gap: 16px;
padding: 0 8px;
}
.splitter-v {
position: absolute;
top: 34px;
bottom: 22px;
left: calc(48px + var(--sidebar-width) - 2px);
width: 5px;
padding: 0;
border: 0;
background: transparent;
cursor: col-resize;
z-index: 6;
}
.splitter-h {
position: absolute;
left: calc(48px + var(--sidebar-width));
right: 0;
bottom: calc(22px + var(--dock-height) - 2px);
height: 5px;
padding: 0;
border: 0;
background: transparent;
cursor: row-resize;
z-index: 6;
}
.splitter-v:hover, .splitter-h:hover { background: var(--accent); }
/* ---------------------------------------------------------------- 工具栏 */
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.page-info { color: var(--fg-muted); padding: 0 4px; }
/* ---------------------------------------------------------------- 数据表格 */
.table-editor { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; min-height: 0; }
.grid-scroll { overflow: auto; min-height: 0; position: relative; }
.data-grid {
border-collapse: collapse;
table-layout: fixed;
font-family: var(--grid-font);
font-size: var(--grid-font-size);
min-width: 100%;
}
.data-grid th, .data-grid td {
border-bottom: 1px solid var(--border-subtle);
border-right: 1px solid var(--border);
height: var(--grid-row-height);
text-align: left;
padding: 0 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-grid thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; }
.data-grid thead th { color: var(--info); font-weight: 400; }
.column-head { background: none; border: 0; color: var(--info); padding: 0; text-align: left; height: 100%; }
.data-grid thead small { float: right; color: var(--fg-muted); font-size: 10px; }
.filter-row th { position: sticky; top: var(--grid-row-height); background: var(--bg-elevated); z-index: 2; }
.filter-input { width: 100%; height: 20px; padding: 0 4px; }
.row-number { width: 46px; min-width: 46px; text-align: right; color: var(--fg-muted); background: var(--bg-elevated); }
.select-cell { width: 28px; min-width: 28px; }
.data-grid tbody tr:hover { background: #ffffff0a; }
.data-grid td[data-action="edit-cell"] { cursor: text; }
.dirty { background: #e2c08d14; position: relative; }
.dirty::before {
content: "";
position: absolute;
top: 0;
left: 0;
border-top: 5px solid var(--modified);
border-right: 5px solid transparent;
}
.row-deleted td { color: var(--fg-disabled); text-decoration: line-through; }
.row-inserted td { border-left: 2px solid var(--modified); }
.inserted { background: #4ec9b00d; }
.null-value { color: var(--fg-muted); font-style: italic; }
.cell-input {
width: calc(100% + 14px);
height: calc(var(--grid-row-height) - 2px);
margin: 0 -7px;
border: 1px solid var(--accent);
background: var(--bg-base);
color: var(--fg-primary);
font: inherit;
padding: 0 6px;
}
.grid-loading { position: sticky; bottom: 0; left: 0; background: var(--bg-elevated); color: var(--info); padding: 4px 8px; }
/* ---------------------------------------------------------------- SQL 控制台 */
.sql-console { display: grid; grid-template-rows: auto minmax(120px, 1fr) minmax(0, 1fr); height: 100%; min-height: 0; }
.sql-editor { display: flex; min-height: 0; position: relative; background: var(--bg-base); border-bottom: 1px solid var(--border); }
.sql-gutter {
margin: 0;
padding: 6px 8px 6px 4px;
color: var(--fg-muted);
text-align: right;
overflow: hidden;
white-space: pre;
font-family: var(--sql-font);
font-size: var(--sql-font-size);
line-height: var(--sql-line-height);
background: var(--bg-elevated);
user-select: none;
min-width: 38px;
}
.sql-layer { position: relative; flex: 1; min-width: 0; }
.sql-highlight, .sql-input {
margin: 0;
padding: 6px 8px;
font-family: var(--sql-font);
font-size: var(--sql-font-size);
line-height: var(--sql-line-height);
tab-size: var(--sql-tab-size);
white-space: pre;
overflow: auto;
border: 0;
}
.sql-highlight { position: absolute; inset: 0; pointer-events: none; color: #c8c8c8; }
.sql-input { position: absolute; inset: 0; width: 100%; height: 100%; background: transparent; color: transparent; caret-color: #ffffff; resize: none; }
.sql-input::selection { background: #264f78; }
.sql-minimap { width: 60px; background: var(--bg-elevated); border-left: 1px solid var(--border); position: relative; }
.minimap-thumb { position: absolute; left: 0; right: 0; background: #ffffff1f; }
.tok-keyword { color: var(--syn-keyword); }
.tok-string { color: var(--syn-string); }
.tok-number { color: var(--syn-number); }
.tok-comment { color: var(--syn-comment); }
.sql-result { overflow: auto; min-height: 0; padding: 0 8px 8px; }
.result-scroll { overflow: auto; }
.result-grid { border-collapse: collapse; margin-top: 6px; }
.result-grid th, .result-grid td { border: 1px solid var(--border); text-align: left; min-width: 100px; padding: 3px 6px; }
.result-grid th { background: var(--bg-elevated); color: var(--info); }
/* ---------------------------------------------------------------- 设置 */
.settings-view { overflow: auto; height: 100%; padding: 0 18px 24px; }
.settings-view header { position: sticky; top: 0; background: var(--bg-base); z-index: 2; }
.settings-view h1 { font-size: 18px; font-weight: 500; margin: 14px 0 6px; }
.settings-group { border: 1px solid var(--border); margin: 12px 0; padding: 4px 12px 10px; }
.settings-group legend { color: var(--info); padding: 0 5px; font-weight: 700; }
.settings-footer { display: flex; align-items: center; gap: 10px; border-top: 1px solid var(--border); margin-top: 8px; padding-top: 8px; }
.preview { border: 1px solid var(--border-subtle); background: var(--bg-base); padding: 8px; margin: 12px 0; }
.preview strong { color: var(--fg-muted); display: block; margin-bottom: 4px; }
.server-settings { border-top: 1px solid var(--border); margin-top: 14px; }
/* ---------------------------------------------------------------- 对话框 */
.dialog {
background: var(--bg-overlay);
border: 1px solid var(--border);
color: var(--fg-primary);
padding: 0;
width: 620px;
max-width: min(760px, 92vw);
max-height: min(760px, 88vh);
}
.dialog::backdrop { background: #0000008c; }
.dialog-body { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: 88vh; }
.dialog-header { background: var(--bg-titlebar); height: 34px; display: flex; align-items: center; padding: 0 10px; }
.dialog-header h2 { margin: 0; font-size: 13px; }
.dialog-content { padding: 12px; overflow: auto; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 8px; border-top: 1px solid var(--border); }
.column-rows { display: grid; gap: 6px; }
.column-row { display: grid; grid-template-columns: minmax(0, 1fr) 140px 24px; gap: 6px; align-items: center; }
/* ---------------------------------------------------------------- 窄屏 */
@media (max-width: 720px) {
.workbench { grid-template-columns: 40px minmax(0, 1fr); }
.sidebar, .splitter-v { display: none; }
.editor, .bottom { grid-column: 2; }
.splitter-h { left: 40px; }
.brand-name { display: none; }
}