大更新:

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

+75 -54
View File
@@ -2,22 +2,25 @@
module Main (main) where
import ChuSQL.Web.Api (AppEnv (..), Live (..), newAppEnv, setLive, webApp)
import ChuSQL.Web.Auth (SessionPolicy (..), newSessionStore)
import ChuSQL.Web.API (AppEnv (..), Live (..), newAppEnvAt, setLive, webApp)
import ChuSQL.Web.Auth (Credential (..), SessionPolicy (..), newSessionStore)
import ChuSQL.Web.Backend (Backend, ipcBackend)
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
import ChuSQL.Web.Config (
WebConfig (..),
defaultPassword,
defaultUser,
loadWebConfig,
loadWebConfigAt,
resolveCredential,
resolvePipeName,
resolveStaticDir,
staticDirCandidates,
usingDefaultCredentials,
)
import ChuSQL.Web.RateLimit (newRateLimiter)
import ChuSQL.Web.Settings (effectiveSettings)
import ChuSQL.Storage.IPC (setPipeName)
import ChuSQL.Web.Settings (effectiveSettings, readSettingsFile)
import ChuSQL.Web.TOML (resolveConfigPath)
import ChuSQL.Web.StorageProcess (
StorageProcess (..),
findStorageServer,
@@ -25,8 +28,9 @@ import ChuSQL.Web.StorageProcess (
stopStorageProcess,
waitForStorage,
)
import Control.Exception (IOException, bracket, try)
import Control.Exception (IOException, bracket, onException, try)
import Data.Char (toLower)
import qualified Data.Map.Strict as Map
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
@@ -42,7 +46,8 @@ import Text.Read (readMaybe)
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
data Options = Options
{ optHost :: Maybe String
{ optConfig :: Maybe FilePath
, optHost :: Maybe String
, optPort :: Maybe Int
, optStatic :: Maybe FilePath
, optStorage :: Maybe FilePath
@@ -65,7 +70,8 @@ data Options = Options
emptyOptions :: Options
emptyOptions =
Options
{ optHost = Nothing
{ optConfig = Nothing
, optHost = Nothing
, optPort = Nothing
, optStatic = Nothing
, optStorage = Nothing
@@ -91,6 +97,7 @@ parseArgs = go emptyOptions
go opts [] = Right opts
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
go opts ("--storage" : v : rest) = go opts{optStorage = Just v} rest
@@ -109,11 +116,9 @@ parseArgs = go emptyOptions
go opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
-- | 读一个整数开关
withInt :: String -> String -> (Int -> Either String Options) -> Either String Options
withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v)
-- | 布尔开关认 1/true/yes/on
isTrue :: String -> Bool
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
@@ -122,27 +127,36 @@ usage :: IO ()
usage = do
putStrLn "usage: chusql-web [options]"
putStrLn ""
putStrLn " --host H listen address (CHUSQL_WEB_HOST, default 127.0.0.1)"
putStrLn " --port N listen port (CHUSQL_WEB_PORT, default 7777)"
putStrLn " --static DIR static asset directory (CHUSQL_WEB_STATIC, default static)"
putStrLn " --user NAME account name (CHUSQL_WEB_USER, default admin)"
putStrLn " --storage SERVER Rust storage exe to spawn (CHUSQL_STORAGE_SERVER)"
putStrLn " --cookie-secure BOOL add Secure to the cookie (CHUSQL_WEB_COOKIE_SECURE, default false)"
putStrLn " --body-limit N max request body bytes (CHUSQL_WEB_BODY_LIMIT, default 65536)"
putStrLn " --session-idle N session idle timeout, sec (CHUSQL_WEB_SESSION_IDLE, default 28800)"
putStrLn " --session-max N session max age, sec (CHUSQL_WEB_SESSION_MAX, default 86400)"
putStrLn " --login-max-attempts N failed logins before 429 (CHUSQL_WEB_LOGIN_MAX_ATTEMPTS, default 5)"
putStrLn " --login-window N lockout window, sec (CHUSQL_WEB_LOGIN_WINDOW, default 300)"
putStrLn " --page-size N rows per page by default (CHUSQL_WEB_PAGE_SIZE, default 25)"
putStrLn " --max-page-size N rows per page ceiling (CHUSQL_WEB_MAX_PAGE_SIZE, default 500)"
putStrLn " --max-rows N rows returned per query (CHUSQL_WEB_MAX_ROWS, default 1000)"
putStrLn " --max-sql-length N max SQL characters (CHUSQL_WEB_MAX_SQL_LENGTH, default 20000)"
putStrLn " --seed BOOL load demo data when the db is empty (CHUSQL_WEB_SEED, default false)"
putStrLn "config: chusql.toml sits at a fixed place and its sections hold every knob below;"
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)"
putStrLn " [web] host/port/static_dir/user/password and the limits; [server] pipe_name;"
putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level"
putStrLn ""
putStrLn "password: CHUSQL_WEB_PASSWORD_HASH > CHUSQL_WEB_PASSWORD > built-in demo password"
putStrLn ("without any of them the demo account is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword)
putStrLn "storage knobs for the spawned process: CHUSQL_PAGE_SIZE / CHUSQL_BTREE_ORDER /"
putStrLn " CHUSQL_BUFFER_POOL_SIZE / CHUSQL_LOG / CHUSQL_PIPE / CHUSQL_DATA_DIR"
putStrLn " --config FILE read that config file instead ([web] host ...)"
putStrLn " --host H listen address ([web] host, default 127.0.0.1)"
putStrLn " --port N listen port ([web] port, default 7777)"
putStrLn " --static DIR static asset directory ([web] static_dir, default static)"
putStrLn " --user NAME administrator name ([web] user, default root)"
putStrLn " --storage SERVER Rust storage exe to spawn ([web] storage_server)"
putStrLn " --cookie-secure BOOL add Secure to the cookie ([web] cookie_secure, default false)"
putStrLn " --body-limit N max request body bytes ([web] body_limit, default 65536)"
putStrLn " --session-idle N session idle timeout, sec ([web] session_idle, default 28800)"
putStrLn " --session-max N session max age, sec ([web] session_max, default 86400)"
putStrLn " --login-max-attempts N failed logins before 429 ([web] login_max_attempts, default 5)"
putStrLn " --login-window N lockout window, sec ([web] login_window, default 300)"
putStrLn " --page-size N rows per page by default ([web] rows_per_page, default 25)"
putStrLn " --max-page-size N rows per page ceiling ([web] max_page_size, default 500)"
putStrLn " --max-rows N rows returned per query ([web] max_rows, default 1000)"
putStrLn " --max-sql-length N max SQL characters ([web] max_sql_length, default 20000)"
putStrLn " --seed BOOL load demo data when the db is empty ([web] seed, default false)"
putStrLn ""
putStrLn "administrator password: user / password in the [web] section of chusql.toml, else the built-in demo"
putStrLn " an empty password means the administrator signs in with no password; ordinary accounts are then refused"
putStrLn ("without either the demo administrator is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword)
putStrLn "ordinary accounts: CREATE USER / ALTER USER / DROP USER from the SQL console (administrator only)"
putStrLn "storage process: [server] pipe_name says where to talk and where a spawned one listens;"
putStrLn " [storage] data_dir, [page] size, [btree] order, [buffer] pool_size and [log] level are its own"
-- | 命令行覆盖配置
applyOptions :: WebConfig -> Options -> WebConfig
@@ -168,20 +182,19 @@ applyOptions cfg opts =
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
}
where
-- | 开关给了用开关的,否则保留原值
orElse :: Maybe a -> a -> a
orElse (Just x) _ = x
orElse Nothing y = y
-- | 配了存储进程路径就拉起来,没配就假设外面已经跑着
startStorageIfWanted :: WebConfig -> IO (Either String (Maybe StorageProcess))
startStorageIfWanted cfg = case wcStorageServer cfg of
startStorageIfWanted :: WebConfig -> FilePath -> IO (Either String (Maybe StorageProcess))
startStorageIfWanted cfg configPath = case wcStorageServer cfg of
Nothing -> pure (Right Nothing)
Just given -> do
found <- findStorageServer (Just given)
case found of
Left e -> pure (Left e)
Right bin -> fmap (fmap Just) (startStorageProcess bin)
Right bin -> fmap (fmap Just) (startStorageProcess bin configPath)
-- | 入口
main :: IO ()
@@ -200,16 +213,20 @@ main = do
-- | 起服务
run :: Options -> IO ()
run opts = do
base <- loadWebConfig
configPath <- resolveConfigPath (optConfig opts)
base <- loadWebConfigAt configPath
let cfg = applyOptions base opts
setPipeName (resolvePipeName cfg)
foundStatic <- resolveStaticDir (wcStaticDir cfg)
case foundStatic of
Nothing -> do
putStrLn ("static directory not found; looked in: " ++ unwords (staticDirCandidates (wcStaticDir cfg)))
exitFailure
Just staticDir -> do
cred <- resolveCredential cfg
storage <- startStorageIfWanted cfg
let settingsFile = configPath
saved <- readSettingsFile settingsFile
cred <- resolveCredential cfg saved
storage <- startStorageIfWanted cfg configPath
case storage of
Left err -> do
putStrLn err
@@ -226,17 +243,18 @@ run opts = do
getCurrentTime
(wcLoginMaxAttempts cfg)
(fromIntegral (wcLoginWindow cfg))
env0 <- newAppEnv backend sessions cred limiter staticDir
env0 <- newAppEnvAt settingsFile backend sessions cred limiter staticDir
`onException` maybe (pure ()) stopStorageProcess maybeStorage
let env =
env0
{ aeCookieSecure = wcCookieSecure cfg
, aeEffective = effectiveSettings cfg
, aeLog = \msg -> TIO.putStrLn ("[web] " <> msg)
, aeLog = TIO.putStrLn
}
setLive env (liveFromConfig cfg)
app <- webApp env
printBanner cfg staticDir maybeStorage
reportCredential cfg
printBanner cfg configPath staticDir maybeStorage
reportCredential cred saved
waitForStorageReport
seedIfWanted cfg backend
let settings =
@@ -273,15 +291,17 @@ serve settings app = do
exitFailure
-- | 口令是从哪来的(用了内置默认口令就明确警告)
reportCredential :: WebConfig -> IO ()
reportCredential cfg
| usingDefaultCredentials cfg = do
reportCredential :: Credential -> Map.Map Text Text -> IO ()
reportCredential cred saved
| usingDefaultCredentials saved = do
putStrLn ""
putStrLn " !! DEMO CREDENTIALS IN USE:"
putStrLn (" !! user " ++ T.unpack (wcUser cfg) ++ " / password " ++ T.unpack defaultPassword)
putStrLn " !! set CHUSQL_WEB_PASSWORD (or CHUSQL_WEB_PASSWORD_HASH) before exposing this server."
putStrLn " !! Administrator credentials are not configured; the built-in demo password is in use:"
putStrLn (" !! user " ++ T.unpack (credUser cred) ++ " / password " ++ T.unpack defaultPassword)
putStrLn " !! set user / password in the [web] section of chusql.toml and restart"
putStrLn ""
| otherwise = putStrLn ("password: from CHUSQL_WEB_PASSWORD(_HASH) (user " ++ T.unpack (wcUser cfg) ++ ")")
| T.null (credEncoded cred) =
putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (passwordless: only the administrator may sign in)")
| otherwise = putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (credentials come from the settings file only)")
-- | 配了 seed 就补一份演示数据
seedIfWanted :: WebConfig -> Backend -> IO ()
@@ -290,10 +310,10 @@ seedIfWanted cfg backend
| otherwise = do
result <- seedDemo backend
case result of
Left e -> TIO.putStrLn ("[web] demo data skipped: " <> T.pack e)
Left e -> TIO.putStrLn ("demo data skipped: " <> T.pack e)
Right report ->
TIO.putStrLn
( "[web] demo data: "
( "demo data: "
<> (if null (srCreated report) then "already present" else "created " <> T.pack (unwords (srCreated report)))
<> (if null (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report)))
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
@@ -305,15 +325,16 @@ waitForStorageReport = do
up <- waitForStorage 20
putStrLn
( if up
then "storage: up (named pipe answered ping)"
then "storage: up (endpoint answered ping)"
else "storage: DOWN (no ping reply; start the Rust storage process or pass --storage)"
)
-- | 启动横幅
printBanner :: WebConfig -> FilePath -> Maybe StorageProcess -> IO ()
printBanner cfg staticDir maybeStorage = do
printBanner :: WebConfig -> FilePath -> FilePath -> Maybe StorageProcess -> IO ()
printBanner cfg configPath staticDir maybeStorage = do
putStrLn "==================== ChuSQL Web ===================="
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
putStrLn ("config file: " ++ configPath)
putStrLn ("static dir: " ++ staticDir)
putStrLn
( "limits: body "
@@ -340,5 +361,5 @@ printBanner cfg staticDir maybeStorage = do
++ "s"
)
case maybeStorage of
Just sp -> putStrLn ("storage proc: " ++ spPipe sp ++ " data dir " ++ spDataDir sp)
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from CHUSQL_PIPE)"
Just sp -> putStrLn ("storage proc: spawned, talking on pipe " ++ spPipe sp)
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from [server] pipe_name)"
+6 -2
View File
@@ -10,17 +10,20 @@ build-type: Simple
library
hs-source-dirs: src
exposed-modules: ChuSQL.Web.Actions
ChuSQL.Web.Api
ChuSQL.Web.Accounts
ChuSQL.Web.API
ChuSQL.Web.Auth
ChuSQL.Web.Backend
ChuSQL.Web.Config
ChuSQL.Web.Demo
ChuSQL.Web.Privileges
ChuSQL.Web.RateLimit
ChuSQL.Web.Secure
ChuSQL.Web.Settings
ChuSQL.Web.Static
ChuSQL.Web.StorageProcess
ChuSQL.Web.UiSettings
ChuSQL.Web.TOML
ChuSQL.Web.UISettings
build-depends: base >= 4.14 && < 5
, aeson
, bytestring
@@ -48,6 +51,7 @@ executable chusql-web
, bytestring
, chusql-engine
, chusql-web
, containers
, crypton
, directory
, filepath
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env node
// CLI 占位入口:功能尚未实现,仅提示用户。
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { parseArgs } from './lib/args.js';
import {
defaultConfigPath, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig,
} from './lib/config.js';
import { openBrowser } from './lib/open.js';
// ChuSQL 前端 CLI:启动开发服务器并管理本地配置。
const cyan = (text) => `\u001b[36m${text}\u001b[0m`;
const green = (text) => `\u001b[32m${text}\u001b[0m`;
const red = (text) => `\u001b[31m${text}\u001b[0m`;
const help = `Usage: chusql <command> [options]
Commands:
web Start the web server
config list List all configuration
config get <key> Get a configuration value
config set <key> <val> Set a configuration value
config unset <key> Remove a configuration value
config path Print the configuration file path
help Show this help message
Global options:
--port <n> Specify port
--host <h> Specify host
--no-open Do not open the browser automatically
--config <path> Use a custom configuration file
--adapter <mock|rest> Specify the data source adapter
--help, -h Show help
--version, -v Show version
Run "chusql <command> --help" for command-specific options.`;
function configCommand(args, configPath) {
const config = readConfig(configPath);
const [key, rawValue] = args.operands;
if (!args.action || args.action === 'list') console.log(JSON.stringify(config, null, 2));
else if (args.action === 'path') console.log(configPath);
else if (args.action === 'get') {
if (!key) throw new Error('Usage: chusql config get <key>');
const value = getAtPath(config, key);
if (value === undefined) throw new Error(`Configuration key not found: ${key}`);
console.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
} else if (args.action === 'set') {
if (!key || rawValue === undefined) throw new Error('Usage: chusql config set <key> <value>');
setAtPath(config, key, parseConfigValue(rawValue));
writeConfig(configPath, config);
console.log(green(`Saved ${key}.`));
} else if (args.action === 'unset') {
if (!key) throw new Error('Usage: chusql config unset <key>');
if (!unsetAtPath(config, key)) throw new Error(`Configuration key not found: ${key}`);
writeConfig(configPath, config);
console.log(green(`Removed ${key}.`));
} else throw new Error(`Unknown config command: ${args.action}`);
}
async function webCommand(args, configPath) {
const stored = readConfig(configPath);
const config = {
...stored,
...(args.port === undefined ? {} : { port: args.port }),
...(args.host === undefined ? {} : { host: args.host }),
...(args.adapter === undefined ? {} : { adapter: args.adapter }),
...(args.autoOpenBrowser === undefined ? {} : { autoOpenBrowser: args.autoOpenBrowser }),
};
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('Configured port must be an integer between 1 and 65535.');
if (!['mock', 'rest'].includes(config.adapter)) throw new Error('Configured adapter must be mock or rest.');
process.env.VITE_CHUSQL_ADAPTER = config.adapter;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const server = await createServer({ root, server: { host: config.host, port: config.port, strictPort: true } });
await server.listen();
const url = `http://${config.host}:${config.port}/`;
console.log(`${cyan('➜')} ${green('ChuSQL Web is running at:')}`);
console.log(` ${url}`);
console.log('Press Ctrl+C to stop.');
if (config.autoOpenBrowser) openBrowser(url);
}
try {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.configPath ?? defaultConfigPath());
if (args.help || args.command === 'help') console.log(help);
else if (args.command === 'version') console.log('ChuSQL 1.0.0');
else if (args.command === 'config') configCommand(args, configPath);
else if (args.command === 'web') await webCommand(args, configPath);
else throw new Error(`Unknown command: ${args.command}`);
} catch (error) {
console.error(red(`Error: ${error instanceof Error ? error.message : String(error)}`));
process.exitCode = 1;
}
-54
View File
@@ -1,54 +0,0 @@
// 命令行参数解析:命令、动作、全局选项与取值校验。
const HOST_PATTERN = /^[A-Za-z0-9.:[\]-]+$/;
function valueAfter(argv, index, label) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${label} requires a value.`);
return value;
}
export function parseArgs(argv) {
const result = { command: 'help', action: '', operands: [], autoOpenBrowser: undefined };
let index = 0;
if (argv[index] && !argv[index].startsWith('-')) {
result.command = argv[index];
index += 1;
} else if (argv.length) {
result.command = argv[0] === '--version' || argv[0] === '-v' ? 'version' : 'help';
}
if (result.command === 'config' && argv[index] && !argv[index].startsWith('-')) {
result.action = argv[index];
index += 1;
}
while (index < argv.length) {
const token = argv[index];
if (token === '--help' || token === '-h') result.help = true;
else if (token === '--no-open') result.autoOpenBrowser = false;
else if (token === '--port') {
const raw = valueAfter(argv, index, 'Port');
if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) throw new Error('Port must be an integer between 1 and 65535.');
result.port = Number(raw);
index += 1;
} else if (token === '--host') {
const host = valueAfter(argv, index, 'Host');
if (!HOST_PATTERN.test(host)) throw new Error('Host contains invalid characters.');
result.host = host;
index += 1;
} else if (token === '--adapter') {
const adapter = valueAfter(argv, index, 'Adapter');
if (adapter !== 'mock' && adapter !== 'rest') throw new Error('Adapter must be mock or rest.');
result.adapter = adapter;
index += 1;
} else if (token === '--config') {
result.configPath = valueAfter(argv, index, 'Config');
index += 1;
} else if (token.startsWith('-')) {
throw new Error(`Unknown option: ${token}`);
} else {
result.operands.push(token);
}
index += 1;
}
return result;
}
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parseArgs } from './args.js';
// 覆盖 CLI 参数解析与非法取值拒绝。
describe('parseArgs', () => {
it('parses command-specific and global options', () => {
expect(parseArgs(['web', '--port', '9000', '--no-open', '--adapter', 'rest'])).toMatchObject({
command: 'web', port: 9000, autoOpenBrowser: false, adapter: 'rest',
});
});
it('rejects unsafe or invalid values', () => {
expect(() => parseArgs(['web', '--port', 'nope'])).toThrow('Port must be an integer');
expect(() => parseArgs(['web', '--host', 'x & calc'])).toThrow('Host contains invalid characters');
expect(() => parseArgs(['web', '--adapter', 'other'])).toThrow('Adapter must be mock or rest');
});
});
-76
View File
@@ -1,76 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// CLI 配置文件读写:默认值、点号路径访问与类型解析。
export const DEFAULT_CONFIG = Object.freeze({
port: 5173,
host: '127.0.0.1',
autoOpenBrowser: true,
adapter: 'mock',
});
export function defaultConfigPath() {
return path.join(os.homedir(), '.chusql', 'config.json');
}
export function readConfig(file = defaultConfigPath()) {
if (!fs.existsSync(file)) {
writeConfig(file, DEFAULT_CONFIG);
return { ...DEFAULT_CONFIG };
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
throw new Error(`Configuration file is not valid JSON: ${file}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`Configuration must be a JSON object: ${file}`);
return { ...DEFAULT_CONFIG, ...parsed };
}
export function writeConfig(file, value) {
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
export function parseConfigValue(raw) {
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
if (raw === 'true') return true;
if (raw === 'false') return false;
return raw;
}
const FORBIDDEN_PATH_PARTS = new Set(['__proto__', 'prototype', 'constructor']);
function configPathParts(dottedPath) {
const parts = dottedPath.split('.').filter(Boolean);
if (!parts.length) throw new Error('Configuration key must not be empty.');
if (parts.some((part) => FORBIDDEN_PATH_PARTS.has(part))) throw new Error('Configuration key contains a forbidden path segment.');
return parts;
}
export function getAtPath(value, dottedPath) {
return configPathParts(dottedPath).reduce((current, part) => current && typeof current === 'object' ? current[part] : undefined, value);
}
export function setAtPath(value, dottedPath, nextValue) {
const parts = configPathParts(dottedPath);
let cursor = value;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object' || Array.isArray(cursor[part])) cursor[part] = {};
cursor = cursor[part];
}
cursor[parts.at(-1)] = nextValue;
}
export function unsetAtPath(value, dottedPath) {
const parts = configPathParts(dottedPath);
let cursor = value;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object') return false;
cursor = cursor[part];
}
return parts.length ? delete cursor[parts.at(-1)] : false;
}
@@ -1,49 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { DEFAULT_CONFIG, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig } from './config.js';
// 覆盖 CLI 配置读写、取值解析与路径安全。
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
});
function temporaryConfig() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'chusql-cli-'));
temporaryDirectories.push(directory);
return path.join(directory, 'nested', 'config.json');
}
describe('CLI configuration', () => {
it('creates the formatted default configuration on first read', () => {
const file = temporaryConfig();
expect(readConfig(file)).toEqual(DEFAULT_CONFIG);
expect(fs.readFileSync(file, 'utf8')).toBe(`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
});
it('persists dotted paths and can remove them', () => {
const file = temporaryConfig();
const config = readConfig(file);
setAtPath(config, 'db.host', 'localhost');
writeConfig(file, config);
expect(getAtPath(readConfig(file), 'db.host')).toBe('localhost');
expect(unsetAtPath(config, 'db.host')).toBe(true);
expect(getAtPath(config, 'db.host')).toBeUndefined();
});
it('parses numbers and booleans without changing ordinary text', () => {
expect(parseConfigValue('5173')).toBe(5173);
expect(parseConfigValue('true')).toBe(true);
expect(parseConfigValue('rest')).toBe('rest');
});
it('rejects prototype-polluting configuration paths', () => {
const config = {};
expect(() => setAtPath(config, '__proto__.polluted', true)).toThrow('forbidden path segment');
expect({}.polluted).toBeUndefined();
});
});
-11
View File
@@ -1,11 +0,0 @@
import { spawn } from 'node:child_process';
// 用系统默认浏览器打开本地地址,并校验 URL 合法性。
export function openBrowser(url) {
if (!/^http:\/\/[A-Za-z0-9.:[\]-]+:\d+\/$/.test(url)) throw new Error('Refusing to open an invalid local URL.');
const command = process.platform === 'win32' ? 'cmd.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url] : [url];
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
}
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<!-- ChuSQL 前端页面壳:挂载根节点并加载入口脚本。 -->
<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="/src/styles/global.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-40
View File
@@ -1,40 +0,0 @@
{
"name": "chusql-web-ide",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "ChuSQL database Web IDE and command-line launcher",
"bin": {
"chusql": "./bin/chusql.js",
"chusql-cli": "./bin/chusql-cli.js"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run test && npm run build"
},
"dependencies": {
"lucide-react": "0.468.0",
"monaco-editor": "0.52.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"vite": "8.3.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@types/node": "22.20.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "25.0.1",
"typescript": "5.7.2",
"vitest": "5.0.2"
},
"engines": {
"node": ">=20.19"
}
}
-18
View File
@@ -1,18 +0,0 @@
/* 工作台布局样式:编辑器面板、标签页、底部面板与提示。 */
.editorPane { display: grid; grid-template-rows: 30px minmax(0, 1fr); height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.tabs { align-items: stretch; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; overflow-x: auto; }
.tab, .tabActive { align-items: center; background: var(--bg-base); border-right: 1px solid var(--border); border-top: 1px solid transparent; display: flex; flex: 0 0 auto; }
.tab { color: var(--fg-muted); }
.tab:hover { color: var(--fg-primary); }
.tabActive { border-top-color: var(--accent); color: var(--fg-primary); }
.tabLabel { background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; max-width: 180px; overflow: hidden; padding: 0 8px 0 12px; text-overflow: ellipsis; white-space: nowrap; }
.tabs button[aria-label] { flex-shrink: 0; height: 20px; margin-right: 4px; opacity: 0; width: 20px; }
.tab:hover button[aria-label], .tabActive button[aria-label], .tabs button[aria-label]:focus-visible { opacity: 1; }
.tabs svg { flex-shrink: 0; }
.notice, .welcome { color: var(--fg-muted); padding: 24px; }
.welcome { align-items: center; display: flex; height: 100%; justify-content: center; text-align: center; }
.bottomTabs { border-bottom: 1px solid var(--border); display: flex; gap: 3px; height: 28px; }
.bottomTabs button { background: transparent; border: 0; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.bottomTabs button:first-child { border-bottom: 1px solid var(--accent); }
.message { color: var(--fg-muted); padding: 8px; }
-150
View File
@@ -1,150 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChangeStore } from './store/changeStore';
import { App } from './App';
import { MockAdapter } from './adapters/MockAdapter';
// App 组件测试:校验行删除、编辑提交与多标签编辑区。
vi.mock('./components/sql/SqlConsole', () => ({ SqlConsole: () => <div data-testid="sql-console" /> }));
describe('App table editing flow', () => {
beforeEach(() => useChangeStore.getState().clear());
it('stages row deletion on the first click and allows undo', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
fireEvent.click(screen.getByRole('button', { name: '删除选中行' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(1);
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(0);
});
it('keeps edits local and commits the complete change set once', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
expect(screen.getByText('资源管理器')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /打开表 users/ }));
const cell = await screen.findByText('User_001');
fireEvent.doubleClick(cell);
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: 'edited' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(() => expect(commit).toHaveBeenCalledTimes(1));
expect(commit.mock.calls[0][0].changes).toHaveLength(1);
});
});
describe('App tabs', () => {
beforeEach(() => useChangeStore.getState().clear());
it('keeps two tables open and switches back to the first one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
expect(await screen.findByText('User_001')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(screen.getByRole('tab', { name: 'orders' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'users' }));
expect(screen.getByRole('tab', { name: 'users' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('does not add a second tab for a table that is already open', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('activates the neighbouring tab after closing the active one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
fireEvent.click(screen.getByRole('button', { name: '关闭 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(screen.queryByRole('tab', { name: 'orders' })).not.toBeInTheDocument();
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('opens one tab for every new query', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '新建查询' }));
fireEvent.click(screen.getByRole('button', { name: '新建查询' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(await screen.findByTestId('sql-console')).toBeInTheDocument();
expect(screen.getAllByTestId('sql-console')).toHaveLength(1);
});
});
describe('App pending changes', () => {
beforeEach(() => useChangeStore.getState().clear());
it('shows a staged row edit as highlighted SQL instead of JSON', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.doubleClick(await screen.findByText('User_001'));
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: "A'B" } });
fireEvent.keyDown(editor, { key: 'Enter' });
const code = await screen.findByTitle("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(code).toHaveTextContent("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(within(code).getByText('UPDATE')).toHaveStyle({ color: '#569CD6' });
expect(within(code).getByText("'A''B'")).toHaveStyle({ color: '#CE9178' });
expect(within(code).getByText('users')).toHaveStyle({ color: 'rgb(200, 200, 200)' });
expect(screen.queryByText(/\{"id":1/)).not.toBeInTheDocument();
});
it('counts a staged index change as a pending change and previews its SQL', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '管理 users 的索引' }));
const indexRows = within(screen.getByRole('dialog')).getAllByRole('listitem');
fireEvent.click(within(indexRows[2]).getByRole('button', { name: '建索引' }));
expect(await screen.findByTitle('CREATE INDEX ON users (age);')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
});
it('creates a staged table only when the change set is committed', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '新建表' }));
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(await screen.findByTitle('CREATE TABLE items (id int, title str);')).toBeInTheDocument();
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(async () => {
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(true);
});
expect(await screen.findByRole('button', { name: '打开表 items' })).toBeInTheDocument();
});
it('selects a single row on click, toggles with Ctrl and extends with Shift', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 2 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_001'));
fireEvent.click(screen.getByText('User_004'), { shiftKey: true });
expect(screen.getByText('已选 4 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_003'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
});
});
-126
View File
@@ -1,126 +0,0 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { MockAdapter } from './adapters/MockAdapter';
import { IconButton } from './components/common/IconButton';
import { TableEditor } from './components/grid/TableEditor';
import { BottomPanel } from './components/layout/BottomPanel';
import { Explorer } from './components/layout/Explorer';
import { Workbench } from './components/layout/Workbench';
import { SchemaDialog, type SchemaAction } from './components/layout/SchemaDialog';
import { SettingsPanel } from './components/settings/SettingsPanel';
import type { QueryHistoryEntry } from './components/sql/SqlConsole';
import { changeCount, toCommitPayload } from './core/changes';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab, type TabState } from './core/tabs';
import { syncUiSettings } from './core/uiSettings';
import { zhCN } from './i18n/zh-CN';
import { useChangeStore } from './store/changeStore';
import { fontFamilyValue, useSettingsStore } from './store/settingsStore';
import type { DataSourceAdapter, SchemaChange, StatementResult, TableMeta } from './types';
import styles from './App.module.css';
// 应用根组件:装配数据源、多标签编辑区、变更提交与设置视图。
interface AppProps { adapter?: DataSourceAdapter }
const SqlConsole = lazy(async () => ({ default: (await import('./components/sql/SqlConsole')).SqlConsole }));
function initialHistory(): QueryHistoryEntry[] {
localStorage.removeItem('chusql.sql.history.v1');
try { const value = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]'); return Array.isArray(value) ? value : []; }
catch { return []; }
}
export function App({ adapter: providedAdapter }: AppProps) {
const adapter = useMemo(() => providedAdapter ?? new MockAdapter(), [providedAdapter]);
const [tables, setTables] = useState<TableMeta[]>([]);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeId, setActiveId] = useState<string>();
const [totals, setTotals] = useState<Record<string, number>>({});
const [messages, setMessages] = useState<string[]>([]);
const [results, setResults] = useState<StatementResult[]>([]);
const [history, setHistory] = useState<QueryHistoryEntry[]>(initialHistory);
const [revision, setRevision] = useState(0);
const [committing, setCommitting] = useState(false);
const [schemaAction, setSchemaAction] = useState<SchemaAction>();
const idRef = useRef(0);
const queryRef = useRef(0);
const { changes, undoStack, redoStack, undoOne, undo, redo, clear, acknowledge } = useChangeStore();
const settings = useSettingsStore((state) => state.settings);
const count = changeCount(changes);
const activeTab = tabs.find((tab) => tab.id === activeId);
const activeTableTab = activeTab?.kind === 'table' ? activeTab : undefined;
const activeMeta = activeTableTab ? tables.find((table) => table.name === activeTableTab.table) : undefined;
const pushMessage = (message: string) => setMessages((current) => [...current, message].slice(-100));
const nextTabId = () => `tab-${(idRef.current += 1)}`;
const applyTabs = (state: TabState) => { setTabs(state.tabs); setActiveId(state.activeId); };
const forgetTotal = (id: string) => setTotals((current) => { const next = { ...current }; delete next[id]; return next; });
const loadTables = async () => {
try { setTables(await adapter.listTables(zhCN.database)); }
catch (reason) { pushMessage(`${zhCN.loadError}: ${reason instanceof Error ? reason.message : String(reason)}`); }
};
useEffect(() => { void loadTables(); }, [adapter]);
useEffect(() => syncUiSettings(), []);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--ui-font', fontFamilyValue(settings.uiFonts));
root.style.setProperty('--grid-font', fontFamilyValue(settings.gridFonts));
root.style.setProperty('--grid-font-size', `${settings.gridFontSize}px`);
root.style.setProperty('--grid-row-height', `${settings.gridRowHeight}px`);
root.style.fontSize = `${settings.uiFontSize}px`;
}, [settings]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (count) event.preventDefault(); };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [count]);
const rollback = () => { clear(); setRevision((value) => value + 1); };
const applySchema = async (change: SchemaChange) => {
if (change.op === 'createTable') await adapter.createTable(change.table, change.columns);
else if (change.op === 'dropTable') await adapter.dropTable(change.table);
else if (change.op === 'createIndex') await adapter.createIndex(change.table, change.column);
else await adapter.dropIndex(change.table, change.column);
};
const commit = async () => {
if (committing) return;
setCommitting(true);
try {
const rows = toCommitPayload(changes);
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
let applied = 0;
let failure = '';
try {
for (const change of creates) { await applySchema(change); applied += 1; }
if (rows.length) {
const result = await adapter.commitChanges({ db: zhCN.database, changes: rows });
applied += result.ok ? rows.length : result.applied;
if (!result.ok) failure = result.errors?.map((item) => item.message).join('; ') ?? '';
}
if (!failure) for (const change of drops) { await applySchema(change); applied += 1; }
} catch (reason) { failure = reason instanceof Error ? reason.message : String(reason); }
if (applied) acknowledge(changes, applied);
if (failure) pushMessage(`${zhCN.commitFailed} (${applied} applied; retry submits only the remainder): ${failure}`);
else pushMessage(zhCN.committed(applied));
await loadTables(); setRevision((value) => value + 1);
} finally { setCommitting(false); }
};
const requestCommit = () => { if (count) void commit(); };
const openTable = (name: string) => applyTabs(openTableTab(tabs, name, nextTabId()));
const newQuery = (sql?: string) => applyTabs(openQueryTab(tabs, zhCN.queryTab((queryRef.current += 1)), nextTabId(), sql));
const openSettings = () => applyTabs(openSettingsTab(tabs, zhCN.settings, nextTabId()));
const closeTabById = (tab: Tab) => {
forgetTotal(tab.id);
applyTabs(closeTab(tabs, tab.id, activeId));
};
let editor = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (tabs.length) {
let body = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (activeTab?.kind === 'sql') body = <Suspense fallback={<div className={styles.notice}>{zhCN.loading}</div>}><SqlConsole key={activeTab.id} adapter={adapter} settings={settings} initialSql={activeTab.sql} onResult={(result) => setResults((current) => [...current, result])} onMessage={pushMessage} onHistory={setHistory} /></Suspense>;
else if (activeTab?.kind === 'settings') body = <SettingsPanel />;
else if (activeTableTab && activeMeta) body = <TableEditor key={activeTableTab.id} adapter={adapter} table={activeMeta} settings={settings} revision={revision} onTotal={(value) => setTotals((current) => ({ ...current, [activeTableTab.id]: value }))} onMessage={pushMessage} />;
editor = <section className={styles.editorPane}><div className={styles.tabs} role="tablist">{tabs.map((tab) => <div key={tab.id} className={tab.id === activeId ? styles.tabActive : styles.tab}><button role="tab" aria-selected={tab.id === activeId} className={styles.tabLabel} onClick={() => setActiveId(tab.id)}>{tab.title}</button><IconButton compact icon={<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8" /></svg>} label={zhCN.closeTab(tab.title)} onClick={() => closeTabById(tab)} /></div>)}</div>{body}</section>;
}
return <><Workbench explorer={<Explorer tables={tables} activeTable={activeMeta?.name} history={history} onOpenTable={openTable} onOpenSql={newQuery} onCreateTable={() => setSchemaAction({ kind: 'create' })} onIndexes={(table) => setSchemaAction({ kind: 'indexes', table })} onDropTable={(table) => setSchemaAction({ kind: 'drop', table })} />} editor={editor} bottom={<BottomPanel changes={changes} results={results} messages={messages} onUndoOne={undoOne} />} status={<><span>{zhCN.connection}</span><span>{zhCN.database}</span>{activeMeta && <span>{activeMeta.name}</span>}{activeTableTab && activeMeta && <span>{zhCN.rowCount(totals[activeTableTab.id] ?? 0)}</span>}<span>{zhCN.pendingCount(count)}</span></>} changeCount={count} canUndo={undoStack.length > 0} canRedo={redoStack.length > 0} onCommit={requestCommit} onRollback={rollback} onUndo={undo} onRedo={redo} onSql={() => newQuery()} onSettings={openSettings} />{schemaAction && <SchemaDialog action={schemaAction} onClose={() => setSchemaAction(undefined)} onChanged={pushMessage} />}</>;
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from 'vitest';
import { MockAdapter } from './MockAdapter';
// MockAdapter 测试:列表行数、排序筛选与变更集提交。
describe('MockAdapter', () => {
it('ships users and orders with 500 rows each', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const tables = await adapter.listTables('chusql');
expect(tables.map((table) => [table.name, table.rowCount])).toEqual([
['users', 500],
['orders', 500],
]);
});
it('sorts and filters before paginating', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const result = await adapter.fetchRows({
db: 'chusql', table: 'users', page: 0, pageSize: 3,
sort: [{ column: 'age', dir: 'desc' }], filters: [{ column: 'age', expression: '>60' }],
});
expect(result.total).toBeGreaterThan(3);
expect(result.rows).toHaveLength(3);
expect(Number(result.rows[0].age)).toBeGreaterThanOrEqual(Number(result.rows[1].age));
});
it('applies a complete change set in one call', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const response = await adapter.commitChanges({
db: 'chusql',
changes: [
{ op: 'update', table: 'users', pk: 1, set: { name: 'edited' } },
{ op: 'delete', table: 'users', pk: 2 },
],
});
expect(response).toEqual({ ok: true, applied: 2 });
const page = await adapter.fetchRows({ db: 'chusql', table: 'users', page: 0, pageSize: 3, sort: [], filters: [] });
expect(page.rows[0].name).toBe('edited');
expect(page.rows.some((row) => row.id === 2)).toBe(false);
});
it('refuses an index on a column that holds duplicate values', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await expect(adapter.createIndex('users', 'age')).rejects.toThrow('duplicate values');
const users = (await adapter.listTables('chusql')).find((table) => table.name === 'users')!;
expect(users.columns.find((column) => column.name === 'age')?.indexed).toBeFalsy();
});
});
@@ -1,178 +0,0 @@
import { matchesFilter } from '../core/filters';
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// 内存数据源:内置 mock 表,供无后端环境演示与测试。
type TableData = { columns: ColumnMeta[]; rows: Array<Record<string, unknown>> };
function makeData(): Map<string, TableData> {
const users = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
name: `User_${String(index + 1).padStart(3, '0')}`,
age: 18 + (index % 55),
active: index % 4 !== 0,
joined_at: `202${index % 5}-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}`,
}));
const orders = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
user_id: (index % 500) + 1,
product: ['Keyboard', 'Monitor', 'Database Course', 'Hard Drive'][index % 4],
amount: 49 + ((index * 37) % 2400),
paid: index % 3 !== 0,
}));
return new Map([
['users', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'name', type: 'str' },
{ name: 'age', type: 'int' },
{ name: 'active', type: 'bool' },
{ name: 'joined_at', type: 'date' },
], rows: users,
}],
['orders', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'user_id', type: 'int', indexed: true },
{ name: 'product', type: 'str' },
{ name: 'amount', type: 'int' },
{ name: 'paid', type: 'bool' },
], rows: orders,
}],
]);
}
function compareValues(left: unknown, right: unknown): number {
if (left === right) return 0;
if (left === null || left === undefined) return -1;
if (right === null || right === undefined) return 1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'zh-CN', { numeric: true });
}
export class MockAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await this.delay();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || this.tables.has(name)) throw new Error('Invalid or already existing table name');
if (!columns.length || new Set(columns.map((column) => column.name)).size !== columns.length) throw new Error('Column names must be non-empty and unique');
this.tables.set(name, { columns: columns.map((column) => ({ ...column, primaryKey: column.name === 'id', indexed: column.name === 'id' })), rows: [] });
}
async dropTable(table: string): Promise<void> {
await this.delay();
if (!this.tables.delete(table)) throw new Error('Table does not exist');
}
async createIndex(table: string, column: string): Promise<void> {
await this.delay();
const found = this.tables.get(table);
const meta = found?.columns.find((item) => item.name === column);
if (!found || !meta || meta.type !== 'int' || meta.indexed) throw new Error('Only unindexed integer columns can be indexed');
if (new Set(found.rows.map((row) => row[column])).size !== found.rows.length) throw new Error('Index column contains duplicate values and must be unique');
meta.indexed = true;
}
async dropIndex(table: string, column: string): Promise<void> {
await this.delay();
const meta = this.tables.get(table)?.columns.find((item) => item.name === column);
if (!meta?.indexed || column === 'id') throw new Error('Index does not exist or is a built-in index that cannot be dropped');
meta.indexed = false;
}
private readonly tables = makeData();
private readonly latencyMs: number | [number, number];
constructor(options: { latencyMs?: number | [number, number] } = {}) {
this.latencyMs = options.latencyMs ?? [80, 200];
}
private async delay(): Promise<void> {
const milliseconds = Array.isArray(this.latencyMs)
? this.latencyMs[0] + Math.random() * (this.latencyMs[1] - this.latencyMs[0])
: this.latencyMs;
if (milliseconds > 0) await new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async listTables(_db: string): Promise<TableMeta[]> {
await this.delay();
return Array.from(this.tables, ([name, table]) => ({ name, rowCount: table.rows.length, columns: table.columns }));
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
await this.delay();
const found = this.tables.get(table);
if (!found) throw new Error(`Unknown table: ${table}`);
return found.columns.map((column) => ({ ...column }));
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
await this.delay();
const found = this.tables.get(params.table);
if (!found) throw new Error(`Unknown table: ${params.table}`);
let rows = found.rows.filter((row) => params.filters.every((filter) => matchesFilter(row[filter.column], filter.expression)));
if (params.search?.trim()) {
const term = params.search.toLocaleLowerCase();
rows = rows.filter((row) => Object.values(row).some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}
if (params.sort.length) {
rows = [...rows].sort((left, right) => {
for (const sort of params.sort) {
const compared = compareValues(left[sort.column], right[sort.column]);
if (compared !== 0) return sort.dir === 'asc' ? compared : -compared;
}
return 0;
});
}
const start = params.page * params.pageSize;
return { rows: rows.slice(start, start + params.pageSize).map((row) => ({ ...row })), total: rows.length };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
await this.delay();
const working = new Map(Array.from(this.tables, ([name, table]) => [name, { columns: table.columns, rows: table.rows.map((row) => ({ ...row })) }]));
const errors: Array<{ index: number; message: string }> = [];
payload.changes.forEach((change, index) => {
const table = working.get(change.table);
if (!table) {
errors.push({ index, message: `Unknown table: ${change.table}` });
return;
}
if (change.op === 'insert') {
if (table.rows.some((row) => row.id === change.values.id)) errors.push({ index, message: `Duplicate id: ${String(change.values.id)}` });
else table.rows.push({ ...change.values });
return;
}
const rowIndex = table.rows.findIndex((row) => row.id === change.pk);
if (rowIndex < 0) {
errors.push({ index, message: `Row not found: ${String(change.pk)}` });
} else if (change.op === 'delete') {
table.rows.splice(rowIndex, 1);
} else {
table.rows[rowIndex] = { ...table.rows[rowIndex], ...change.set };
}
});
if (errors.length) return { ok: false, applied: 0, errors };
this.tables.clear();
for (const [name, table] of working) this.tables.set(name, table);
return { ok: true, applied: payload.changes.length };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
await this.delay();
const normalized = sql.trim().replace(/;$/, '');
const select = normalized.match(/^select\s+(.+?)\s+from\s+([A-Za-z_][A-Za-z0-9_]*)/is);
if (select) {
const table = this.tables.get(select[2]);
if (!table) return { type: 'error', durationMs: performance.now() - started, message: `Unknown table: ${select[2]}` };
const columns = select[1].trim() === '*' ? table.columns.map((column) => column.name) : select[1].split(',').map((item) => item.trim());
return {
type: 'rows', columns,
rows: table.rows.slice(0, 200).map((row) => columns.map((column) => row[column])),
durationMs: performance.now() - started,
};
}
if (/^(insert|update|delete|create|drop)\b/i.test(normalized)) {
return { type: 'affected', affected: 1, durationMs: performance.now() - started };
}
return { type: 'error', durationMs: performance.now() - started, message: 'MockAdapter cannot execute this statement' };
}
}
@@ -1,53 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RestAdapter } from './RestAdapter';
// RestAdapter 测试:接口路径编码、响应归一化与错误处理。
describe('RestAdapter', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses structured schema endpoints and encodes identifiers in paths', async () => {
const request = vi.fn().mockImplementation(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', request);
const adapter = new RestAdapter();
await adapter.createTable('items', [{ name: 'id', type: 'int' }]);
await adapter.createIndex('items', 'sku');
await adapter.dropIndex('items', 'sku');
await adapter.dropTable('a/b');
expect(request.mock.calls.map(([path, init]) => [path, init.method, init.body])).toEqual([
['/api/tables', 'POST', JSON.stringify({ name: 'items', columns: [{ name: 'id', type: 'int' }] })],
['/api/tables/items/indexes', 'POST', JSON.stringify({ column: 'sku' })],
['/api/tables/items/indexes/sku', 'DELETE', undefined],
['/api/tables/a%2Fb', 'DELETE', undefined],
]);
});
it('normalizes the existing Haskell catalog response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([{
table: 'users', rowCount: 2,
columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }],
indexes: [{ column: 'id', builtIn: true }],
}]), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const tables = await new RestAdapter().listTables('chusql');
expect(tables).toEqual([{ name: 'users', rowCount: 2, columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }] }]);
});
it('maps pages, sorting and filters to the existing rows endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
columns: ['id', 'name'], rows: [[1, 'Ada']], total: 1,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', request);
const result = await new RestAdapter().fetchRows({
db: 'chusql', table: 'users', page: 2, pageSize: 20,
sort: [{ column: 'name', dir: 'desc' }], filters: [{ column: 'name', expression: 'Ada' }],
});
expect(result).toEqual({ rows: [{ id: 1, name: 'Ada' }], total: 1 });
expect(String(request.mock.calls[0][0])).toContain('/api/tables/users/rows?');
expect(String(request.mock.calls[0][0])).toContain('offset=40');
expect(String(request.mock.calls[0][0])).toContain('sort=name');
});
it('turns non-JSON and structured API failures into safe errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('<stack trace>', { status: 500 })));
await expect(new RestAdapter().listTables('chusql')).rejects.toThrow('Request failed with status 500');
});
});
@@ -1,153 +0,0 @@
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// REST 数据源:调用后端 JSON 接口并校验返回结构。
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function asColumns(value: unknown): ColumnMeta[] {
if (!Array.isArray(value)) throw new Error('The server returned an invalid column list.');
return value.map((entry) => {
if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') {
throw new Error('The server returned an invalid column description.');
}
if (!['int', 'str', 'bool', 'date'].includes(entry.type)) throw new Error(`Unsupported column type: ${entry.type}`);
return {
name: entry.name,
type: entry.type as ColumnMeta['type'],
...(typeof entry.primaryKey === 'boolean' ? { primaryKey: entry.primaryKey } : {}),
...(typeof entry.indexed === 'boolean' ? { indexed: entry.indexed } : {}),
...(typeof entry.prime === 'boolean' ? { prime: entry.prime } : {}),
...(typeof entry.distinct === 'number' ? { distinct: entry.distinct } : {}),
...(typeof entry.statsCapped === 'boolean' ? { statsCapped: entry.statsCapped } : {}),
};
});
}
function asTable(value: unknown): TableMeta {
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
throw new Error('The server returned an invalid table description.');
}
return { name: value.table, rowCount: value.rowCount, columns: asColumns(value.columns) };
}
async function requestJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, { credentials: 'same-origin', ...init });
let body: unknown;
try {
body = await response.json();
} catch {
body = undefined;
}
if (!response.ok) {
const message = isObject(body) && typeof body.message === 'string'
? body.message
: `Request failed with status ${response.status}.`;
throw new Error(message);
}
return body;
}
function jsonInit(method: string, body?: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
};
}
function changeRequest(change: CommitChange): [string, RequestInit] {
const table = encodeURIComponent(change.table);
if (change.op === 'insert') return [`/api/tables/${table}/rows`, jsonInit('POST', { values: change.values })];
const pk = encodeURIComponent(String(change.pk));
if (change.op === 'delete') return [`/api/tables/${table}/rows/${pk}`, jsonInit('DELETE')];
return [`/api/tables/${table}/rows/${pk}`, jsonInit('PATCH', { values: change.set })];
}
export class RestAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await requestJson('/api/tables', jsonInit('POST', { name, columns }));
}
async dropTable(table: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'));
}
async createIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }));
}
async dropIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'));
}
async listTables(_db: string): Promise<TableMeta[]> {
const body = await requestJson('/api/tables');
if (!Array.isArray(body)) throw new Error('The server returned an invalid table list.');
return body.map(asTable);
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
return asTable(await requestJson(`/api/tables/${encodeURIComponent(table)}`)).columns;
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
const query = new URLSearchParams({
limit: String(params.pageSize),
offset: String(params.page * params.pageSize),
});
const firstSort = params.sort[0];
if (firstSort) {
query.set('sort', firstSort.column);
query.set('dir', firstSort.dir);
}
if (params.filters.length) {
query.set('filter', JSON.stringify(params.filters.map((filter) => ({ column: filter.column, value: filter.expression }))));
}
const body = await requestJson(`/api/tables/${encodeURIComponent(params.table)}/rows?${query}`);
if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((column) => typeof column === 'string') || !Array.isArray(body.rows) || typeof body.total !== 'number') {
throw new Error('The server returned an invalid row page.');
}
const columns = body.columns as string[];
const rows = body.rows.map((raw) => {
if (!Array.isArray(raw)) throw new Error('The server returned an invalid row.');
return Object.fromEntries(columns.map((column, index) => [column, raw[index]]));
});
return { rows, total: body.total };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
let applied = 0;
for (const [index, change] of payload.changes.entries()) {
const [url, init] = changeRequest(change);
try {
await requestJson(url, init);
applied += 1;
} catch (error) {
return {
ok: false,
applied,
errors: [{ index, message: error instanceof Error ? error.message : String(error) }],
};
}
}
return { ok: true, applied };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
try {
const body = await requestJson('/api/query', jsonInit('POST', { sql }));
if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) {
throw new Error('The server returned an invalid query result.');
}
const durationMs = performance.now() - started;
return body.columns.length
? { type: 'rows', columns: body.columns as string[], rows: body.rows as unknown[][], durationMs }
: { type: 'affected', affected: typeof body.rowCount === 'number' ? body.rowCount : 0, durationMs };
} catch (error) {
return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) };
}
}
}
@@ -1,25 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthGate } from './AuthGate';
// 覆盖登录门的会话校验与登录流程。
describe('AuthGate', () => {
afterEach(() => vi.unstubAllGlobals());
it('keeps REST content private until a successful login', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
render(<AuthGate><p>protected content</p></AuthGate>);
expect(await screen.findByRole('heading', { name: '登录 ChuSQL' })).toBeInTheDocument();
expect(screen.queryByText('protected content')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'chusql' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(screen.getByText('protected content')).toBeInTheDocument());
expect(request).toHaveBeenLastCalledWith('/api/login', expect.objectContaining({ method: 'POST' }));
});
});
@@ -1,56 +0,0 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import styles from './auth.module.css';
// 登录门组件:先查会话状态,未登录时渲染登录表单。
type AuthState = 'checking' | 'signed-out' | 'signed-in';
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>('checking');
const [user, setUser] = useState('root');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
void fetch('/api/session', { credentials: 'same-origin', signal: controller.signal })
.then((response) => setState(response.ok ? 'signed-in' : 'signed-out'))
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) setState('signed-out');
});
return () => controller.abort();
}, []);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setFailed(false);
try {
const response = await fetch('/api/login', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, password }),
});
if (response.ok) setState('signed-in');
else setFailed(true);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
};
if (state === 'signed-in') return children;
if (state === 'checking') return <main className={styles.center}><p>{zhCN.signingIn}</p></main>;
return <main className={styles.center}>
<form className={styles.login} onSubmit={(event) => void submit(event)}>
<header><Database size={18} /><h1>{zhCN.signInTitle}</h1></header>
<label><span>{zhCN.userName}</span><input autoFocus autoComplete="username" value={user} onChange={(event) => setUser(event.target.value)} /></label>
<label><span>{zhCN.password}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{failed && <p role="alert">{zhCN.signInFailed}</p>}
<button disabled={busy || !user || !password}>{zhCN.signIn}</button>
</form>
</main>;
}
@@ -1,19 +0,0 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './common.module.css';
// 通用图标按钮:支持紧凑模式,标签兼作无障碍名。
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
compact?: boolean;
}
export function IconButton({ icon, label, compact = false, ...props }: IconButtonProps) {
return (
<button className={`${styles.iconButton} ${compact ? styles.compact : ''}`} aria-label={label} title={label} {...props}>
{icon}
{!compact && <span>{label}</span>}
</button>
);
}
@@ -1,39 +0,0 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from './IconButton';
import styles from './common.module.css';
// 通用模态对话框:标题栏、内容区与可选页脚。
interface ModalProps {
title: string;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
}
export function Modal({ title, children, footer, onClose }: ModalProps) {
const titleId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return undefined;
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
return () => {
if (typeof dialog.close === 'function') dialog.close();
else dialog.removeAttribute('open');
};
}, []);
return (
<dialog ref={dialogRef} aria-labelledby={titleId} className={styles.dialog} onCancel={(event) => { event.preventDefault(); onClose(); }}>
<header className={styles.dialogHeader}>
<h2 id={titleId}>{title}</h2>
<IconButton icon={<X size={14} />} label={zhCN.close} compact onClick={onClose} />
</header>
<div className={styles.dialogBody}>{children}</div>
{footer && <footer className={styles.dialogFooter}>{footer}</footer>}
</dialog>
);
}
@@ -1,11 +0,0 @@
/* 登录页样式:居中卡片与表单控件。 */
.center { align-items: center; background: var(--bg-base); display: flex; height: 100%; justify-content: center; }
.center > p { color: var(--fg-muted); }
.login { background: var(--bg-elevated); border: 1px solid var(--border); display: grid; gap: 12px; padding: 18px; width: 320px; }
.login header { align-items: center; display: flex; gap: 8px; }
.login h1 { font-size: 14px; margin: 0; }
.login label { display: grid; gap: 5px; }
.login label span { color: var(--fg-muted); font-size: 11px; }
.login input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
.login p { color: var(--danger); margin: 0; }
.login button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 26px; }
@@ -1,41 +0,0 @@
/* 通用组件样式:图标按钮与模态对话框。 */
.iconButton {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 2px;
color: var(--fg-primary);
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 6px;
height: 24px;
padding: 0 7px;
}
.iconButton:hover:not(:disabled) { background: var(--bg-active); }
.iconButton:focus-visible { border-color: var(--accent); outline: 0; }
.iconButton:disabled { color: var(--fg-disabled); cursor: default; }
.compact { justify-content: center; padding: 0; width: 24px; }
.dialog {
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--fg-primary);
max-height: min(720px, 85vh);
max-width: min(760px, 90vw);
padding: 0;
width: 620px;
}
.dialog::backdrop { background: rgb(0 0 0 / 55%); }
.dialogHeader { align-items: center; background: var(--bg-titlebar); display: flex; height: 34px; justify-content: space-between; padding: 0 8px; }
.dialogHeader h2 { font-size: 13px; margin: 0; }
.dialogBody { max-height: 60vh; overflow: auto; padding: 12px; }
.dialogFooter { border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; padding: 8px; }
.iconButton { flex-shrink: 0; }
.dialog button:not(.iconButton) { cursor: pointer; min-height: 26px; }
.dialog button:disabled { cursor: default; opacity: .5; }
.dialogFooter button, .dialogBody button { background: var(--bg-active); border: 1px solid var(--border); color: var(--fg-primary); padding: 4px 8px; }
.dialog [role="alert"] { color: var(--danger); }
@@ -1,54 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DataGrid } from './DataGrid';
// 数据网格测试:表头排序、筛选弹层、行选中模式与列宽调整。
describe('DataGrid column controls', () => {
it('keeps row selection out of the grid columns and exposes header sorting and filtering', () => {
const onFilter = vi.fn();
const onSort = vi.fn();
const onSelectRow = vi.fn();
const onWidth = vi.fn();
render(<DataGrid
table="users"
columns={[{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }]}
rows={[{ id: 1, name: 'Ada' }]}
changes={{ updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() }}
nullText="NULL"
filters={{}}
widths={{}}
selectedRows={new Set()}
onSort={onSort}
onFilter={onFilter}
onWidth={onWidth}
onSelectRow={onSelectRow}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
expect(screen.queryByText('#')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'name' }));
expect(onSort).toHaveBeenCalledWith('name');
fireEvent.click(screen.getByRole('button', { name: '筛选 name' }));
fireEvent.change(screen.getByRole('textbox', { name: '筛选 name' }), { target: { value: 'Ada' } });
expect(onFilter).toHaveBeenCalledWith('name', 'Ada');
fireEvent.click(screen.getByText('Ada'));
expect(onSelectRow).toHaveBeenCalledWith(0, 'single');
fireEvent.click(screen.getByText('Ada'), { ctrlKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'toggle');
fireEvent.click(screen.getByText('Ada'), { shiftKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'range');
fireEvent.keyDown(screen.getByText('Ada'), { key: ' ' });
expect(onSelectRow).toHaveBeenCalledTimes(4);
fireEvent.keyDown(screen.getByRole('separator', { name: '调整 name 列宽' }), { key: 'ArrowRight' });
expect(onWidth).toHaveBeenCalledWith('name', 150);
expect(screen.getByRole('table')).toHaveStyle({ width: '280px' });
fireEvent.keyDown(screen.getByRole('textbox', { name: '筛选 name' }), { key: 'Escape' });
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
});
});
@@ -1,111 +0,0 @@
import { useMemo, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from 'react';
import type { ChangeSet, ColumnMeta } from '../../types';
import { changeKey } from '../../core/changes';
import { zhCN } from '../../i18n/zh-CN';
import styles from './grid.module.css';
// 数据网格:列排序、筛选、列宽拖拽、行选中与单元格编辑。
export interface GridRow extends Record<string, unknown> { _tempId?: string }
export type SelectionMode = 'single' | 'toggle' | 'range';
interface DataGridProps {
table: string;
columns: ColumnMeta[];
rows: GridRow[];
changes: ChangeSet;
nullText: string;
sort?: { column: string; dir: 'asc' | 'desc' };
filters: Record<string, string>;
widths: Record<string, number>;
selectedRows: Set<string>;
onSort: (column: string) => void;
onFilter: (column: string, expression: string) => void;
onWidth: (column: string, width: number) => void;
onSelectRow: (index: number, mode: SelectionMode) => void;
onEdit: (row: GridRow, column: string, oldValue: unknown, newValue: unknown) => void;
onDelete: (row: GridRow) => void;
}
interface EditingCell { rowIndex: number; column: string; value: unknown }
// 行键:临时行用 tempId,其余用主键。
export function rowKey(row: GridRow, index: number): string { return row._tempId ?? String(row.id ?? index); }
function valueFor(row: GridRow, table: string, column: string, changes: ChangeSet): unknown {
if (row._tempId) return changes.inserts.get(row._tempId)?.values[column] ?? row[column];
return changes.updates.get(changeKey(table, row.id, column))?.newValue ?? row[column];
}
function Editor({ column, value, onChange, onKeyDown, onBlur }: { column: ColumnMeta; value: unknown; onChange: (value: unknown) => void; onKeyDown: (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => void; onBlur: () => void }) {
if (column.type === 'bool') return <select autoFocus aria-label={zhCN.editCell(column.name)} value={String(value)} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur}><option value="true">true</option><option value="false">false</option></select>;
return <input autoFocus aria-label={zhCN.editCell(column.name)} type={column.type === 'int' ? 'number' : column.type === 'date' ? 'date' : 'text'} value={String(value ?? '')} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} />;
}
export function DataGrid(props: DataGridProps) {
const [editing, setEditing] = useState<EditingCell>();
const [filterColumn, setFilterColumn] = useState<string>();
const deleted = useMemo(() => new Set(Array.from(props.changes.deletes.values()).filter((item) => item.table === props.table).map((item) => String(item.pk))), [props.changes, props.table]);
const commitEdit = () => {
if (!editing) return;
const row = props.rows[editing.rowIndex];
const column = props.columns.find((item) => item.name === editing.column);
if (!row || !column) return setEditing(undefined);
let next: unknown = editing.value;
if (column.type === 'int') next = Number(editing.value);
if (column.type === 'bool') next = editing.value === true || editing.value === 'true';
props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), next);
setEditing(undefined);
};
const onEditorKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => {
if (event.key === 'Escape') setEditing(undefined);
if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); commitEdit(); }
};
const paste = (event: ClipboardEvent<HTMLTableCellElement>, rowIndex: number, columnIndex: number) => {
const matrix = event.clipboardData.getData('text/plain').replace(/\r/g, '').split('\n').filter((line) => line.length).map((line) => line.split('\t'));
if (!matrix.length) return;
event.preventDefault();
matrix.forEach((values, y) => values.forEach((value, x) => {
const row = props.rows[rowIndex + y];
const column = props.columns[columnIndex + x];
if (row && column && !column.primaryKey) props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), value);
}));
};
const beginResize = (event: PointerEvent<HTMLSpanElement>, column: string) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = props.widths[column] ?? 140;
const move = (next: globalThis.PointerEvent) => props.onWidth(column, Math.max(70, startWidth + next.clientX - startX));
const stop = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', stop);
};
return (
<table className={styles.grid} style={{ width: props.columns.reduce((sum, column) => sum + (props.widths[column.name] ?? 140), 0) }}>
<colgroup>{props.columns.map((column) => <col key={column.name} style={{ width: props.widths[column.name] ?? 140 }} />)}</colgroup>
<thead>
<tr>{props.columns.map((column) => <th key={column.name} aria-sort={props.sort?.column === column.name ? props.sort.dir === 'asc' ? 'ascending' : 'descending' : 'none'}>
<div className={styles.columnHeader}><button className={styles.columnButton} aria-label={column.name} onClick={() => props.onSort(column.name)}>{column.name}<span>{props.sort?.column === column.name ? props.sort.dir === 'asc' ? '▲' : '▼' : ''}</span></button>
<button className={styles.filterButton} aria-label={zhCN.filter(column.name)} aria-expanded={filterColumn === column.name} aria-pressed={Boolean(props.filters[column.name])} onClick={() => setFilterColumn(filterColumn === column.name ? undefined : column.name)}><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"><path d="M2 3h12L9.5 8v5l-3-1.5V8Z" /></svg></button></div>
{filterColumn === column.name && <div className={styles.filterPopover}><input autoFocus aria-label={zhCN.filter(column.name)} placeholder={zhCN.filterPlaceholder} value={props.filters[column.name] ?? ''} onChange={(event) => props.onFilter(column.name, event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' || event.key === 'Enter') { setFilterColumn(undefined); event.currentTarget.closest('th')?.querySelector('button')?.focus(); } }} /><button onClick={() => props.onFilter(column.name, '')}>{zhCN.clear}</button></div>}
<span className={styles.resizeHandle} role="separator" aria-label={zhCN.resizeColumn(column.name)} aria-orientation="vertical" tabIndex={0} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); props.onWidth(column.name, Math.max(70, (props.widths[column.name] ?? 140) + (event.key === 'ArrowRight' ? 10 : -10))); } }} onPointerDown={(event) => beginResize(event, column.name)} />
</th>)}</tr>
</thead>
<tbody>
{props.rows.map((row, rowIndex) => {
const key = rowKey(row, rowIndex);
const isDeleted = !row._tempId && deleted.has(String(row.id));
return <tr key={key} aria-selected={props.selectedRows.has(key)} className={`${isDeleted ? styles.deleted : ''} ${row._tempId ? styles.inserted : ''}`} onContextMenu={(event) => { event.preventDefault(); props.onDelete(row); }}>
{props.columns.map((column, columnIndex) => {
const value = valueFor(row, props.table, column.name, props.changes);
const dirty = row._tempId || props.changes.updates.has(changeKey(props.table, row.id, column.name));
const isEditing = editing?.rowIndex === rowIndex && editing.column === column.name;
return <td key={column.name} className={dirty ? styles.dirty : ''} tabIndex={0} onClick={(event) => { if (!isEditing && event.detail < 2) props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : event.shiftKey ? 'range' : 'single'); }} onKeyDown={(event) => { if (!isEditing && event.key === ' ') { event.preventDefault(); props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : 'single'); } }} onPaste={(event) => paste(event, rowIndex, columnIndex)} onDoubleClick={() => !column.primaryKey && setEditing({ rowIndex, column: column.name, value })}>
{isEditing ? <Editor column={column} value={editing.value} onChange={(value) => setEditing({ ...editing, value })} onKeyDown={onEditorKeyDown} onBlur={commitEdit} /> : value === null || value === undefined ? <span className={styles.nullValue}>{props.nullText}</span> : String(value)}
</td>;
})}
</tr>;
})}
</tbody>
</table>
);
}
@@ -1,16 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { RowDialog } from './RowDialog';
// 插入行对话框测试:整数校验与暂存值。
it('requires an explicit integer key and stages typed values without generating an id', () => {
const onInsert = vi.fn();
render(<RowDialog table={{ name: 'users', rowCount: 500, columns: [{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }, { name: 'active', type: 'bool' }] }} onClose={vi.fn()} onInsert={onInsert} />);
expect(screen.getByLabelText('id')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('id:请输入合法整数');
fireEvent.change(screen.getByLabelText('id'), { target: { value: '800' } });
fireEvent.change(screen.getByLabelText('name'), { target: { value: "Ada's" } });
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).toHaveBeenCalledWith({ id: 800, name: "Ada's", active: false });
});
@@ -1,26 +0,0 @@
import { useState } from 'react';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import type { TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from './grid.module.css';
// 插入行对话框:按列类型校验并暂存待提交变更。
export function RowDialog({ table, onClose, onInsert }: { table: TableMeta; onClose: () => void; onInsert: (values: Record<string, unknown>) => void }) {
const [draft, setDraft] = useState<Record<string, string>>(() => Object.fromEntries(table.columns.map((column) => [column.name, column.type === 'bool' ? 'false' : ''])));
const [error, setError] = useState('');
const submit = () => {
const values: Record<string, unknown> = {};
for (const column of table.columns) {
const value = draft[column.name];
if (column.type === 'int' && (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))) return setError(zhCN.invalidInteger(column.name));
if (column.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) return setError(zhCN.chooseDate(column.name));
values[column.name] = column.type === 'int' ? Number(value) : column.type === 'bool' ? value === 'true' : value;
}
onInsert(values);
};
return <Modal title={zhCN.insertRowTitle(table.name)} onClose={onClose} footer={<><button onClick={onClose}>{zhCN.cancel}</button><button className={styles.primaryButton} onClick={submit}>{zhCN.addToPending}</button></>}>
<p>{zhCN.insertRowHint}</p>
<div className={styles.formFields}>{table.columns.map((column) => <label key={column.name}><span>{column.name} <small>{columnTypeLabels[column.type] ?? column.type}{column.primaryKey ? zhCN.primaryKeyBadge : ''}</small></span>{column.type === 'bool' ? <select aria-label={column.name} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })}><option value="false">{zhCN.booleanFalse}</option><option value="true">{zhCN.booleanTrue}</option></select> : <input aria-label={column.name} type={column.type === 'date' ? 'date' : 'text'} inputMode={column.type === 'int' ? 'numeric' : undefined} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })} />}</label>)}</div>
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -1,113 +0,0 @@
import { Plus, RefreshCw, Replace, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { DataSourceAdapter, FilterExpr, TableMeta } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { useChangeStore } from '../../store/changeStore';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import { DataGrid, rowKey, type GridRow, type SelectionMode } from './DataGrid';
import { RowDialog } from './RowDialog';
import styles from './grid.module.css';
// 表数据编辑器:分页加载、工具栏操作与批量删除/替换。
interface TableEditorProps { adapter: DataSourceAdapter; table: TableMeta; settings: IdeSettings; revision: number; onTotal: (count: number) => void; onMessage: (message: string) => void }
export function TableEditor({ adapter, table, settings, revision, onTotal, onMessage }: TableEditorProps) {
const [rows, setRows] = useState<GridRow[]>([]);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<{ column: string; dir: 'asc' | 'desc' }>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [widths, setWidths] = useState<Record<string, number>>(() => JSON.parse(localStorage.getItem(`chusql.widths.${table.name}`) ?? '{}'));
const [selectedRows, setSelectedRows] = useState(new Set<string>());
const [selectionAnchor, setSelectionAnchor] = useState<number>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [replaceOpen, setReplaceOpen] = useState(false);
const [find, setFind] = useState('');
const [replacement, setReplacement] = useState('');
const [insertOpen, setInsertOpen] = useState(false);
const requestId = useRef(0);
const { changes, updateCell, insertRow, updateInsert, deleteRow } = useChangeStore();
const inserted = useMemo(() => Array.from(changes.inserts.values()).filter((item) => item.table === table.name).map((item): GridRow => ({ ...item.values, _tempId: item.tempId })), [changes, table.name]);
const visibleRows: GridRow[] = [...rows, ...inserted];
const activeFilters: FilterExpr[] = Object.entries(filters).filter(([, value]) => value.trim()).map(([column, expression]) => ({ column, expression }));
const load = async (nextPage: number, append: boolean) => {
const id = ++requestId.current; setLoading(true); setError('');
try {
const result = await adapter.fetchRows({ db: zhCN.database, table: table.name, page: nextPage, pageSize: settings.pageSize, sort: sort ? [sort] : [], filters: activeFilters });
if (id !== requestId.current) return;
setRows((current) => append ? [...current, ...result.rows] : result.rows);
if (!append) { setSelectedRows(new Set()); setSelectionAnchor(undefined); }
setTotal(result.total); setPage(nextPage); onTotal(result.total);
} catch (reason) { if (id === requestId.current) setError(reason instanceof Error ? reason.message : String(reason)); }
finally { if (id === requestId.current) setLoading(false); }
};
useEffect(() => { const timer = window.setTimeout(() => void load(0, false), 180); return () => window.clearTimeout(timer); }, [table.name, settings.pageSize, sort?.column, sort?.dir, JSON.stringify(filters), revision]);
const sortColumn = (column: string) => setSort((current) => current?.column !== column ? { column, dir: 'asc' } : { column, dir: current.dir === 'asc' ? 'desc' : 'asc' });
const setWidth = (column: string, width: number) => setWidths((current) => { const next = { ...current, [column]: width }; localStorage.setItem(`chusql.widths.${table.name}`, JSON.stringify(next)); return next; });
const addRow = (values: Record<string, unknown>) => {
const tempId = `temp-${crypto.randomUUID()}`;
insertRow(table.name, tempId, values);
setInsertOpen(false);
onMessage(zhCN.rowStaged);
};
const stageDelete = (row: GridRow) => {
deleteRow(table.name, row._tempId ?? row.id, row, Boolean(row._tempId));
};
const selectRow = (index: number, mode: SelectionMode) => {
const key = rowKey(visibleRows[index], index);
if (mode === 'range' && selectionAnchor !== undefined) {
const from = Math.min(selectionAnchor, index);
const to = Math.max(selectionAnchor, index);
setSelectedRows(new Set(visibleRows.slice(from, to + 1).map((row, offset) => rowKey(row, from + offset))));
return;
}
if (mode === 'toggle') {
setSelectedRows((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; });
setSelectionAnchor(index);
return;
}
setSelectedRows(new Set([key]));
setSelectionAnchor(index);
};
const removeSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => { if (selectedRows.has(row._tempId ?? String(row.id ?? index))) stageDelete(row); });
setSelectedRows(new Set());
setSelectionAnchor(undefined);
};
const replaceSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => {
const key = row._tempId ?? String(row.id ?? index); if (!selectedRows.has(key)) return;
table.columns.filter((column) => !column.primaryKey && column.type === 'str').forEach((column) => {
const oldValue = row._tempId ? changes.inserts.get(row._tempId)?.values[column.name] : row[column.name];
if (String(oldValue ?? '').includes(find)) {
const next = String(oldValue ?? '').replaceAll(find, replacement);
if (row._tempId) updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column.name]: next });
else updateCell(table.name, row.id, column.name, row[column.name], next);
}
});
}); setReplaceOpen(false);
};
return <section className={styles.tableEditor}>
<div className={styles.toolbar}>
<IconButton icon={<RefreshCw size={14} />} label={zhCN.refresh} onClick={() => void load(0, false)} />
<IconButton icon={<Plus size={14} />} label={zhCN.insertRow} onClick={() => setInsertOpen(true)} />
<IconButton icon={<Trash2 size={14} />} label={zhCN.deleteRows} disabled={!selectedRows.size} onClick={removeSelected} />
<IconButton icon={<Replace size={14} />} label={zhCN.batchReplace} onClick={() => selectedRows.size ? setReplaceOpen(true) : onMessage(zhCN.selectedRowsRequired)} />
<span className={styles.spacer} /><span title={zhCN.selectRowsHint}>{zhCN.selectedRows(selectedRows.size)}</span><span>{zhCN.rowCount(total)}</span>
</div>
{replaceOpen && <div className={styles.replaceBar}><label>{zhCN.findText}<input value={find} onChange={(event) => setFind(event.target.value)} /></label><label>{zhCN.replaceText}<input value={replacement} onChange={(event) => setReplacement(event.target.value)} /></label><button onClick={replaceSelected}>{zhCN.replaceSelected}</button><button onClick={() => setReplaceOpen(false)}>{zhCN.close}</button></div>}
<div className={styles.viewport} onScroll={(event) => { const target = event.currentTarget; if (!loading && rows.length < total && target.scrollTop + target.clientHeight >= target.scrollHeight - 32) void load(page + 1, true); }}>
{error ? <div role="alert" className={styles.empty}>{zhCN.loadError}: {error} <button onClick={() => void load(0, false)}>{zhCN.retry}</button></div> : <DataGrid table={table.name} columns={table.columns} rows={visibleRows} changes={changes} nullText={settings.nullText} sort={sort} filters={filters} widths={widths} selectedRows={selectedRows} onSort={sortColumn} onFilter={(column, expression) => setFilters((current) => ({ ...current, [column]: expression }))} onWidth={setWidth} onSelectRow={selectRow} onEdit={(row, column, oldValue, newValue) => row._tempId ? updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column]: newValue }) : updateCell(table.name, row.id, column, oldValue, newValue)} onDelete={stageDelete} />}
{loading && <div className={styles.loading}>{zhCN.loading}</div>}
{!loading && !visibleRows.length && !error && <div className={styles.empty}>{zhCN.noRows}</div>}
</div>
{insertOpen && <RowDialog table={table} onClose={() => setInsertOpen(false)} onInsert={addRow} />}
</section>;
}
@@ -1,54 +0,0 @@
/* 数据网格与表结构对话框的样式 */
.tableEditor { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; }
.spacer { flex: 1; }
.replaceBar { align-items: center; background: var(--bg-overlay); border-bottom: 1px solid var(--border); display: flex; gap: 10px; min-height: 32px; padding: 3px 7px; }
.replaceBar label { align-items: center; display: flex; gap: 5px; }
.replaceBar input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; }
.replaceBar button, .empty button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 24px; }
.viewport { flex: 1; min-height: 0; overflow: auto; position: relative; }
.grid { border-collapse: collapse; font-family: var(--grid-font); font-size: var(--grid-font-size); table-layout: fixed; }
.grid th, .grid td { border-bottom: 1px solid var(--border-subtle); border-right: 1px solid var(--border); height: var(--grid-row-height); overflow: hidden; padding: 0 7px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.grid thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; }
.grid thead th { color: var(--info); font-weight: 400; position: relative; overflow: visible; }
.filterRow th { background: var(--bg-elevated); position: sticky; top: var(--grid-row-height); }
.filterRow input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 20px; padding: 0 4px; width: 100%; }
.columnButton { background: transparent; border: 0; color: var(--info); cursor: pointer; font: inherit; height: 100%; padding: 0; text-align: left; }
.columnButton span { color: var(--fg-primary); margin-left: 5px; }
.resizeHandle { bottom: 0; cursor: col-resize; position: absolute; right: -3px; top: 0; width: 7px; }
.grid thead small { color: var(--fg-muted); float: right; font-size: 10px; }
.grid tbody tr:hover { background: rgb(255 255 255 / 4%); }
.rowNumber { background: var(--bg-elevated); color: var(--fg-muted); min-width: 42px !important; text-align: right !important; width: 42px; }
.selectColumn { width: 28px; }
.numberColumn { width: 42px; }
.dirty { background: rgb(226 192 141 / 8%); position: relative; }
.dirty::before { border-right: 5px solid transparent; border-top: 5px solid var(--modified); content: ''; left: 0; position: absolute; top: 0; }
.deleted { color: var(--fg-disabled); text-decoration: line-through; }
.inserted { border-left: 2px solid var(--modified); }
.nullValue { color: var(--fg-muted); font-style: italic; }
.grid input, .grid select { background: var(--bg-base); border: 1px solid var(--accent); color: var(--fg-primary); font: inherit; height: calc(var(--grid-row-height) - 2px); margin: 0 -6px; outline: 0; padding: 0 5px; width: calc(100% + 12px); }
.empty { color: var(--fg-muted); padding: 28px; text-align: center; }
.loading { background: var(--bg-elevated); bottom: 0; color: var(--info); left: 0; padding: 4px 8px; position: sticky; }
.primaryButton { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; min-height: 24px; }
.columnHeader { display: flex; align-items: center; height: var(--grid-row-height); gap: 4px; }
.columnButton { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.filterButton { display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 2px; color: var(--fg-muted); flex-shrink: 0; cursor: pointer; }
.filterButton:hover, .filterButton[aria-pressed="true"] { color: var(--info); background: var(--bg-active); }
.filterPopover { position: absolute; top: 100%; left: 0; z-index: 4; padding: 8px; width: 220px; background: var(--bg-overlay); border: 1px solid var(--border); }
.filterPopover input { width: 100%; margin: 0 0 6px; }
.filterPopover button { background: var(--bg-active); color: var(--fg-primary); border: 1px solid var(--border); }
.grid tbody tr[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, var(--bg-base)); }
.grid td:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.resizeHandle { touch-action: none; z-index: 3; }
.toolbar, .replaceBar { flex-shrink: 0; }
.formFields { display: grid; gap: 12px; }
.formFields label { display: grid; gap: 5px; }
.formFields input, .formFields select, .columnDefinition input, .columnDefinition select { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); padding: 5px 7px; min-width: 0; }
.formFields small, .indexList small { color: var(--fg-muted); }
.columnDefinition { display: grid; grid-template-columns: minmax(0, 1fr) 100px 56px; gap: 8px; }
.indexList { list-style: none; padding: 0; margin: 12px 0; }
.indexList li { display: grid; grid-template-columns: minmax(80px, 1fr) 120px 90px; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.operationConfirm { border: 1px solid var(--border); padding: 10px; }
.operationConfirm button + button { margin-left: 8px; }
.dangerButton { background: transparent; border: 1px solid var(--danger); color: var(--danger); padding: 4px 8px; }
.dangerButton:disabled { opacity: .45; }
@@ -1,44 +0,0 @@
import { Undo2 } from 'lucide-react';
import { useState } from 'react';
import type { ChangeSet, SchemaChange, StatementResult } from '../../types';
import { deleteSql, insertSql, schemaSql, updateSql } from '../../core/changeSql';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './bottom.module.css';
// 底部面板:待提交变更、查询结果与消息三个标签页。
interface BottomPanelProps { changes: ChangeSet; results: StatementResult[]; messages: string[]; onUndoOne: (kind: keyof ChangeSet, key: string) => void }
// 结构变更的中文描述。
function schemaLabel(change: SchemaChange): string {
if (change.op === 'createTable') return zhCN.createTableChange(change.table);
if (change.op === 'dropTable') return zhCN.dropTableChange(change.table);
if (change.op === 'createIndex') return zhCN.createIndexChange(change.table, change.column);
return zhCN.dropIndexChange(change.table, change.column);
}
// 按执行器的 token 配色渲染一条 SQL。
function SqlText({ sql }: { sql: string }) {
return <>{tokenizeSql(sql).map((token, index) => <span key={index} style={{ color: token.kind === 'plain' ? SQL_PLAIN_COLOR : SQL_TOKEN_COLORS[token.kind] }}>{token.text}</span>)}</>;
}
export function BottomPanel({ changes, results, messages, onUndoOne }: BottomPanelProps) {
const [tab, setTab] = useState<'changes' | 'results' | 'messages'>('changes');
const [resultIndex, setResultIndex] = useState(0);
const entries = [
...Array.from(changes.updates, ([key, value]) => ({ kind: 'updates' as const, key, label: zhCN.updateChange(value.table, value.column), detail: updateSql(value.table, value.pk, value.column, value.newValue) })),
...Array.from(changes.inserts, ([key, value]) => ({ kind: 'inserts' as const, key, label: zhCN.insertChange(value.table), detail: insertSql(value.table, value.values) })),
...Array.from(changes.deletes, ([key, value]) => ({ kind: 'deletes' as const, key, label: zhCN.deleteChange(value.table), detail: deleteSql(value.table, value.pk) })),
...Array.from(changes.schemas, ([key, value]) => ({ kind: 'schemas' as const, key, label: schemaLabel(value), detail: schemaSql(value) })),
];
const result = results[resultIndex];
return <div className={styles.panel}>
<nav><button className={tab === 'changes' ? styles.active : ''} onClick={() => setTab('changes')}>{zhCN.pendingChanges}</button><button className={tab === 'results' ? styles.active : ''} onClick={() => setTab('results')}>{zhCN.results}</button><button className={tab === 'messages' ? styles.active : ''} onClick={() => setTab('messages')}>{zhCN.messages}</button></nav>
<div className={styles.body}>
{tab === 'changes' && (entries.length ? <ul className={styles.changes}>{entries.map((entry) => <li key={`${entry.kind}-${entry.key}`}><strong>{entry.label}</strong><code title={entry.detail}><SqlText sql={entry.detail} /></code><IconButton compact icon={<Undo2 size={13} />} label={zhCN.undoChange} onClick={() => onUndoOne(entry.kind, entry.key)} /></li>)}</ul> : <p>{zhCN.noPendingChanges}</p>)}
{tab === 'results' && (result ? <><select aria-label={zhCN.results} value={resultIndex} onChange={(event) => setResultIndex(Number(event.target.value))}>{results.map((item, index) => <option key={index} value={index}>{zhCN.resultLabel(index + 1, item.rows?.length ?? item.affected ?? 0, item.durationMs)}</option>)}</select>{result.type === 'rows' ? <div className={styles.resultScroll}><table><thead><tr>{result.columns?.map((column) => <th key={column}>{column}</th>)}</tr></thead><tbody>{result.rows?.map((row, rowIndex) => <tr key={rowIndex}>{row.map((value, columnIndex) => <td key={columnIndex}>{value === null ? zhCN.nullValue : String(value)}</td>)}</tr>)}</tbody></table></div> : <p>{result.message ?? zhCN.executionAffected(result.affected ?? 0, result.durationMs)}</p>}</> : <p>{zhCN.noResults}</p>)}
{tab === 'messages' && <ol className={styles.messages}>{messages.map((message, index) => <li key={`${index}-${message}`}>{message}</li>)}</ol>}
</div>
</div>;
}
@@ -1,23 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { Explorer } from './Explorer';
// 资源管理器测试:列图标、展开收起与打开表。
it('expands columns with distinct primary key, index and ordinary column icons', () => {
const onOpenTable = vi.fn();
render(<Explorer tables={[{ name: 'users', rowCount: 1, columns: [
{ name: 'id', type: 'int', primaryKey: true },
{ name: 'email', type: 'str', indexed: true },
{ name: 'name', type: 'str' },
] }]} history={[]} onOpenTable={onOpenTable} onOpenSql={vi.fn()} />);
const expand = screen.getByRole('button', { name: '展开 users 的列' });
expect(screen.queryByText('email')).not.toBeInTheDocument();
fireEvent.click(expand);
expect(expand).toHaveAttribute('aria-expanded', 'true');
for (const name of ['主键列', '索引列', '普通列']) expect(screen.getByRole('img', { name })).toBeInTheDocument();
expect(onOpenTable).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(onOpenTable).toHaveBeenCalledWith('users');
fireEvent.click(expand);
expect(screen.queryByText('email')).not.toBeInTheDocument();
});
@@ -1,59 +0,0 @@
import { ChevronDown, Database, FileClock, GitCompareArrows, Table2, Plus, ListFilter, Trash2 } from 'lucide-react';
import { IconButton } from '../common/IconButton';
import type { ColumnMeta, TableMeta } from '../../types';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
import { useState } from 'react';
// 资源管理器:库-表-列树、变更集与查询历史入口。
interface ExplorerProps {
tables: TableMeta[];
activeTable?: string;
history: Array<{ sql: string; at: string }>;
onOpenTable: (name: string) => void;
onOpenSql: (sql?: string) => void;
onCreateTable?: () => void;
onIndexes?: (table: TableMeta) => void;
onDropTable?: (table: TableMeta) => void;
}
// 列图标的可访问名,按主键/索引/普通区分。
function columnKind(column: ColumnMeta): string {
if (column.primaryKey) return zhCN.columnPrimaryKey;
return column.indexed ? zhCN.columnIndexed : zhCN.columnOrdinary;
}
export function Explorer({ tables, activeTable, history, onOpenTable, onOpenSql, onCreateTable, onIndexes, onDropTable }: ExplorerProps) {
const [expanded, setExpanded] = useState(new Set<string>());
return (
<aside className={styles.sidebar} aria-label={zhCN.explorer}>
<h2>{zhCN.explorer}</h2>
{onCreateTable && <IconButton icon={<Plus size={14} />} label={zhCN.newTable} onClick={onCreateTable} />}
<div className={styles.sectionTitle}><ChevronDown size={13} />{zhCN.databaseSection}</div>
<div className={styles.treeRoot}><Database size={14} /><span>{zhCN.database}</span></div>
<ul className={styles.tableList}>
{tables.map((table) => (
<li key={table.name}>
<div className={styles.tableEntry}>
<button className={styles.expandButton} aria-label={zhCN.expandColumns(table.name)} aria-expanded={expanded.has(table.name)} onClick={() => setExpanded((current) => { const next = new Set(current); if (next.has(table.name)) next.delete(table.name); else next.add(table.name); return next; })}><svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d={expanded.has(table.name) ? 'm4 6 4 4 4-4' : 'm6 4 4 4-4 4'} /></svg></button>
<button
className={activeTable === table.name ? styles.activeTreeItem : styles.treeItem}
aria-label={zhCN.openTable(table.name)}
onClick={() => onOpenTable(table.name)}
>
<Table2 size={13} /><span>{table.name}</span><small>{table.rowCount}</small>
</button>
{onIndexes && <IconButton compact icon={<ListFilter size={13} />} label={zhCN.manageIndexes(table.name)} onClick={() => onIndexes(table)} />}
{onDropTable && <IconButton compact icon={<Trash2 size={13} />} label={zhCN.dropTableAction(table.name)} onClick={() => onDropTable(table)} />}
</div>
{expanded.has(table.name) && <ul className={styles.columnList}>{table.columns.map((column) => <li key={column.name}><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" role="img" aria-label={columnKind(column)}><title>{columnKind(column)}</title>{column.primaryKey ? <><circle cx="5" cy="5" r="3" /><path d="m7 7 6 6m-3-3 2-2m0 4 2-2" /></> : column.indexed ? <><path d="M2 3h9v10H2ZM5 3v10M2 6h9M2 10h9m10-5 4 3-4 3" /></> : <><rect x="4" y="2" width="8" height="12" rx="1" /><path d="M4 6h8M4 10h8" /></>}</svg><span>{column.name}</span><small>{columnTypeLabels[column.type] ?? column.type}</small></li>)}</ul>}
</li>
))}
</ul>
<div className={styles.sectionTitle}><GitCompareArrows size={13} />{zhCN.changesSection}</div>
<div className={styles.sectionTitle}><FileClock size={13} />{zhCN.historySection}</div>
<button className={styles.treeItem} onClick={() => onOpenSql()}>{zhCN.newQuery}</button>
<ul className={styles.historyList}>{history.slice(0, 8).map((entry) => <li key={`${entry.at}-${entry.sql}`}><button title={entry.sql} onClick={() => onOpenSql(entry.sql)}>{entry.sql}</button></li>)}</ul>
</aside>
);
}
@@ -1,73 +0,0 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { MockAdapter } from '../../adapters/MockAdapter';
import { useChangeStore } from '../../store/changeStore';
import { SchemaDialog } from './SchemaDialog';
// 建表/删表/索引对话框测试:校验、直接响应与结构变更暂存。
afterEach(cleanup);
beforeEach(() => useChangeStore.getState().clear());
it('stages a create table change with explicit columns and rejects duplicate columns', () => {
const onChanged = vi.fn();
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={onChanged} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'id' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(screen.getByRole('alert')).toHaveTextContent('列名重复');
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE TABLE items (id int, title str);');
expect(onClose).toHaveBeenCalled();
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] },
]);
});
it('stages a drop table change on the first click without asking for the name', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'drop', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.queryByLabelText(/输入/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '永久删除表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:DROP TABLE users;');
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([{ op: 'dropTable', table: 'users' }]);
expect((await adapter.listTables('chusql')).some((item) => item.name === 'users')).toBe(true);
});
it('protects the built-in primary key index and non-integer columns', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={vi.fn()} />);
const rows = screen.getAllByRole('listitem');
expect(within(rows[0]).getByRole('button')).toBeDisabled();
expect(within(rows[1]).getByRole('button')).toBeDisabled();
expect(within(rows[2]).getByRole('button')).toBeEnabled();
});
it('stages index creation and deletion on the first click and reflects the pending state', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await adapter.createTable('items', [{ name: 'id', type: 'int' }, { name: 'sku', type: 'int' }]);
const table = (await adapter.listTables('chusql')).find((item) => item.name === 'items')!;
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.getByText('无索引')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '建索引' }));
expect(screen.getByText('唯一索引')).toBeInTheDocument();
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE INDEX ON items (sku);');
fireEvent.click(within(screen.getAllByRole('listitem')[1]).getByRole('button', { name: '删索引' }));
expect(screen.getByText('无索引')).toBeInTheDocument();
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:items:sku', 'dropIndex:items:sku']);
});
it('leaves nothing staged when the create dialog is cancelled', () => {
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={vi.fn()} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalled();
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
});
@@ -1,69 +0,0 @@
import { useState } from 'react';
import { schemaSql } from '../../core/changeSql';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import { useChangeStore } from '../../store/changeStore';
import type { ColumnMeta, SchemaChange, TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from '../grid/grid.module.css';
// 建表、删表与索引对话框:结构变更一律暂存,提交时才下发。
export type SchemaAction = { kind: 'create' } | { kind: 'drop' | 'indexes'; table: TableMeta };
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 列类型的中文名,未知类型原样显示。
function typeLabel(type: string): string {
return columnTypeLabels[type] ?? type;
}
export function SchemaDialog({ action, onClose, onChanged }: {
action: SchemaAction; onClose: () => void; onChanged: (message: string) => void;
}) {
const [name, setName] = useState('');
const [columns, setColumns] = useState<Array<Pick<ColumnMeta, 'name' | 'type'>>>([{ name: 'id', type: 'int' }, { name: '', type: 'str' }]);
const [error, setError] = useState('');
const stageSchema = useChangeStore((state) => state.stageSchema);
const schemas = useChangeStore((state) => state.changes.schemas);
const target = action.kind === 'create' ? undefined : action.table;
const stagedIndex = new Map<string, boolean>();
for (const change of schemas.values()) {
if (change.table !== target?.name) continue;
if (change.op === 'createIndex') stagedIndex.set(change.column, true);
else if (change.op === 'dropIndex') stagedIndex.set(change.column, false);
}
const stage = (change: SchemaChange) => { stageSchema(change); onChanged(zhCN.stagedMessage(schemaSql(change))); };
const create = () => {
if (!identifier.test(name) || columns.some((column) => !identifier.test(column.name))) return setError(zhCN.invalidIdentifier);
if (new Set(columns.map((column) => column.name)).size !== columns.length) return setError(zhCN.duplicateColumns);
stage({ op: 'createTable', table: name, columns });
onClose();
};
const drop = () => {
if (!target) return;
stage({ op: 'dropTable', table: target.name });
onClose();
};
const changeIndex = (column: string, indexed: boolean) => {
if (!target) return;
stage(indexed
? { op: 'dropIndex', table: target.name, column }
: { op: 'createIndex', table: target.name, column });
};
const title = action.kind === 'create' ? zhCN.newTableTitle : `${action.kind === 'drop' ? zhCN.dropTableTitle : zhCN.indexManagerTitle} · ${target?.name}`;
return <Modal title={title} onClose={onClose} footer={<><button onClick={onClose}>{action.kind === 'indexes' ? zhCN.done : zhCN.cancel}</button>{action.kind === 'create' && <button className={styles.primaryButton} onClick={create}>{zhCN.createTable}</button>}{action.kind === 'drop' && <button className={styles.dangerButton} onClick={drop}>{zhCN.dropTablePermanently}</button>}</>}>
{action.kind === 'create' ? <div className={styles.formFields}>
<label>{zhCN.tableName}<input autoFocus aria-label={zhCN.tableName} value={name} onChange={(event) => setName(event.target.value)} /></label>
<p>{zhCN.createTableHint}</p>
{columns.map((column, index) => <div className={styles.columnDefinition} key={index}><input aria-label={zhCN.columnName(index + 1)} value={column.name} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} /><select aria-label={zhCN.columnType(index + 1)} value={column.type} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, type: event.target.value as ColumnMeta['type'] } : item))}><option value="int">{zhCN.typeInt}</option><option value="str">{zhCN.typeStr}</option><option value="bool">{zhCN.typeBool}</option></select><button disabled={index === 0} aria-label={zhCN.removeColumn(index + 1)} onClick={() => setColumns(columns.filter((_, i) => i !== index))}>{zhCN.remove}</button></div>)}
<button onClick={() => setColumns([...columns, { name: '', type: 'str' }])}>{zhCN.addColumn}</button>
</div> : action.kind === 'drop' ? <>
<p>{zhCN.dropTableHint(target?.name ?? '', target?.rowCount ?? 0)}</p>
</> : <>
<p>{zhCN.indexHint}</p>
<ul className={styles.indexList}>{target?.columns.map((column) => {
const indexed = stagedIndex.get(column.name) ?? Boolean(column.indexed);
return <li key={column.name}><span>{column.name} <small>{typeLabel(column.type)}</small></span><span>{column.primaryKey || column.name === 'id' ? zhCN.builtinIndex : indexed ? zhCN.uniqueIndex : zhCN.noIndex}</span><button disabled={column.name === 'id' || (!indexed && column.type !== 'int')} onClick={() => changeIndex(column.name, indexed)}>{indexed ? zhCN.deleteIndex : zhCN.createIndex}</button></li>;
})}</ul>
</>}
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -1,149 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { zhCN } from '../../i18n/zh-CN';
import { Workbench } from './Workbench';
// 工作台布局测试:拖动、方向键、夹紧范围与本地存储持久化。
// 用最简桩节点渲染工作台
function renderWorkbench() {
render(<Workbench explorer={<div />} editor={<div />} bottom={<div />} status={<div />} changeCount={0} canUndo={false} canRedo={false} onCommit={vi.fn()} onRollback={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSql={vi.fn()} onSettings={vi.fn()} />);
}
// 取侧边栏与船坞两条拖动条
function splitters() {
return { sidebar: screen.getByRole('separator', { name: zhCN.resizeSidebar }), dock: screen.getByRole('separator', { name: zhCN.resizeDock }) };
}
// 取底部船坞默认高度
function defaultDock() { return Math.round(window.innerHeight * 0.22); }
// 取底部船坞高度上限
function dockCeiling() { return Math.max(96, Math.round(window.innerHeight * 0.7)); }
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('resizes the sidebar and the dock by 16px with arrow keys', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '266');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 16));
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('clamps the sidebar to 160..560 and the dock to 96 and above', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
for (let i = 0; i < 40; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
for (let i = 0; i < 60; i += 1) fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(dockCeiling()));
});
it('exposes separator roles with orientation, value and bounds', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-orientation', 'vertical');
expect(sidebar).toHaveAttribute('aria-valuemin', '160');
expect(sidebar).toHaveAttribute('aria-valuemax', '560');
expect(sidebar).toHaveAttribute('tabindex', '0');
expect(dock).toHaveAttribute('aria-orientation', 'horizontal');
expect(dock).toHaveAttribute('aria-valuemin', '96');
expect(dock).toHaveAttribute('aria-valuemax', String(dockCeiling()));
expect(dock).toHaveAttribute('tabindex', '0');
});
it('stores changed sizes in localStorage under chusql.layout.v1', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(JSON.parse(localStorage.getItem('chusql.layout.v1') ?? '{}')).toEqual({ sidebarWidth: 266, dockHeight: defaultDock() + 16 });
});
it('restores saved sizes from localStorage on the first render', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 300, dockHeight: 150 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '300');
expect(dock).toHaveAttribute('aria-valuenow', '150');
});
it('clamps saved sizes that fall outside the allowed range', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 9999, dockHeight: 1 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
expect(dock).toHaveAttribute('aria-valuenow', '96');
});
it('falls back to default sizes when the stored layout is unreadable', () => {
localStorage.setItem('chusql.layout.v1', '{not json');
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('widens the sidebar by 40px when the vertical splitter is dragged right', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 340, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '290');
});
it('makes the dock taller by 40px when the horizontal splitter is dragged up', () => {
renderWorkbench();
const { dock } = splitters();
act(() => {
dock.dispatchEvent(new MouseEvent('pointerdown', { clientY: 500, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientY: 460, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 40));
});
it('stops resizing after the pointer is released', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
});
@@ -1,121 +0,0 @@
import { Database, RotateCcw, Settings, TerminalSquare, Undo2 } from 'lucide-react';
import { useEffect, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
import { IconButton } from '../common/IconButton';
import { zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
// 工作台外壳:标题栏、活动栏与编辑器/底栏/状态栏布局,面板边界可拖动。
interface WorkbenchProps {
explorer: ReactNode;
editor: ReactNode;
bottom: ReactNode;
status: ReactNode;
changeCount: number;
canUndo: boolean;
canRedo: boolean;
onCommit: () => void;
onRollback: () => void;
onUndo: () => void;
onRedo: () => void;
onSql: () => void;
onSettings: () => void;
}
type SplitterKind = 'sidebar' | 'dock';
interface LayoutSize { sidebar: number; dock: number }
interface DragState { kind: SplitterKind; start: number; size: number }
const STORAGE_KEY = 'chusql.layout.v1';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 560;
const DOCK_MIN = 96;
const KEY_STEP = 16;
// 底部船坞高度上限取窗口高度的七成
function dockMax() { return Math.max(DOCK_MIN, Math.round(window.innerHeight * 0.7)); }
// 把尺寸夹紧到允许区间并取整
function clampSize(kind: SplitterKind, value: number) {
const rounded = Math.round(value);
return kind === 'sidebar' ? Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, rounded)) : Math.min(dockMax(), Math.max(DOCK_MIN, rounded));
}
// 读取本地存储的面板尺寸,异常时用默认值
function readLayout(): LayoutSize {
const fallback = { sidebar: 250, dock: Math.round(window.innerHeight * 0.22) };
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '') as { sidebarWidth?: unknown; dockHeight?: unknown };
if (typeof raw.sidebarWidth !== 'number' || typeof raw.dockHeight !== 'number') return fallback;
return { sidebar: clampSize('sidebar', raw.sidebarWidth), dock: clampSize('dock', raw.dockHeight) };
} catch { return fallback; }
}
// 写入面板尺寸到本地存储,失败时静默忽略
function writeLayout(size: LayoutSize) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sidebarWidth: size.sidebar, dockHeight: size.dock })); } catch { return; }
}
export function Workbench(props: WorkbenchProps) {
const [layout, setLayout] = useState<LayoutSize>(readLayout);
const [drag, setDrag] = useState<DragState>();
const { sidebar, dock } = layout;
useEffect(() => { writeLayout(layout); }, [layout]);
useEffect(() => {
if (!drag) return;
const move = (event: globalThis.PointerEvent) => setLayout((current) => {
const delta = drag.kind === 'sidebar' ? event.clientX - drag.start : drag.start - event.clientY;
const next = drag.size + delta;
return drag.kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', next) } : { ...current, dock: clampSize('dock', next) };
});
const stop = () => setDrag(undefined);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
}, [drag]);
const resize = (kind: SplitterKind, value: number) => setLayout((current) => (kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', value) } : { ...current, dock: clampSize('dock', value) }));
const startDrag = (kind: SplitterKind, event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
setDrag({ kind, start: kind === 'sidebar' ? event.clientX : event.clientY, size: kind === 'sidebar' ? sidebar : dock });
};
const onSplitterKeyDown = (kind: SplitterKind, event: KeyboardEvent<HTMLButtonElement>) => {
const step = kind === 'sidebar'
? (event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : 0)
: (event.key === 'ArrowUp' ? KEY_STEP : event.key === 'ArrowDown' ? -KEY_STEP : 0);
if (!step) return;
event.preventDefault();
resize(kind, (kind === 'sidebar' ? sidebar : dock) + step);
};
return (
<div className={styles.workbench} style={{ '--sidebar-width': `${sidebar}px`, '--dock-height': `${dock}px` } as CSSProperties}>
<header className={styles.titlebar}>
<span className={styles.brand}><Database size={15} />{zhCN.connection}</span>
<nav>
<IconButton icon={<Database size={14} />} label={zhCN.commit(props.changeCount)} disabled={!props.changeCount} onClick={props.onCommit} />
<IconButton icon={<RotateCcw size={14} />} label={zhCN.rollback} disabled={!props.changeCount} onClick={props.onRollback} />
<IconButton icon={<Undo2 size={14} />} label={zhCN.undo} disabled={!props.canUndo} onClick={props.onUndo} />
<IconButton icon={<Undo2 size={14} transform="rotate(180)" />} label={zhCN.redo} disabled={!props.canRedo} onClick={props.onRedo} />
<IconButton icon={<Settings size={14} />} label={zhCN.settings} onClick={props.onSettings} />
</nav>
</header>
<div className={styles.activitybar} aria-label={zhCN.activityBar}><button aria-label={zhCN.databaseSection}><Database size={20} /></button><button aria-label={zhCN.sqlConsole} onClick={props.onSql}><TerminalSquare size={20} /></button><button aria-label={zhCN.settings} onClick={props.onSettings}><Settings size={20} /></button><GitMark /></div>
{props.explorer}
<main className={styles.editor}>{props.editor}</main>
<section className={styles.bottom}>{props.bottom}</section>
<footer className={styles.statusbar}>{props.status}</footer>
<button type="button" className={`${styles.splitterV}${drag?.kind === 'sidebar' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="vertical" aria-label={zhCN.resizeSidebar} aria-valuenow={sidebar} aria-valuemin={SIDEBAR_MIN} aria-valuemax={SIDEBAR_MAX} tabIndex={0} onPointerDown={(event) => startDrag('sidebar', event)} onKeyDown={(event) => onSplitterKeyDown('sidebar', event)} />
<button type="button" className={`${styles.splitterH}${drag?.kind === 'dock' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="horizontal" aria-label={zhCN.resizeDock} aria-valuenow={dock} aria-valuemin={DOCK_MIN} aria-valuemax={dockMax()} tabIndex={0} onPointerDown={(event) => startDrag('dock', event)} onKeyDown={(event) => onSplitterKeyDown('dock', event)} />
</div>
);
}
function GitMark() {
return <span aria-hidden="true">⌘</span>;
}
@@ -1,15 +0,0 @@
/* 底部面板:标签页、变更列表与结果表格样式 */
.panel { display: grid; grid-template-rows: 28px minmax(0, 1fr); height: 100%; }
.panel nav { border-bottom: 1px solid var(--border); display: flex; gap: 2px; }
.panel nav button { background: transparent; border: 0; border-bottom: 1px solid transparent; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.panel nav .active { border-bottom-color: var(--accent); }
.body { min-height: 0; overflow: auto; padding: 6px 8px; }
.body p { color: var(--fg-muted); margin: 4px; }
.changes, .messages { list-style: none; margin: 0; padding: 0; }
.changes li { align-items: center; border-bottom: 1px solid var(--border-subtle); display: grid; gap: 10px; grid-template-columns: 200px minmax(0, 1fr) 24px; min-height: 26px; }
.changes code { color: rgb(200 200 200); font-family: var(--ui-font); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messages li { border-bottom: 1px solid var(--border-subtle); min-height: 22px; }
.body select { background: var(--bg-overlay); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; }
.resultScroll { overflow: auto; }
.resultScroll table { border-collapse: collapse; margin-top: 6px; }
.resultScroll th, .resultScroll td { border: 1px solid var(--border); min-width: 100px; padding: 3px 6px; text-align: left; }
@@ -1,43 +0,0 @@
/* 工作台整体网格布局与资源管理器样式 */
.workbench { --sidebar-width: 250px; --dock-height: 22vh; display: grid; grid-template: 34px minmax(0, 1fr) var(--dock-height) 22px / 48px var(--sidebar-width) minmax(0, 1fr); height: 100vh; min-height: 360px; position: relative; }
.titlebar { align-items: center; background: var(--bg-titlebar); display: flex; grid-column: 1 / -1; justify-content: space-between; padding: 0 6px 0 12px; }
.titlebar nav { display: flex; gap: 2px; }
.brand { align-items: center; display: inline-flex; gap: 7px; }
.activitybar { align-items: center; background: #333; color: var(--fg-muted); display: flex; flex-direction: column; gap: 18px; grid-column: 1; grid-row: 2 / 4; padding-top: 13px; }
.activitybar button { align-items: center; background: transparent; border: 0; color: inherit; display: flex; height: 28px; justify-content: center; padding: 0; width: 100%; }
.activitybar button:hover, .activitybar button:focus-visible { color: var(--fg-primary); }
.sidebar { background: var(--bg-elevated); border-right: 1px solid var(--border); grid-column: 2; grid-row: 2 / 4; min-width: 0; overflow: auto; }
.sidebar h2 { font-size: 12px; letter-spacing: .12em; margin: 0; padding: 11px 12px; text-transform: uppercase; }
.sectionTitle { align-items: center; display: flex; font-size: 11px; font-weight: 700; gap: 4px; letter-spacing: .08em; padding: 6px 8px; text-transform: uppercase; }
.treeRoot { align-items: center; display: flex; gap: 5px; height: 22px; padding-left: 19px; }
.tableList { list-style: none; margin: 0 0 8px; padding: 0; }
.treeItem, .activeTreeItem { align-items: center; background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); cursor: pointer; display: flex; font: inherit; gap: 6px; height: 22px; padding: 0 8px 0 32px; text-align: left; width: 100%; }
.treeItem:hover { background: var(--bg-active); }
.activeTreeItem { background: var(--bg-active); border-left-color: var(--accent); }
.treeItem small, .activeTreeItem small { color: var(--fg-muted); margin-left: auto; }
.historyList { list-style: none; margin: 0; padding: 0 8px 8px 32px; }
.historyList button { background: transparent; border: 0; color: var(--fg-muted); font: inherit; height: 22px; max-width: 100%; overflow: hidden; padding: 0; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.editor { background: var(--bg-base); grid-column: 3; grid-row: 2; min-height: 0; min-width: 0; overflow: hidden; }
.bottom { background: var(--bg-elevated); border-top: 1px solid var(--border); grid-column: 3; grid-row: 3; min-height: 0; overflow: auto; }
.statusbar { align-items: center; background: var(--bg-statusbar); color: #fff; display: flex; gap: 16px; grid-column: 1 / -1; padding: 0 8px; }
.splitterV { position: absolute; top: 34px; bottom: 22px; left: calc(48px + var(--sidebar-width) - 2px); width: 5px; cursor: col-resize; background: transparent; border: 0; padding: 0; z-index: 6; }
.splitterH { position: absolute; left: calc(48px + var(--sidebar-width)); right: 0; bottom: calc(22px + var(--dock-height) - 2px); height: 5px; cursor: row-resize; background: transparent; border: 0; padding: 0; z-index: 6; }
.splitterV:hover, .splitterV:focus-visible, .splitterV.dragging { background: var(--accent); }
.splitterH:hover, .splitterH:focus-visible, .splitterH.dragging { background: var(--accent); }
@media (max-width: 720px) {
.workbench { grid-template-columns: 40px minmax(0, 1fr); }
.sidebar { display: none; }
.splitterV { display: none; }
.splitterH { left: 40px; }
.editor, .bottom { grid-column: 2; }
.titlebar nav .iconButton span { display: none; }
}
.tableEntry { display: flex; align-items: center; padding-left: 16px; }
.tableEntry .treeItem, .tableEntry .activeTreeItem { padding-left: 4px; min-width: 0; }
.expandButton { display: flex; flex-shrink: 0; padding: 3px; background: transparent; border: 0; color: var(--fg-muted); cursor: pointer; }
.columnList { list-style: none; padding: 0 10px 4px 42px; }
.columnList li { display: flex; align-items: center; gap: 6px; min-height: 24px; }
.columnList svg { flex-shrink: 0; color: var(--info); }
.columnList svg[aria-label="Primary key"] { color: var(--modified); }
.columnList small { margin-left: auto; color: var(--fg-muted); }
@@ -1,24 +0,0 @@
import { GripVertical, Plus, Trash2 } from 'lucide-react';
import { useState, type CSSProperties, type DragEvent, type ReactNode } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './settings.module.css';
// 字体链编辑器:增删、拖动排序并实时预览。
interface FontChainEditorProps { label: string; value: string[]; onChange: (fonts: string[]) => void; previewStyle: CSSProperties; children?: ReactNode }
export function FontChainEditor({ label, value, onChange, previewStyle, children }: FontChainEditorProps) {
const [dragged, setDragged] = useState<number>();
const move = (event: DragEvent, target: number) => {
event.preventDefault();
if (dragged === undefined || dragged === target) return;
const next = [...value]; const [font] = next.splice(dragged, 1); next.splice(target, 0, font); onChange(next); setDragged(target);
};
return <fieldset className={styles.fontGroup}><legend>{label}</legend>
<div className={styles.fontList}>{value.map((font, index) => <div className={styles.fontRow} key={index} onDragOver={(event) => move(event, index)}><span draggable onDragStart={() => setDragged(index)} onDragEnd={() => setDragged(undefined)} title={zhCN.moveFont}><GripVertical size={13} /></span><input aria-label={`${label} ${zhCN.fontName} ${index + 1}`} value={font} onChange={(event) => onChange(value.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} /><IconButton icon={<Trash2 size={13} />} compact label={zhCN.removeFont(font)} disabled={value.length === 1} onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} /></div>)}</div>
<IconButton icon={<Plus size={13} />} label={zhCN.addFallback} onClick={() => onChange([...value, ''])} />
{children}
<div className={styles.preview} style={previewStyle}><strong>{zhCN.preview}</strong><span>{zhCN.previewText}</span></div>
</fieldset>;
}
@@ -1,35 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, expect, it } from 'vitest';
import { SettingsPanel } from './SettingsPanel';
import { useSettingsStore } from '../../store/settingsStore';
// 覆盖设置面板的输入聚焦与数值提交。
beforeEach(() => useSettingsStore.getState().reset());
it('preserves the font input and focus while typing and deleting', () => {
render(<SettingsPanel />);
const input = screen.getAllByRole('textbox')[0];
input.focus();
fireEvent.change(input, { target: { value: 'Consola' } });
expect(screen.getAllByRole('textbox')[0]).toBe(input);
expect(input).toHaveFocus();
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue('');
expect(input).toHaveFocus();
});
it('allows an empty numeric draft and commits a bounded value on blur', () => {
render(<SettingsPanel />);
const input = screen.getAllByRole('spinbutton')[0];
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue(null);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(13);
fireEvent.change(input, { target: { value: '15' } });
fireEvent.blur(input);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(15);
fireEvent.change(input, { target: { value: '99' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue(16);
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue(16);
});
@@ -1,46 +0,0 @@
import { RotateCcw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { fontFamilyValue, useSettingsStore } from '../../store/settingsStore';
import { IconButton } from '../common/IconButton';
import { FontChainEditor } from './FontChainEditor';
import styles from './settings.module.css';
// 设置面板:外观与行为两组选项及字体预览。
function NumberField({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
if (!draft.trim() || !Number.isFinite(Number(draft))) { setDraft(String(value)); return; }
const next = Math.min(max, Math.max(min, Math.round(Number(draft))));
setDraft(String(next)); onChange(next);
};
return <label className={styles.field}><span>{label}</span><input type="number" min={min} max={max} value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === 'Enter') commit(); if (event.key === 'Escape') setDraft(String(value)); }} /></label>;
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <label className={styles.toggle}><input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /><span>{label}</span></label>;
}
export function SettingsPanel() {
const [section, setSection] = useState<'appearance' | 'behavior'>('appearance');
const { settings, setNumber, setBoolean, setText, setFonts, reset } = useSettingsStore();
return <div className={styles.settings}>
<aside><h2>{zhCN.settings}</h2><button className={section === 'appearance' ? styles.active : ''} onClick={() => setSection('appearance')}>{zhCN.settingsAppearance}</button><button className={section === 'behavior' ? styles.active : ''} onClick={() => setSection('behavior')}>{zhCN.settingsBehavior}</button></aside>
<section><header><h1>{section === 'appearance' ? zhCN.settingsAppearance : zhCN.settingsBehavior}</h1></header>
{section === 'appearance' ? <>
<FontChainEditor label={zhCN.interfaceFont} value={settings.uiFonts} onChange={(value) => setFonts('uiFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.uiFonts), fontSize: settings.uiFontSize }}><NumberField label={zhCN.fontSize} value={settings.uiFontSize} min={11} max={16} onChange={(value) => setNumber('uiFontSize', value)} /></FontChainEditor>
<FontChainEditor label={zhCN.gridFont} value={settings.gridFonts} onChange={(value) => setFonts('gridFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.gridFonts), fontSize: settings.gridFontSize, lineHeight: `${settings.gridRowHeight}px` }}><NumberField label={zhCN.fontSize} value={settings.gridFontSize} min={10} max={18} onChange={(value) => setNumber('gridFontSize', value)} /><NumberField label={zhCN.rowHeight} value={settings.gridRowHeight} min={18} max={40} onChange={(value) => setNumber('gridRowHeight', value)} /></FontChainEditor>
<FontChainEditor label={zhCN.sqlFont} value={settings.sqlFonts} onChange={(value) => setFonts('sqlFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: `${settings.sqlLineHeight}px` }}><NumberField label={zhCN.fontSize} value={settings.sqlFontSize} min={10} max={20} onChange={(value) => setNumber('sqlFontSize', value)} /><NumberField label={zhCN.rowHeight} value={settings.sqlLineHeight} min={16} max={40} onChange={(value) => setNumber('sqlLineHeight', value)} /></FontChainEditor>
</> : <div className={styles.behavior}>
<NumberField label={zhCN.pageSize} value={settings.pageSize} min={10} max={500} onChange={(value) => setNumber('pageSize', value)} />
<label className={styles.field}><span>{zhCN.nullDisplay}</span><input value={settings.nullText} onChange={(event) => setText('nullText', event.target.value)} /></label>
<NumberField label={zhCN.tabSize} value={settings.sqlTabSize} min={1} max={8} onChange={(value) => setNumber('sqlTabSize', value)} />
<Toggle label={zhCN.showLineNumbers} checked={settings.sqlLineNumbers} onChange={(value) => setBoolean('sqlLineNumbers', value)} />
<Toggle label={zhCN.autocomplete} checked={settings.autocomplete} onChange={(value) => setBoolean('autocomplete', value)} />
<Toggle label={zhCN.minimap} checked={settings.minimap} onChange={(value) => setBoolean('minimap', value)} />
</div>}
<footer><IconButton icon={<RotateCcw size={14} />} label={zhCN.restoreDefaults} onClick={reset} /></footer>
</section>
</div>;
}
@@ -1,29 +0,0 @@
/* 设置面板样式:侧边分组、字段与字体预览。 */
.settings { display: grid; grid-template-columns: 210px minmax(0, 1fr); height: 100%; overflow: hidden; }
.settings aside { background: var(--bg-elevated); border-right: 1px solid var(--border); padding-top: 8px; }
.settings aside h2 { font-size: 12px; letter-spacing: .12em; margin: 8px 12px 12px; text-transform: uppercase; }
.settings aside button { background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); display: block; font: inherit; height: 28px; padding: 0 12px; text-align: left; width: 100%; }
.settings aside button:hover, .settings aside .active { background: var(--bg-active); }
.settings aside .active { border-left-color: var(--accent); }
.settings > section { overflow: auto; padding: 0 18px 20px; }
.settings header { background: var(--bg-base); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 2; }
.settings h1 { font-size: 18px; font-weight: 500; margin: 0; padding: 16px 0 10px; }
.fontGroup { border: 1px solid var(--border); margin: 14px 0; padding: 9px; }
.fontGroup legend { color: var(--info); font-weight: 700; padding: 0 5px; }
.fontList { display: grid; gap: 3px; margin-bottom: 6px; }
.fontRow { align-items: center; display: grid; gap: 5px; grid-template-columns: 16px minmax(160px, 360px) 24px; }
.fontRow input, .field input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
.field { align-items: center; display: grid; gap: 10px; grid-template-columns: 150px 120px; margin: 7px 0; }
.toggle { align-items: center; display: flex; gap: 8px; margin: 10px 0; }
.preview { background: var(--bg-base); border: 1px solid var(--border-subtle); display: grid; gap: 4px; margin-top: 9px; min-height: 52px; padding: 7px; }
.preview strong { color: var(--fg-muted); font-family: var(--ui-font); font-size: 11px; }
.behavior { padding: 12px 0; }
.settings footer { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 8px; }
@media (max-width: 720px) { .settings { grid-template-columns: 140px minmax(0, 1fr); } .settings > section { padding-inline: 10px; } }
@media (max-width: 720px) {
.settings { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.settings aside { display: flex; border-right: 0; border-bottom: 1px solid var(--border); padding: 0; }
.settings aside h2 { display: none; }
.fontRow { grid-template-columns: 16px minmax(0, 1fr) 24px; }
.field { grid-template-columns: minmax(0, 1fr) 90px; }
}
@@ -1,119 +0,0 @@
import { Play, PlayCircle, Square, TextSelect } from 'lucide-react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import 'monaco-editor/min/vs/editor/editor.main.css';
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
import { useEffect, useMemo, useRef, useState } from 'react';
import { splitSqlStatements, type SqlStatement } from '../../core/sql';
import { SQL_TOKEN_COLORS } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import type { DataSourceAdapter, StatementResult } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { IconButton } from '../common/IconButton';
import styles from './sql.module.css';
import { restoreEditorLayout } from './editorLayout';
import { fontFamilyValue } from '../../store/settingsStore';
// SQL 控制台:Monaco 编辑器、语句切分与逐条执行。
(self as unknown as { MonacoEnvironment: { getWorker: () => Worker } }).MonacoEnvironment = { getWorker: () => new EditorWorker() };
export interface QueryHistoryEntry { sql: string; at: string; ok: boolean; durationMs: number }
interface SqlConsoleProps {
adapter: DataSourceAdapter;
settings: IdeSettings;
initialSql?: string;
onResult: (result: StatementResult) => void;
onMessage: (message: string) => void;
onHistory: (history: QueryHistoryEntry[]) => void;
}
type RunStatus = { state: 'pending' | 'running' | 'success' | 'error'; durationMs?: number };
function readHistory(): QueryHistoryEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]');
return Array.isArray(parsed) ? parsed.slice(0, 50) : [];
} catch { return []; }
}
export function SqlConsole({ adapter, settings, initialSql = 'SELECT * FROM users;', onResult, onMessage, onHistory }: SqlConsoleProps) {
const host = useRef<HTMLDivElement>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor>();
const stopRequested = useRef(false);
const [sql, setSql] = useState(initialSql);
const [running, setRunning] = useState(false);
const [statuses, setStatuses] = useState<Record<string, RunStatus>>({});
const statements = useMemo(() => splitSqlStatements(sql), [sql]);
useEffect(() => {
if (!host.current) return undefined;
const stopLayoutRestore = restoreEditorLayout(host.current);
monaco.editor.defineTheme('chusql-dark', {
base: 'vs-dark', inherit: true,
rules: [
{ token: 'keyword', foreground: SQL_TOKEN_COLORS.keyword.slice(1) },
{ token: 'string', foreground: SQL_TOKEN_COLORS.string.slice(1) },
{ token: 'number', foreground: SQL_TOKEN_COLORS.number.slice(1) },
{ token: 'comment', foreground: SQL_TOKEN_COLORS.comment.slice(1) },
],
colors: { 'editor.background': '#1e1e1e', 'editorLineNumber.foreground': '#858585', 'editor.selectionBackground': '#264f78' },
});
const instance = monaco.editor.create(host.current, {
value: sql, language: 'sql', theme: 'chusql-dark', glyphMargin: true,
automaticLayout: true, minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts),
fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize,
lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete,
});
editor.current = instance;
const subscription = instance.onDidChangeModelContent(() => setSql(instance.getValue()));
return () => { stopLayoutRestore(); subscription.dispose(); instance.dispose(); editor.current = undefined; };
}, []);
useEffect(() => { editor.current?.updateOptions({ minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize, lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete }); }, [settings]);
useEffect(() => { setStatuses(Object.fromEntries(statements.map((statement) => [statement.id, { state: 'pending' }]))); }, [sql]);
useEffect(() => {
const instance = editor.current;
if (!instance) return;
const decorations = statements.map((statement) => {
const status = statuses[statement.id]?.state ?? 'pending';
return { range: new monaco.Range(statement.startLine, 1, statement.endLine, 1), options: { isWholeLine: status === 'running', className: status === 'running' ? styles.runningLine : undefined, linesDecorationsClassName: status === 'running' ? styles.runningEdge : undefined, glyphMarginClassName: styles[`glyph${status[0].toUpperCase()}${status.slice(1)}`] } };
});
const ids = instance.deltaDecorations([], decorations);
return () => { instance.deltaDecorations(ids, []); };
}, [statements, statuses]);
const recordHistory = (statement: SqlStatement, result: StatementResult) => {
const next = [{ sql: statement.sql, at: new Date().toISOString(), ok: result.type !== 'error', durationMs: result.durationMs }, ...readHistory()].slice(0, 50);
localStorage.setItem('chusql.sql.history.v2', JSON.stringify(next)); onHistory(next);
};
const run = async (items: SqlStatement[]) => {
if (!items.length) return onMessage(zhCN.noStatement);
stopRequested.current = false; setRunning(true);
for (const statement of items) {
if (stopRequested.current) break;
setStatuses((current) => ({ ...current, [statement.id]: { state: 'running' } }));
const result = await adapter.execStatement({ db: zhCN.database, sql: statement.sql });
setStatuses((current) => ({ ...current, [statement.id]: { state: result.type === 'error' ? 'error' : 'success', durationMs: result.durationMs } }));
recordHistory(statement, result); onResult(result);
if (result.type === 'error') onMessage(result.message ?? zhCN.statementFailed);
else if (result.type === 'rows') onMessage(zhCN.executionSuccess(result.rows?.length ?? 0, result.durationMs));
else onMessage(zhCN.executionAffected(result.affected ?? 0, result.durationMs));
}
if (stopRequested.current) onMessage(zhCN.executionStopped);
setRunning(false);
};
const pending = () => statements.find((statement) => statuses[statement.id]?.state !== 'success');
const selection = () => {
const instance = editor.current; const range = instance?.getSelection();
if (!instance || !range || range.isEmpty()) return [];
return splitSqlStatements(instance.getModel()?.getValueInRange(range) ?? '');
};
return <section className={styles.console}>
<div className={styles.toolbar}><IconButton icon={<PlayCircle size={14} />} label={zhCN.runNext} disabled={running} onClick={() => { const next = pending(); void run(next ? [next] : []); }} /><IconButton icon={<Play size={14} />} label={zhCN.runAll} disabled={running} onClick={() => void run(statements)} /><IconButton icon={<TextSelect size={14} />} label={zhCN.runSelection} disabled={running} onClick={() => void run(selection())} /><IconButton icon={<Square size={13} />} label={zhCN.stop} disabled={!running} onClick={() => { stopRequested.current = true; }} /></div>
<div className={styles.editor} ref={host} aria-label={zhCN.sqlConsole} />
<ol className={styles.statementList}>{statements.map((statement) => { const status = statuses[statement.id] ?? { state: 'pending' }; return <li key={statement.id}><span>{status.state === 'pending' ? '○' : status.state === 'running' ? '▶' : status.state === 'success' ? '✓' : '✗'}</span><span>{status.state === 'pending' ? zhCN.statementPending : status.state === 'running' ? zhCN.statementRunning : status.state === 'success' ? zhCN.statementSuccess : zhCN.statementFailed}</span>{status.durationMs !== undefined && <small>{status.durationMs.toFixed(1)} ms</small>}</li>; })}</ol>
</section>;
}
@@ -1,25 +0,0 @@
import { expect, it, vi } from 'vitest';
import { restoreEditorLayout } from './editorLayout';
// editorLayout 测试:只恢复布局声明,不碰已生效样式。
it('restores only layout declarations from CSP-blocked editor markup', () => {
const host = document.createElement('div');
const line = document.createElement('div');
line.setAttribute('style', 'left:20px;width:36px;background-image:url(https://example.invalid/x);');
const setProperty = vi.fn();
Object.defineProperty(line, 'style', { value: { length: 0, setProperty } });
host.append(line);
const stop = restoreEditorLayout(host);
expect(setProperty.mock.calls).toEqual([['left', '20px'], ['width', '36px']]);
stop();
});
it('leaves already active CSSOM styles untouched', () => {
const host = document.createElement('div');
host.style.width = '200px';
const setProperty = vi.spyOn(host.style, 'setProperty');
const stop = restoreEditorLayout(host);
expect(setProperty).not.toHaveBeenCalled();
stop();
});
@@ -1,36 +0,0 @@
// 修复被 CSP 拦截的 Monaco 内联样式:用 CSSOM 重放布局属性。
const layoutProperties = new Set([
'left', 'right', 'top', 'bottom', 'width', 'height', 'min-width', 'max-width',
'position', 'display', 'line-height', 'font-size', 'font-family', 'font-weight',
'font-style', 'letter-spacing', 'vertical-align', 'white-space', 'transform',
'color', 'background-color', 'border-color', 'border-width', 'border-style',
'padding-left', 'padding-right', 'margin-left', 'margin-right', 'opacity',
]);
export function restoreEditorLayout(host: HTMLElement): () => void {
const parsed = document.createElement('span').style;
const restore = (element: Element) => {
if (!(element instanceof HTMLElement) || element.style.length) return;
const raw = element.getAttribute('style');
if (!raw) return;
parsed.cssText = raw;
for (const property of Array.from(parsed)) {
const value = parsed.getPropertyValue(property);
if (layoutProperties.has(property) && !/url\s*\(|expression\s*\(/i.test(value)) element.style.setProperty(property, value);
}
};
const restoreTree = (element: Element) => {
restore(element);
element.querySelectorAll('[style]').forEach(restore);
};
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes' && record.target instanceof Element) restore(record.target);
else record.addedNodes.forEach((node) => { if (node instanceof Element) restoreTree(node); });
}
});
observer.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
restoreTree(host);
return () => observer.disconnect();
}
@@ -1,15 +0,0 @@
/* SQL 控制台样式:工具栏、编辑器与语句状态条。 */
.console { display: grid; grid-template: 30px minmax(0, 1fr) auto / minmax(0, 1fr); height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; }
.editor { min-height: 0; min-width: 0; overflow: hidden; position: relative; }
.statementList { background: var(--bg-elevated); border-top: 1px solid var(--border); display: flex; gap: 12px; list-style: none; margin: 0; min-height: 28px; overflow-x: auto; padding: 4px 8px; }
.statementList li { align-items: center; color: var(--fg-muted); display: flex; gap: 5px; white-space: nowrap; }
.statementList small { color: var(--success); }
.runningLine { background: rgb(0 122 204 / 14%); }
.runningEdge { border-left: 2px solid var(--accent); margin-left: 3px; }
.glyphPending::before { color: var(--fg-muted); content: '○'; }
.glyphRunning::before { color: var(--info); content: '▶'; }
.glyphSuccess::before { color: var(--success); content: '✓'; }
.glyphError::before { color: var(--danger); content: '✗'; }
.editor > :global(.monaco-editor) { position: absolute; inset: 0; }
.statementList { max-height: 64px; }
@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest';
import { createEmptyChangeSet } from './changes';
import { changeSql, changesSql, deleteSql, insertSql, schemaSql, sqlIdentifier, sqlLiteral, updateSql } from './changeSql';
// 覆盖展示用 SQL 的字面量、标识符与结构语句。
describe('sql literals and identifiers', () => {
it('doubles single quotes and keeps null, numbers and booleans bare', () => {
expect(sqlLiteral("O'Brien")).toBe("'O''Brien'");
expect(sqlLiteral(7)).toBe('7');
expect(sqlLiteral(false)).toBe('false');
expect(sqlLiteral(null)).toBe('NULL');
expect(sqlLiteral(undefined)).toBe('NULL');
});
it('quotes identifiers only when they leave the whitelist', () => {
expect(sqlIdentifier('users')).toBe('users');
expect(sqlIdentifier('we"ird')).toBe('"we""ird"');
});
});
describe('row statements', () => {
it('renders update, insert and delete', () => {
expect(updateSql('users', 1, 'name', "A'B")).toBe("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(insertSql('users', { id: 9, name: 'Ada' })).toBe("INSERT INTO users (id, name) VALUES (9, 'Ada');");
expect(deleteSql('users', 2)).toBe('DELETE FROM users WHERE id = 2;');
});
});
describe('schema statements', () => {
it('matches the statements the server Actions build', () => {
expect(schemaSql({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] })).toBe('CREATE TABLE items (id int, title str);');
expect(schemaSql({ op: 'dropTable', table: 'items' })).toBe('DROP TABLE items;');
expect(schemaSql({ op: 'createIndex', table: 'items', column: 'sku' })).toBe('CREATE INDEX ON items (sku);');
expect(schemaSql({ op: 'dropIndex', table: 'items', column: 'sku' })).toBe('DROP INDEX ON items (sku);');
});
});
describe('changesSql', () => {
it('lists creates first, then row changes, then drops', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(changesSql(changes)).toEqual([
'CREATE TABLE items (id int);',
"UPDATE items SET name = 'B' WHERE id = 1;",
'DROP TABLE items;',
]);
});
it('renders a single pending change through changeSql', () => {
expect(changeSql({ op: 'insert', table: 'users', values: { id: 3 } })).toBe('INSERT INTO users (id) VALUES (3);');
});
});
-61
View File
@@ -1,61 +0,0 @@
import type { ChangeSet, PendingChange, SchemaChange } from '../types';
import { pendingChanges } from './changes';
// 展示用 SQL 生成:标识符走白名单,字符串单引号翻倍。
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 字面量:字符串加引号并翻倍单引号。
export function sqlLiteral(value: unknown): string {
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: string): string {
return identifier.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
}
// 单元格修改语句。
export function updateSql(table: string, pk: unknown, column: string, value: unknown): string {
return `UPDATE ${sqlIdentifier(table)} SET ${sqlIdentifier(column)} = ${sqlLiteral(value)} WHERE id = ${sqlLiteral(pk)};`;
}
// 插入语句。
export function insertSql(table: string, values: Record<string, unknown>): string {
const columns = Object.keys(values);
return `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(', ')}) VALUES (${columns.map((column) => sqlLiteral(values[column])).join(', ')});`;
}
// 删除语句。
export function deleteSql(table: string, pk: unknown): string {
return `DELETE FROM ${sqlIdentifier(table)} WHERE id = ${sqlLiteral(pk)};`;
}
// 结构变更语句,与 Actions 拼法一致。
export function schemaSql(change: SchemaChange): string {
if (change.op === 'createTable') {
const columns = change.columns.map((column) => `${sqlIdentifier(column.name)} ${column.type}`).join(', ');
return `CREATE TABLE ${sqlIdentifier(change.table)} (${columns});`;
}
if (change.op === 'dropTable') return `DROP TABLE ${sqlIdentifier(change.table)};`;
if (change.op === 'createIndex') return `CREATE INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`;
return `DROP INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`;
}
// 渲染一条提交项为展示用 SQL。
export function changeSql(change: PendingChange): string {
if (change.op === 'insert') return insertSql(change.table, change.values);
if (change.op === 'delete') return deleteSql(change.table, change.pk);
if (change.op === 'update') {
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)};`;
}
return schemaSql(change);
}
// 按提交顺序渲染整套变更。
export function changesSql(changes: ChangeSet): string[] {
return pendingChanges(changes).map(changeSql);
}
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest';
import { changeCount, createEmptyChangeSet, pendingChanges, toCommitPayload } from './changes';
// 覆盖提交载荷的合并、顺序与结构变更。
describe('toCommitPayload', () => {
it('coalesces cell updates into one row update', () => {
const changes = createEmptyChangeSet();
changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
changes.updates.set('users:1:age', { table: 'users', pk: 1, column: 'age', oldValue: 20, newValue: 21 });
expect(toCommitPayload(changes)).toEqual([
{ op: 'update', table: 'users', pk: 1, set: { name: 'B', age: 21 } },
]);
});
it('keeps inserts and deletes in a stable order', () => {
const changes = createEmptyChangeSet();
changes.inserts.set('tmp-1', { table: 'users', tempId: 'tmp-1', values: { id: 9, name: 'N' } });
changes.deletes.set('users:2', { table: 'users', pk: 2, row: { id: 2, name: 'D' } });
expect(toCommitPayload(changes)).toEqual([
{ op: 'insert', table: 'users', values: { id: 9, name: 'N' } },
{ op: 'delete', table: 'users', pk: 2 },
]);
});
it('leaves schema changes out of the row payload', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' });
expect(toCommitPayload(changes)).toEqual([]);
});
});
describe('pendingChanges', () => {
it('puts creates first, then row changes, then drops', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(pendingChanges(changes)).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] },
{ op: 'update', table: 'items', pk: 1, set: { name: 'B' } },
{ op: 'dropTable', table: 'items' },
]);
});
it('counts structural changes together with row changes', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' });
changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(changeCount(changes)).toBe(3);
});
});
-72
View File
@@ -1,72 +0,0 @@
import type { ChangeSet, CommitChange, PendingChange, SchemaChange } from '../types';
// 变更集工具:创建、克隆、计数与提交载荷组装。
// 建一个空的变更集。
export function createEmptyChangeSet(): ChangeSet {
return { updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() };
}
// 深拷贝变更集,供撤销栈留底。
export function cloneChangeSet(changes: ChangeSet): ChangeSet {
return {
updates: new Map(changes.updates),
inserts: new Map(changes.inserts),
deletes: new Map(changes.deletes),
schemas: new Map(changes.schemas),
};
}
// 统计待提交项,含结构变更。
export function changeCount(changes: ChangeSet): number {
return changes.updates.size + changes.inserts.size + changes.deletes.size + changes.schemas.size;
}
// 结构变更的稳定标识,撤销与确认共用。
export function schemaKey(change: SchemaChange): string {
return change.op === 'createIndex' || change.op === 'dropIndex'
? `${change.op}:${change.table}:${change.column}`
: `${change.op}:${change.table}`;
}
// 组装 DML 提交载荷:同行的多列修改并成一条。
export function toCommitPayload(changes: ChangeSet): CommitChange[] {
const updates = new Map<string, Extract<CommitChange, { op: 'update' }>>();
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(),
...Array.from(changes.inserts.values(), (insert): CommitChange => ({
op: 'insert', table: insert.table, values: { ...insert.values },
})),
...Array.from(changes.deletes.values(), (deleted): CommitChange => ({
op: 'delete', table: deleted.table, pk: deleted.pk,
})),
];
}
// 按执行顺序合并结构变更与 DML 变更。
export function pendingChanges(changes: ChangeSet): PendingChange[] {
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
return [...creates, ...toCommitPayload(changes), ...drops];
}
// 单元格变更的键,表 + 主键 + 列。
export function changeKey(table: string, pk: unknown, column: string): string {
return `${table}:${JSON.stringify(pk)}:${column}`;
}
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { matchesFilter } from './filters';
// 覆盖筛选表达式的数值、通配与布尔匹配。
describe('matchesFilter', () => {
it('supports numeric comparisons', () => {
expect(matchesFilter(101, '>100')).toBe(true);
expect(matchesFilter(100, '>100')).toBe(false);
});
it('supports SQL-like wildcard matching without regular-expression injection', () => {
expect(matchesFilter('Alphabet', 'like %pha%')).toBe(true);
expect(matchesFilter('[abc]', 'like %[abc]%')).toBe(true);
expect(matchesFilter('aXXb', 'like a.b')).toBe(false);
});
it('matches booleans, null and plain text', () => {
expect(matchesFilter(true, 'true')).toBe(true);
expect(matchesFilter(null, 'null')).toBe(true);
expect(matchesFilter('Alice', 'alice')).toBe(true);
});
});
-39
View File
@@ -1,39 +0,0 @@
// 网格筛选表达式解析:比较、like 通配与纯文本匹配。
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function likePattern(raw: string): RegExp {
const escaped = escapeRegExp(raw).replaceAll('%', '.*').replaceAll('_', '.');
return new RegExp(`^${escaped}$`, 'i');
}
export function matchesFilter(value: unknown, rawExpression: string): boolean {
const expression = 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);
}
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { splitSqlStatements } from './sql';
// 覆盖语句切分的分号、注释与行号计算。
describe('splitSqlStatements', () => {
it('splits a script into executable statements', () => {
expect(splitSqlStatements('SELECT 1; SELECT 2;').map((item) => item.sql)).toEqual([
'SELECT 1',
'SELECT 2',
]);
});
it('keeps semicolons inside strings and comments', () => {
const sql = "SELECT 'a;b'; -- keep ; here\nSELECT 2 /* and ; here */;";
expect(splitSqlStatements(sql).map((item) => item.sql)).toEqual([
"SELECT 'a;b'",
'-- keep ; here\nSELECT 2 /* and ; here */',
]);
});
it('reports one-based source lines for gutter decorations', () => {
const statements = splitSqlStatements('\nSELECT 1;\n\nSELECT 2');
expect(statements.map(({ startLine, endLine }) => [startLine, endLine])).toEqual([
[2, 2],
[4, 4],
]);
});
});
-76
View File
@@ -1,76 +0,0 @@
// SQL 脚本切分:识别字符串、注释并按分号拆分语句。
export interface SqlStatement {
id: string;
sql: string;
startLine: number;
endLine: number;
}
function lineAt(source: string, index: number): number {
let line = 1;
for (let cursor = 0; cursor < index; cursor += 1) {
if (source[cursor] === '\n') line += 1;
}
return line;
}
export function splitSqlStatements(source: string): SqlStatement[] {
const spans: Array<[number, number]> = [];
let start = 0;
let mode: 'normal' | 'single' | 'double' | 'line-comment' | 'block-comment' = 'normal';
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (mode === 'single') {
if (char === "'" && next === "'") index += 1;
else if (char === "'") mode = 'normal';
continue;
}
if (mode === 'double') {
if (char === '"' && next === '"') index += 1;
else if (char === '"') mode = 'normal';
continue;
}
if (mode === 'line-comment') {
if (char === '\n') mode = 'normal';
continue;
}
if (mode === 'block-comment') {
if (char === '*' && next === '/') {
mode = 'normal';
index += 1;
}
continue;
}
if (char === "'") mode = 'single';
else if (char === '"') mode = 'double';
else if (char === '-' && next === '-') {
mode = 'line-comment';
index += 1;
} else if (char === '/' && next === '*') {
mode = 'block-comment';
index += 1;
} else if (char === ';') {
spans.push([start, index]);
start = index + 1;
}
}
spans.push([start, source.length]);
return spans.flatMap(([from, to], ordinal) => {
const raw = source.slice(from, to);
const leading = raw.search(/\S/);
if (leading < 0) return [];
const sql = raw.trim();
const absoluteStart = from + leading;
const startLine = lineAt(source, absoluteStart);
return [{
id: `statement-${ordinal + 1}`,
sql,
startLine,
endLine: startLine + (sql.match(/\n/g)?.length ?? 0),
}];
});
}
@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from './sqlHighlight';
// 覆盖 SQL 高亮切分与执行器配色常量。
describe('tokenizeSql', () => {
it('marks keywords, strings and numbers while keeping the text intact', () => {
const sql = "UPDATE users SET name = 'A''B' WHERE id = 12;";
const tokens = tokenizeSql(sql);
expect(tokens.map((token) => token.text).join('')).toBe(sql);
expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['UPDATE', 'SET', 'WHERE']);
expect(tokens.filter((token) => token.kind === 'string').map((token) => token.text)).toEqual(["'A''B'"]);
expect(tokens.filter((token) => token.kind === 'number').map((token) => token.text)).toEqual(['12']);
});
it('recognises line comments and leaves unknown words plain', () => {
expect(tokenizeSql('SELECT * FROM users -- note').at(-1)).toEqual({ text: '-- note', kind: 'comment' });
expect(tokenizeSql('select users').map((token) => token.kind)).toEqual(['keyword', 'plain']);
});
it('splits schema statements used by the pending list', () => {
const tokens = tokenizeSql('CREATE TABLE items (id int, title str);');
expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['CREATE', 'TABLE', 'int', 'str']);
expect(tokens.map((token) => token.text).join('')).toBe('CREATE TABLE items (id int, title str);');
});
});
describe('SQL_TOKEN_COLORS', () => {
it('matches the colours the SQL console theme uses', () => {
expect(SQL_TOKEN_COLORS).toEqual({ keyword: '#569CD6', string: '#CE9178', number: '#B5CEA8', comment: '#6A9955' });
});
it('paints everything that is not a keyword in the plain grey', () => {
expect(SQL_PLAIN_COLOR).toBe('rgb(200, 200, 200)');
});
});
@@ -1,76 +0,0 @@
export const SQL_TOKEN_COLORS = {
keyword: '#569CD6',
string: '#CE9178',
number: '#B5CEA8',
comment: '#6A9955',
} as const;
export const SQL_PLAIN_COLOR = 'rgb(200, 200, 200)';
export type SqlTokenKind = 'plain' | keyof typeof SQL_TOKEN_COLORS;
export interface SqlToken { text: string; kind: SqlTokenKind }
// SQL 词法高亮:与执行器共用同一套 token 配色。
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',
]);
const wordChar = /[A-Za-z0-9_]/;
// 切分 SQL 为关键字、字符串、数字、注释与普通片段。
export function tokenizeSql(sql: string): SqlToken[] {
const tokens: SqlToken[] = [];
let plain = '';
let index = 0;
const flush = () => { if (plain) { tokens.push({ text: plain, kind: 'plain' }); plain = ''; } };
while (index < sql.length) {
const char = sql[index];
if (char === "'") {
flush();
let end = index + 1;
while (end < sql.length) {
if (sql[end] !== "'") { end += 1; continue; }
if (sql[end + 1] === "'") { end += 2; continue; }
end += 1;
break;
}
tokens.push({ text: sql.slice(index, end), kind: 'string' });
index = end;
continue;
}
if (char === '-' && sql[index + 1] === '-') {
flush();
const end = sql.indexOf('\n', index);
tokens.push({ text: end < 0 ? sql.slice(index) : sql.slice(index, end), kind: 'comment' });
index = end < 0 ? sql.length : end;
continue;
}
if (char >= '0' && char <= '9') {
flush();
let end = index;
while (end < sql.length && (wordChar.test(sql[end]) || sql[end] === '.')) end += 1;
tokens.push({ text: sql.slice(index, end), kind: 'number' });
index = end;
continue;
}
if (wordChar.test(char)) {
let end = index;
while (end < sql.length && wordChar.test(sql[end])) end += 1;
const word = sql.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;
}
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, it } from 'vitest';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab } from './tabs';
// 覆盖标签页打开与关闭的状态迁移。
const base: Tab[] = [
{ id: 'tab-1', kind: 'table', title: 'users', table: 'users' },
{ id: 'tab-2', kind: 'table', title: 'orders', table: 'orders' },
{ id: 'tab-3', kind: 'sql', title: '查询 1' },
];
describe('openTableTab', () => {
it('activates an existing tab instead of appending a duplicate', () => {
const state = openTableTab(base, 'users', 'tab-9');
expect(state.tabs).toHaveLength(3);
expect(state.activeId).toBe('tab-1');
expect(state.tabs).toBe(base);
});
it('appends a new tab for a table that is not open yet', () => {
const state = openTableTab(base, 'products', 'tab-9');
expect(state.tabs).toHaveLength(4);
expect(state.tabs[3]).toEqual({ id: 'tab-9', kind: 'table', title: 'products', table: 'products' });
expect(state.activeId).toBe('tab-9');
});
});
describe('openQueryTab', () => {
it('always appends so queries can be opened multiple times', () => {
const first = openQueryTab(base, '查询 1', 'tab-9');
const second = openQueryTab(first.tabs, '查询 2', 'tab-10', 'SELECT 1');
expect(second.tabs).toHaveLength(5);
expect(second.activeId).toBe('tab-10');
expect(second.tabs[4]).toEqual({ id: 'tab-10', kind: 'sql', title: '查询 2', sql: 'SELECT 1' });
});
});
describe('openSettingsTab', () => {
it('keeps a single settings tab and activates it', () => {
const first = openSettingsTab(base, '设置', 'tab-9');
const second = openSettingsTab(first.tabs, '设置', 'tab-10');
expect(second.tabs.filter((tab) => tab.kind === 'settings')).toHaveLength(1);
expect(second.tabs).toHaveLength(4);
expect(second.activeId).toBe('tab-9');
});
});
describe('closeTab', () => {
it('activates the tab that shifts in from the right', () => {
const state = closeTab(base, 'tab-1', 'tab-1');
expect(state.tabs.map((tab) => tab.id)).toEqual(['tab-2', 'tab-3']);
expect(state.activeId).toBe('tab-2');
});
it('falls back to the left neighbour when the last tab closes', () => {
const state = closeTab(base, 'tab-3', 'tab-3');
expect(state.activeId).toBe('tab-2');
});
it('keeps the active tab when a different one closes', () => {
const state = closeTab(base, 'tab-2', 'tab-3');
expect(state.tabs).toHaveLength(2);
expect(state.activeId).toBe('tab-3');
});
it('clears the active id when the only tab closes', () => {
const state = closeTab([base[0]], 'tab-1', 'tab-1');
expect(state.tabs).toHaveLength(0);
expect(state.activeId).toBeUndefined();
});
it('does not mutate the input array', () => {
const snapshot = JSON.stringify(base);
closeTab(base, 'tab-2', 'tab-2');
openTableTab(base, 'products', 'tab-9');
openQueryTab(base, '查询 2', 'tab-10');
openSettingsTab(base, '设置', 'tab-11');
expect(JSON.stringify(base)).toBe(snapshot);
});
});
-35
View File
@@ -1,35 +0,0 @@
// 标签页状态迁移:打开与关闭均返回新状态,不改入参数组。
export type TabKind = 'table' | 'sql' | 'settings';
export interface Tab { id: string; kind: TabKind; title: string; table?: string; sql?: string }
export interface TabState { tabs: Tab[]; activeId: string | undefined }
// 打开表标签:同表已开则激活,否则追加。
export function openTableTab(tabs: Tab[], name: string, id: string): TabState {
const existing = tabs.find((tab) => tab.kind === 'table' && tab.table === name);
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'table', title: name, table: name }], activeId: id };
}
// 打开查询标签:每次追加,可选初始 SQL。
export function openQueryTab(tabs: Tab[], title: string, id: string, sql?: string): TabState {
return { tabs: [...tabs, { id, kind: 'sql', title, sql }], activeId: id };
}
// 打开设置标签:只保留一个,已存在则激活。
export function openSettingsTab(tabs: Tab[], title: string, id: string): TabState {
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: Tab[], id: string, activeId: string | undefined): TabState {
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 };
return { tabs: remaining, activeId: (remaining[index] ?? remaining[index - 1])?.id };
}
@@ -1,115 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS, useSettingsStore } from '../store/settingsStore';
import { loadUiSettings, saveUiSettings, syncUiSettings } from './uiSettings';
// 覆盖远端设置的校验读取、写回与防抖同步。
describe('uiSettings', () => {
let stop: (() => void) | undefined;
beforeEach(() => {
localStorage.clear();
useSettingsStore.getState().reset();
});
afterEach(() => {
stop?.();
stop = undefined;
vi.unstubAllGlobals();
localStorage.clear();
});
it('keeps known keys only and clamps the remote payload', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
uiFonts: ['', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', ' '],
gridFonts: 'Consolas',
sqlFonts: ['Consolas'],
uiFontSize: 99,
gridFontSize: 9,
gridRowHeight: 17.4,
sqlFontSize: 13.5,
sqlLineHeight: 60,
sqlTabSize: 0,
pageSize: 2,
nullText: 'NULL',
sqlLineNumbers: false,
autocomplete: 'yes',
minimap: true,
unknownKey: 5,
}), { status: 200 }));
vi.stubGlobal('fetch', request);
await expect(loadUiSettings()).resolves.toEqual({
uiFonts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10'],
sqlFonts: ['Consolas'],
uiFontSize: 16,
gridFontSize: 10,
gridRowHeight: 18,
sqlFontSize: 14,
sqlLineHeight: 40,
sqlTabSize: 1,
pageSize: 10,
nullText: 'NULL',
sqlLineNumbers: false,
minimap: true,
});
expect(request).toHaveBeenCalledWith('/api/ui-settings', { credentials: 'same-origin' });
});
it('returns undefined instead of throwing when the read fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('[]', { status: 200 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
});
it('puts the whole settings object back to the endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(true);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith('/api/ui-settings', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(DEFAULT_SETTINGS),
});
});
it('reports a failed write as false', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })));
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false);
});
it('merges remote settings first and then debounces one write', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ uiFonts: ['Consolas'], uiFontSize: 15, autocomplete: false }), { status: 200 }))
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
stop = syncUiSettings();
await vi.waitFor(() => expect(useSettingsStore.getState().settings.autocomplete).toBe(false));
expect(useSettingsStore.getState().settings.uiFonts).toEqual(['Consolas']);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(15);
expect(request).toHaveBeenCalledTimes(1);
useSettingsStore.getState().setNumber('pageSize', 300);
useSettingsStore.getState().setNumber('pageSize', 320);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2), { timeout: 2000 });
const [url, init] = request.mock.calls[1] as [string, RequestInit];
expect(url).toBe('/api/ui-settings');
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body)).pageSize).toBe(320);
});
});
-104
View File
@@ -1,104 +0,0 @@
import { useSettingsStore, type IdeSettings } from '../store/settingsStore';
// 远端 IDE 设置的读取、校验与防抖回写。
const endpoint = '/api/ui-settings';
const debounceMs = 400;
const fontKeys = ['uiFonts', 'gridFonts', 'sqlFonts'] as const;
const booleanKeys = ['sqlLineNumbers', 'autocomplete', 'minimap'] as const;
const numberKeys = ['uiFontSize', 'gridFontSize', 'gridRowHeight', 'sqlFontSize', 'sqlLineHeight', 'sqlTabSize', 'pageSize'] as const;
type NumberKey = (typeof numberKeys)[number];
const numberRanges: Record<NumberKey, [number, number]> = {
uiFontSize: [11, 16],
gridFontSize: [10, 18],
gridRowHeight: [18, 40],
sqlFontSize: [10, 20],
sqlLineHeight: [16, 40],
sqlTabSize: [1, 8],
pageSize: [10, 500],
};
// 收敛字体链:去空串并截断到 10 项。
function readFonts(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.filter((font): font is string => typeof font === 'string' && font.trim().length > 0).slice(0, 10);
}
// 只取已知键,数值夹紧,类型不符即丢弃。
function parseUiSettings(body: unknown): Partial<IdeSettings> {
const parsed: Partial<IdeSettings> = {};
if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed;
const source = body as Record<string, unknown>;
for (const key of fontKeys) {
const fonts = readFonts(source[key]);
if (fonts) parsed[key] = fonts;
}
for (const key of numberKeys) {
const value = source[key];
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const [min, max] = numberRanges[key];
parsed[key] = Math.min(max, Math.max(min, Math.round(value)));
}
for (const key of booleanKeys) {
const value = source[key];
if (typeof value === 'boolean') parsed[key] = value;
}
if (typeof source.nullText === 'string') parsed.nullText = source.nullText;
return parsed;
}
// 读取远端设置,失败时返回 undefined。
export async function loadUiSettings(): Promise<Partial<IdeSettings> | undefined> {
try {
const response = await fetch(endpoint, { credentials: 'same-origin' });
if (!response.ok) return undefined;
const body: unknown = await response.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
return parseUiSettings(body);
} catch {
return undefined;
}
}
// 全量写回远端设置,失败时返回 false。
export async function saveUiSettings(settings: IdeSettings): Promise<boolean> {
try {
const response = await fetch(endpoint, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return response.ok;
} catch {
return false;
}
}
// 先并入远端设置,再防抖回写后续变更。
export function syncUiSettings(): () => void {
let stopped = false;
let unsubscribe: (() => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const cancel = () => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } };
void (async () => {
const remote = await loadUiSettings();
if (stopped) return;
if (remote) useSettingsStore.setState((state) => ({ settings: { ...state.settings, ...remote } }));
unsubscribe = useSettingsStore.subscribe((state, previous) => {
if (state.settings === previous.settings) return;
cancel();
timer = setTimeout(() => {
timer = undefined;
void saveUiSettings(useSettingsStore.getState().settings);
}, debounceMs);
});
})();
return () => {
stopped = true;
unsubscribe?.();
cancel();
};
}
-161
View File
@@ -1,161 +0,0 @@
// 中文界面文案表:全站 UI 文本的唯一来源。
export const zhCN = {
appName: 'ChuSQL 数据库终端',
signingIn: '正在检查登录状态…',
signInTitle: '登录 ChuSQL',
userName: '用户名',
password: '密码',
signIn: '登录',
signInFailed: '登录失败,请检查用户名和密码',
connection: '本地连接',
database: 'chusql',
explorer: '资源管理器',
databaseSection: '数据库',
activityBar: '活动栏',
selectionColumn: '选择',
changesSection: '变更集',
historySection: '历史',
sqlConsole: 'SQL 控制台',
newQuery: '新建查询',
runNext: '单步执行',
runAll: '全部执行',
runSelection: '执行选中',
stop: '停止',
statementPending: '待执行',
statementRunning: '执行中',
statementSuccess: '成功',
statementFailed: '失败',
executionStopped: '已停止;已发出的语句可能仍在服务端运行',
executionSuccess: (rows: number, duration: number) => `执行成功,返回 ${rows} 行 (${duration.toFixed(1)} ms)`,
executionAffected: (rows: number, duration: number) => `执行成功,影响 ${rows} 行 (${duration.toFixed(1)} ms)`,
resultLabel: (index: number, rows: number, duration: number) => `结果 ${index} (${rows} 行, ${duration.toFixed(1)} ms)`,
noStatement: '没有可执行的 SQL 语句',
queryHistory: '查询历史',
settings: '设置',
settingsAppearance: '外观与字体',
settingsBehavior: '数据与编辑器',
interfaceFont: '界面字体',
gridFont: '表格字体',
sqlFont: 'SQL 编辑器字体',
fontName: '字体名称',
addFallback: '添加备用字体',
removeFont: (font: string) => `删除字体 ${font}`,
moveFont: '拖动调整字体顺序',
fontSize: '字号',
rowHeight: '行高',
tabSize: 'Tab 宽度',
showLineNumbers: '显示行号',
pageSize: '分页大小',
nullDisplay: 'NULL 显示文本',
autocomplete: '自动补全',
minimap: 'Minimap',
restoreDefaults: '恢复默认',
preview: '实时预览',
previewText: 'ChuSQL 数据库终端 SELECT * FROM users;',
commit: (count: number) => `提交 (${count})`,
rollback: '回滚',
undo: '撤销',
redo: '重做',
insertRow: '插入行',
deleteRows: '删除选中行',
batchReplace: '批量替换',
findText: '查找内容',
replaceText: '替换为',
replaceSelected: '替换选中区域',
selectedRowsRequired: '请先选择要操作的行',
refresh: '刷新',
filter: (column: string) => `筛选 ${column}`,
openTable: (table: string) => `打开表 ${table}`,
closeTab: (table: string) => `关闭 ${table}`,
editCell: (column: string) => `编辑 ${column}`,
rowSelect: (row: number) => `选择第 ${row} 行`,
noRows: '没有符合条件的数据',
loading: '正在加载…',
loadError: '加载失败',
retry: '重试',
previousPage: '上一页',
nextPage: '下一页',
loadMore: '加载更多',
pendingChanges: '待提交变更',
updateChange: (table: string, column: string) => `修改 ${table}.${column}`,
insertChange: (table: string) => `向 ${table} 插入一行`,
deleteChange: (table: string) => `从 ${table} 删除一行`,
createTableChange: (table: string) => `新建表 ${table}`,
dropTableChange: (table: string) => `删除表 ${table}`,
createIndexChange: (table: string, column: string) => `为 ${table}.${column} 建索引`,
dropIndexChange: (table: string, column: string) => `删除 ${table}.${column} 的索引`,
undoChange: '撤销此变更',
noPendingChanges: '没有待提交变更',
noResults: '尚无查询结果',
results: '结果',
messages: '消息',
cancel: '取消',
committed: (count: number) => `已提交 ${count} 项变更`,
commitFailed: '提交失败,变更已保留',
rowCount: (count: number) => `${count} 行`,
pendingCount: (count: number) => `待提交 ${count} 项`,
nullValue: 'NULL',
emptyWorkspace: '从左侧资源管理器打开一张表,或打开 SQL 控制台。',
queryTab: (index: number) => `查询 ${index}`,
resizeSidebar: '拖动调整侧边栏宽度',
resizeDock: '拖动调整底部面板高度',
close: '关闭',
booleanTrue: '是',
booleanFalse: '否',
done: '完成',
clear: '清空',
remove: '移除',
newTable: '新建表',
expandColumns: (table: string) => `展开 ${table} 的列`,
manageIndexes: (table: string) => `管理 ${table} 的索引`,
dropTableAction: (table: string) => `删除表 ${table}`,
columnPrimaryKey: '主键列',
columnIndexed: '索引列',
columnOrdinary: '普通列',
newTableTitle: '新建表',
dropTableTitle: '删除表',
indexManagerTitle: '索引管理',
tableName: '表名',
columnName: (index: number) => `列名 ${index}`,
columnType: (index: number) => `列类型 ${index}`,
removeColumn: (index: number) => `移除第 ${index} 列`,
addColumn: '添加列',
createTable: '建表',
dropTablePermanently: '永久删除表',
typeInt: '整数',
typeStr: '文本',
typeBool: '布尔',
typeDate: '日期',
stagedMessage: (sql: string) => `已暂存:${sql}`,
builtinIndex: '内建主键索引',
uniqueIndex: '唯一索引',
noIndex: '无索引',
createIndex: '建索引',
deleteIndex: '删索引',
createTableHint: '作为待提交变更暂存,提交时建表。整数 id 固定为内建主键;至少填一列,并删掉多余的空列。',
dropTableHint: (table: string, rows: number) => `作为待提交变更暂存:提交后会删掉 ${table} 的全部 ${rows} 行、列定义与索引,且不可撤销。`,
indexHint: '索引变更先暂存,提交时才下发。目前只支持唯一整数索引,列里有重复值时提交会失败;内建 id 索引不能删除。',
invalidIdentifier: '表名与列名必须以字母或下划线开头,且只能包含字母、数字与下划线。',
duplicateColumns: '列名重复',
insertRowTitle: (table: string) => `插入行 · ${table}`,
addToPending: '加入待提交',
insertRowHint: '每个字段都要填。加入后仍可修改或撤销,点顶部的「提交」才会写入数据库。',
primaryKeyBadge: ' · 主键(必填且唯一)',
invalidInteger: (column: string) => `${column}:请输入合法整数`,
chooseDate: (column: string) => `${column}:请选择日期`,
selectRowsHint: '单击选中一行;Ctrl+单击多选,Shift+单击连选,空格键也可选中',
selectedRows: (count: number) => `已选 ${count} 行`,
rowStaged: '新行已加入待提交',
filterPlaceholder: '筛选表达式',
resizeColumn: (column: string) => `调整 ${column} 列宽`,
} as const;
export const columnTypeLabels: Record<string, string> = {
int: zhCN.typeInt,
str: zhCN.typeStr,
bool: zhCN.typeBool,
date: zhCN.typeDate,
};
export type Messages = typeof zhCN;
-16
View File
@@ -1,16 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { MockAdapter } from './adapters/MockAdapter';
import { RestAdapter } from './adapters/RestAdapter';
import { AuthGate } from './components/common/AuthGate';
import './styles/global.css';
// 前端入口:按环境选择 mock/rest 数据源并挂载根组件。
const adapterName = import.meta.env.VITE_CHUSQL_ADAPTER ?? (import.meta.env.DEV ? 'mock' : 'rest');
const adapter = adapterName === 'rest' ? new RestAdapter() : new MockAdapter();
const root = document.getElementById('root');
if (!root) throw new Error('Root element is missing.');
const application = <App adapter={adapter} />;
createRoot(root).render(<StrictMode>{adapterName === 'rest' ? <AuthGate>{application}</AuthGate> : application}</StrictMode>);
@@ -1,65 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useChangeStore } from './changeStore';
// 覆盖变更记录、结构变更暂存与部分提交确认。
describe('changeStore', () => {
beforeEach(() => useChangeStore.getState().clear());
it('removes successful inserts after a partial commit so retries cannot duplicate them', () => {
const store = useChangeStore.getState();
store.insertRow('users', 'temp-a', { id: 901 });
store.insertRow('users', 'temp-b', { id: 902 });
const submitted = useChangeStore.getState().changes;
store.acknowledge(submitted, 1);
expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']);
store.undo();
expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']);
});
it('records edits without writing through and supports undo/redo', () => {
const store = useChangeStore.getState();
store.updateCell('users', 1, 'name', 'A', 'B');
expect(useChangeStore.getState().changes.updates.size).toBe(1);
useChangeStore.getState().undo();
expect(useChangeStore.getState().changes.updates.size).toBe(0);
useChangeStore.getState().redo();
expect(useChangeStore.getState().changes.updates.get('users:1:name')?.newValue).toBe('B');
});
it('removes a new row instead of staging a delete for it', () => {
const store = useChangeStore.getState();
store.insertRow('users', 'temp-1', { name: 'N' });
useChangeStore.getState().deleteRow('users', 'temp-1', { name: 'N' }, true);
expect(useChangeStore.getState().changes.inserts.size).toBe(0);
expect(useChangeStore.getState().changes.deletes.size).toBe(0);
});
it('stages structural changes and can undo a single one', () => {
const store = useChangeStore.getState();
store.stageSchema({ op: 'createIndex', table: 'users', column: 'age' });
store.stageSchema({ op: 'dropTable', table: 'items' });
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']);
useChangeStore.getState().undoOne('schemas', 'createIndex:users:age');
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['dropTable:items']);
useChangeStore.getState().undo();
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']);
});
it('keeps unapplied changes when the commit stops after a structural change', () => {
const store = useChangeStore.getState();
store.stageSchema({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
store.insertRow('users', 'temp-a', { id: 901 });
store.acknowledge(useChangeStore.getState().changes, 1);
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-a']);
});
it('drops a structural change once the whole queue is acknowledged', () => {
const store = useChangeStore.getState();
store.insertRow('users', 'temp-a', { id: 901 });
store.stageSchema({ op: 'dropTable', table: 'items' });
store.acknowledge(useChangeStore.getState().changes, 2);
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
expect(useChangeStore.getState().changes.inserts.size).toBe(0);
});
});
@@ -1,108 +0,0 @@
import { create } from 'zustand';
import { changeKey, cloneChangeSet, createEmptyChangeSet, pendingChanges, schemaKey } from '../core/changes';
import type { ChangeSet, SchemaChange } from '../types';
// 待提交变更状态:增删改记录、撤销重做与提交确认。
interface ChangeState {
changes: ChangeSet;
undoStack: ChangeSet[];
redoStack: ChangeSet[];
updateCell: (table: string, pk: unknown, column: string, oldValue: unknown, newValue: unknown) => void;
insertRow: (table: string, tempId: string, values: Record<string, unknown>) => void;
updateInsert: (tempId: string, values: Record<string, unknown>) => void;
deleteRow: (table: string, pk: unknown, row: Record<string, unknown>, isInserted?: boolean) => void;
stageSchema: (change: SchemaChange) => void;
undoOne: (kind: keyof ChangeSet, key: string) => void;
undo: () => void;
redo: () => void;
clear: () => void;
acknowledge: (submitted: ChangeSet, applied: number) => void;
}
function withHistory(state: ChangeState, mutate: (next: ChangeSet) => void): Partial<ChangeState> {
const next = cloneChangeSet(state.changes);
mutate(next);
return {
changes: next,
undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100),
redoStack: [],
};
}
export const useChangeStore = create<ChangeState>((set) => ({
changes: createEmptyChangeSet(),
undoStack: [],
redoStack: [],
updateCell: (table, pk, column, oldValue, newValue) => set((state) => withHistory(state, (next) => {
const key = changeKey(table, pk, column);
if (Object.is(oldValue, newValue)) next.updates.delete(key);
else next.updates.set(key, { table, pk, column, oldValue, newValue });
})),
insertRow: (table, tempId, values) => set((state) => withHistory(state, (next) => {
next.inserts.set(tempId, { table, tempId, values: { ...values } });
})),
updateInsert: (tempId, values) => set((state) => withHistory(state, (next) => {
const insert = next.inserts.get(tempId);
if (insert) next.inserts.set(tempId, { ...insert, values: { ...values } });
})),
deleteRow: (table, pk, row, isInserted = false) => set((state) => withHistory(state, (next) => {
if (isInserted) {
next.inserts.delete(String(pk));
return;
}
for (const [key, update] of next.updates) {
if (update.table === table && Object.is(update.pk, pk)) next.updates.delete(key);
}
next.deletes.set(`${table}:${JSON.stringify(pk)}`, { table, pk, row: { ...row } });
})),
stageSchema: (change) => set((state) => withHistory(state, (next) => {
next.schemas.set(schemaKey(change), change);
})),
undoOne: (kind, key) => set((state) => withHistory(state, (next) => {
next[kind].delete(key);
})),
undo: () => set((state) => {
const previous = state.undoStack.at(-1);
if (!previous) return state;
return {
changes: cloneChangeSet(previous),
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, cloneChangeSet(state.changes)].slice(-100),
};
}),
redo: () => set((state) => {
const next = state.redoStack.at(-1);
if (!next) return state;
return {
changes: cloneChangeSet(next),
undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100),
redoStack: state.redoStack.slice(0, -1),
};
}),
clear: () => set({ changes: createEmptyChangeSet(), undoStack: [], redoStack: [] }),
acknowledge: (submitted, applied) => set((state) => {
const next = cloneChangeSet(state.changes);
const completed = pendingChanges(submitted).slice(0, applied);
const inserted = Array.from(submitted.inserts.entries());
let insertIndex = 0;
for (const operation of completed) {
if (operation.op === 'insert') {
const [key, value] = inserted[insertIndex++];
if (next.inserts.get(key) === value) next.inserts.delete(key);
} else if (operation.op === 'delete') {
const key = `${operation.table}:${JSON.stringify(operation.pk)}`;
if (next.deletes.get(key) === submitted.deletes.get(key)) next.deletes.delete(key);
} else if (operation.op === 'update') {
for (const column of Object.keys(operation.set)) {
const key = changeKey(operation.table, operation.pk, column);
if (next.updates.get(key) === submitted.updates.get(key)) next.updates.delete(key);
}
} else {
const key = schemaKey(operation);
if (next.schemas.get(key) === submitted.schemas.get(key)) next.schemas.delete(key);
}
}
return { changes: next, undoStack: [], redoStack: [] };
}),
}));
@@ -1,24 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_SETTINGS, fontFamilyValue, useSettingsStore } from './settingsStore';
// 覆盖字体链转义与数值范围收敛。
describe('settingsStore', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.getState().reset();
});
it('quotes a user-defined fallback chain safely', () => {
expect(fontFamilyValue(['JetBrains Mono', 'Consolas'])).toBe('"JetBrains Mono", "Consolas", monospace');
expect(fontFamilyValue(['A"B', ' '])).toBe('"A\\"B", monospace');
});
it('clamps numeric settings to the supported range', () => {
useSettingsStore.getState().setNumber('gridRowHeight', 200);
expect(useSettingsStore.getState().settings.gridRowHeight).toBe(40);
useSettingsStore.getState().setNumber('pageSize', 1);
expect(useSettingsStore.getState().settings.pageSize).toBe(10);
expect(DEFAULT_SETTINGS.uiFontSize).toBe(13);
});
});
@@ -1,78 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// IDE 设置状态:默认值、范围收敛与本地持久化。
export interface IdeSettings {
uiFonts: string[];
uiFontSize: number;
gridFonts: string[];
gridFontSize: number;
gridRowHeight: number;
sqlFonts: string[];
sqlFontSize: number;
sqlLineHeight: number;
sqlTabSize: number;
sqlLineNumbers: boolean;
pageSize: number;
nullText: string;
autocomplete: boolean;
minimap: boolean;
}
export const DEFAULT_SETTINGS: IdeSettings = 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,
});
const ranges: Record<string, [number, number]> = {
uiFontSize: [11, 16],
gridFontSize: [10, 18],
gridRowHeight: [18, 40],
sqlFontSize: [10, 20],
sqlLineHeight: [16, 40],
sqlTabSize: [1, 8],
pageSize: [10, 500],
};
export function fontFamilyValue(chain: string[]): string {
const fonts = chain.map((font) => font.trim()).filter(Boolean).map((font) => `"${font.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`);
return [...fonts, 'monospace'].join(', ');
}
interface SettingsState {
settings: IdeSettings;
setNumber: (key: keyof IdeSettings, value: number) => void;
setBoolean: (key: keyof IdeSettings, value: boolean) => void;
setText: (key: keyof IdeSettings, value: string) => void;
setFonts: (key: 'uiFonts' | 'gridFonts' | 'sqlFonts', value: string[]) => void;
reset: () => void;
}
export const useSettingsStore = create<SettingsState>()(persist((set) => ({
settings: { ...DEFAULT_SETTINGS },
setNumber: (key, value) => set((state) => {
const range = ranges[key];
const next = range ? Math.min(range[1], Math.max(range[0], Math.round(value))) : value;
return { settings: { ...state.settings, [key]: next } };
}),
setBoolean: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })),
setText: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })),
setFonts: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value.slice(0, 10) } })),
reset: () => set({ settings: { ...DEFAULT_SETTINGS } }),
}), {
name: 'chusql.ide.settings.v1',
partialize: (state) => ({ settings: state.settings }),
}));
-48
View File
@@ -1,48 +0,0 @@
/* 全局样式:主题变量、基础重置与滚动条。 */
: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-control: #c586c0;
--syn-string: #ce9178;
--syn-number: #b5cea8;
--syn-comment: #6a9955;
--syn-function: #dcdcaa;
--syn-type: #4ec9b0;
--syn-variable: #9cdcfe;
--ui-font: "JetBrains Mono", Consolas, "Cascadia Code", monospace;
--grid-font: "JetBrains Mono", Consolas, monospace;
--grid-font-size: 12px;
--grid-row-height: 24px;
color: var(--fg-primary);
font-family: var(--ui-font);
font-size: 13px;
font-synthesis: none;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg-base); }
button, input, select, textarea { font-family: inherit; }
button { border-radius: 2px; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
::-webkit-scrollbar { height: 10px; width: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-active); }
@media (prefers-reduced-motion: no-preference) { button, input, select { transition: background-color 100ms ease-out, border-color 100ms ease-out; } }
-7
View File
@@ -1,7 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// 测试环境初始化:注册 jest-dom 断言与自动清理。
afterEach(cleanup);
-92
View File
@@ -1,92 +0,0 @@
// 共享类型定义:数据源适配器接口、元数据与查询结果结构。
export type DataValue = string | number | boolean | null;
export interface ColumnMeta {
name: string;
type: 'int' | 'str' | 'bool' | 'date';
primaryKey?: boolean;
indexed?: boolean;
prime?: boolean;
distinct?: number;
statsCapped?: boolean;
}
export interface TableMeta {
name: string;
rowCount: number;
columns: ColumnMeta[];
}
export interface FilterExpr {
column: string;
expression: string;
}
export interface FetchRowsParams {
db: string;
table: string;
page: number;
pageSize: number;
sort: Array<{ column: string; dir: 'asc' | 'desc' }>;
filters: FilterExpr[];
search?: string;
}
export type SchemaChange =
| { op: 'createTable'; table: string; columns: Array<Pick<ColumnMeta, 'name' | 'type'>> }
| { op: 'dropTable'; table: string }
| { op: 'createIndex'; table: string; column: string }
| { op: 'dropIndex'; table: string; column: string };
export type CommitChange =
| { op: 'update'; table: string; pk: unknown; set: Record<string, unknown> }
| { op: 'insert'; table: string; values: Record<string, unknown> }
| { op: 'delete'; table: string; pk: unknown };
export type PendingChange = CommitChange | SchemaChange;
export interface StatementResult {
type: 'rows' | 'affected' | 'error';
columns?: string[];
rows?: unknown[][];
affected?: number;
durationMs: number;
message?: string;
}
export interface DataSourceAdapter {
createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void>;
dropTable(table: string): Promise<void>;
createIndex(table: string, column: string): Promise<void>;
dropIndex(table: string, column: string): Promise<void>;
listTables(db: string): Promise<TableMeta[]>;
getColumns(db: string, table: string): Promise<ColumnMeta[]>;
fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }>;
commitChanges(payload: {
db: string;
changes: CommitChange[];
}): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }>;
execStatement(params: { db: string; sql: string }): Promise<StatementResult>;
}
export interface ChangeSet {
updates: Map<string, {
table: string;
pk: unknown;
column: string;
oldValue: unknown;
newValue: unknown;
}>;
inserts: Map<string, {
table: string;
tempId: string;
values: Record<string, unknown>;
}>;
deletes: Map<string, {
table: string;
pk: unknown;
row: Record<string, unknown>;
}>;
schemas: Map<string, SchemaChange>;
}
-8
View File
@@ -1,8 +0,0 @@
/// <reference types="vite/client" />
// Vite 环境类型声明:客户端环境变量与 CSS Modules 模块。
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-13
View File
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
-34
View File
@@ -1,34 +0,0 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// Vite 配置:React 插件、Monaco nonce 注入与构建输出。
export default defineConfig({
plugins: [react(), {
name: 'monaco-style-nonce',
enforce: 'pre',
transform(code, id) {
if (!id.split('\\').join('/').includes('/monaco-editor/esm/')) return;
const source = "document.createElement('style')";
if (!code.includes(source)) return;
return code.split(source).join(`Object.assign(document.createElement('style'), { nonce: document.querySelector('meta[name="chusql-style-nonce"]')?.content || '' })`);
},
}],
base: '/static/',
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
outDir: '../static',
emptyOutDir: true,
assetsDir: '',
rollupOptions: {
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: (asset) => asset.names?.some((name) => name.endsWith('.css')) ? 'style.css' : '[name][extname]',
},
},
},
});
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from 'vitest/config';
// Vitest 配置:jsdom 环境与测试初始化脚本。
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
restoreMocks: true,
},
});
+257
View File
@@ -0,0 +1,257 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Accounts (
Accounts, AccountError (..), Principal (..), PasswordPolicy (..), defaultPasswordPolicy,
newAccounts, accountsPolicy, setAccountsPolicy, passwordAllowed,
rootUserName, principalName, principalIsRoot,
authenticate, currentPrincipal,
listAccounts, findAccount, AccountCommand (..), accountCommand, runAccountCommand,
) where
import ChuSQL.Storage.IPC (Account (..), Request (..))
import ChuSQL.Syntax.AST (Statement (..))
import ChuSQL.Web.Auth
import ChuSQL.Web.Backend (Backend (..), currentStamp)
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isDigit)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Text (Text)
import qualified Data.Text as T
-- 账号服务:root 来自配置,普通账号由 root 用 CREATE/ALTER/DROP USER 管,口令在这里做策略检查与 PBKDF2 派生。
data PasswordPolicy = PasswordPolicy
{ ppMinLength :: Int
, ppClasses :: Int
}
deriving (Show, Eq)
-- | 默认:至少 12 个字符、至少两类字符
defaultPasswordPolicy :: PasswordPolicy
defaultPasswordPolicy = PasswordPolicy 12 2
data AccountError = AccountError Text Text deriving (Show, Eq)
-- | 当前身份:配置里的管理员,或系统表里的一个普通账号
data Principal
= Root Text
| Ordinary Account
deriving (Eq)
instance Show Principal where
show (Root name) = "root " ++ show name
show (Ordinary a) = "account " ++ show (accountUser a)
-- | 这个身份叫什么
principalName :: Principal -> Text
principalName (Root name) = name
principalName (Ordinary a) = accountUser a
-- | 是不是配置里的管理员
principalIsRoot :: Principal -> Bool
principalIsRoot (Root _) = True
principalIsRoot (Ordinary _) = False
data Accounts = Accounts
{ acBackend :: Backend
, acSessions :: SessionStore
, acLock :: MVar ()
, acPolicy :: IORef PasswordPolicy
, acRoot :: Credential
}
-- | 只记下配置根账号;root 不写系统表,所以启动时不需要任何持久化动作
newAccounts :: Backend -> SessionStore -> Credential -> IO Accounts
newAccounts backend sessions credential = do
lock <- newMVar ()
policy <- newIORef defaultPasswordPolicy
pure (Accounts backend sessions lock policy credential)
-- | 账号名统一小写去空白
normalize :: Text -> Text
normalize = T.toLower . T.strip
-- | 配置里的管理员叫什么
rootName :: Accounts -> Text
rootName = normalize . credUser . acRoot
-- | 管理员名字(界面显示用)
rootUserName :: Accounts -> Text
rootUserName = rootName
accountsPolicy :: Accounts -> IO PasswordPolicy
accountsPolicy = readIORef . acPolicy
setAccountsPolicy :: Accounts -> PasswordPolicy -> IO ()
setAccountsPolicy service = writeIORef (acPolicy service)
-- | 口令策略:长度 + 字符类别(历史与有效期随自助改密一起取消)
passwordAllowed :: PasswordPolicy -> Text -> Either AccountError ()
passwordAllowed policy password
| T.length password < ppMinLength policy || T.length password > 256 = bad "password length does not meet policy"
| classes < ppClasses policy = bad "password needs more character classes"
| otherwise = Right ()
where
bad = Left . AccountError "bad_request"
classes =
length
( filter id
[ T.any isAsciiLower password
, T.any isAsciiUpper password
, T.any isDigit password
, T.any (not . isAlphaNum) password
]
)
-- | 账号名规则:ASCII 字母数字与点、短横线、下划线
validateUserName :: Text -> Either AccountError ()
validateUserName name
| T.null name || T.length name > 64 || not (T.all allowed name) =
Left (AccountError "bad_request" "invalid account name")
| otherwise = Right ()
where
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
-- | 新建/改口令时的额外限制:管理员的名字保留给配置里的 root
validateNewName :: Accounts -> Text -> Either AccountError ()
validateNewName service name = do
validateUserName name
if normalize name == rootName service
then Left (AccountError "bad_request" "the administrator name is reserved")
else Right ()
storageFailure :: AccountError
storageFailure = AccountError "storage_error" "account storage unavailable"
unauthorized :: AccountError
unauthorized = AccountError "unauthorized" "invalid account or session"
-- | 存储侧报错翻成账号错误
storageResult :: Either String [Account] -> Either AccountError ()
storageResult (Right _) = Right ()
storageResult (Left "account already exists") = Left (AccountError "conflict" "account already exists")
storageResult (Left "unknown account") = Left (AccountError "not_found" "unknown account")
storageResult (Left _) = Left storageFailure
-- | 读系统表里的普通账号
readAccounts :: Accounts -> IO (Either AccountError [Account])
readAccounts service = fmap (either (const (Left storageFailure)) Right) (beAccounts (acBackend service) ReqAccountsList)
-- | 当前令牌对应的身份;会话版本与账号 revision 对不上就失效
currentUnlocked :: Accounts -> Text -> IO (Either AccountError Principal)
currentUnlocked service token = do
session <- lookupVersionedSession (acSessions service) token
case session of
Nothing -> pure (Left unauthorized)
Just (user, revision)
| user == rootName service && revision == Nothing -> pure (Right (Root (rootName service)))
| otherwise -> do
stored <- readAccounts service
pure $ do
accounts <- stored
account <- case filter ((== user) . accountUser) accounts of
[a] -> Right a
_ -> Left unauthorized
if revision == Just (accountRevision account)
then Right (Ordinary account)
else Left unauthorized
currentPrincipal :: Accounts -> Text -> IO (Either AccountError Principal)
currentPrincipal service token = withMVar (acLock service) $ \_ -> currentUnlocked service token
-- | 登录:先比配置里的 root,再查系统表里的普通账号(没有切换身份的入口)。
-- 配置里的口令留空即"免密":只有管理员能用空口令进来,普通账号一律拒绝。
authenticate :: Accounts -> Text -> Text -> IO (Either AccountError Text)
authenticate service user password = withMVar (acLock service) $ \_ ->
if normalize user == rootName service
then signInRoot
else
if passwordless
then pure (Left (AccountError "forbidden" "this server only accepts the administrator"))
else signInOrdinary
where
passwordless = T.null (credEncoded (acRoot service))
signInRoot
| passwordless =
if T.null password
then Right <$> createVersionedSession (acSessions service) (rootName service) Nothing
else pure (Left unauthorized)
| verifyPassword (credEncoded (acRoot service)) password =
Right <$> createVersionedSession (acSessions service) (rootName service) Nothing
| otherwise = pure (Left unauthorized)
signInOrdinary = do
stored <- readAccounts service
case stored of
Left err -> pure (Left err)
Right accounts -> case filter ((== normalize user) . accountUser) accounts of
[a] | verifyPassword (accountHash a) password -> do
stamp <- currentStamp
written <- beAccounts (acBackend service) (ReqAccountLogin (accountUser a) (Just stamp))
case written of
Left _ -> pure (Left storageFailure)
Right _ -> Right <$> createVersionedSession (acSessions service) (accountUser a) (Just (accountRevision a))
_ -> pure (Left unauthorized)
-- | 系统表里的普通账号清单(root 专用)
listAccounts :: Accounts -> IO (Either AccountError [Account])
listAccounts service = withMVar (acLock service) $ \_ -> readAccounts service
-- | 按用户名找一个普通账号
findAccount :: Accounts -> Text -> IO (Either AccountError Account)
findAccount service name = withMVar (acLock service) $ \_ -> do
stored <- readAccounts service
pure $ do
accounts <- stored
case filter ((== normalize name) . accountUser) accounts of
[a] -> Right a
[] -> Left (AccountError "not_found" "unknown account")
_ -> Left storageFailure
-- | 建一个普通账号
createUser :: Accounts -> Text -> Text -> IO (Either AccountError ())
createUser service name password = withMVar (acLock service) $ \_ -> do
policy <- accountsPolicy service
case validateNewName service name >> passwordAllowed policy password of
Left err -> pure (Left err)
Right () -> do
encoded <- hashPassword password
storageResult <$> beAccounts (acBackend service) (ReqAccountCreate (normalize name) encoded)
-- | 改一个普通账号的口令(管理员重置,不受历史/有效期限制)
resetPassword :: Accounts -> Text -> Text -> IO (Either AccountError ())
resetPassword service name password = withMVar (acLock service) $ \_ -> do
policy <- accountsPolicy service
case validateNewName service name >> passwordAllowed policy password of
Left err -> pure (Left err)
Right () -> do
encoded <- hashPassword password
storageResult <$> beAccounts (acBackend service) (ReqAccountReset (normalize name) encoded)
-- | 删一个普通账号
dropUser :: Accounts -> Text -> IO (Either AccountError ())
dropUser service name = withMVar (acLock service) $ \_ ->
case validateUserName name of
Left err -> pure (Left err)
Right () -> storageResult <$> beAccounts (acBackend service) (ReqAccountDrop (normalize name))
-- | 账号管理命令:SQL 语句与一键接口在这上面汇合
data AccountCommand
= CreateAccount Text Text
| ResetAccountPassword Text Text
| DropAccount Text
deriving (Show, Eq)
-- | 一条语句是不是账号管理语句
accountCommand :: Statement -> Maybe AccountCommand
accountCommand (CreateUser name password) = Just (CreateAccount (T.pack name) (T.pack password))
accountCommand (AlterUser name password) = Just (ResetAccountPassword (T.pack name) (T.pack password))
accountCommand (DropUser name) = Just (DropAccount (T.pack name))
accountCommand _ = Nothing
-- | 执行一条账号管理命令;普通账号一律拒绝
runAccountCommand :: Accounts -> Principal -> AccountCommand -> IO (Either AccountError ())
runAccountCommand _ (Ordinary _) _ = pure (Left (AccountError "forbidden" "administrator required"))
runAccountCommand service (Root _) command = case command of
CreateAccount name password -> createUser service name password
ResetAccountPassword name password -> resetPassword service name password
DropAccount name -> dropUser service name
+78 -30
View File
@@ -9,6 +9,8 @@ module ChuSQL.Web.Actions (
coerceValue,
sqlLiteral,
escapeStringLiteral,
createDatabaseSql,
dropDatabaseSql,
createTableSql,
dropTableSql,
createIndexSql,
@@ -19,9 +21,13 @@ module ChuSQL.Web.Actions (
updateRowSql,
deleteRowSql,
selectRowsSql,
createUserSql,
alterUserSql,
dropUserSql,
) where
import ChuSQL.Model (Column (..), Value (..))
import ChuSQL.Model (Column (..), ColumnType (..), Value (..), integerType, numericType, parseColumnType, plainColumn, typeName)
import qualified ChuSQL.Model as M
import qualified Data.Aeson as A
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.List (intercalate)
@@ -69,21 +75,11 @@ requireNonEmpty what xs
-- | 线上列类型名(和数据字典里的写法一致)转本地类型
columnTypeOf :: Text -> Maybe Column
columnTypeOf t = case T.toLower (T.strip t) of
"int" -> Just TInt
"integer" -> Just TInt
"str" -> Just TStr
"text" -> Just TStr
"varchar" -> Just TStr
"bool" -> Just TBool
"boolean" -> Just TBool
_ -> Nothing
columnTypeOf = fmap plainColumn . parseColumnType . T.unpack . T.strip
-- | 本地类型转线上名字
columnTypeName :: Column -> Text
columnTypeName TInt = "int"
columnTypeName TStr = "str"
columnTypeName TBool = "bool"
columnTypeName = T.pack . typeName . columnType
-- | 字符串字面量转义:单引号翻倍(引擎的词法器就是这么折回去的)
escapeStringLiteral :: String -> String
@@ -91,31 +87,48 @@ escapeStringLiteral = concatMap (\c -> if c == '\'' then "''" else [c])
-- | 值转 SQL 字面量
sqlLiteral :: Value -> String
sqlLiteral VNull = "NULL"
sqlLiteral (VInt n) = show n
sqlLiteral (VFloat d) = show d
sqlLiteral (VStr s) = "'" ++ escapeStringLiteral s ++ "'"
sqlLiteral (VBool True) = "TRUE"
sqlLiteral (VBool False) = "FALSE"
-- | JSON 值按列类型转成引擎的值;类型对不上就报错
coerceValue :: Column -> A.Value -> Either String Value
coerceValue TInt v = case v of
coerceValue col v = case v of
A.Null -> Right VNull
A.Bool b -> M.coerceValue t (VBool b)
A.Number n -> case floatingOrInteger n :: Either Double Integer of
Right i -> Right (VInt (fromIntegral i))
Left _ -> Left "expected an integer"
A.String s -> case readMaybe (T.unpack (T.strip s)) :: Maybe Int of
Just i -> Right (VInt i)
Nothing -> Left ("expected an integer, got: " ++ T.unpack s)
_ -> Left "expected an integer"
coerceValue TStr v = case v of
A.String s -> Right (VStr (T.unpack s))
_ -> Left "expected a string"
coerceValue TBool v = case v of
A.Bool b -> Right (VBool b)
A.String s -> case T.toLower (T.strip s) of
"true" -> Right (VBool True)
"false" -> Right (VBool False)
_ -> Left ("expected true/false, got: " ++ T.unpack s)
_ -> Left "expected true/false"
Right i -> M.coerceValue t (VInt (fromIntegral i))
Left d -> M.coerceValue t (VFloat d)
A.String s
| integerType t -> case readMaybe (T.unpack (T.strip s)) :: Maybe Int of
Just i -> M.coerceValue t (VInt i)
Nothing -> Left ("expected an integer, got: " ++ T.unpack s)
| numericType t -> case readMaybe (T.unpack (T.strip s)) :: Maybe Double of
Just d -> M.coerceValue t (VFloat d)
Nothing -> Left ("expected a number, got: " ++ T.unpack s)
| t == CBool -> case T.toLower (T.strip s) of
"true" -> Right (VBool True)
"false" -> Right (VBool False)
_ -> Left ("expected true/false, got: " ++ T.unpack s)
| otherwise -> M.coerceValue t (VStr (T.unpack s))
_ -> Left "unsupported value"
where
t = columnType col
-- | 构造 CREATE DATABASE
createDatabaseSql :: Text -> Either String String
createDatabaseSql name = do
db <- checkIdent "database" name
pure ("CREATE DATABASE " ++ T.unpack db)
-- | 构造 DROP DATABASE
dropDatabaseSql :: Text -> Either String String
dropDatabaseSql name = do
db <- checkIdent "database" name
pure ("DROP DATABASE " ++ T.unpack db)
-- | 构造 CREATE TABLE 语句
createTableSql :: CreateTableSpec -> Either String String
@@ -235,3 +248,38 @@ orderClause Nothing = pure ""
orderClause (Just (column, ascending)) = do
col <- checkIdent "column" column
pure (" ORDER BY " ++ T.unpack col ++ (if ascending then " ASC" else " DESC"))
-- | 账号名:与普通标识符同一套规则,另允许点与短横线(写成字符串形式)
accountName :: Text -> Either String Text
accountName name
| T.null name || T.length name > 64 = Left "account name is empty or too long"
| not (T.all allowed name) = Left ("account name may only contain ASCII letters, digits, dot, dash or underscore: " ++ T.unpack name)
| otherwise = Right name
where
allowed c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("_.-" :: String)
-- | 单引号字符串字面量
quoted :: Text -> String
quoted value = "'" ++ escapeStringLiteral (T.unpack value) ++ "'"
-- | 构造 CREATE USER(口令是明文,账号服务负责加盐派生)
createUserSql :: Text -> Text -> Either String String
createUserSql name password = do
user <- accountName name
if T.null password || T.length password > 256
then Left "password must not be empty or longer than 256 characters"
else pure ("CREATE USER " ++ T.unpack user ++ " IDENTIFIED BY " ++ quoted password)
-- | 构造 ALTER USER(改一个普通账号的口令)
alterUserSql :: Text -> Text -> Either String String
alterUserSql name password = do
user <- accountName name
if T.null password || T.length password > 256
then Left "password must not be empty or longer than 256 characters"
else pure ("ALTER USER " ++ T.unpack user ++ " IDENTIFIED BY " ++ quoted password)
-- | 构造 DROP USER
dropUserSql :: Text -> Either String String
dropUserSql name = do
user <- accountName name
pure ("DROP USER " ++ T.unpack user)
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -14,9 +14,10 @@ module ChuSQL.Web.Auth (
sessionPolicy,
setSessionPolicy,
createSession,
createVersionedSession,
lookupVersionedSession,
lookupSession,
deleteSession,
deleteOtherSessions,
sessionToken,
) where
@@ -118,6 +119,7 @@ data Session = Session
{ sessUser :: Text
, sessCreated :: UTCTime
, sessSeen :: UTCTime
, sessRevision :: Maybe Integer
}
data SessionPolicy = SessionPolicy
@@ -166,33 +168,31 @@ sessionToken = hexEncode <$> getRandomBytes 32
-- | 开一条会话,返回令牌
createSession :: SessionStore -> Text -> IO Text
createSession store user = do
createSession store user = createVersionedSession store user Nothing
createVersionedSession :: SessionStore -> Text -> Maybe Integer -> IO Text
createVersionedSession store user revision = do
token <- sessionToken
now <- storeClock store
pol <- sessionPolicy store
modifyMVar_ (storeMap store) $ \m ->
pure (Map.insert token (Session user now now) (prune now pol m))
pure (Map.insert token (Session user now now revision) (prune now pol m))
pure token
-- | 查令牌并刷新最后活动时间
lookupSession :: SessionStore -> Text -> IO (Maybe Text)
lookupSession store token = do
lookupSession store token = fmap (fmap fst) (lookupVersionedSession store token)
lookupVersionedSession :: SessionStore -> Text -> IO (Maybe (Text, Maybe Integer))
lookupVersionedSession store token = do
now <- storeClock store
pol <- sessionPolicy store
modifyMVar (storeMap store) $ \m -> do
let kept = prune now pol m
pure $ case Map.lookup token kept of
Nothing -> (kept, Nothing)
Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s))
Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s, sessRevision s))
-- | 删掉一条会话(退出登录)
deleteSession :: SessionStore -> Text -> IO ()
deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token)
-- | 删掉除这条外的全部会话,返回条数
deleteOtherSessions :: SessionStore -> Text -> IO Int
deleteOtherSessions store keep = do
removed <- modifyMVar (storeMap store) $ \m -> do
let kept = Map.filterWithKey (\k _ -> k == keep) m
pure (kept, Map.size m - Map.size kept)
pure removed
+309 -34
View File
@@ -4,27 +4,45 @@ module ChuSQL.Web.Backend (
StatementResult (..),
Backend (..),
columnsFromStatement,
currentStamp,
exprTables,
inDatabase,
internalTable,
grantsTable,
ipcBackend,
memoryBackend,
membersTable,
rolesTable,
statementNeedsDatabase,
statementTables,
systemDatabaseName,
tableInfoOf,
tableRefsOf,
) where
import ChuSQL.Engine (runStatement, runStatementM)
import ChuSQL.Model
import ChuSQL.Semantic (prepare)
import ChuSQL.Storage (MonadStorage (schema))
import ChuSQL.Storage.IPC (
Account (..),
IPCStorage (..),
Request (..),
Response (..),
SchemaColumn (..),
TableInfo (..),
doListCatalog,
sendRequest,
)
import ChuSQL.Syntax.AST
import ChuSQL.Syntax.Parser (parseStatement)
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar, withMVar)
import Data.List (nub)
import Data.Text (Text)
import Data.Time (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import qualified Data.Aeson as A
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.Text as T
-- 后端:跑一条语句与取数据字典,分 IPC(真存储)和内存(测试)两套。
@@ -37,40 +55,52 @@ data Backend = Backend
{ beStatement :: String -> IO (Either String StatementResult)
, beCatalog :: IO (Either String [TableInfo])
, bePing :: IO Bool
, beAccounts :: Request -> IO (Either String [Account])
, beWithDatabase :: String -> Backend
, beDatabases :: IO (Either String [String])
}
-- | 按给定列名取列,固定列顺序
columnsFromStatement :: Database -> Statement -> [String]
columnsFromStatement db stmt = case stmt of
Select cols from _ _ _
| cols == [allColumns] -> wildcard from
| otherwise -> cols
columnsFromStatement db stmt = case prepare db stmt of
Right (Select cols _ _ _ _ _) -> cols
Right (SelectExpr items _ _ _ _ _) -> map fst items
_ -> []
where
-- | 展开 *:单表按列序,JOIN 左表在前
wildcard (FromTable mAlias tbl) = case lookup tbl db of
Nothing -> []
Just t -> [qualify mAlias c | c <- colNames t]
wildcard (FromJoin left mAlias tbl _) = wildcard left ++ wildcard (FromTable mAlias tbl)
-- | 走命名管道的后端(真存储)
-- | 走本地端点的后端(真存储:Windows 具名管道 / Unix 套接字)
ipcBackend :: IO Backend
ipcBackend = do
lock <- newMVar ()
pure
Backend
{ beStatement = \sql -> withMVar lock $ \_ -> runIpc sql
let backend current = Backend
{ beStatement = \sql -> withMVar lock $ \_ -> runIpc current sql
, beCatalog = withMVar lock $ \_ -> do
result <- doListCatalog
pure (either Left Right result)
response <- sendRequest (ReqInDatabase current ReqListCatalog)
pure $ case response of
RespCatalog infos -> Right infos
RespError err -> Left err
_ -> Left "unexpected catalog response"
, bePing = withMVar lock $ \_ -> do
resp <- sendRequest ReqPing
pure (case resp of RespPong -> True; _ -> False)
, beAccounts = \req -> withMVar lock $ \_ -> do
response <- sendRequest req
pure $ case response of
RespAccounts accounts -> Right accounts
RespError err -> Left err
_ -> Left "unexpected account response"
, beWithDatabase = backend
, beDatabases = withMVar lock $ \_ -> do
response <- sendRequest (ReqDatabase "list_databases" "")
pure $ case response of
RespTables names -> Right names
RespError err -> Left err
_ -> Left "unexpected database response"
}
-- 没有默认库:没选库时 current 是空串,任何请求都得先选库
pure (backend "")
where
-- | 解析并执行,空结果时补一次表头
runIpc :: String -> IO (Either String StatementResult)
runIpc sql = case parseStatement sql of
runIpc :: String -> String -> IO (Either String StatementResult)
runIpc current sql = case fmap (inDatabase current) (parseStatement sql) of
Left e -> pure (Left e)
Right stmt -> do
result <- runIPCStorage (runStatementM stmt)
@@ -84,42 +114,287 @@ ipcBackend = do
(r : _) -> pure (map fst r)
pure (Right (StatementResult cols rows))
-- | 内存后端(测试):状态就是一个 `Database`
memoryBackend :: MVar Database -> Backend
memoryBackend ref =
-- | 内存后端(测试):状态就是一个 `Database`,名字是它代表的库
memoryBackend :: String -> MVar Database -> Backend
memoryBackend = memoryBackendWith False
-- | 同一份内存状态可以按普通库(trusted=False)或系统库(trusted=True)视角跑:
-- 系统库要能读写角色/授权表,普通库连看都不许看。
memoryBackendWith :: Bool -> String -> MVar Database -> Backend
memoryBackendWith trusted name ref =
Backend
{ beStatement = \sql -> modifyMVar ref $ \db -> case parseStatement sql of
Left e -> pure (db, Left e)
Right stmt -> case runStatement db stmt of
{ beStatement = \sql -> case parseStatement sql of
Left e -> pure (Left e)
-- 内存夹具也有"库"的概念:USE 只能切到它有的两个名字(自己 + system),
-- 与 beDatabases 口径一致;引擎的内存存储不实现库管理,所以拦在这里。
Right (UseDatabase target)
| target == name || target == systemDatabaseName -> pure (Right (StatementResult [] []))
| otherwise -> pure (Left ("unknown database: " ++ target))
Right stmt -> modifyMVar ref $ \db -> case runStatement (scoped db) stmt of
Left e -> pure (db, Left e)
Right (db', rows) -> pure (db', Right (StatementResult (colsOf db stmt rows) rows))
Right (db', rows)
| any (hidden . fst) db' -> pure (db, Left "reserved system table")
| otherwise -> pure (filter (hidden . fst) db ++ db', Right (StatementResult (colsOf (scoped db) stmt rows) rows))
, beCatalog = do
db <- readMVar ref
pure (Right (map tableInfoOf db))
pure (Right (map tableInfoOf (filter (not . internalTable . fst) db)))
, bePing = pure True
, beAccounts = \req -> do
stamp <- currentStamp
modifyMVar ref $ \db -> case memoryAccounts db stamp req of
Left err -> pure (db, Left err)
Right accounts -> pure ((usersTable, Table usersTable [("account", TStr)]
[[("account", VStr (BL.unpack (A.encode a))) ] | a <- accounts]) : filter ((/= usersTable) . fst) db, Right accounts)
, beWithDatabase = withDatabase
, beDatabases = pure (Right (nub [name, systemDatabaseName]))
}
where
-- | 有行用行上的键,空结果回退语法树
scoped = if trusted then filter (not . reserved . fst) else visible
hidden = if trusted then reserved else internalTable
colsOf db stmt rows = case rows of
[] -> columnsFromStatement db stmt
(r : _) -> map fst r
-- 系统库视角给权限服务用;夹具自己的库走普通视角;没选库时只放行不碰表的语句
withDatabase target
| target == systemDatabaseName = memoryBackendWith True name ref
| target == name = memoryBackendWith False name ref
| null target = noDatabaseBackend (memoryBackendWith False name ref)
| otherwise = unavailableDatabase ("unknown database: " ++ target) (memoryBackendWith False name ref)
unavailableDatabase :: String -> Backend -> Backend
unavailableDatabase message backend = backend
{ beStatement = \_ -> pure (Left message)
, beCatalog = pure (Left message)
}
-- | 没选库时的视角:不碰表的语句(USE、建库、账号与角色)照跑,
-- 一旦语句里出现裸表名就报 no database selected。
-- 与存储层口径一致:`db.table` 这种限定名不需要先选库。
noDatabaseBackend :: Backend -> Backend
noDatabaseBackend backend = backend
{ beStatement = \sql -> case parseStatement sql of
Left e -> pure (Left e)
Right stmt
| statementNeedsDatabase stmt -> pure (Left "no database selected")
| otherwise -> beStatement backend sql
, beCatalog = pure (Left "no database selected")
}
-- | Database context is an immutable request value, never shared connection state.
-- 子查询里出现的表名同样要带上数据库前缀,否则会落到错误的库里。
inDatabase :: String -> Statement -> Statement
inDatabase current = walk
where
walk :: Statement -> Statement
walk stmt = case stmt of
q@Select{} ->
q
{ selectFrom = source (selectFrom q)
, selectWhere = fmap expr (selectWhere q)
}
q@SelectExpr{} ->
q
{ selectItems = [(label, expr e) | (label, e) <- selectItems q]
, selectFrom = source (selectFrom q)
, selectWhere = fmap expr (selectWhere q)
}
Insert t cols rows -> Insert (table t) cols (map (map expr) rows)
Delete t cond -> Delete (table t) (fmap expr cond)
Update t assigns cond -> Update (table t) [(c, expr e) | (c, e) <- assigns] (fmap expr cond)
CreateTable t cols -> CreateTable (table t) cols
DropTable t -> DropTable (table t)
CreateIndex t c -> CreateIndex (table t) c
DropIndex t c -> DropIndex (table t) c
DropColumn t c -> DropColumn (table t) c
AddColumn t def -> AddColumn (table t) def
RenameColumn t old new -> RenameColumn (table t) old new
AlterColumnType t c ty -> AlterColumnType (table t) c ty
AlterColumnDefault t c v -> AlterColumnDefault (table t) c v
AlterColumnNull t c b -> AlterColumnNull (table t) c b
other -> other
table t | '.' `elem` t || null current = t
| otherwise = current ++ "." ++ t
source FromUnit = FromUnit
source (FromTable a t) = FromTable a (table t)
source (FromJoin k l a t e) = FromJoin k (source l) a (table t) (expr e)
-- 表达式里的子查询也要一起改写
expr (ScalarSub sq) = ScalarSub (sub sq)
expr (ExistsSub sq neg) = ExistsSub (sub sq) neg
expr (InSub a sq neg) = InSub (expr a) (sub sq) neg
expr (InList a items neg) = InList (expr a) (map expr items) neg
expr (Add a b) = Add (expr a) (expr b)
expr (Sub a b) = Sub (expr a) (expr b)
expr (Mul a b) = Mul (expr a) (expr b)
expr (Div a b) = Div (expr a) (expr b)
expr (Neg a) = Neg (expr a)
expr (Gt a b) = Gt (expr a) (expr b)
expr (Lt a b) = Lt (expr a) (expr b)
expr (Eq a b) = Eq (expr a) (expr b)
expr (And a b) = And (expr a) (expr b)
expr (Or a b) = Or (expr a) (expr b)
expr (IsNull a) = IsNull (expr a)
expr (IsNotNull a) = IsNotNull (expr a)
expr (CountOf a) = CountOf (expr a)
expr (SumOf a) = SumOf (expr a)
expr (AvgOf a) = AvgOf (expr a)
expr (MinOf a) = MinOf (expr a)
expr (MaxOf a) = MaxOf (expr a)
expr other = other
sub sq = sq {subqueryStatement = walk (subqueryStatement sq)}
-- | 语句里出现的表(含 JOIN、子查询、INSERT/UPDATE/DELETE 的目标)
statementTables :: Statement -> [Text]
statementTables stmt = nub (case stmt of
Select{} ->
tableRefsOf (selectFrom stmt)
++ concatMap exprTables (maybe [] (: []) (selectWhere stmt))
SelectExpr{} ->
tableRefsOf (selectFrom stmt)
++ concatMap exprTables (maybe [] (: []) (selectWhere stmt) ++ map snd (selectItems stmt))
Insert table _ rows -> T.pack table : concatMap exprTables (concat rows)
Update table assigns cond -> T.pack table : concatMap exprTables (map snd assigns ++ maybe [] (: []) cond)
Delete table cond -> T.pack table : concatMap exprTables (maybe [] (: []) cond)
CreateTable table _ -> [T.pack table]
DropTable table -> [T.pack table]
CreateIndex table _ -> [T.pack table]
DropIndex table _ -> [T.pack table]
DropColumn table _ -> [T.pack table]
AddColumn table _ -> [T.pack table]
RenameColumn table _ _ -> [T.pack table]
AlterColumnType table _ _ -> [T.pack table]
AlterColumnDefault table _ _ -> [T.pack table]
AlterColumnNull table _ _ -> [T.pack table]
_ -> [])
-- | 语句需不需要一个当前库:出现的表里有没写库名的就必须要
statementNeedsDatabase :: Statement -> Bool
statementNeedsDatabase stmt = any (not . T.any (== '.')) (statementTables stmt)
-- | FROM 子句里的所有表
tableRefsOf :: FromClause -> [Text]
tableRefsOf FromUnit = []
tableRefsOf (FromTable _ name) = [T.pack name]
tableRefsOf (FromJoin _ left _ name cond) = tableRefsOf left ++ [T.pack name] ++ exprTables cond
-- | 表达式里出现的表(子查询是唯一的来路)
exprTables :: Expr -> [Text]
exprTables expr = case expr of
ScalarSub sub -> statementTables (subqueryStatement sub)
ExistsSub sub _ -> statementTables (subqueryStatement sub)
InSub value sub _ -> exprTables value ++ statementTables (subqueryStatement sub)
InList value items _ -> concatMap exprTables (value : items)
Add a b -> both a b
Sub a b -> both a b
Mul a b -> both a b
Div a b -> both a b
Gt a b -> both a b
Lt a b -> both a b
Eq a b -> both a b
And a b -> both a b
Or a b -> both a b
Neg a -> exprTables a
IsNull a -> exprTables a
IsNotNull a -> exprTables a
CountOf a -> exprTables a
SumOf a -> exprTables a
AvgOf a -> exprTables a
MinOf a -> exprTables a
MaxOf a -> exprTables a
_ -> []
where
both a b = exprTables a ++ exprTables b
-- | 一张表的线上信息(内存实现:没有索引,统计现算)
tableInfoOf :: (String, Table) -> TableInfo
tableInfoOf (name, tbl) =
TableInfo
{ tiTable = name
, tiColumns = [SchemaColumn c (wireType ty) | (c, ty) <- tableCols tbl]
, tiColumns = [wireColumn c ty | (c, ty) <- tableCols tbl]
, tiRows = length (tableRows tbl)
, tiIndexes = []
, tiStats = [(c, distinctOf c, False) | (c, _) <- tableCols tbl]
}
where
-- | 某一列有几个不同值
distinctOf c = length (nub [v | r <- tableRows tbl, Just v <- [lookup c r]])
-- | 本地列类型转线上字符串(和 IPC 那边同一套写法)
wireType :: Column -> String
wireType TInt = "int"
wireType TStr = "str"
wireType TBool = "bool"
wireType = typeName . columnType
-- | 本地列转线上 schema
wireColumn :: String -> Column -> SchemaColumn
wireColumn name col =
SchemaColumn
{ scName = name
, scType = wireType col
, scNullable = columnNullable col
, scDefault = columnDefault col
, scAutoIncrement = columnAutoIncrement col
, scPrimaryKey = columnPrimaryKey col
, scUnique = columnUnique col
, scCheck = columnCheck col
}
-- | 系统库:账号、角色与授权表住在这里,其中的表都是系统表,只给管理员
systemDatabaseName :: String
systemDatabaseName = "system"
usersTable :: String
usersTable = "__chusql_users"
-- | 角色与授权表:住在 system 库,名字不带 `__chusql_` 前缀(Rust 存储层拒收那个前缀)
rolesTable :: String
rolesTable = "sys_roles"
grantsTable :: String
grantsTable = "sys_grants"
membersTable :: String
membersTable = "sys_members"
internalNames :: [String]
internalNames = [rolesTable, grantsTable, membersTable]
reserved :: String -> Bool
reserved = T.isPrefixOf "__chusql_" . T.toLower . T.pack
-- | 服务自己的内部表:目录里不出现,普通作用域读写都拒(系统作用域才放行角色/授权表)
internalTable :: String -> Bool
internalTable name = reserved name || T.unpack (T.toLower (T.pack name)) `elem` internalNames
visible :: Database -> Database
visible = filter (not . internalTable . fst)
-- | 当前 UTC 时间,写法与引擎的 timestamp 一致
currentStamp :: IO T.Text
currentStamp = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> getCurrentTime
memoryAccounts :: Database -> T.Text -> Request -> Either String [Account]
memoryAccounts db stamp req = do
accounts <- case lookup usersTable db of
Nothing -> Right []
Just table -> mapM decodeAccount (tableRows table)
case req of
ReqAccountsList -> Right accounts
ReqAccountCreate u h
| any ((== T.toLower u) . accountUser) accounts -> Left "account already exists"
| otherwise -> Right (accounts ++ [Account (1 + maximum (0 : map accountId accounts)) (T.toLower u) h 1 stamp Nothing])
ReqAccountReset u h -> case filter ((== T.toLower u) . accountUser) accounts of
[_] -> Right [if accountUser x == T.toLower u then x{accountHash = h, accountRevision = accountRevision x + 1} else x | x <- accounts]
_ -> Left "unknown account"
ReqAccountLogin u at -> case filter ((== T.toLower u) . accountUser) accounts of
[_] -> Right [if accountUser x == T.toLower u then x{accountLastLoginAt = at} else x | x <- accounts]
_ -> Left "unknown account"
ReqAccountDrop u -> case filter ((== T.toLower u) . accountUser) accounts of
[] -> Left "unknown account"
_ -> Right [x | x <- accounts, accountUser x /= T.toLower u]
_ -> Left "invalid account request"
where
decodeAccount row = case lookup "account" row of
Just (VStr encoded) -> A.eitherDecode (BL.pack encoded)
_ -> Left "invalid account record"
+119 -96
View File
@@ -6,22 +6,31 @@ module ChuSQL.Web.Config (
defaultUser,
defaultPassword,
usingDefaultCredentials,
loadWebConfig,
loadWebConfigAt,
resolveCredential,
resolveStaticDir,
staticDirCandidates,
rootUserName,
canonicalSettingKeys,
resolvePipeName,
) where
import ChuSQL.Storage.IPC (defaultPipeName)
import ChuSQL.Web.Auth (Credential (..), hashPassword)
import ChuSQL.Web.TOML (readSection)
import Data.Char (toLower)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesDirectoryExist)
import System.Environment (lookupEnv)
import Text.Read (readMaybe)
-- 运行参数:命令行 > 环境变量 > 内置默认;默认只听本机 127.0.0.1:7777。
-- 运行参数:命令行 > 全局 chusql.toml 的分区 > 内置默认,不看环境变量。
-- 配置文件位置是固定的(安装时定下,见 ChuSQL.Web.TOML.defaultConfigFile),
-- 想读别处的文件只能在命令行用 --config 指。
-- 自己那段是 [web];管名与数据目录取自 [server] pipe_name / [storage] data_dir,
-- 因为它们是存储进程的事实,前端只当端点读。root 凭据只认 [web] 里的明文。
-- | 默认账号名
defaultUser :: Text
@@ -36,8 +45,6 @@ data WebConfig = WebConfig
, wcPort :: Int
, wcStaticDir :: FilePath
, wcUser :: Text
, wcPasswordHash :: Maybe Text
, wcPassword :: Maybe Text
, wcStorageServer :: Maybe FilePath
, wcPipeName :: Maybe String
, wcDataDir :: Maybe FilePath
@@ -62,9 +69,7 @@ defaultWebConfig =
{ wcHost = "127.0.0.1"
, wcPort = 7777
, wcStaticDir = "static"
, wcUser = defaultUser
, wcPasswordHash = Nothing
, wcPassword = Nothing
, wcUser = ""
, wcStorageServer = Nothing
, wcPipeName = Nothing
, wcDataDir = Nothing
@@ -81,68 +86,114 @@ defaultWebConfig =
, wcSeedDemo = False
}
-- | 这份配置用的还是内置默认口令吗
usingDefaultCredentials :: WebConfig -> Bool
usingDefaultCredentials cfg = wcPasswordHash cfg == Nothing && wcPassword cfg == Nothing
-- | [web] 分区里的键统一成连字符写法(scripts/chusql.toml 用的是下划线)
-- 两种写法同时出现时,连字符(设置页写的规范形式)优先。
canonicalSettingKeys :: Map.Map Text Text -> Map.Map Text Text
canonicalSettingKeys saved =
foldr (\(key, value) acc -> Map.insert (canonicalKey key) value acc) Map.empty (Map.toList saved)
where
canonicalKey key = fromMaybe key (lookup key keyAliases)
-- | 备一份凭据:哈希 > 明文 > 内置默认
resolveCredential :: WebConfig -> IO Credential
resolveCredential cfg = case (wcPasswordHash cfg, wcPassword cfg) of
(Just encoded, _) -> pure (Credential (wcUser cfg) encoded)
(Nothing, Just plain) -> do
encoded <- hashPassword plain
pure (Credential (wcUser cfg) encoded)
(Nothing, Nothing) -> do
encoded <- hashPassword defaultPassword
pure (Credential (wcUser cfg) encoded)
-- | 下划线写法到连字符写法的别名
keyAliases :: [(Text, Text)]
keyAliases =
[ ("static_dir", "static-dir")
, ("session_idle", "session-idle")
, ("session_max", "session-max")
, ("login_max_attempts", "login-max-attempts")
, ("login_window", "login-window")
, ("body_limit", "body-limit")
, ("rows_per_page", "rows-per-page")
, ("max_page_size", "max-page-size")
, ("max_rows", "max-rows")
, ("max_sql_length", "max-sql-length")
, ("password_min_length", "password-min-length")
, ("password_classes", "password-classes")
, ("storage_server", "storage-server")
]
-- | 从环境变量读配置,缺省用内置默认
loadWebConfig :: IO WebConfig
loadWebConfig = do
let d = defaultWebConfig
host <- envOr "CHUSQL_WEB_HOST" (wcHost d)
port <- envInt "CHUSQL_WEB_PORT" (wcPort d)
staticDir <- envOr "CHUSQL_WEB_STATIC" (wcStaticDir d)
user <- T.pack <$> envOr "CHUSQL_WEB_USER" (T.unpack (wcUser d))
passwordHash <- envText "CHUSQL_WEB_PASSWORD_HASH"
password <- envText "CHUSQL_WEB_PASSWORD"
server <- envMaybe "CHUSQL_STORAGE_SERVER"
pipe <- envMaybe "CHUSQL_PIPE"
dataDir <- envMaybe "CHUSQL_DATA_DIR"
secure <- envBool "CHUSQL_WEB_COOKIE_SECURE" (wcCookieSecure d)
limit <- envInt "CHUSQL_WEB_BODY_LIMIT" (wcBodyLimit d)
idle <- envInt "CHUSQL_WEB_SESSION_IDLE" (wcSessionIdle d)
sessionMax <- envInt "CHUSQL_WEB_SESSION_MAX" (wcSessionMax d)
loginMax <- envInt "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" (wcLoginMaxAttempts d)
loginWindow <- envInt "CHUSQL_WEB_LOGIN_WINDOW" (wcLoginWindow d)
pageSize <- envInt "CHUSQL_WEB_PAGE_SIZE" (wcPageSize d)
maxPageSize <- envInt "CHUSQL_WEB_MAX_PAGE_SIZE" (wcMaxPageSize d)
maxRows <- envInt "CHUSQL_WEB_MAX_ROWS" (wcMaxRows d)
maxSqlLength <- envInt "CHUSQL_WEB_MAX_SQL_LENGTH" (wcMaxSqlLength d)
seedDemo <- envBool "CHUSQL_WEB_SEED" (wcSeedDemo d)
pure
WebConfig
{ wcHost = host
, wcPort = port
, wcStaticDir = staticDir
, wcUser = user
, wcPasswordHash = passwordHash
, wcPassword = password
, wcStorageServer = server
, wcPipeName = pipe
, wcDataDir = dataDir
, wcCookieSecure = secure
, wcBodyLimit = limit
, wcSessionIdle = idle
, wcSessionMax = sessionMax
, wcLoginMaxAttempts = loginMax
, wcLoginWindow = loginWindow
, wcPageSize = pageSize
, wcMaxPageSize = maxPageSize
, wcMaxRows = maxRows
, wcMaxSqlLength = maxSqlLength
, wcSeedDemo = seedDemo
}
-- | 这份配置用的还是内置默认口令吗。
-- 显式留空(password = "")是"只允许管理员免密登录",算已经配置过。
usingDefaultCredentials :: Map.Map Text Text -> Bool
usingDefaultCredentials saved = Map.notMember "password" saved
-- | 设置文件里某个键的非空值
nonEmptyValue :: Text -> Map.Map Text Text -> Maybe Text
nonEmptyValue key saved = case T.strip <$> Map.lookup key saved of
Just value | not (T.null value) -> Just value
_ -> Nothing
-- | root 凭据只认设置文件里的明文:给了明文就哈希它;显式留空就是免密(空编码串);
-- 没配这一项才回落到内置演示口令。
resolveCredential :: WebConfig -> Map.Map Text Text -> IO Credential
resolveCredential cfg saved = do
encoded <- case Map.lookup "password" saved of
Just plain | not (T.null (T.strip plain)) -> hashPassword (T.strip plain)
Just _ -> pure ""
Nothing -> hashPassword defaultPassword
pure (Credential (rootUserName cfg saved) encoded)
-- | 管理员名字:命令行 --user > 设置文件 user > 内置默认
rootUserName :: WebConfig -> Map.Map Text Text -> Text
rootUserName cfg saved
| not (T.null (T.strip (wcUser cfg))) = T.strip (wcUser cfg)
| otherwise = fromMaybe defaultUser (nonEmptyValue "user" saved)
-- | 读配置:内置默认 < [web] 分区(端点取自 [server] / [storage])。
-- user / password 不在这里读:它们由 rootUserName / resolveCredential 直接看设置文件。
loadWebConfigAt :: FilePath -> IO WebConfig
loadWebConfigAt path = do
raw <- readSection path "web"
server <- readSection path "server"
storage <- readSection path "storage"
pure (withEndpoint server storage (webConfigFromSection (canonicalSettingKeys raw)))
-- | 端点:存储进程 bind 的管名与它用的数据目录,前端只读不改
withEndpoint :: Map.Map Text Text -> Map.Map Text Text -> WebConfig -> WebConfig
withEndpoint server storage cfg =
cfg
{ wcPipeName = T.unpack <$> nonEmptyValue "pipe_name" server
, wcDataDir = T.unpack <$> nonEmptyValue "data_dir" storage
}
-- | 实际要连的管名:配置里没有就用与存储层一致的默认值
resolvePipeName :: WebConfig -> String
resolvePipeName cfg = fromMaybe defaultPipeName (wcPipeName cfg)
-- | [web] 分区里的启动参数,缺失或坏值一律回到内置默认
webConfigFromSection :: Map.Map Text Text -> WebConfig
webConfigFromSection saved =
defaultWebConfig
{ wcHost = T.unpack (sectionText "host" saved (T.pack (wcHost defaultWebConfig)))
, wcPort = sectionInt "port" saved (wcPort defaultWebConfig)
, wcStaticDir = T.unpack (sectionText "static-dir" saved (T.pack (wcStaticDir defaultWebConfig)))
, wcCookieSecure = sectionBool "cookie-secure" saved (wcCookieSecure defaultWebConfig)
, wcBodyLimit = sectionInt "body-limit" saved (wcBodyLimit defaultWebConfig)
, wcSessionIdle = sectionInt "session-idle" saved (wcSessionIdle defaultWebConfig)
, wcSessionMax = sectionInt "session-max" saved (wcSessionMax defaultWebConfig)
, wcLoginMaxAttempts = sectionInt "login-max-attempts" saved (wcLoginMaxAttempts defaultWebConfig)
, wcLoginWindow = sectionInt "login-window" saved (wcLoginWindow defaultWebConfig)
, wcPageSize = sectionInt "rows-per-page" saved (wcPageSize defaultWebConfig)
, wcMaxPageSize = sectionInt "max-page-size" saved (wcMaxPageSize defaultWebConfig)
, wcMaxRows = sectionInt "max-rows" saved (wcMaxRows defaultWebConfig)
, wcMaxSqlLength = sectionInt "max-sql-length" saved (wcMaxSqlLength defaultWebConfig)
, wcSeedDemo = sectionBool "seed" saved (wcSeedDemo defaultWebConfig)
, wcStorageServer = T.unpack <$> nonEmptyValue "storage-server" saved
}
-- | 分区里一个键的非空字符串值
sectionText :: Text -> Map.Map Text Text -> Text -> Text
sectionText key saved def = fromMaybe def (nonEmptyValue key saved)
-- | 分区里的整数,解析不出来就用内置默认
sectionInt :: Text -> Map.Map Text Text -> Int -> Int
sectionInt key saved def = fromMaybe def (nonEmptyValue key saved >>= readMaybe . T.unpack)
-- | 分区里的布尔(1/true/yes/on 都算真)
sectionBool :: Text -> Map.Map Text Text -> Bool -> Bool
sectionBool key saved def = case nonEmptyValue key saved of
Nothing -> def
Just value -> map toLower (T.unpack value) `elem` ["1", "true", "yes", "on"]
-- | 静态目录候选位置,按顺序取第一个存在的
staticDirCandidates :: FilePath -> [FilePath]
@@ -157,31 +208,3 @@ resolveStaticDir given = go (staticDirCandidates given)
go (d : ds) = do
ok <- doesDirectoryExist d
if ok then pure (Just d) else go ds
-- | 读一个字符串,缺省给默认值
envOr :: String -> String -> IO String
envOr name def = fromMaybe def <$> lookupEnv name
-- | 读一个环境变量(空串当没设)
envMaybe :: String -> IO (Maybe String)
envMaybe name = do
v <- lookupEnv name
pure (case v of Just s | not (null s) -> Just s; _ -> Nothing)
-- | 读成 Text
envText :: String -> IO (Maybe Text)
envText name = fmap (fmap T.pack) (envMaybe name)
-- | 读成整数,解析不出来就用默认值
envInt :: String -> Int -> IO Int
envInt name def = do
v <- envMaybe name
pure (fromMaybe def (v >>= \s -> readMaybe s))
-- | 读成布尔(1/true/yes/on 都算真)
envBool :: String -> Bool -> IO Bool
envBool name def = do
v <- envMaybe name
pure $ case v of
Nothing -> def
Just s -> map toLower s `elem` ["1", "true", "yes", "on"]
-2
View File
@@ -106,7 +106,6 @@ seedDemo backend = do
(const (SeedReport (map T.unpack missingNames) skipped (map (T.unpack . snd) wantedIndexes)))
indexes
where
-- | 逐条执行,遇到错就停
runAll :: [Either String String] -> IO (Either String ())
runAll [] = pure (Right ())
runAll (sql : rest) = case sql of
@@ -117,7 +116,6 @@ seedDemo backend = do
Left e -> pure (Left e)
Right _ -> runAll rest
-- | 这张表上是否还没有这个索引
indexMissing :: [TableInfo] -> T.Text -> T.Text -> Bool
indexMissing infos t c = case lookup (T.unpack t) [(tiTable i, tiIndexes i) | i <- infos] of
Nothing -> True
+396
View File
@@ -0,0 +1,396 @@
{-# LANGUAGE OverloadedStrings #-}
-- | 角色与权限服务。
--
-- 模型:权限挂在角色上,用户通过角色成员资格继承角色权限;管理员(root)隐式全权,
-- 新账号默认零权限。语句(CREATE ROLE / DROP ROLE / GRANT / REVOKE)由引擎解析并做
-- 基本校验,真正落库在这里。
--
-- 存储:system 库里的三张普通表(`sys_roles` / `sys_grants` / `sys_members`)。
-- 起普通名字是因为 Rust 存储层拒收 `__chusql_` 前缀的表名,那条路走不通;
-- 读写都借 system 作用域后端的普通 SQL,内存后端与 IPC 后端共用一套逻辑。
module ChuSQL.Web.Privileges (
Grant (..),
Membership (..),
PrivilegeCommand (..),
PrivilegeError (..),
Privileges,
RoleView (..),
authorize,
authorizeTables,
filterTables,
listRoleViews,
newPrivileges,
normalizeObject,
privilegeCommand,
runPrivilegeCommand,
) where
import ChuSQL.Model (Row, Value (..))
import ChuSQL.Syntax.AST
import ChuSQL.Web.Accounts (Principal, principalIsRoot, principalName)
import ChuSQL.Web.Actions (sqlLiteral)
import ChuSQL.Web.Backend (
Backend (..),
StatementResult (..),
exprTables,
grantsTable,
membersTable,
rolesTable,
systemDatabaseName,
tableRefsOf,
)
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Data.List (intercalate, isInfixOf, nub, sort)
import Data.Text (Text)
import qualified Data.Text as T
-- | 出错口径与账号服务一致,映射由 API 层统一做
data PrivilegeError = PrivilegeError Text Text
deriving (Show, Eq)
-- | 一条角色授权:角色 + 具体权限 + 对象(`*` 或 `库.表`)
data Grant = Grant
{ grantRole :: Text
, grantPrivilege :: Text
, grantObject :: Text
}
deriving (Show, Eq, Ord)
-- | 角色成员资格
data Membership = Membership
{ memberRole :: Text
, memberUser :: Text
}
deriving (Show, Eq, Ord)
-- | 一个角色的全貌
data RoleView = RoleView
{ roleName :: Text
, roleGrants :: [Grant]
, roleMembers :: [Text]
}
deriving (Show, Eq)
data PrivilegeCommand
= CreateRoleCommand Text
| DropRoleCommand Text
| GrantPrivilegesCommand [Text] Text Text
| RevokePrivilegesCommand [Text] Text Text
| GrantRoleCommand Text [Text]
| RevokeRoleCommand Text [Text]
deriving (Show, Eq)
data Privileges = Privileges
{ pvBackend :: Backend
, pvLock :: MVar ()
}
-- | 服务固定看 system 库;构造时不碰存储,第一次用才建表
newPrivileges :: Backend -> IO Privileges
newPrivileges base = do
lock <- newMVar ()
pure (Privileges (beWithDatabase base systemDatabaseName) lock)
-- | 语句 → 管理命令(引擎只解析与基本校验,不认的语句给 Nothing)
privilegeCommand :: Statement -> Maybe PrivilegeCommand
privilegeCommand stmt = case stmt of
CreateRole name -> Just (CreateRoleCommand (T.pack name))
DropRole name -> Just (DropRoleCommand (T.pack name))
GrantPrivileges privs obj role ->
Just (GrantPrivilegesCommand (map T.toLower (map T.pack privs)) (T.pack obj) (T.pack role))
RevokePrivileges privs obj role ->
Just (RevokePrivilegesCommand (map T.toLower (map T.pack privs)) (T.pack obj) (T.pack role))
GrantRole role members -> Just (GrantRoleCommand (T.pack role) (map T.pack members))
RevokeRole role members -> Just (RevokeRoleCommand (T.pack role) (map T.pack members))
_ -> Nothing
-- | 执行管理命令:只有管理员能跑,命令提到的角色必须已经存在
runPrivilegeCommand :: Privileges -> Principal -> Text -> PrivilegeCommand -> IO (Either PrivilegeError ())
runPrivilegeCommand service principal database command
| not (principalIsRoot principal) = pure (Left forbidden)
| needsDatabase = pure (Left (PrivilegeError "no_database" "no database selected"))
| otherwise = withTables service (applyCommand service database command)
where
-- 授权到具体对象(不是 `*`)时要把裸表名补成 `库.表`,没选库就无从补全
needsDatabase = T.null database && case command of
GrantPrivilegesCommand _ object _ -> T.strip object /= "*"
RevokePrivilegesCommand _ object _ -> T.strip object /= "*"
_ -> False
-- | 语句鉴权:管理员全通;普通身份按语句需要的每一项 (表, 权限) 查
authorize :: Privileges -> Principal -> Text -> Statement -> IO (Either PrivilegeError ())
authorize service principal database stmt
| principalIsRoot principal = pure (Right ())
| otherwise = case requiredActions stmt of
Nothing -> pure (Left forbidden)
Just needed -> authorizeTables service principal database needed
-- | 直接查一组 (表, 权限):一键接口用这个
authorizeTables :: Privileges -> Principal -> Text -> [(Text, Text)] -> IO (Either PrivilegeError ())
authorizeTables service principal database needed
| principalIsRoot principal = pure (Right ())
| otherwise = withTables service $ do
grants <- grantsOfUser service (principalName principal)
pure (grants >>= \found -> checkAll database found needed)
-- | 只留下用户有 SELECT 权限的表(管理员看全部);表清单接口用
filterTables :: Privileges -> Principal -> Text -> [Text] -> IO (Either PrivilegeError [Text])
filterTables service principal database tables
| principalIsRoot principal = pure (Right tables)
| otherwise = withTables service $ do
grants <- grantsOfUser service (principalName principal)
pure ((\found -> [table | table <- tables, checkAll database found [(table, "select")] == Right ()]) <$> grants)
-- | 角色总览(REST 与测试用)
listRoleViews :: Privileges -> IO (Either PrivilegeError [RoleView])
listRoleViews service = withTables service $ do
names <- roleNames service
case names of
Left err -> pure (Left err)
Right known -> sequence <$> mapM view (sort known)
where
view role = do
grants <- roleGrantsOf service role
members <- membersOfRole service role
pure (RoleView role <$> grants <*> members)
-- | 授权对象落库口径:`*` 原样,裸表名按当前库补全
normalizeObject :: Text -> Text -> Text
normalizeObject database object
| T.strip object == "*" = "*"
| T.any (== '.') object = T.toLower (T.strip object)
| otherwise = normalizeUser database <> "." <> normalizeUser object
-- | 一条语句需要哪些 (表, 权限);Nothing 表示管理员专属
requiredActions :: Statement -> Maybe [(Text, Text)]
requiredActions stmt = case stmt of
Select{} -> Just (selectNeeds (selectFrom stmt) (maybe [] (: []) (selectWhere stmt)))
SelectExpr{} ->
Just (selectNeeds (selectFrom stmt) (maybe [] (: []) (selectWhere stmt) ++ map snd (selectItems stmt)))
Insert table _ rows -> Just ((T.pack table, "insert") : selectsOf (concat rows))
Update table assigns cond -> Just ((T.pack table, "update") : selectsOf (map snd assigns ++ maybe [] (: []) cond))
Delete table cond -> Just ((T.pack table, "delete") : selectsOf (maybe [] (: []) cond))
_ -> Nothing
where
selectNeeds source exprs = [(ref, "select") | ref <- nub (tableRefsOf source)] ++ selectsOf exprs
selectsOf exprs = [(ref, "select") | ref <- nub (concatMap exprTables exprs)]
-- | 每项 (表, 权限) 都要被某条授权覆盖
checkAll :: Text -> [Grant] -> [(Text, Text)] -> Either PrivilegeError ()
checkAll database grants needed = case [pair | pair@(table, privilege) <- needed, not (covered table privilege)] of
[] -> Right ()
((table, privilege) : _) ->
Left (PrivilegeError "forbidden" ("permission denied: " <> T.toUpper privilege <> " ON " <> table))
where
covered table privilege =
let target = normalizeObject database table
in any
(\grant -> (grantObject grant == "*" || grantObject grant == target) && grantPrivilege grant == privilege)
grants
-- | 一个用户实际能用的授权:他所属角色身上的那些
grantsOfUser :: Privileges -> Text -> IO (Either PrivilegeError [Grant])
grantsOfUser service user = do
members <- readRows service membersTable
rows <- readRows service grantsTable
pure $ do
memberRows <- members
grantRows <- rows
let mine = [normalizeRole (cell "role" row) | row <- memberRows, normalizeUser (cell "member" row) == normalizeUser user]
pure [grantOf row | row <- grantRows, normalizeRole (cell "role" row) `elem` mine]
-- | 一个角色挂着的授权
roleGrantsOf :: Privileges -> Text -> IO (Either PrivilegeError [Grant])
roleGrantsOf service role = do
rows <- readRows service grantsTable
pure $ fmap (map grantOf . filter (\row -> normalizeRole (cell "role" row) == normalizeRole role)) rows
-- | 角色名清单(统一小写)
roleNames :: Privileges -> IO (Either PrivilegeError [Text])
roleNames service = do
rows <- readRows service rolesTable
pure (map (normalizeRole . cell "name") <$> rows)
-- | 一个角色的成员清单
membersOfRole :: Privileges -> Text -> IO (Either PrivilegeError [Text])
membersOfRole service role = do
rows <- readRows service membersTable
pure $
fmap
(map (normalizeUser . cell "member") . filter (\row -> normalizeRole (cell "role" row) == normalizeRole role))
rows
-- | 角色必须存在,否则命令不落地
requireRole :: Privileges -> Text -> IO (Either PrivilegeError ())
requireRole service role = do
known <- roleNames service
pure $ case known of
Left err -> Left err
Right names
| normalizeRole role `elem` names -> Right ()
| otherwise -> Left (unknownRole role)
-- | 真正落库(表已经保证存在)
applyCommand :: Privileges -> Text -> PrivilegeCommand -> IO (Either PrivilegeError ())
applyCommand service database command = case command of
CreateRoleCommand role -> do
known <- roleNames service
case known of
Left err -> pure (Left err)
Right names
| normalizeRole role `elem` names -> pure (Left (PrivilegeError "conflict" ("role already exists: " <> role)))
| otherwise -> exec service (insertSql rolesTable ["name"] [normalizeRole role])
DropRoleCommand role -> do
allowed <- requireRole service role
case allowed of
Left err -> pure (Left err)
Right () -> do
first <- exec service ("DELETE FROM " ++ rolesTable ++ " WHERE name = " ++ roleLiteral role)
second <- exec service ("DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role)
third <- exec service ("DELETE FROM " ++ membersTable ++ " WHERE role = " ++ roleLiteral role)
pure (sequence_ [first, second, third])
GrantPrivilegesCommand privileges object role -> do
allowed <- requireRole service role
case allowed of
Left err -> pure (Left err)
Right () -> each (expandPrivileges privileges) $ \privilege -> do
let target = normalizeObject database object
cleared <- exec service (deleteGrantSql role privilege target)
case cleared of
Left err -> pure (Left err)
Right () ->
exec service (insertSql grantsTable ["role", "privilege", "object"] [normalizeRole role, privilege, target])
RevokePrivilegesCommand privileges object role -> do
allowed <- requireRole service role
case allowed of
Left err -> pure (Left err)
Right () ->
each (expandPrivileges privileges) $ \privilege ->
exec service (deleteGrantSql role privilege (normalizeObject database object))
GrantRoleCommand role users -> do
allowed <- requireRole service role
case allowed of
Left err -> pure (Left err)
Right () ->
each users $ \user -> do
known <- membersOfRole service role
case known of
Left err -> pure (Left err)
Right members
| normalizeUser user `elem` members -> pure (Right ())
| otherwise ->
exec service (insertSql membersTable ["role", "member"] [normalizeRole role, normalizeUser user])
RevokeRoleCommand role users -> do
allowed <- requireRole service role
case allowed of
Left err -> pure (Left err)
Right () ->
each users $ \user ->
exec
service
( "DELETE FROM " ++ membersTable ++ " WHERE role = " ++ roleLiteral role
++ " AND member = " ++ literal (normalizeUser user)
)
-- | 建表只在第一次用时做;已经有了就算成功
withTables :: Privileges -> IO (Either PrivilegeError a) -> IO (Either PrivilegeError a)
withTables service action = withMVar (pvLock service) $ \_ -> do
ready <- ensureTables service
case ready of
Left err -> pure (Left err)
Right () -> action
ensureTables :: Privileges -> IO (Either PrivilegeError ())
ensureTables service = each tableDefs (createTable service)
where
tableDefs =
[ (rolesTable, "(name VARCHAR(64))")
, (grantsTable, "(role VARCHAR(64), privilege VARCHAR(16), object VARCHAR(128))")
, (membersTable, "(role VARCHAR(64), member VARCHAR(64))")
]
createTable :: Privileges -> (String, String) -> IO (Either PrivilegeError ())
createTable service (table, columns) = do
result <- beStatement (pvBackend service) ("CREATE TABLE " ++ table ++ " " ++ columns)
pure $ case result of
Right _ -> Right ()
Left err
| "exists" `isInfixOf` err -> Right ()
| otherwise -> Left (storageError err)
-- | 跑一条内部写语句
exec :: Privileges -> String -> IO (Either PrivilegeError ())
exec service sql = do
result <- beStatement (pvBackend service) sql
pure $ case result of
Left err -> Left (storageError err)
Right _ -> Right ()
-- | 读一张内部表
readRows :: Privileges -> String -> IO (Either PrivilegeError [Row])
readRows service table = do
result <- beStatement (pvBackend service) ("SELECT * FROM " ++ table)
pure $ case result of
Left err -> Left (storageError err)
Right res -> Right (srRows res)
-- | 行里取一列文本
cell :: String -> Row -> Text
cell column row = case lookup column row of
Just (VStr value) -> T.pack value
_ -> ""
grantOf :: Row -> Grant
grantOf row =
Grant
{ grantRole = normalizeRole (cell "role" row)
, grantPrivilege = T.toLower (T.strip (cell "privilege" row))
, grantObject = T.toLower (T.strip (cell "object" row))
}
deleteGrantSql :: Text -> Text -> Text -> String
deleteGrantSql role privilege object =
"DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role
++ " AND privilege = " ++ literal privilege ++ " AND object = " ++ literal object
insertSql :: String -> [String] -> [Text] -> String
insertSql table columns values =
"INSERT INTO " ++ table ++ " (" ++ intercalate ", " columns ++ ") VALUES (" ++ intercalate ", " (map literal values) ++ ")"
-- | 授权里的 ALL 展开成四种具体权限(存具体权限,鉴权不做兜底)
expandPrivileges :: [Text] -> [Text]
expandPrivileges privileges = nub (concatMap expand (map (T.toLower . T.strip) privileges))
where
expand "all" = ["select", "insert", "update", "delete"]
expand other = [other]
-- | 一串动作挨着跑,遇到第一个错误就停
each :: [a] -> (a -> IO (Either PrivilegeError ())) -> IO (Either PrivilegeError ())
each [] _ = pure (Right ())
each (item : rest) action = do
result <- action item
case result of
Left err -> pure (Left err)
Right () -> each rest action
roleLiteral :: Text -> String
roleLiteral = literal . normalizeRole
literal :: Text -> String
literal = sqlLiteral . VStr . T.unpack
normalizeRole :: Text -> Text
normalizeRole = T.toLower . T.strip
normalizeUser :: Text -> Text
normalizeUser = T.toLower . T.strip
forbidden :: PrivilegeError
forbidden = PrivilegeError "forbidden" "administrator required"
unknownRole :: Text -> PrivilegeError
unknownRole role = PrivilegeError "not_found" ("unknown role: " <> role)
storageError :: String -> PrivilegeError
storageError message = PrivilegeError "storage_error" ("privilege storage: " <> T.pack message)
+104 -116
View File
@@ -5,9 +5,8 @@ module ChuSQL.Web.Settings (
settingCatalogue,
findItem,
isRootOnly,
isLockedSetting,
isRestartRequired,
settingsFileCandidates,
resolveSettingsFile,
readSettingsFile,
writeSettingsFile,
applySettings,
@@ -16,72 +15,93 @@ module ChuSQL.Web.Settings (
effectiveSettings,
) where
import ChuSQL.Web.Config (WebConfig (..))
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import ChuSQL.Web.Config (WebConfig (..), canonicalSettingKeys, defaultUser)
import ChuSQL.Web.TOML (readSection, writeSection)
import Data.List (nub)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Control.Exception (IOException, try)
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (takeDirectory, (</>))
-- Web 配置项目录,与启动脚本共用一份设置文件,支持热改与重启生效。
-- Web 配置项目录,与启动脚本共用一份全局 chusql.toml;
-- 每个键只写在它所属的那一段里,web 不再复制存储层的键。
data SettingItem = SettingItem
{ siKey :: Text
, siLabel :: Text
, siGroup :: Text
, siKind :: Text
, siEnv :: Text
, siDefault :: Text
, siRootOnly :: Bool
, siRestart :: Bool
, siSection :: Text
, siTomlKey :: Text
}
deriving (Show, Eq)
-- | 全部可配置项(顺序即界面里的顺序)
settingCatalogue :: [SettingItem]
settingCatalogue =
[ item "port" "HTTP port" "server" "int" "CHUSQL_WEB_PORT" "7777" True True
, item "host" "Listen address" "server" "text" "CHUSQL_WEB_HOST" "127.0.0.1" True True
, item "static-dir" "Static directory" "server" "text" "CHUSQL_WEB_STATIC" "static" True True
, item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "CHUSQL_WEB_COOKIE_SECURE" "0" True False
, item "body-limit" "Max request body (bytes)" "limits" "int" "CHUSQL_WEB_BODY_LIMIT" "65536" False False
, item "session-idle" "Session idle timeout (s)" "limits" "int" "CHUSQL_WEB_SESSION_IDLE" "28800" False False
, item "session-max" "Session max age (s)" "limits" "int" "CHUSQL_WEB_SESSION_MAX" "86400" False False
, item "login-max-attempts" "Failed logins before lockout" "limits" "int" "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" "5" False False
, item "login-window" "Lockout window (s)" "limits" "int" "CHUSQL_WEB_LOGIN_WINDOW" "300" False False
, item "rows-per-page" "Rows per page" "limits" "int" "CHUSQL_WEB_PAGE_SIZE" "25" False False
, item "max-page-size" "Rows per page ceiling" "limits" "int" "CHUSQL_WEB_MAX_PAGE_SIZE" "500" False False
, item "max-rows" "Rows per query" "limits" "int" "CHUSQL_WEB_MAX_ROWS" "1000" False False
, item "max-sql-length" "Max SQL characters" "limits" "int" "CHUSQL_WEB_MAX_SQL_LENGTH" "20000" False False
, item "user" "Account name" "auth" "text" "CHUSQL_WEB_USER" "root" True True
, item "password" "Password (plain, hashed at start)" "auth" "secret" "CHUSQL_WEB_PASSWORD" "" True True
, item "password-hash" "Password hash (pbkdf2)" "auth" "secret" "CHUSQL_WEB_PASSWORD_HASH" "" True True
, item "data-dir" "Data directory" "storage" "text" "CHUSQL_DATA_DIR" "" True True
, item "pipe-name" "Named pipe" "storage" "text" "CHUSQL_PIPE" "" True True
, item "storage-page-size" "Storage page size" "storage" "int" "CHUSQL_PAGE_SIZE" "4096" True True
, item "storage-btree-order" "Storage B+tree order" "storage" "int" "CHUSQL_BTREE_ORDER" "4" True True
, item "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "CHUSQL_BUFFER_POOL_SIZE" "1024" True True
, item "storage-log" "Storage log level" "storage" "text" "CHUSQL_LOG" "info" True True
, item "seed" "Seed demo data on start" "storage" "bool" "CHUSQL_WEB_SEED" "1" True True
[ item "port" "HTTP port" "server" "int" "7777" True True
, item "host" "Listen address" "server" "text" "127.0.0.1" True True
, item "static-dir" "Static directory" "server" "text" "static" True True
, item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "0" True False
, item "body-limit" "Max request body (bytes)" "limits" "int" "65536" False False
, item "session-idle" "Session idle timeout (s)" "limits" "int" "28800" False False
, item "session-max" "Session max age (s)" "limits" "int" "86400" False False
, item "login-max-attempts" "Failed logins before lockout" "limits" "int" "5" False False
, item "login-window" "Lockout window (s)" "limits" "int" "300" False False
, item "rows-per-page" "Rows per page" "limits" "int" "25" False False
, item "max-page-size" "Rows per page ceiling" "limits" "int" "500" False False
, item "max-rows" "Rows per query" "limits" "int" "1000" False False
, item "max-sql-length" "Max SQL characters" "limits" "int" "20000" False False
, item "user" "管理员账号(设置文件)" "auth" "text" "root" True True
, item "password" "管理员口令(设置文件,明文)" "auth" "secret" "" True True
, item "password-min-length" "口令最短长度" "auth" "int" "12" True False
, item "password-classes" "口令字符类别数" "auth" "int" "2" True False
, owned "data-dir" "Data directory" "storage" "text" "" "storage" "data_dir"
, owned "pipe-name" "Endpoint name" "storage" "text" "" "server" "pipe_name"
, owned "storage-page-size" "Storage page size" "storage" "int" "4096" "page" "size"
, owned "storage-btree-order" "Storage B+tree order" "storage" "int" "4" "btree" "order"
, owned "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "1024" "buffer" "pool_size"
, owned "storage-log" "Storage log level" "storage" "text" "info" "log" "level"
, item "storage-server" "Storage server executable" "storage" "text" "" True True
, item "seed" "Seed demo data on start" "storage" "bool" "1" True True
]
where
item k l g kind env def rootOnly restart =
-- web 自己的键:分区写死 [web],TOML 键名就是界面键名
item k l g kind def rootOnly restart =
SettingItem
{ siKey = k
, siLabel = l
, siGroup = g
, siKind = kind
, siEnv = env
, siDefault = def
, siRootOnly = rootOnly
, siRestart = restart
, siSection = webSection
, siTomlKey = k
}
-- 存储层的键:只读自己那段
owned k l g kind def section tomlKey =
SettingItem
{ siKey = k
, siLabel = l
, siGroup = g
, siKind = kind
, siDefault = def
, siRootOnly = True
, siRestart = True
, siSection = section
, siTomlKey = tomlKey
}
-- | web 自己那段
webSection :: Text
webSection = "web"
-- | 目录里涉及的分区(按出现顺序,去重)
catalogueSections :: [Text]
catalogueSections = nub [siSection i | i <- settingCatalogue]
-- | 按 key 找一项
findItem :: Text -> Maybe SettingItem
@@ -93,6 +113,14 @@ findItem key = case [i | i <- settingCatalogue, siKey i == key] of
isRootOnly :: Text -> Bool
isRootOnly key = maybe False siRootOnly (findItem key)
-- | 只读项:root 凭据只认设置文件里的明文,网页端不能改(改文件后重启生效)
lockedKeys :: [Text]
lockedKeys = ["user", "password"]
-- | 这项在界面上是不是只读
isLockedSetting :: Text -> Bool
isLockedSetting = (`elem` lockedKeys)
-- | 这项是不是要重启才生效
isRestartRequired :: Text -> Bool
isRestartRequired key = maybe False siRestart (findItem key)
@@ -100,7 +128,8 @@ isRestartRequired key = maybe False siRestart (findItem key)
-- | 改完立刻生效的配置键
liveKeys :: [Text]
liveKeys =
[ "body-limit"
[ "password-min-length", "password-classes"
, "body-limit"
, "session-idle"
, "session-max"
, "login-max-attempts"
@@ -111,87 +140,42 @@ liveKeys =
, "max-sql-length"
]
-- | 设置文件的候选位置
settingsFileCandidates :: [FilePath]
settingsFileCandidates =
[ "script" </> "chusql.settings.json"
, ".." </> "script" </> "chusql.settings.json"
, "chusql.settings.json"
]
-- | 找现成的设置文件,否则挑目录已存在的候选
resolveSettingsFile :: IO FilePath
resolveSettingsFile = do
found <- firstExistingFile settingsFileCandidates
case found of
Just path -> pure path
Nothing -> do
ready <- firstExistingDir settingsFileCandidates
pure (maybe (headOr settingsFileCandidates "chusql.settings.json") id ready)
where
firstExistingFile [] = pure Nothing
firstExistingFile (p : ps) = do
ok <- doesFileExist p
if ok then pure (Just p) else firstExistingFile ps
-- | 候选所在目录是否已存在
firstExistingDir [] = pure Nothing
firstExistingDir (p : ps) = do
let dir = takeDirectory p
ok <- if null dir then pure True else doesDirectoryExist dir
if ok then pure (Just p) else firstExistingDir ps
-- | 候选列表里的第一个
headOr :: [a] -> a -> a
headOr (x : _) _ = x
headOr [] d = d
-- | 读设置文件:键 -> 字符串值(文件不存在或坏掉都当空)
-- | 读目录里用到的每个分区,摊成界面键;只有 [web] 统一成连字符写法
readSettingsFile :: FilePath -> IO (Map.Map Text Text)
readSettingsFile path = do
exists <- doesFileExist path
if not exists
then pure Map.empty
else do
raw <- readFileUtf8 path
pure $ case A.eitherDecodeStrict' raw of
Left _ -> Map.empty
Right (A.Object o) -> Map.fromList [(K.toText k, jsonToText v) | (k, v) <- KM.toList o]
Right _ -> Map.empty
readSettingsFile path = fmap Map.unions (mapM readSectionOf catalogueSections)
where
-- | JSON 值当字符串看
jsonToText v = case v of
A.String s -> s
A.Number n -> T.pack (show n)
A.Bool b -> if b then "1" else "0"
_ -> ""
readSectionOf section = do
raw <- readSection path section
let values = if section == webSection then canonicalSettingKeys raw else raw
pure
( Map.fromList
[ (siKey i, value)
| i <- settingCatalogue
, siSection i == section
, Just value <- [Map.lookup (siTomlKey i) values]
]
)
-- | 读文件(UTF-8 字节读,避免依赖系统编码)
readFileUtf8 :: FilePath -> IO BS.ByteString
readFileUtf8 = BS.readFile
-- | 写设置文件,只写非空值
-- | 按分区写回:每个键落进自己的段,别的分区与注释原样保留
writeSettingsFile :: FilePath -> Map.Map Text Text -> IO (Either String ())
writeSettingsFile path values = do
let dir = takeDirectory path
prepared <- try (ensureDir dir) :: IO (Either IOException ())
case prepared of
Left e -> pure (Left ("cannot create the settings directory " ++ dir ++ ": " ++ show e))
Right () -> do
let encoded =
A.encode
( A.Object
( KM.fromList
[ (K.fromText k, A.String v)
| (k, v) <- Map.toList values
, not (T.null v)
]
)
)
written <- try (BS.writeFile path (BL.toStrict encoded)) :: IO (Either IOException ())
pure (either (Left . \e -> "cannot write " ++ path ++ ": " ++ show e) Right written)
writeSettingsFile path values = writeSections catalogueSections
where
ensureDir dir = if null dir then pure () else createDirectoryIfMissing True dir
writeSections [] = pure (Right ())
writeSections (section : rest) = do
let entries =
Map.fromList
[ (siTomlKey i, value)
| i <- settingCatalogue
, siSection i == section
, Just value <- [Map.lookup (siKey i) values]
]
if Map.null entries
then writeSections rest
else do
written <- writeSection path section entries
case written of
Left err -> pure (Left err)
Right () -> writeSections rest
-- | 把界面上传来的值合并进现有文件内容(只接受目录里有的键)
applySettings :: Map.Map Text Text -> Map.Map Text Text -> Either String (Map.Map Text Text)
@@ -209,6 +193,10 @@ applySettings current = go (Right current) . Map.toList
checkKind :: SettingItem -> Text -> Either String ()
checkKind spec value
| T.null (T.strip value) = Right ()
| Just (lo, hi) <- lookup (siKey spec) [("password-min-length", (8, 256)), ("password-classes", (1, 4))] =
case reads (T.unpack (T.strip value)) :: [(Int, String)] of
[(n, "")] | n >= lo && n <= hi -> Right ()
_ -> Left (T.unpack (siKey spec) ++ " is outside the permitted range")
| siKind spec == "int" = case reads (T.unpack (T.strip value)) :: [(Int, String)] of
[(_, "")] -> Right ()
_ -> Left (T.unpack (siKey spec) ++ " needs an integer, got: " ++ T.unpack value)
@@ -238,7 +226,7 @@ effectiveSettings cfg =
, ("max-page-size", tshow (wcMaxPageSize cfg))
, ("max-rows", tshow (wcMaxRows cfg))
, ("max-sql-length", tshow (wcMaxSqlLength cfg))
, ("user", wcUser cfg)
, ("user", if T.null (T.strip (wcUser cfg)) then defaultUser else T.strip (wcUser cfg))
, ("seed", boolText (wcSeedDemo cfg))
, ("data-dir", maybe "" T.pack (wcDataDir cfg))
, ("pipe-name", maybe "" T.pack (wcPipeName cfg))
-1
View File
@@ -33,7 +33,6 @@ safeRelative t
| map toLower (takeExtension (T.unpack t)) `notElem` allowedExtensions = Nothing
| otherwise = Just (T.unpack t)
where
-- | 只允许字母数字与 ._-
badChar c = not (isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String))
-- | 读一个静态文件;不存在(或是目录)给 Nothing
+35 -41
View File
@@ -4,19 +4,22 @@ module ChuSQL.Web.StorageProcess (
StorageProcess (..),
findStorageServer,
startStorageProcess,
startStorageProcessAt,
platformBinaryName,
storageChildArgs,
stopStorageProcess,
waitForStorage,
) where
import ChuSQL.Storage.IPC (Request (..), Response (..), closeConnection, sendRequest)
import ChuSQL.Storage.IPC (Request (..), Response (..), closeConnection, sendRequest, setPipeName)
import ChuSQL.Web.Config (loadWebConfigAt, resolvePipeName)
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Control.Monad (filterM)
import Data.Time.Clock.POSIX (getPOSIXTime)
import System.Directory (createDirectoryIfMissing, doesFileExist, getTemporaryDirectory)
import System.Environment (getEnvironment, setEnv)
import System.Directory (doesFileExist)
import System.Exit (ExitCode)
import System.FilePath ((</>))
import System.Info (os)
import System.IO (Handle)
import System.Process (
CreateProcess (..),
@@ -29,13 +32,21 @@ import System.Process (
)
-- 拉起并管理 Rust 存储进程,起来后等管道应答 ping 再继续。
-- 调参、管名与数据目录都在全局 chusql.toml 里,孩子自己读;这里只负责起进程、
-- 用 --config 把「读哪份配置文件」告诉它,并等就绪。
data StorageProcess = StorageProcess
{ spProcess :: ProcessHandle
, spPipe :: String
, spDataDir :: FilePath
}
-- | 可执行文件名跟平台走:Windows 带 .exe,其它平台不带。同一个 target 目录里可能
-- 同时躺着一份别处交叉编译出来的 .exe(Windows PE),在 Linux 上误选它只会拿到
-- config file not found(Windows 进程看不到 /tmp/... 这样的路径)。目标平台当参数传,
-- 两个分支都能被测试直接钉住。
platformBinaryName :: String -> String -> FilePath
platformBinaryName targetOs stem = if targetOs == "mingw32" then stem ++ ".exe" else stem
-- | 找存储进程,没有就给路径提示
findStorageServer :: Maybe FilePath -> IO (Either String FilePath)
findStorageServer (Just p) = do
@@ -49,36 +60,37 @@ findStorageServer Nothing = do
, "target"
]
modes = ["release", "debug"]
exes = ["chusql-storage.exe", "chusql-storage"]
exes = [platformBinaryName os "chusql-storage"]
candidates = [d </> m </> e | d <- dirs, m <- modes, e <- exes]
found <- filterM doesFileExist candidates
pure $ case found of
(p : _) -> Right p
[] -> Left "no Rust storage server found; run `cargo build --release` in chusql-storage, or pass --storage <path>"
-- | 起存储进程并等它就绪
startStorageProcess :: FilePath -> IO (Either String StorageProcess)
startStorageProcess bin = do
stamp <- round . (* 1000000) <$> getPOSIXTime :: IO Integer
tmp <- getTemporaryDirectory
let pipe = "chusql-web-" ++ show stamp
dir = tmp </> pipe
createDirectoryIfMissing True dir
envs <-
childEnv
[ ("CHUSQL_PIPE", pipe)
, ("CHUSQL_DATA_DIR", dir)
]
-- | 按配置文件里的管名起存储进程
startStorageProcess :: FilePath -> FilePath -> IO (Either String StorageProcess)
startStorageProcess bin configPath = do
cfg <- loadWebConfigAt configPath
startStorageProcessAt bin (resolvePipeName cfg) configPath
-- | 给存储子进程的参数:配置文件位置是固定的,唯一的显式覆盖就是写进 --config
storageChildArgs :: FilePath -> [String]
storageChildArgs configPath = ["--config", configPath]
-- | 用给定的管名起存储进程,并把本进程的端点也指到它;环境原样继承
startStorageProcessAt :: FilePath -> String -> FilePath -> IO (Either String StorageProcess)
startStorageProcessAt bin pipe configPath = do
let child = (proc bin (storageChildArgs configPath)){std_out = NoStream, std_err = Inherit}
started <-
try (createProcess (proc bin []){env = Just envs, std_out = NoStream, std_err = Inherit}) ::
try (createProcess child) ::
IO (Either SomeException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
case started of
Left e -> pure (Left ("failed to start the storage process: " ++ show e))
Right (_, _, _, ph) -> do
setEnv "CHUSQL_PIPE" pipe
setEnv "CHUSQL_DATA_DIR" dir
setPipeName pipe
ready <- waitForStorage (200 :: Int)
if ready
then pure (Right (StorageProcess ph pipe dir))
then pure (Right (StorageProcess ph pipe))
else do
_ <- try (terminateProcess ph) :: IO (Either SomeException ())
pure (Left "storage process did not become ready within 5 seconds (no ping reply on the pipe)")
@@ -99,21 +111,3 @@ waitForStorage n = do
case r of
Right RespPong -> pure True
_ -> threadDelay 25000 >> waitForStorage (n - 1)
-- | 子进程环境:继承并覆盖两个专用变量
childEnv :: [(String, String)] -> IO [(String, String)]
childEnv extra = do
base <- getEnvironment
let ours = ["CHUSQL_PIPE", "CHUSQL_DATA_DIR"]
kept = filter (\(k, _) -> k `notElem` ours) base
missing (k, _) = not (any ((== k) . fst) kept)
defaults = filter missing storageDefaults
pure (kept ++ defaults ++ extra)
-- | 存储进程的调参默认值(可以被用户环境里的同名变量覆盖)
storageDefaults :: [(String, String)]
storageDefaults =
[ ("CHUSQL_PAGE_SIZE", "4096")
, ("CHUSQL_BTREE_ORDER", "4")
, ("CHUSQL_LOG", "info")
]
+163
View File
@@ -0,0 +1,163 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.TOML
( readSection
, writeSection
, defaultConfigFile
, resolveConfigPath
) where
import Control.Exception (IOException, try)
import qualified Data.ByteString as BS
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesFileExist, getXdgDirectory)
import System.FilePath (takeDirectory, (</>))
-- 全局 chusql.toml 的最小读写:只认 [section] 与 key = value。
-- 读写都是一次性打开就关,不长期占着文件句柄,别的层随时能读同一份文件。
-- | 读一个分区;文件不存在或没有这个分区都当空表
readSection :: FilePath -> Text -> IO (Map Text Text)
readSection path section = do
exists <- doesFileExist path
if not exists
then pure Map.empty
else do
raw <- BS.readFile path
pure (Map.fromList (sectionEntries section (T.lines (TE.decodeUtf8 raw))))
-- | 只重写一个分区,其它行(含注释与别的层)原样保留
writeSection :: FilePath -> Text -> Map Text Text -> IO (Either String ())
writeSection path section values = do
prepared <- prepareDirectory
case prepared of
Left err -> pure (Left err)
Right () -> do
exists <- doesFileExist path
raw <- if exists then TE.decodeUtf8 <$> BS.readFile path else pure ""
let updated = T.unlines (rewrite section values (T.lines raw))
written <- tryWrite (TE.encodeUtf8 updated)
pure written
where
prepareDirectory = do
let dir = takeDirectory path
if null dir
then pure (Right ())
else do
done <- try (createDirectoryIfMissing True dir) :: IO (Either IOException ())
pure (either (Left . show) (const (Right ())) done)
tryWrite bytes = do
done <- try (BS.writeFile path bytes) :: IO (Either IOException ())
pure (either (Left . show) (const (Right ())) done)
-- | 分区里的键值对
sectionEntries :: Text -> [Text] -> [(Text, Text)]
sectionEntries wanted = go ""
where
go _ [] = []
go current (line : rest)
| Just name <- sectionName line = go name rest
| current /= wanted = go current rest
| otherwise = case assignment line of
Nothing -> go current rest
Just entry -> entry : go current rest
-- | 是不是分区头,返回分区名
sectionName :: Text -> Maybe Text
sectionName line = case T.strip line of
stripped
| "[" `T.isPrefixOf` stripped && "]" `T.isSuffixOf` stripped && T.length stripped > 2 ->
Just (T.strip (T.drop 1 (T.dropEnd 1 stripped)))
_ -> Nothing
-- | 是不是 key = value
assignment :: Text -> Maybe (Text, Text)
assignment line = case T.breakOn "=" (stripComment line) of
(key, rest)
| not (T.null rest)
, let name = T.strip key
, not (T.null name) ->
Just (name, unquote (T.strip (T.drop 1 rest)))
_ -> Nothing
-- | 去掉行尾注释(引号里的 # 不算)
stripComment :: Text -> Text
stripComment = T.pack . go False . T.unpack
where
go _ [] = []
go inString ('#' : rest)
| not inString = []
| otherwise = '#' : go inString rest
go inString ('"' : rest) = '"' : go (not inString) rest
go inString (ch : rest) = ch : go inString rest
-- | 去掉包裹的引号
unquote :: Text -> Text
unquote value
| T.length value >= 2 && T.head value == '"' && T.last value == '"' = T.drop 1 (T.dropEnd 1 value)
| T.length value >= 2 && T.head value == '\'' && T.last value == '\'' = T.drop 1 (T.dropEnd 1 value)
| otherwise = value
-- | 行级重写:本分区里已有的键就地更新,空值删行,缺的键补在分区末尾
rewrite :: Text -> Map Text Text -> [Text] -> [Text]
rewrite section values = addMissing . replaceInSection . dropEmpties
where
dropEmpties = filter (\line -> maybe True (not . T.null . snd) (assignment line))
replaceInSection = go ""
go _ [] = []
go current (line : rest)
| Just name <- sectionName line = line : go name rest
| current /= section = line : go current rest
| otherwise = case assignment line of
Just (key, _) | Just value <- Map.lookup key values -> assignmentLine key value : go current rest
_ -> line : go current rest
addMissing lines' =
let present = Set.fromList (keysOf section lines')
missing = [(key, value) | (key, value) <- Map.toList values, not (Set.member key present), not (T.null value)]
in if null missing then lines' else appendSection section missing lines'
assignmentLine :: Text -> Text -> Text
assignmentLine key value = key <> " = " <> quote value
quote :: Text -> Text
quote value
| T.all allowed value && not (T.null value) = value
| otherwise = "\"" <> T.replace "\"" "\\\"" value <> "\""
where
allowed ch = ch `elem` ("0123456789.-" :: String)
-- | 取某个分区里出现过的键
keysOf :: Text -> [Text] -> [Text]
keysOf wanted = go ""
where
go _ [] = []
go current (line : rest)
| Just name <- sectionName line = go name rest
| current /= wanted = go current rest
| otherwise = case assignment line of
Just (key, _) -> key : go current rest
Nothing -> go current rest
-- | 没有这个分区就在末尾补一个
appendSection :: Text -> [(Text, Text)] -> [Text] -> [Text]
appendSection section entries lines' =
lines'
++ (if null lines' || not (T.null (last lines')) then [""] else [])
++ ("[" <> section <> "]") : [assignmentLine key value | (key, value) <- entries]
-- | 全局配置文件的位置:安装时定下的固定路径,跟随系统惯例
-- (Windows: %APPDATA%\ChuSQL\chusql.toml;其它平台: $XDG_CONFIG_HOME 或 ~/.config 下的 ChuSQL/chusql.toml)
defaultConfigFile :: IO FilePath
defaultConfigFile = do
dir <- getXdgDirectory XdgConfig "ChuSQL"
pure (dir </> "chusql.toml")
-- | 定位配置文件:命令行 --config 指的是哪份就读哪份,否则用固定路径
resolveConfigPath :: Maybe FilePath -> IO FilePath
resolveConfigPath (Just path) = pure path
resolveConfigPath Nothing = defaultConfigFile
+17 -19
View File
@@ -1,11 +1,11 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.UiSettings (
module ChuSQL.Web.UISettings (
uiSettingsFileCandidates,
resolveUiSettingsFile,
validateUiSettings,
readUiSettings,
writeUiSettings,
resolveUISettingsFile,
validateUISettings,
readUISettings,
writeUISettings,
) where
import Control.Exception (IOException, try)
@@ -22,19 +22,19 @@ import qualified Data.Vector as V
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (takeDirectory, (</>))
-- 前端 IDE 设置单独存一份文件,与启动配置 chusql.settings.json 互不干扰。
-- 前端 IDE 设置单独存一份文件,与全局启动配置 chusql.toml 互不干扰。
-- | IDE 设置文件的候选位置
uiSettingsFileCandidates :: [FilePath]
uiSettingsFileCandidates =
[ "script" </> "chusql.ui.settings.json"
, ".." </> "script" </> "chusql.ui.settings.json"
[ "scripts" </> "chusql.ui.settings.json"
, ".." </> "scripts" </> "chusql.ui.settings.json"
, "chusql.ui.settings.json"
]
-- | 找现成的设置文件,否则挑目录已存在的候选
resolveUiSettingsFile :: IO FilePath
resolveUiSettingsFile = do
resolveUISettingsFile :: IO FilePath
resolveUISettingsFile = do
found <- firstExistingFile uiSettingsFileCandidates
case found of
Just path -> pure path
@@ -42,7 +42,6 @@ resolveUiSettingsFile = do
ready <- firstExistingDir uiSettingsFileCandidates
pure (maybe firstCandidate id ready)
where
-- | 候选清单里的第一个
firstCandidate = case uiSettingsFileCandidates of
(p : _) -> p
[] -> "chusql.ui.settings.json"
@@ -52,7 +51,6 @@ resolveUiSettingsFile = do
ok <- doesFileExist p
if ok then pure (Just p) else firstExistingFile ps
-- | 候选所在目录是否已存在
firstExistingDir [] = pure Nothing
firstExistingDir (p : ps) = do
let dir = takeDirectory p
@@ -60,8 +58,8 @@ resolveUiSettingsFile = do
if ok then pure (Just p) else firstExistingDir ps
-- | 校验一份 IDE 设置,未知键与越界都给错
validateUiSettings :: A.Value -> Either String (Map.Map Text A.Value)
validateUiSettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
validateUISettings :: A.Value -> Either String (Map.Map Text A.Value)
validateUISettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
where
checkField (rawKey, value) =
let key = K.toText rawKey
@@ -84,7 +82,7 @@ validateUiSettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
pair key value = (key, value)
validateUiSettings _ = Left "settings must be a JSON object"
validateUISettings _ = Left "settings must be a JSON object"
fontChainMax :: Int
fontChainMax = 10
@@ -150,8 +148,8 @@ boolValue key value = case value of
_ -> Left (T.unpack key ++ " expects a boolean")
-- | 读 IDE 设置文件,缺文件或坏内容都给空对象
readUiSettings :: FilePath -> IO A.Value
readUiSettings path = do
readUISettings :: FilePath -> IO A.Value
readUISettings path = do
exists <- doesFileExist path
if not exists
then pure emptyObject
@@ -166,8 +164,8 @@ readUiSettings path = do
emptyObject = A.Object KM.empty
-- | 写 IDE 设置文件,先建目录再落盘
writeUiSettings :: FilePath -> Map.Map Text A.Value -> IO (Either String ())
writeUiSettings path values = do
writeUISettings :: FilePath -> Map.Map Text A.Value -> IO (Either String ())
writeUISettings path values = do
prepared <- try (ensureDir (takeDirectory path)) :: IO (Either IOException ())
case prepared of
Left e -> pure (Left ("cannot create the settings directory " ++ takeDirectory path ++ ": " ++ show e))
+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('&', '&amp;')
.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; }
}
+1307 -359
View File
File diff suppressed because it is too large. Load diff