sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1

- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
logan committed 2026-05-23 14:41:06 +08:00
1 parent 6fcd9465cf
commit 4c9427e546
105 files changed
+23680 -471

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { EditorState, Compartment } from '@codemirror/state';
import type { Extension } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
import { sql, SQLDialect } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import {
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
} from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { rectangularSelection } from '@codemirror/rectangular-selection';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SqlEditorProps {
value: string;
onChange: (val: string) => void;
placeholder?: string;
language?: 'sql' | 'redis' | 'mongodb';
height?: string;
readOnly?: boolean;
onExecute?: () => void;
}
// ---------------------------------------------------------------------------
// Language configs
// ---------------------------------------------------------------------------
const REDIS_KEYWORDS = [
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET',
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD',
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN',
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER',
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL',
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL',
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT',
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH',
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE',
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN',
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX',
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE',
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF',
];
const MONGODB_KEYWORDS = [
// CRUD
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany',
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete',
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct',
// Aggregation stages
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup',
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount',
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count',
// Operators
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not',
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then',
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays',
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last',
// Index / admin
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint',
// Collection / DB
'use', 'show', 'db', 'collection', 'collections', 'databases',
// JS keywords commonly used
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while',
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof',
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal',
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
];
function getSQLExtension(language: SqlEditorProps['language']): Extension {
const lang = language ?? 'sql';
if (lang === 'redis') {
const dialect = SQLDialect.define({
keywords: REDIS_KEYWORDS.join(' '),
});
return sql({ dialect });
}
if (lang === 'mongodb') {
const dialect = SQLDialect.define({
keywords: MONGODB_KEYWORDS.join(' '),
});
return sql({ dialect });
}
return sql();
}
// ---------------------------------------------------------------------------
// Custom theme to match app CSS variables
// ---------------------------------------------------------------------------
const appTheme = EditorView.theme({
'&': {
fontSize: '13px',
backgroundColor: 'var(--dg-bg-primary)',
color: 'var(--dg-text-primary)',
border: '1px solid var(--dg-border)',
borderRadius: '6px',
},
'.cm-content': {
caretColor: 'var(--dg-text-primary)',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
},
'.cm-cursor': {
borderLeftColor: 'var(--dg-text-primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
},
'.cm-gutters': {
backgroundColor: 'var(--dg-bg-tertiary)',
color: 'var(--dg-text-muted)',
border: 'none',
borderRight: '1px solid var(--dg-border)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--dg-bg-tertiary)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
'.cm-placeholder': {
color: 'var(--dg-text-muted)',
fontStyle: 'italic',
},
'.cm-scroller': {
overflow: 'auto',
},
});
// ---------------------------------------------------------------------------
// formatSQL helper
// ---------------------------------------------------------------------------
const SQL_BREAK_KEYWORDS = [
'SELECT',
'FROM',
'WHERE',
'ORDER BY',
'GROUP BY',
'HAVING',
'LIMIT',
'OFFSET',
'JOIN',
'INNER JOIN',
'LEFT JOIN',
'RIGHT JOIN',
'FULL JOIN',
'CROSS JOIN',
'LEFT OUTER JOIN',
'RIGHT OUTER JOIN',
'FULL OUTER JOIN',
'ON',
'UNION',
'UNION ALL',
'INTERSECT',
'EXCEPT',
'INSERT INTO',
'VALUES',
'UPDATE',
'SET',
'DELETE FROM',
'CREATE TABLE',
'ALTER TABLE',
'DROP TABLE',
];
const SQL_KEYWORDS_UPPER = new Set([
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON',
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS',
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT',
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER',
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION',
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT',
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN',
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE',
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON',
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE',
]);
export function formatSQL(input: string): string {
// Normalise whitespace
let formatted = input.replace(/\s+/g, ' ').trim();
// Uppercase SQL keywords (word-boundary aware)
formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => {
const upper = match.toUpperCase();
return SQL_KEYWORDS_UPPER.has(upper) ? upper : match;
});
// Insert newlines before major clauses
// We iterate in the order they might appear; use a single pass regex approach.
for (const kw of SQL_BREAK_KEYWORDS) {
// Only break on keyword that is not already at start of a line
const escaped = kw.replace(/\s+/g, '\\s+');
const re = new RegExp(`(?<!^)(?<!\\n)\\b(${escaped})\\b`, 'gi');
formatted = formatted.replace(re, '\n$1');
}
// Clean up any double newlines
formatted = formatted.replace(/\n{2,}/g, '\n');
return formatted.trim();
}
// ---------------------------------------------------------------------------
// SqlEditor component
// ---------------------------------------------------------------------------
const SqlEditor: React.FC<SqlEditorProps> = ({
value,
onChange,
placeholder = 'Enter SQL query…',
language = 'sql',
height = '200px',
readOnly = false,
onExecute,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const langCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const placeholderCompartmentRef = useRef(new Compartment());
const isExternalUpdateRef = useRef(false);
// Keep onChange in a stable ref so callbacks never go stale
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Ctrl+Enter handler
const runKeymap = useCallback(
(): Extension[] => [
keymap.of([
{
key: 'Mod-Enter',
run: () => {
if (onExecute) {
onExecute();
} else {
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
}
return true;
},
},
{
key: 'Mod-Shift-f',
run: (view: EditorView) => {
const current = view.state.doc.toString();
const formatted = formatSQL(current);
if (formatted !== current) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
});
}
return true;
},
},
]),
],
[onExecute],
);
// Create editor once
useEffect(() => {
if (!containerRef.current) return;
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdateRef.current) {
onChangeRef.current(update.state.doc.toString());
}
});
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
highlightSelectionMatches(),
rectangularSelection(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
langCompartmentRef.current.of(getSQLExtension(language)),
readOnlyCompartmentRef.current.of(
EditorState.readOnly.of(readOnly),
),
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
updateListener,
...runKeymap(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// We intentionally only run this once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes into the editor (avoid loops)
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentContent = view.state.doc.toString();
if (currentContent !== value) {
isExternalUpdateRef.current = true;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
isExternalUpdateRef.current = false;
}
}, [value]);
// Update language when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language)),
});
}, [language]);
// Update readOnly when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: readOnlyCompartmentRef.current.reconfigure(
EditorState.readOnly.of(readOnly),
),
});
}, [readOnly]);
// Update placeholder when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: placeholderCompartmentRef.current.reconfigure(
cmPlaceholder(placeholder ?? ''),
),
});
}, [placeholder]);
return (
<div
ref={containerRef}
style={{ height, minHeight: height }}
className="sql-editor-wrapper"
/>
);
};
export default SqlEditor;