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