大更新:
1、完善SQL语法 2、移除node前端,只保留静态资源 3、写好安装脚本 4、做好linux适配
This commit is contained in:
1 parent
687c012841
commit
df6d348995
166 files changed
+16317
-10763
No files matched your search
@@ -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
@@ -0,0 +1,497 @@
|
||||
// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置默认值。
|
||||
// 不碰 DOM、不发请求,方便单独阅读与替换。
|
||||
|
||||
// ---------------------------------------------------------------- 转义
|
||||
|
||||
// 所有拼进 innerHTML 的文本都要先过这里(表名、列名、单元格值都是用户数据)。
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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(', ');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user