From 35a014e57138916e0d9b3b6d1a030bae7301e121 Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 24 May 2026 18:50:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9F=A5=E8=AF=A2=E9=A1=B5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20+=20=E6=9D=83=E9=99=90=E4=BD=93=E7=B3=BB=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis DB0~DB15 下拉选择,默认DB0 - 查询历史粘贴按钮(一键填入SQL编辑器) - 去掉导出历史按钮 - SchemaSidebar 按目标数据库筛选 - SQL自动补全 schema 双层注册(db→tables, table→columns) - 后端 db-schema 接口(INFORMATION_SCHEMA.COLUMNS 一次加载) - autocompletion 扩展激活 - Redis key 树形结构(冒号分隔层级) - Redis 查询模板 GET/HGETALL/HGET - guest 角色彻底移除,改为 auditor - 登录页优化:去掉演示账号、placeholder 中文提示 --- backend/app/api/v1/endpoints/auth.py | 3 + .../app/api/v1/endpoints/instance_detail.py | 36 +++- backend/app/schemas/user.py | 4 +- frontend/src/api/instanceDetail.ts | 6 + frontend/src/components/SchemaSidebar.tsx | 181 ++++++++++++++---- frontend/src/components/SqlEditor.tsx | 56 +++++- frontend/src/constants/menu.ts | 6 +- frontend/src/hooks/useAuth.tsx | 24 +-- frontend/src/lib/queryTemplates.ts | 24 +++ frontend/src/pages/LoginPage.tsx | 18 +- frontend/src/pages/QueriesPage.tsx | 94 ++++++--- frontend/src/pages/UsersPage.tsx | 2 +- frontend/src/types/index.ts | 2 +- 13 files changed, 345 insertions(+), 111 deletions(-) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index dbc6ef8..9a645f7 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -22,6 +22,9 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): if not user.is_active: raise HTTPException(status_code=403, detail="用户已禁用") + if user.role == "guest": + raise HTTPException(status_code=403, detail="访客账户已禁用,请联系管理员") + user.last_login = datetime.now(timezone.utc) await db.commit() diff --git a/backend/app/api/v1/endpoints/instance_detail.py b/backend/app/api/v1/endpoints/instance_detail.py index 39b6bc0..44c911d 100644 --- a/backend/app/api/v1/endpoints/instance_detail.py +++ b/backend/app/api/v1/endpoints/instance_detail.py @@ -30,7 +30,7 @@ async def _get_decrypted_instance(db: AsyncSession, instance_id: int) -> Instanc return instance -async def _mysql_query(instance: Instance, sql: str, database: Optional[str] = None) -> list: +async def _mysql_query(instance: Instance, sql: str, database: Optional[str] = None, params: Optional[list] = None) -> list: """Execute a MySQL query and return rows as list of dicts.""" import aiomysql conn = await aiomysql.connect( @@ -40,7 +40,7 @@ async def _mysql_query(instance: Instance, sql: str, database: Optional[str] = N ) try: async with conn.cursor(aiomysql.DictCursor) as cur: - await cur.execute(sql) + await cur.execute(sql, params) rows = await cur.fetchall() return rows finally: @@ -120,6 +120,38 @@ async def list_tables( raise HTTPException(status_code=500, detail="查询表列表失败: " + str(e)) +# ---- DB Schema (for autocomplete) ---- + +@router.get("/{instance_id}/detail/db-schema") +async def get_db_schema( + instance_id: int, + database: str = Query(..., description="数据库名"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin", "dba", "developer")), +): + """一次性返回整个库的 {table: [columns]} 结构,用于前端 SQL 自动补全""" + instance = await _get_decrypted_instance(db, instance_id) + await check_instance_access(current_user, instance, db) + if instance.db_type != "mysql": + raise HTTPException(status_code=400, detail="仅支持MySQL实例") + try: + rows = await _mysql_query( + instance, + "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME, ORDINAL_POSITION", + database=database, + params=[database], + ) + schema: dict[str, list[str]] = {} + for row in rows: + tbl = row["TABLE_NAME"] + col = row["COLUMN_NAME"] + schema.setdefault(tbl, []).append(col) + return schema + except Exception as e: + raise HTTPException(status_code=500, detail="获取库结构失败: " + str(e)) + + # ---- Table Detail ---- @router.get("/{instance_id}/detail/table-detail") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index be9f938..c94df23 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -9,14 +9,14 @@ class UserCreate(BaseModel): password: str = Field(..., min_length=6) email: Optional[str] = None phone: Optional[str] = None - role: str = Field(default="developer", pattern="^(admin|dba|developer|guest)$") + role: str = Field(default="developer", pattern="^(admin|dba|developer|auditor)$") class UserUpdate(BaseModel): display_name: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None - role: Optional[str] = Field(default=None, pattern="^(admin|dba|developer|guest)$") + role: Optional[str] = Field(default=None, pattern="^(admin|dba|developer|auditor)$") is_active: Optional[bool] = None diff --git a/frontend/src/api/instanceDetail.ts b/frontend/src/api/instanceDetail.ts index e20d7f2..8f8ddea 100644 --- a/frontend/src/api/instanceDetail.ts +++ b/frontend/src/api/instanceDetail.ts @@ -12,6 +12,12 @@ export const instanceDetailApi = { params: { database }, }), + /** 获取整库表+列结构(用于 SQL 自动补全) */ + getDbSchema: (instanceId: number, database: string) => + request.get>(`/instances/${instanceId}/detail/db-schema`, { + params: { database }, + }), + /** 获取表详情(DDL + 字段 + 索引) */ getTableDetail: (instanceId: number, database: string, table: string) => request.get(`/instances/${instanceId}/detail/table-detail`, { diff --git a/frontend/src/components/SchemaSidebar.tsx b/frontend/src/components/SchemaSidebar.tsx index c1679a6..f1f4f16 100644 --- a/frontend/src/components/SchemaSidebar.tsx +++ b/frontend/src/components/SchemaSidebar.tsx @@ -23,6 +23,134 @@ interface RedisKeyItem { ttl: number; } +/** Redis key 树节点 */ +interface RedisTreeNode { + name: string; + fullPrefix: string; + isLeaf: boolean; + keyItem?: RedisKeyItem; // 叶子节点才有 + children: Map; +} + +/** 把 flat keys 按 ':' 构建成树 */ +function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode { + const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() }; + for (const item of keys) { + const parts = item.key.split(':'); + let node = root; + let prefix = ''; + for (let i = 0; i < parts.length; i++) { + prefix += (i === 0 ? '' : ':') + parts[i]; + if (!node.children.has(parts[i])) { + node.children.set(parts[i], { + name: parts[i], + fullPrefix: prefix, + isLeaf: i === parts.length - 1, + keyItem: i === parts.length - 1 ? item : undefined, + children: new Map(), + }); + } + node = node.children.get(parts[i])!; + } + } + return root; +} + +/** Redis 树形渲染组件 */ +function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail }: { + tree: RedisTreeNode; + expanded: Set; + setExpanded: React.Dispatch>>; + selectedKey: string | null; + onSelectKey: (key: string) => void; + keyDetail: any; + loadingDetail: boolean; +}) { + const toggle = (prefix: string) => { + setExpanded(prev => { + const next = new Set(prev); + next.has(prefix) ? next.delete(prefix) : next.add(prefix); + return next; + }); + }; + + const renderNode = (node: RedisTreeNode, depth: number) => { + const sorted = [...node.children.values()].sort((a, b) => { + // 文件夹优先,叶子在后 + if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1; + return a.name.localeCompare(b.name); + }); + + return sorted.map(child => { + const indent = depth * 12; + + if (child.isLeaf && child.keyItem) { + // 叶子节点:实际 Redis key + return ( +
+ + {selectedKey === child.keyItem.key && ( +
+ {loadingDetail ? ( +
+ ... +
+ ) : keyDetail ? ( +
+
+ {keyDetail.type} + TTL: {formatTTL(keyDetail.ttl)} +
+
+                      {typeof keyDetail.value === 'object'
+                        ? JSON.stringify(keyDetail.value, null, 2)
+                        : String(keyDetail.value)}
+                    
+
+ ) : null} +
+ )} +
+ ); + } + + // 文件夹节点 + const isOpen = expanded.has(child.fullPrefix); + const childCount = child.children.size; + return ( +
+ + {isOpen && renderNode(child, depth + 1)} +
+ ); + }); + }; + + return
{renderNode(tree, 0)}
; +} + /** 每个数据库节点缓存的表列表 */ interface DbNode { name: string; @@ -66,6 +194,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } const [redisPattern, setRedisPattern] = useState('*'); const [redisKeys, setRedisKeys] = useState([]); const [loadingRedis, setLoadingRedis] = useState(false); + const [redisExpanded, setRedisExpanded] = useState>(new Set()); const [selectedKey, setSelectedKey] = useState(null); const [keyDetail, setKeyDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); @@ -186,6 +315,8 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } /* ---------------------------------------------------------------- */ const filteredDatabases = databases.filter(dbName => { + // 如果已选目标数据库,只显示该库 + if (database && dbName !== database) return false; if (!search) return true; const kw = search.toLowerCase(); // 库名匹配 @@ -433,7 +564,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } - {/* Key List + Detail */} + {/* Key Tree + Detail */}
{loadingRedis ? (
@@ -448,45 +579,15 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } ) : redisKeys.length === 0 ? (
无 Keys
) : ( -
- {redisKeys.map(k => ( -
- - - {/* Key Detail */} - {selectedKey === k.key && ( -
- {loadingDetail ? ( -
- ... -
- ) : keyDetail ? ( -
-
- {keyDetail.type} - TTL: {formatTTL(keyDetail.ttl)} -
-
-                                {typeof keyDetail.value === 'object'
-                                  ? JSON.stringify(keyDetail.value, null, 2)
-                                  : String(keyDetail.value)}
-                              
-
- ) : null} -
- )} -
- ))} -
+ )}
diff --git a/frontend/src/components/SqlEditor.tsx b/frontend/src/components/SqlEditor.tsx index 5c54c9a..84d3b6a 100644 --- a/frontend/src/components/SqlEditor.tsx +++ b/frontend/src/components/SqlEditor.tsx @@ -11,7 +11,7 @@ import { defaultHighlightStyle, bracketMatching, } from '@codemirror/language'; -import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; // --------------------------------------------------------------------------- @@ -26,6 +26,8 @@ export interface SqlEditorProps { height?: string; readOnly?: boolean; onExecute?: () => void; + /** SQL schema: { [dbName]: { [tableName]: string[] (columns) } } or { [tableName]: string[] } */ + schema?: Record | string[]>; } // --------------------------------------------------------------------------- @@ -74,7 +76,7 @@ const MONGODB_KEYWORDS = [ 'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef', ]; -function getSQLExtension(language: SqlEditorProps['language']): Extension { +function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension { const lang = language ?? 'sql'; if (lang === 'redis') { @@ -91,6 +93,43 @@ function getSQLExtension(language: SqlEditorProps['language']): Extension { return sql({ dialect }); } + // MySQL/PG — pass schema for autocomplete (tables + columns) + if (schema && Object.keys(schema).length > 0) { + // Flatten: if first value is an array, it's { table: columns[] } + // if first value is an object, it's { db: { table: columns[] } } + const sampleValue = Object.values(schema)[0]; + let tables: Record = {}; + + if (Array.isArray(sampleValue)) { + // Direct: { table: [columns] } + tables = Object.fromEntries( + Object.entries(schema).map(([k, v]) => [k, v as string[]]) + ); + } else { + // Nested: { db: { table: [columns] } } + // Register: db → table names (so "db." shows tables) + // Register: table → columns (so "table." shows columns) + for (const [db, tableMap] of Object.entries(schema)) { + const entries = tableMap as Record; + const tableNames = Object.keys(entries); + // "shop_db" → shows table names as completions after "." + tables[db] = tableNames; + for (const [table, cols] of Object.entries(entries)) { + // "goods" → shows column names after "." + if (cols.length > 0) { + tables[table] = cols; + } + } + } + } + + const dialect = SQLDialect.define({ + keywords: '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 primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze', + schema: tables, + }); + return sql({ dialect }); + } + return sql(); } @@ -228,6 +267,7 @@ const SqlEditor: React.FC = ({ height = '200px', readOnly = false, onExecute, + schema, }) => { const containerRef = useRef(null); const viewRef = useRef(null); @@ -294,11 +334,15 @@ const SqlEditor: React.FC = ({ history(), bracketMatching(), closeBrackets(), + autocompletion({ + activateOnTyping: true, + icons: false, + }), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), appTheme, oneDark, - langCompartmentRef.current.of(getSQLExtension(language)), + langCompartmentRef.current.of(getSQLExtension(language, schema)), readOnlyCompartmentRef.current.of( EditorState.readOnly.of(readOnly), ), @@ -345,14 +389,14 @@ const SqlEditor: React.FC = ({ } }, [value]); - // Update language when it changes + // Update language/schema when they change useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ - effects: langCompartmentRef.current.reconfigure(getSQLExtension(language)), + effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)), }); - }, [language]); + }, [language, schema]); // Update readOnly when it changes useEffect(() => { diff --git a/frontend/src/constants/menu.ts b/frontend/src/constants/menu.ts index 2e4c7b2..e9695be 100644 --- a/frontend/src/constants/menu.ts +++ b/frontend/src/constants/menu.ts @@ -8,11 +8,11 @@ export const menuSections = [ ]; export const menuItems = [ - { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] }, + { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'] }, { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, { id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] }, - { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, - { id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, + { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'] }, + { id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, { id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] }, { id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] }, diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index a330448..fad1d5a 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -3,15 +3,15 @@ import { menuItems } from '@/constants/menu'; const defaultUser = { id: 0, - username: 'guest', + username: '', email: '', - role: 'guest' as const, - roleName: '访客', - avatar: 'G', + role: 'developer' as const, + roleName: '', + avatar: '', }; import { authApi } from '@/api/auth'; -export type UserRole = 'admin' | 'dba' | 'developer' | 'guest'; +export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor'; /** 细粒度权限类型 */ export type PermissionAction = @@ -98,7 +98,7 @@ const roleNameMap: Record = { admin: '超级管理员', dba: '数据库管理员', developer: '开发人员', - guest: '访客', + auditor: '审计员', }; /** 角色权重映射,用于权限级别比较 */ @@ -106,16 +106,16 @@ const roleWeightMap: Record = { admin: 100, dba: 80, developer: 60, - guest: 20, + auditor: 40, }; const routePermissionMap: Record = { - dashboard: ['admin', 'dba', 'developer', 'guest'], + dashboard: ['admin', 'dba', 'developer', 'auditor'], instances: ['admin', 'dba'], 'instance-groups': ['admin'], 'data-dictionary': ['admin', 'dba', 'developer'], - tickets: ['admin', 'dba', 'developer'], - queries: ['admin', 'dba', 'developer', 'guest'], + tickets: ['admin', 'dba', 'developer', 'auditor'], + queries: ['admin', 'dba', 'developer'], roles: ['admin'], users: ['admin'], 'audit-logs': ['admin', 'dba'], @@ -130,7 +130,7 @@ export const actionPermissionMap: Record = { 'ticket.execute': ['admin', 'dba'], 'ticket.rollback': ['admin', 'dba'], 'ticket.delete': ['admin'], - 'query.create': ['admin', 'dba', 'developer', 'guest'], + 'query.create': ['admin', 'dba', 'developer'], 'query.export': ['admin', 'dba', 'developer'], 'instance.add': ['admin', 'dba'], 'instance.edit': ['admin', 'dba'], @@ -451,7 +451,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isAdmin: user.role === 'admin', isDBA: user.role === 'dba', isDeveloper: user.role === 'developer', - isGuest: user.role === 'guest', + isGuest: user.role === 'auditor', hasRoleLevel, }), [user, isLoggedIn, login, logout, switchRole, hasPermission, hasActionPermission, hasDataSourcePermission, checkDataSourcePermissions, refreshPermissions, diff --git a/frontend/src/lib/queryTemplates.ts b/frontend/src/lib/queryTemplates.ts index 8657c0a..85dc0ce 100644 --- a/frontend/src/lib/queryTemplates.ts +++ b/frontend/src/lib/queryTemplates.ts @@ -130,6 +130,30 @@ export const queryTemplates: QueryTemplate[] = [ }, supportedTypes: ['mysql', 'postgresql', 'mongodb'], }, + { + id: 'redis-get', + name: 'GET 查询', + description: '获取 Redis String 类型 key 的值', + category: '基础查询', + sql: { redis: 'GET {key}' }, + supportedTypes: ['redis'], + }, + { + id: 'redis-hgetall', + name: 'HGETALL 查询', + description: '获取 Redis Hash 类型 key 的所有字段和值', + category: '基础查询', + sql: { redis: 'HGETALL {key}' }, + supportedTypes: ['redis'], + }, + { + id: 'redis-hget', + name: 'HGET 查询', + description: '获取 Redis Hash 类型 key 中指定字段的值', + category: '基础查询', + sql: { redis: 'HGET {key} {field}' }, + supportedTypes: ['redis'], + }, ]; /** diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 30d8c2d..524f8aa 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -92,12 +92,12 @@ export default function LoginPage() {
- +
setUsername(e.target.value)} /> @@ -110,7 +110,7 @@ export default function LoginPage() { setPassword(e.target.value)} /> @@ -127,18 +127,6 @@ export default function LoginPage() { {error &&

{error}

} - -
-

演示账号

-
- - -
-
diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index c1b6bd9..8c6c2bd 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star, StarOff, Download, Search, ArrowUpDown, Database, Table2, ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3, - KeyRound, FolderOpen, Server, Zap, + KeyRound, FolderOpen, Server, Zap, ClipboardPaste, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useActionPermission } from '@/hooks/useAuth'; @@ -294,6 +294,7 @@ function NewQueryPanel({ const [databases, setDatabases] = useState([]); const [loadingDbs, setLoadingDbs] = useState(false); const [instances, setInstances] = useState>([]); + const [editorSchema, setEditorSchema] = useState>>({}); const [executing, setExecuting] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); @@ -314,19 +315,40 @@ function NewQueryPanel({ }).catch(() => { setInstances([]); }); }, []); - // 选实例后加载数据库列表 + // 选实例后加载数据库列表 + 表名(用于 SQL 自动补全) useEffect(() => { - if (!instanceId) { setDatabases([]); setDatabase(''); return; } - const inst = instances.find(i => String(i.id) === instanceId); + if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; } + const inst = instances.find(i => i.id === Number(instanceId)); + if (inst?.db_type === 'redis') { + setDatabases([]); setDatabase('0'); setEditorSchema({}); return; + } const type = inst?.db_type || ''; if (type === 'mysql' || type === 'postgres' || type === 'postgresql') { setLoadingDbs(true); instanceDetailApi.listDatabases(Number(instanceId)) - .then(data => setDatabases(Array.isArray(data) ? data : [])) - .catch(() => setDatabases([])) + .then(async data => { + const dbs = Array.isArray(data) ? data : []; + setDatabases(dbs); + // 快速加载所有库的表名(不含列名) + const schema: Record> = {}; + await Promise.all(dbs.map(async db => { + try { + const tables = await instanceDetailApi.listTables(Number(instanceId), db); + if (Array.isArray(tables)) { + schema[db] = {}; + for (const t of tables) { + schema[db][t.name] = []; + } + } + } catch { /* ignore */ } + })); + setEditorSchema(schema); + }) + .catch(() => { setDatabases([]); setEditorSchema({}); }) .finally(() => setLoadingDbs(false)); } else { setDatabases([]); + setEditorSchema({}); } }, [instanceId, instances]); @@ -337,6 +359,23 @@ function NewQueryPanel({ const dbType = (selectedInstance?.db_type || 'mysql') as DBType; const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const; + // 选了目标数据库后,加载该库的完整列名用于补全 + useEffect(() => { + if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return; + // 排除 Redis 残留的纯数字 database(如 "0") + if (/^\d+$/.test(database)) return; + instanceDetailApi.getDbSchema(Number(instanceId), database) + .then(data => { + if (data && typeof data === 'object') { + setEditorSchema(prev => ({ + ...prev, + [database]: data as Record, + })); + } + }) + .catch(() => { /* ignore */ }); + }, [instanceId, database, dbType]); + const templates = useMemo(() => getTemplatesForType(dbType), [dbType]); const templateCategories = useMemo(() => { const cats = new Set(templates.map(t => t.category)); @@ -459,20 +498,6 @@ function NewQueryPanel({ downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv); }, [result]); - const handleExportHistory = useCallback(() => { - const columns = ['序号', 'SQL', '数据库类型', '执行时长(ms)', '状态', '时间']; - const rows = history.map((h, i) => ({ - 序号: i + 1, - SQL: h.sql, - 数据库类型: h.db_type, - '执行时长(ms)': h.execution_time_ms, - 状态: h.status === 'success' ? '成功' : '失败', - 时间: new Date(h.created_at).toLocaleString(), - })); - const csv = exportToCSV(columns, rows); - downloadCSV(`query_history_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv); - }, [history]); - const catLabel: Record = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询', }; @@ -490,15 +515,6 @@ function NewQueryPanel({ )}
- {history.length > 0 && ( - - )} +
))} @@ -576,6 +599,17 @@ function NewQueryPanel({
+ {dbType === 'redis' ? ( + + ) : ( + )}
@@ -696,6 +731,7 @@ function NewQueryPanel({ language={editorLang} height="240px" onExecute={handleExecute} + schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined} />
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index e4d7951..1b04139 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -55,7 +55,7 @@ const roleOptions = [ { value: 'admin', label: '超级管理员' }, { value: 'dba', label: '数据库管理员' }, { value: 'developer', label: '开发人员' }, - { value: 'guest', label: '访客' }, + { value: 'auditor', label: '审计员' }, ]; const statusOptions = [ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bbb18eb..9ba3f45 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -2,7 +2,7 @@ export interface User { id: number; username: string; email: string; - role: 'admin' | 'dba' | 'developer' | 'guest'; + role: 'admin' | 'dba' | 'developer' | 'auditor'; roleName: string; department: string; lastLogin: string;