feat: 查询页增强 + 权限体系优化

- 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 中文提示
This commit is contained in:
logan committed 2026-05-24 18:50:08 +08:00
1 parent aa2e26c827
commit 35a014e571
13 files changed
+345 -111

No files matched your search

+6
View File
@@ -12,6 +12,12 @@ export const instanceDetailApi = {
params: { database },
}),
/** 获取整库表+列结构(用于 SQL 自动补全) */
getDbSchema: (instanceId: number, database: string) =>
request.get<any, Record<string, string[]>>(`/instances/${instanceId}/detail/db-schema`, {
params: { database },
}),
/** 获取表详情(DDL + 字段 + 索引) */
getTableDetail: (instanceId: number, database: string, table: string) =>
request.get<any, TableDetail>(`/instances/${instanceId}/detail/table-detail`, {
+141 -40
View File
@@ -23,6 +23,134 @@ interface RedisKeyItem {
ttl: number;
}
/** Redis key 树节点 */
interface RedisTreeNode {
name: string;
fullPrefix: string;
isLeaf: boolean;
keyItem?: RedisKeyItem; // 叶子节点才有
children: Map<string, RedisTreeNode>;
}
/** 把 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<string>;
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
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 (
<div key={child.fullPrefix}>
<button
onClick={() => onSelectKey(child.keyItem!.key)}
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
}`}
style={{ paddingLeft: 8 + indent }}
>
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
</button>
{selectedKey === child.keyItem.key && (
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
{loadingDetail ? (
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : keyDetail ? (
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
{typeof keyDetail.value === 'object'
? JSON.stringify(keyDetail.value, null, 2)
: String(keyDetail.value)}
</pre>
</div>
) : null}
</div>
)}
</div>
);
}
// 文件夹节点
const isOpen = expanded.has(child.fullPrefix);
const childCount = child.children.size;
return (
<div key={child.fullPrefix}>
<button
onClick={() => toggle(child.fullPrefix)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
style={{ paddingLeft: 8 + indent }}
>
{isOpen
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
}
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
<span className="truncate flex-1 text-left">{child.name}</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
</button>
{isOpen && renderNode(child, depth + 1)}
</div>
);
});
};
return <div>{renderNode(tree, 0)}</div>;
}
/** 每个数据库节点缓存的表列表 */
interface DbNode {
name: string;
@@ -66,6 +194,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
const [redisPattern, setRedisPattern] = useState('*');
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
const [loadingRedis, setLoadingRedis] = useState(false);
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyDetail, setKeyDetail] = useState<any>(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 }
</button>
</div>
{/* Key List + Detail */}
{/* Key Tree + Detail */}
<div className="flex-1 overflow-y-auto">
{loadingRedis ? (
<div className="space-y-1 p-2">
@@ -448,45 +579,15 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
) : redisKeys.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
) : (
<div className="divide-y divide-[var(--dg-border-light)]">
{redisKeys.map(k => (
<div key={k.key}>
<button
onClick={() => handleSelectKey(k.key)}
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs transition-colors ${
selectedKey === k.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
}`}
>
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="font-mono truncate flex-1 text-left">{k.key}</span>
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{k.type}</span>
</button>
{/* Key Detail */}
{selectedKey === k.key && (
<div className="px-2 pb-2">
{loadingDetail ? (
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : keyDetail ? (
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
{typeof keyDetail.value === 'object'
? JSON.stringify(keyDetail.value, null, 2)
: String(keyDetail.value)}
</pre>
</div>
) : null}
</div>
)}
</div>
))}
</div>
<RedisTree
tree={buildRedisTree(redisKeys)}
expanded={redisExpanded}
setExpanded={setRedisExpanded}
selectedKey={selectedKey}
onSelectKey={handleSelectKey}
keyDetail={keyDetail}
loadingDetail={loadingDetail}
/>
)}
</div>
</div>
+50 -6
View File
@@ -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, Record<string, string[]> | 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<string, readonly string[]> = {};
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<string, string[]>;
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<SqlEditorProps> = ({
height = '200px',
readOnly = false,
onExecute,
schema,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@@ -294,11 +334,15 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
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<SqlEditorProps> = ({
}
}, [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(() => {
+3 -3
View File
@@ -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'] },
+12 -12
View File
@@ -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<UserRole, string> = {
admin: '超级管理员',
dba: '数据库管理员',
developer: '开发人员',
guest: '访客',
auditor: '审计员',
};
/** 角色权重映射,用于权限级别比较 */
@@ -106,16 +106,16 @@ const roleWeightMap: Record<UserRole, number> = {
admin: 100,
dba: 80,
developer: 60,
guest: 20,
auditor: 40,
};
const routePermissionMap: Record<string, UserRole[]> = {
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<string, UserRole[]> = {
'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,
+24
View File
@@ -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'],
},
];
/**
+3 -15
View File
@@ -92,12 +92,12 @@ export default function LoginPage() {
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">用户名 / 邮箱</label>
<label className="block text-sm font-medium mb-2">用户名</label>
<div className="relative">
<UserCog className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
className="w-full pl-10 pr-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
placeholder="admin"
placeholder="请输入用户名"
value={username}
onChange={e => setUsername(e.target.value)}
/>
@@ -110,7 +110,7 @@ export default function LoginPage() {
<input
type="password"
className="w-full pl-10 pr-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
placeholder="123456"
placeholder="请输入密码"
value={password}
onChange={e => setPassword(e.target.value)}
/>
@@ -127,18 +127,6 @@ export default function LoginPage() {
</button>
{error && <p className="text-xs text-red-400 text-center mt-2">{error}</p>}
</form>
<div className="mt-6 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<p className="text-xs text-[var(--dg-text-muted)] text-center">演示账号</p>
<div className="flex gap-2 mt-2">
<button onClick={() => { setUsername('admin'); setPassword('123456'); }} className="flex-1 py-2 text-xs rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 hover:bg-blue-500/20 transition-colors">
管理员
</button>
<button onClick={() => { setUsername('developer'); setPassword('123456'); }} className="flex-1 py-2 text-xs rounded-md bg-green-500/10 text-green-400 border border-green-500/20 hover:bg-green-500/20 transition-colors">
开发者
</button>
</div>
</div>
</div>
</div>
</div>
+65 -29
View File
@@ -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<string[]>([]);
const [loadingDbs, setLoadingDbs] = useState(false);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string }>>([]);
const [editorSchema, setEditorSchema] = useState<Record<string, Record<string, string[]>>>({});
const [executing, setExecuting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(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<string, Record<string, string[]>> = {};
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<string, string[]>,
}));
}
})
.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<string, string> = {
all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询',
};
@@ -490,15 +515,6 @@ function NewQueryPanel({
)}
</div>
<div className="flex items-center gap-2">
{history.length > 0 && (
<button
onClick={handleExportHistory}
className="flex items-center gap-2 px-3 py-2 text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Download className="w-3.5 h-3.5" />
导出历史
</button>
)}
<button
onClick={() => setShowHistory(!showHistory)}
className={`flex items-center gap-2 px-3 py-2 text-xs rounded-lg border transition-colors ${
@@ -549,6 +565,13 @@ function NewQueryPanel({
>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
<button
onClick={() => setSql(h.sql)}
className="p-1 rounded text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="粘贴到编辑器"
>
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
))}
</div>
@@ -576,6 +599,17 @@ function NewQueryPanel({
</div>
<div>
<label className="block text-sm font-medium mb-2">目标数据库</label>
{dbType === 'redis' ? (
<select
value={database || '0'}
onChange={e => setDatabase(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
>
{Array.from({ length: 16 }, (_, i) => (
<option key={i} value={String(i)}>DB{i}</option>
))}
</select>
) : (
<select
value={database}
onChange={e => setDatabase(e.target.value)}
@@ -586,6 +620,7 @@ function NewQueryPanel({
<option key={db} value={db}>{db}</option>
))}
</select>
)}
</div>
</div>
@@ -696,6 +731,7 @@ function NewQueryPanel({
language={editorLang}
height="240px"
onExecute={handleExecute}
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined}
/>
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1.5">
+1 -1
View File
@@ -55,7 +55,7 @@ const roleOptions = [
{ value: 'admin', label: '超级管理员' },
{ value: 'dba', label: '数据库管理员' },
{ value: 'developer', label: '开发人员' },
{ value: 'guest', label: '访客' },
{ value: 'auditor', label: '审计员' },
];
const statusOptions = [
+1 -1
View File
@@ -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;