feat: enhance redis and production IA

This commit is contained in:
Logan.Li committed 2026-07-07 10:39:33 +08:00
1 parent 644981eef4
commit 58bbc394e9
76 files changed
+1702 -721

No files matched your search

+4 -2
View File
@@ -17,7 +17,8 @@ const WorkflowPage = lazy(() => import('@/pages/WorkflowPage'));
const AutoTaskPage = lazy(() => import('@/pages/AutoTaskPage'));
const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
const AuditLogsPage = lazy(() => import('@/pages/AuditLogsPage'));
const DataDictionaryPage = lazy(() => import('@/pages/DataDictionaryPage'));
const AnnouncementPage = lazy(() => import('@/pages/AnnouncementPage'));
const NotificationPage = lazy(() => import('@/pages/NotificationPage'));
const InstanceDetailPage = lazy(() => import('@/pages/InstanceDetailPage'));
const RedisDetailPage = lazy(() => import('@/pages/RedisDetailPage'));
const MongoDBDetailPage = lazy(() => import('@/pages/MongoDBDetailPage'));
@@ -73,7 +74,6 @@ function AppRoutes() {
<Route path="instances/:id" element={<InstanceDetailPage />} />
<Route path="redis/:id" element={<RedisDetailPage />} />
<Route path="mongo/:id" element={<MongoDBDetailPage />} />
<Route path="data-dictionary" element={<DataDictionaryPage />} />
<Route path="tickets" element={<TicketsPage />} />
<Route path="tickets/create" element={<TicketCreatePage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
@@ -84,6 +84,8 @@ function AppRoutes() {
<Route path="audit-logs" element={<AuditLogsPage />} />
<Route path="workflow" element={<WorkflowPage />} />
<Route path="auto-task" element={<AutoTaskPage />} />
<Route path="notifications" element={<NotificationPage />} />
<Route path="announcement" element={<AnnouncementPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
+1 -34
View File
@@ -1,34 +1 @@
import request from './request';
export interface LoginResponse {
access_token: string;
token_type: string;
user: {
id: number;
username: string;
display_name: string;
role: string;
email: string | null;
data_source_permissions?: string;
custom_permissions?: string;
group_ids?: number[];
};
}
export interface MeResponse {
id: number;
username: string;
display_name: string;
role: string;
email: string | null;
data_source_permissions?: string;
custom_permissions?: string;
group_ids?: number[];
}
export const authApi = {
login: (username: string, password: string) =>
request.post<any, LoginResponse>('/auth/login', { username, password }),
getMe: () => request.get<any, MeResponse>('/auth/me'),
logout: () => request.post('/auth/logout'),
};
export * from './auth/auth';
+34
View File
@@ -0,0 +1,34 @@
import request from '../request';
export interface LoginResponse {
access_token: string;
token_type: string;
user: {
id: number;
username: string;
display_name: string;
role: string;
email: string | null;
data_source_permissions?: string;
custom_permissions?: string;
group_ids?: number[];
};
}
export interface MeResponse {
id: number;
username: string;
display_name: string;
role: string;
email: string | null;
data_source_permissions?: string;
custom_permissions?: string;
group_ids?: number[];
}
export const authApi = {
login: (username: string, password: string) =>
request.post<any, LoginResponse>('/auth/login', { username, password }),
getMe: () => request.get<any, MeResponse>('/auth/me'),
logout: () => request.post('/auth/logout'),
};
+2
View File
@@ -0,0 +1,2 @@
export * from './auth';
+1 -5
View File
@@ -1,5 +1 @@
import request from './request';
export const dashboardApi = {
getStats: () => request.get<any, any>('/dashboard/stats'),
};
export * from './dashboard/dashboard';
+5
View File
@@ -0,0 +1,5 @@
import request from '../request';
export const dashboardApi = {
getStats: () => request.get<any, any>('/dashboard/stats'),
};
+2
View File
@@ -0,0 +1,2 @@
export * from './dashboard';
+51
View File
@@ -0,0 +1,51 @@
import request from '../request';
export interface DBTypeOverview {
type: string;
name: string;
icon: string;
description: string;
category: string;
version: string;
supported_operations: number;
security_rules_count: number;
}
export interface DBTypeQueryType {
type: string;
description: string;
example: string;
}
export interface DBTypeDetail {
type: string;
name: string;
icon: string;
description: string;
version: string;
category: string;
supported_query_types: DBTypeQueryType[];
security_rules: string[];
tips: string[];
common_commands: Record<string, string>;
limitations: string[];
}
export interface DBTypeCommand {
name: string;
command: string;
}
export const dbTypesApi = {
list: () =>
request.get<any, { items: DBTypeOverview[]; total: number }>('/db-types'),
getDetail: (dbType: string) =>
request.get<any, DBTypeDetail>('/db-types/' + dbType),
getCommands: (dbType: string) =>
request.get<any, { items: DBTypeCommand[]; total: number }>('/db-types/' + dbType + '/commands'),
getTips: (dbType: string) =>
request.get<any, { items: string[]; total: number }>('/db-types/' + dbType + '/tips'),
};
+22
View File
@@ -0,0 +1,22 @@
import request from '../request';
export var dictionaryApi = {
listDatabases: function (instanceId: number) {
return request.get('/dictionary/instances/' + instanceId + '/databases');
},
listTables: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables', {
params: { database: database },
});
},
getTableDetail: function (instanceId: number, tableName: string, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, {
params: { database: database },
});
},
exportDictionary: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/export', {
params: { database: database },
});
},
};
+8
View File
@@ -0,0 +1,8 @@
export * from './db-types';
export * from './dictionary';
export * from './instance';
export * from './instance-detail';
export * from './instance-group';
export * from './mongo';
export * from './redis';
@@ -0,0 +1,89 @@
import request from '../request';
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
export const instanceDetailApi = {
/** 获取 MySQL 数据库列表 */
listDatabases: (instanceId: number) =>
request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
/** 获取表列表(含引擎、行数等) */
listTables: (instanceId: number, database: string) =>
request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
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`, {
params: { database, table },
}),
/** SHOW PROCESSLIST */
getProcesslist: (instanceId: number) =>
request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
/** SHOW GLOBAL STATUS */
getStatus: (instanceId: number) =>
request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
`/instances/${instanceId}/detail/status`
),
/** 终止进程 */
killProcess: (instanceId: number, pid: number) =>
request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
/** 获取数据库用户列表 */
listUsers: (instanceId: number) =>
request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
};
/** 表元信息 */
export interface TableMeta {
name: string;
engine: string;
rows: number;
data_length: number;
index_length: number;
comment: string;
collation: string;
create_time: string;
update_time: string;
}
/** 表详情 */
export interface TableDetail {
ddl: string;
columns: ColumnInfo[];
indexes: IndexInfo[];
}
export interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
}
export interface IndexInfo {
Table: string;
Non_unique: number;
Key_name: string;
Seq_in_index: number;
Column_name: string;
Collation: string;
Cardinality: number;
Sub_part: string | null;
Packed: string | null;
Null: string;
Index_type: string;
Comment: string;
Index_comment: string;
}
@@ -0,0 +1,38 @@
import request from '../request';
export interface InstanceGroup {
id: number;
name: string;
description?: string;
db_type: string;
permissions?: string[];
allowed_databases?: string[];
is_active: boolean;
created_at?: string;
updated_at?: string;
readonly_instance_ids?: number[];
write_instance_ids?: number[];
readonly_instance_count: number;
write_instance_count: number;
user_count: number;
readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
users?: { id: number; username: string; display_name: string; role: string }[];
}
export const instanceGroupApi = {
list: (params?: any) => request.get<any, InstanceGroup[]>('/instance-groups', { params }),
get: (id: number) => request.get<any, InstanceGroup>(`/instance-groups/${id}`),
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
delete: (id: number) => request.delete(`/instance-groups/${id}`),
assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') =>
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }),
removeInstance: (groupId: number, instanceId: number, accessType?: string) =>
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }),
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
updateUserGroups: (userId: number, groupIds: number[]) =>
request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }),
};
+15
View File
@@ -0,0 +1,15 @@
import request from '../request';
export const instanceApi = {
list: (params?: any) => request.get<any, any[]>('/instances', { params }),
accessible: (accessType: 'readonly' | 'write') => request.get<any, any[]>('/instances/accessible', { params: { access_type: accessType } }),
get: (id: number) => request.get<any, any>(`/instances/${id}`),
create: (data: any) => request.post<any, any>('/instances', data),
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
delete: (id: number) => request.delete(`/instances/${id}`),
testConnection: (id: number) => request.post<any, any>(`/instances/${id}/test-connection`),
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
metrics: (id: number) => request.get<any, any>(`/instances/${id}/metrics`),
};
+24
View File
@@ -0,0 +1,24 @@
import request from '../request';
export var mongoApi = {
getDatabases: function (id: number) {
return request.get('/mongo/' + id + '/databases');
},
getCollections: function (id: number, database: string) {
return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections');
},
getCollectionStats: function (id: number, collection: string, database?: string) {
var params: Record<string, any> = {};
if (database) { params.database = database; }
return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params });
},
query: function (id: number, data: Record<string, any>) {
return request.post('/mongo/' + id + '/query', data);
},
execute: function (id: number, data: Record<string, any>) {
return request.post('/mongo/' + id + '/execute', data);
},
testConnection: function (id: number) {
return request.post('/mongo/' + id + '/test-connection');
},
};
+47
View File
@@ -0,0 +1,47 @@
import request from '../request';
export const redisApi = {
// Redis 信息概览
getInfo: (instanceId: number) =>
request.get<any, any>('/redis/' + instanceId + '/info'),
// DBSIZE
getDbsize: (instanceId: number, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
// 键列表
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 键详情
getKeyDetail: (instanceId: number, key: string, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
// 慢日志
getSlowlog: (instanceId: number, count?: number) =>
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
// 客户端列表
getClients: (instanceId: number) =>
request.get<any, any>('/redis/' + instanceId + '/clients'),
// 执行命令
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
request.post<any, any>('/redis/' + instanceId + '/command', data),
// 获取键列表(兼容旧接口)
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 创建键
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
request.post<any, any>('/redis/' + instanceId + '/keys', data),
// 更新键值(整体替换 value,并可设置 TTL)
updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) =>
request.put<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { params: { db: data.db ?? 0 } }),
// 删除键
deleteKey: (instanceId: number, key: string, db?: number) =>
request.delete<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
};
+1 -50
View File
@@ -1,51 +1,2 @@
import request from './request';
export * from './datasource/db-types';
export interface DBTypeOverview {
type: string;
name: string;
icon: string;
description: string;
category: string;
version: string;
supported_operations: number;
security_rules_count: number;
}
export interface DBTypeQueryType {
type: string;
description: string;
example: string;
}
export interface DBTypeDetail {
type: string;
name: string;
icon: string;
description: string;
version: string;
category: string;
supported_query_types: DBTypeQueryType[];
security_rules: string[];
tips: string[];
common_commands: Record<string, string>;
limitations: string[];
}
export interface DBTypeCommand {
name: string;
command: string;
}
export const dbTypesApi = {
list: () =>
request.get<any, { items: DBTypeOverview[]; total: number }>('/db-types'),
getDetail: (dbType: string) =>
request.get<any, DBTypeDetail>('/db-types/' + dbType),
getCommands: (dbType: string) =>
request.get<any, { items: DBTypeCommand[]; total: number }>('/db-types/' + dbType + '/commands'),
getTips: (dbType: string) =>
request.get<any, { items: string[]; total: number }>('/db-types/' + dbType + '/tips'),
};
+1 -21
View File
@@ -1,22 +1,2 @@
import request from './request';
export * from './datasource/dictionary';
export var dictionaryApi = {
listDatabases: function (instanceId: number) {
return request.get('/dictionary/instances/' + instanceId + '/databases');
},
listTables: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables', {
params: { database: database },
});
},
getTableDetail: function (instanceId: number, tableName: string, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, {
params: { database: database },
});
},
exportDictionary: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/export', {
params: { database: database },
});
},
};
+8
View File
@@ -0,0 +1,8 @@
// API unified exports. Prefer importing domain APIs from "@/api" or "@/api/<domain>".
export * from './auth';
export * from './dashboard';
export * from './datasource';
export * from './query';
export * from './ticket';
export * from './system';
+1 -14
View File
@@ -1,15 +1,2 @@
import request from './request';
export * from './datasource/instance';
export const instanceApi = {
list: (params?: any) => request.get<any, any[]>('/instances', { params }),
accessible: (accessType: 'readonly' | 'write') => request.get<any, any[]>('/instances/accessible', { params: { access_type: accessType } }),
get: (id: number) => request.get<any, any>(`/instances/${id}`),
create: (data: any) => request.post<any, any>('/instances', data),
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
delete: (id: number) => request.delete(`/instances/${id}`),
testConnection: (id: number) => request.post<any, any>(`/instances/${id}/test-connection`),
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
metrics: (id: number) => request.get<any, any>(`/instances/${id}/metrics`),
};
+1 -88
View File
@@ -1,89 +1,2 @@
import request from './request';
export * from './datasource/instance-detail';
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
export const instanceDetailApi = {
/** 获取 MySQL 数据库列表 */
listDatabases: (instanceId: number) =>
request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
/** 获取表列表(含引擎、行数等) */
listTables: (instanceId: number, database: string) =>
request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
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`, {
params: { database, table },
}),
/** SHOW PROCESSLIST */
getProcesslist: (instanceId: number) =>
request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
/** SHOW GLOBAL STATUS */
getStatus: (instanceId: number) =>
request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
`/instances/${instanceId}/detail/status`
),
/** 终止进程 */
killProcess: (instanceId: number, pid: number) =>
request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
/** 获取数据库用户列表 */
listUsers: (instanceId: number) =>
request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
};
/** 表元信息 */
export interface TableMeta {
name: string;
engine: string;
rows: number;
data_length: number;
index_length: number;
comment: string;
collation: string;
create_time: string;
update_time: string;
}
/** 表详情 */
export interface TableDetail {
ddl: string;
columns: ColumnInfo[];
indexes: IndexInfo[];
}
export interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
}
export interface IndexInfo {
Table: string;
Non_unique: number;
Key_name: string;
Seq_in_index: number;
Column_name: string;
Collation: string;
Cardinality: number;
Sub_part: string | null;
Packed: string | null;
Null: string;
Index_type: string;
Comment: string;
Index_comment: string;
}
+1 -37
View File
@@ -1,38 +1,2 @@
import request from './request';
export * from './datasource/instance-group';
export interface InstanceGroup {
id: number;
name: string;
description?: string;
db_type: string;
permissions?: string[];
allowed_databases?: string[];
is_active: boolean;
created_at?: string;
updated_at?: string;
readonly_instance_ids?: number[];
write_instance_ids?: number[];
readonly_instance_count: number;
write_instance_count: number;
user_count: number;
readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
users?: { id: number; username: string; display_name: string; role: string }[];
}
export const instanceGroupApi = {
list: (params?: any) => request.get<any, InstanceGroup[]>('/instance-groups', { params }),
get: (id: number) => request.get<any, InstanceGroup>(`/instance-groups/${id}`),
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
delete: (id: number) => request.delete(`/instance-groups/${id}`),
assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') =>
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }),
removeInstance: (groupId: number, instanceId: number, accessType?: string) =>
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }),
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
updateUserGroups: (userId: number, groupIds: number[]) =>
request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }),
};
+1 -23
View File
@@ -1,24 +1,2 @@
import request from './request';
export * from './datasource/mongo';
export var mongoApi = {
getDatabases: function (id: number) {
return request.get('/mongo/' + id + '/databases');
},
getCollections: function (id: number, database: string) {
return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections');
},
getCollectionStats: function (id: number, collection: string, database?: string) {
var params: Record<string, any> = {};
if (database) { params.database = database; }
return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params });
},
query: function (id: number, data: Record<string, any>) {
return request.post('/mongo/' + id + '/query', data);
},
execute: function (id: number, data: Record<string, any>) {
return request.post('/mongo/' + id + '/execute', data);
},
testConnection: function (id: number) {
return request.post('/mongo/' + id + '/test-connection');
},
};
+1 -8
View File
@@ -1,9 +1,2 @@
import request from './request';
export * from './system/notification';
export const notificationApi = {
list: () => request.get<any, any[]>('/notifications'),
create: (data: any) => request.post<any, any>('/notifications', data),
update: (id: number, data: any) => request.put<any, any>(`/notifications/${id}`, data),
delete: (id: number) => request.delete(`/notifications/${id}`),
test: (id: number) => request.post<any, any>(`/notifications/${id}/test`),
};
+1 -15
View File
@@ -1,15 +1 @@
import request from './request';
export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) =>
request.post<any, any>('/query', data),
formatSQL: (sql: string) =>
request.post<any, any>('/sql-assistant/format', { sql }),
analyzeSQL: (sql: string, db_type: string) =>
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
getDBTypeDetail: (dbType: string) =>
request.get<any, any>('/db-types/' + dbType),
};
export * from './query/query';
+2
View File
@@ -0,0 +1,2 @@
export * from './query';
+15
View File
@@ -0,0 +1,15 @@
import request from '../request';
export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) =>
request.post<any, any>('/query', data),
formatSQL: (sql: string) =>
request.post<any, any>('/sql-assistant/format', { sql }),
analyzeSQL: (sql: string, db_type: string) =>
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
getDBTypeDetail: (dbType: string) =>
request.get<any, any>('/db-types/' + dbType),
};
+1 -42
View File
@@ -1,43 +1,2 @@
import request from './request';
export * from './datasource/redis';
export const redisApi = {
// Redis 信息概览
getInfo: (instanceId: number) =>
request.get<any, any>('/redis/' + instanceId + '/info'),
// DBSIZE
getDbsize: (instanceId: number, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
// 键列表
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 键详情
getKeyDetail: (instanceId: number, key: string, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
// 慢日志
getSlowlog: (instanceId: number, count?: number) =>
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
// 客户端列表
getClients: (instanceId: number) =>
request.get<any, any>('/redis/' + instanceId + '/clients'),
// 执行命令
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
request.post<any, any>('/redis/' + instanceId + '/command', data),
// 获取键列表(兼容旧接口)
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
// 创建键
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
request.post<any, any>('/redis/' + instanceId + '/keys', data),
// 删除键
deleteKey: (instanceId: number, key: string, db?: number) =>
request.delete<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
};
+4
View File
@@ -0,0 +1,4 @@
export * from './notification';
export * from './user';
export * from './workflow';
+9
View File
@@ -0,0 +1,9 @@
import request from '../request';
export const notificationApi = {
list: () => request.get<any, any[]>('/notifications'),
create: (data: any) => request.post<any, any>('/notifications', data),
update: (id: number, data: any) => request.put<any, any>(`/notifications/${id}`, data),
delete: (id: number) => request.delete(`/notifications/${id}`),
test: (id: number) => request.post<any, any>(`/notifications/${id}/test`),
};
+25
View File
@@ -0,0 +1,25 @@
import request from '../request';
export const userApi = {
list: (params?: any) => request.get<any, any>('/users', { params }),
get: (id: number) => request.get<any, any>(`/users/${id}`),
create: (data: any) => request.post<any, any>('/users', data),
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
delete: (id: number) => request.delete(`/users/${id}`),
resetPassword: (id: number, data: { new_password: string }) =>
request.post(`/users/${id}/reset-password`, data),
batchImport: (data: any) => request.post('/users/batch-import', data),
batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data),
downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }),
};
export const ldapApi = {
getConfig: () => request.get('/ldap-config'),
updateConfig: (data: any) => request.put('/ldap-config', data),
testConnection: (data: any) => request.post('/ldap-config/test', data),
listOus: () => request.post('/ldap-config/ou-list'),
listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}),
createOu: (data: any) => request.post('/ldap-config/create-ou', data),
createUser: (data: any) => request.post('/ldap-config/create-user', data),
batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data),
};
+11
View File
@@ -0,0 +1,11 @@
import request from '../request';
export const workflowApi = {
list: () => request.get<any, any[]>('/workflows'),
get: (id: number) => request.get<any, any>(`/workflows/${id}`),
create: (data: any) => request.post<any, any>('/workflows', data),
update: (id: number, data: any) => request.put<any, any>(`/workflows/${id}`, data),
delete: (id: number) => request.delete(`/workflows/${id}`),
setDefault: (id: number) => request.post<any, any>(`/workflows/${id}/set-default`),
clone: (id: number) => request.post<any, any>(`/workflows/${id}/clone`),
};
+1 -37
View File
@@ -1,37 +1 @@
import request from './request';
export var ticketApi = {
list: function (params?: any) {
return request.get('/tickets', { params: params });
},
get: function (id: number) {
return request.get('/tickets/' + id);
},
create: function (data: any) {
return request.post('/tickets', data);
},
review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) {
return request.post('/tickets/' + id + '/review', data);
},
execute: function (id: number) {
return request.post('/tickets/' + id + '/execute');
},
statistics: function () {
return request.get('/tickets/statistics');
},
timeline: function (id: number) {
return request.get('/tickets/' + id + '/timeline');
},
approvals: function (id: number) {
return request.get('/tickets/' + id + '/approvals');
},
cancel: function (id: number) {
return request.post('/tickets/' + id + '/cancel');
},
rollback: function (id: number) {
return request.post('/tickets/' + id + '/rollback');
},
audit: function (data: { sql_content: string; instance_id: number; database: string }) {
return request.post('/tickets/audit', data);
},
};
export * from './ticket/ticket';
+2
View File
@@ -0,0 +1,2 @@
export * from './ticket';
+37
View File
@@ -0,0 +1,37 @@
import request from '../request';
export var ticketApi = {
list: function (params?: any) {
return request.get('/tickets', { params: params });
},
get: function (id: number) {
return request.get('/tickets/' + id);
},
create: function (data: any) {
return request.post('/tickets', data);
},
review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) {
return request.post('/tickets/' + id + '/review', data);
},
execute: function (id: number) {
return request.post('/tickets/' + id + '/execute');
},
statistics: function () {
return request.get('/tickets/statistics');
},
timeline: function (id: number) {
return request.get('/tickets/' + id + '/timeline');
},
approvals: function (id: number) {
return request.get('/tickets/' + id + '/approvals');
},
cancel: function (id: number) {
return request.post('/tickets/' + id + '/cancel');
},
rollback: function (id: number) {
return request.post('/tickets/' + id + '/rollback');
},
audit: function (data: { sql_content: string; instance_id: number; database: string }) {
return request.post('/tickets/audit', data);
},
};
+1 -24
View File
@@ -1,25 +1,2 @@
import request from './request';
export * from './system/user';
export const userApi = {
list: (params?: any) => request.get<any, any>('/users', { params }),
get: (id: number) => request.get<any, any>(`/users/${id}`),
create: (data: any) => request.post<any, any>('/users', data),
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
delete: (id: number) => request.delete(`/users/${id}`),
resetPassword: (id: number, data: { new_password: string }) =>
request.post(`/users/${id}/reset-password`, data),
batchImport: (data: any) => request.post('/users/batch-import', data),
batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data),
downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }),
};
export const ldapApi = {
getConfig: () => request.get('/ldap-config'),
updateConfig: (data: any) => request.put('/ldap-config', data),
testConnection: (data: any) => request.post('/ldap-config/test', data),
listOus: () => request.post('/ldap-config/ou-list'),
listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}),
createOu: (data: any) => request.post('/ldap-config/create-ou', data),
createUser: (data: any) => request.post('/ldap-config/create-user', data),
batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data),
};
+1 -10
View File
@@ -1,11 +1,2 @@
import request from './request';
export * from './system/workflow';
export const workflowApi = {
list: () => request.get<any, any[]>('/workflows'),
get: (id: number) => request.get<any, any>(`/workflows/${id}`),
create: (data: any) => request.post<any, any>('/workflows', data),
update: (id: number, data: any) => request.put<any, any>(`/workflows/${id}`, data),
delete: (id: number) => request.delete(`/workflows/${id}`),
setDefault: (id: number) => request.post<any, any>(`/workflows/${id}/set-default`),
clone: (id: number) => request.post<any, any>(`/workflows/${id}/clone`),
};
@@ -0,0 +1,474 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AlignLeft,
Code2,
Columns3,
Database,
FileJson,
Gauge,
Hash,
Info,
Key,
RefreshCw,
Search,
Table2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { instanceDetailApi, type IndexInfo, type TableMeta } from '@/api/datasource/instance-detail';
interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
Comment?: string;
}
interface NormalizedTableDetail {
columns: ColumnInfo[];
indexes: IndexInfo[];
ddl: string;
}
type DetailTab = 'columns' | 'indexes' | 'ddl';
function getTypeIcon(type: string): { icon: React.ElementType; color: string } {
const lower = type.toLowerCase();
if (lower.includes('int') || lower.includes('float') || lower.includes('decimal') || lower.includes('double')) {
return { icon: Hash, color: 'text-blue-400' };
}
if (lower.includes('char') || lower.includes('text') || lower.includes('blob')) {
return { icon: AlignLeft, color: 'text-green-400' };
}
if (lower.includes('date') || lower.includes('time') || lower.includes('timestamp')) {
return { icon: Info, color: 'text-cyan-400' };
}
if (lower.includes('json')) {
return { icon: FileJson, color: 'text-yellow-400' };
}
return { icon: Hash, color: 'text-[var(--dg-text-muted)]' };
}
function formatBytes(value?: number): string {
if (!value || value <= 0) return '-';
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
return `${value} B`;
}
function highlightDDL(ddl: string): string {
const keywords = [
'CREATE', 'TABLE', 'IF', 'NOT', 'EXISTS', 'PRIMARY', 'KEY', 'UNIQUE', 'INDEX',
'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'NULL', 'AUTO_INCREMENT',
'CHARSET', 'COLLATE', 'ENGINE', 'COMMENT', 'INT', 'VARCHAR', 'TEXT', 'DATETIME',
'TIMESTAMP', 'BOOLEAN', 'DECIMAL', 'BIGINT', 'UNSIGNED', 'SIGNED', 'ON', 'DELETE',
'UPDATE', 'CASCADE', 'SET', 'DROP', 'ALTER', 'ADD', 'COLUMN', 'MODIFY', 'CHANGE',
];
let highlighted = ddl
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
keywords.forEach(kw => {
highlighted = highlighted.replace(new RegExp(`\\b${kw}\\b`, 'gi'), `<span class="text-cyan-400 font-semibold">${kw}</span>`);
});
highlighted = highlighted.replace(/'[^']*'/g, '<span class="text-green-400">$&</span>');
highlighted = highlighted.replace(/`[^`]*`/g, '<span class="text-blue-400">$&</span>');
highlighted = highlighted.replace(/\b\d+\b/g, '<span class="text-orange-400">$&</span>');
return highlighted;
}
function normalizeTableName(table: TableMeta | string): string {
return typeof table === 'string' ? table : table.name || '';
}
function groupIndexes(indexes: IndexInfo[]) {
const grouped = new Map<string, IndexInfo[]>();
indexes.forEach(index => {
const key = index.Key_name || 'UNKNOWN';
grouped.set(key, [...(grouped.get(key) || []), index]);
});
return Array.from(grouped.entries()).map(([name, rows]) => ({
name,
columns: rows
.sort((a, b) => (a.Seq_in_index || 0) - (b.Seq_in_index || 0))
.map(row => row.Column_name)
.filter(Boolean),
nonUnique: rows[0]?.Non_unique,
type: rows[0]?.Index_type || '-',
comment: rows[0]?.Index_comment || rows[0]?.Comment || '-',
}));
}
export default function DataDictionaryPanel({ instanceId }: { instanceId: number }) {
const [databases, setDatabases] = useState<string[]>([]);
const [currentDb, setCurrentDb] = useState('');
const [tables, setTables] = useState<TableMeta[]>([]);
const [selectedTable, setSelectedTable] = useState('');
const [tableDetail, setTableDetail] = useState<NormalizedTableDetail | null>(null);
const [searchFilter, setSearchFilter] = useState('');
const [activeTab, setActiveTab] = useState<DetailTab>('columns');
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const loadDatabases = useCallback(() => {
setLoadingDatabases(true);
instanceDetailApi.listDatabases(instanceId)
.then((res: any) => {
const list = Array.isArray(res) ? res : (res?.databases || []);
setDatabases(list);
setCurrentDb(prev => prev && list.includes(prev) ? prev : (list[0] || ''));
})
.catch(() => {
setDatabases([]);
setCurrentDb('');
})
.finally(() => setLoadingDatabases(false));
}, [instanceId]);
const loadTables = useCallback(() => {
if (!currentDb) {
setTables([]);
setSelectedTable('');
return;
}
setLoadingTables(true);
instanceDetailApi.listTables(instanceId, currentDb)
.then((res: any) => {
const list = Array.isArray(res) ? res : (res?.tables || []);
setTables(list);
const tableNames = list.map(normalizeTableName).filter(Boolean);
setSelectedTable(prev => prev && tableNames.includes(prev) ? prev : (tableNames[0] || ''));
})
.catch(() => {
setTables([]);
setSelectedTable('');
})
.finally(() => setLoadingTables(false));
}, [currentDb, instanceId]);
const loadTableDetail = useCallback(() => {
if (!currentDb || !selectedTable) {
setTableDetail(null);
return;
}
setLoadingDetail(true);
instanceDetailApi.getTableDetail(instanceId, currentDb, selectedTable)
.then((res: any) => {
setTableDetail({
columns: res?.columns || [],
indexes: res?.indexes || [],
ddl: res?.ddl || res?.create_ddl || '',
});
})
.catch(() => setTableDetail(null))
.finally(() => setLoadingDetail(false));
}, [currentDb, instanceId, selectedTable]);
useEffect(() => {
loadDatabases();
}, [loadDatabases]);
useEffect(() => {
loadTables();
}, [loadTables]);
useEffect(() => {
loadTableDetail();
}, [loadTableDetail]);
const filteredTables = useMemo(() => {
const keyword = searchFilter.trim().toLowerCase();
if (!keyword) return tables;
return tables.filter(table => normalizeTableName(table).toLowerCase().includes(keyword));
}, [searchFilter, tables]);
const selectedMeta = useMemo(
() => tables.find(table => normalizeTableName(table) === selectedTable),
[selectedTable, tables]
);
const indexes = useMemo(() => groupIndexes(tableDetail?.indexes || []), [tableDetail?.indexes]);
const tabs: { key: DetailTab; label: string; icon: React.ElementType }[] = [
{ key: 'columns', label: '字段', icon: Columns3 },
{ key: 'indexes', label: '索引', icon: Key },
{ key: 'ddl', label: 'DDL', icon: Code2 },
];
return (
<div className="grid grid-cols-[280px_minmax(0,1fr)] gap-4">
<aside className="space-y-4 min-w-0">
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span className="flex items-center gap-2">
<Database className="w-4 h-4 text-blue-400" />
数据库
</span>
<button
onClick={loadDatabases}
className="w-7 h-7 rounded-md flex items-center justify-center hover:bg-[var(--dg-bg-hover)]"
title="刷新数据库"
>
<RefreshCw className={`w-3.5 h-3.5 ${loadingDatabases ? 'animate-spin text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
</button>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<select
value={currentDb}
onChange={event => {
setCurrentDb(event.target.value);
setSelectedTable('');
setTableDetail(null);
}}
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/30"
>
<option value="">选择数据库</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span className="flex items-center gap-2">
<Table2 className="w-4 h-4 text-green-400" />
表
</span>
<span className="text-xs text-[var(--dg-text-muted)]">{tables.length}</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="relative mb-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<input
value={searchFilter}
onChange={event => setSearchFilter(event.target.value)}
placeholder="搜索表"
className="w-full pl-8 pr-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/30"
/>
</div>
<div className="max-h-[520px] overflow-y-auto scrollbar-thin space-y-1">
{loadingTables ? (
<div className="flex items-center justify-center py-8 text-sm text-[var(--dg-text-muted)]">
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
加载表...
</div>
) : filteredTables.length === 0 ? (
<div className="py-8 text-center text-sm text-[var(--dg-text-muted)]">
{currentDb ? '暂无表' : '请先选择数据库'}
</div>
) : filteredTables.map(table => {
const name = normalizeTableName(table);
const isActive = selectedTable === name;
return (
<button
key={name}
onClick={() => {
setSelectedTable(name);
setActiveTab('columns');
}}
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left text-sm transition-colors ${
isActive ? 'bg-blue-500/15 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]'
}`}
>
<Table2 className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{name}</span>
</button>
);
})}
</div>
</CardContent>
</Card>
</aside>
<section className="min-w-0">
{!selectedTable ? (
<Card className="dg-card h-full">
<CardContent className="flex flex-col items-center justify-center py-20 text-[var(--dg-text-muted)]">
<Table2 className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">选择一个表查看字段、索引和 DDL</p>
</CardContent>
</Card>
) : (
<Card className="dg-card">
<CardHeader className="pb-3 border-b border-[var(--dg-border)]">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<CardTitle className="text-base font-semibold truncate">{selectedTable}</CardTitle>
{selectedMeta?.comment && (
<p className="text-xs text-[var(--dg-text-muted)] mt-1 truncate">{selectedMeta.comment}</p>
)}
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<span className="text-xs px-2 py-1 rounded-full bg-blue-500/15 text-blue-400">
{Number(selectedMeta?.rows || 0).toLocaleString()} 行
</span>
<span className="text-xs px-2 py-1 rounded-full bg-cyan-500/15 text-cyan-400 flex items-center gap-1">
<Gauge className="w-3 h-3" />
{formatBytes((selectedMeta?.data_length || 0) + (selectedMeta?.index_length || 0))}
</span>
{selectedMeta?.engine && (
<span className="text-xs px-2 py-1 rounded-full bg-green-500/15 text-green-400">
{selectedMeta.engine}
</span>
)}
<button
onClick={loadTableDetail}
className="w-7 h-7 rounded-md flex items-center justify-center hover:bg-[var(--dg-bg-hover)]"
title="刷新表结构"
>
<RefreshCw className={`w-3.5 h-3.5 ${loadingDetail ? 'animate-spin text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
</button>
</div>
</div>
</CardHeader>
<div className="flex border-b border-[var(--dg-border)] px-5">
{tabs.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
isActive
? 'border-blue-500 text-blue-400'
: 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
<CardContent className="p-5">
{loadingDetail ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--dg-text-muted)]">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-blue-400" />
加载结构...
</div>
) : activeTab === 'columns' ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
<th className="text-left px-4 py-2.5 font-semibold">可空</th>
<th className="text-left px-4 py-2.5 font-semibold">键</th>
<th className="text-left px-4 py-2.5 font-semibold">默认值</th>
<th className="text-left px-4 py-2.5 font-semibold">额外</th>
<th className="text-left px-4 py-2.5 font-semibold">注释</th>
</tr>
</thead>
<tbody>
{(tableDetail?.columns || []).map(col => {
const { icon: TypeIcon, color } = getTypeIcon(col.Type || '');
return (
<tr key={col.Field} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2.5 font-mono text-blue-400">
<span className="flex items-center gap-1.5">
<TypeIcon className={`w-3.5 h-3.5 ${color}`} />
{col.Field}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-green-400">{col.Type}</td>
<td className="px-4 py-2.5">
<span className={`text-xs px-1.5 py-0.5 rounded ${col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
{col.Null === 'YES' ? 'YES' : 'NO'}
</span>
</td>
<td className="px-4 py-2.5">{col.Key || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-[var(--dg-text-muted)]">{col.Default ?? 'NULL'}</td>
<td className="px-4 py-2.5 text-xs text-[var(--dg-text-muted)]">{col.Extra || '-'}</td>
<td className="px-4 py-2.5 text-xs text-[var(--dg-text-muted)] max-w-[220px] truncate" title={col.Comment}>{col.Comment || '-'}</td>
</tr>
);
})}
{(!tableDetail?.columns || tableDetail.columns.length === 0) && (
<tr>
<td colSpan={7} className="text-center py-10 text-[var(--dg-text-muted)]">暂无字段信息</td>
</tr>
)}
</tbody>
</table>
</div>
) : activeTab === 'indexes' ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">索引名</th>
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
<th className="text-left px-4 py-2.5 font-semibold">唯一</th>
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
<th className="text-left px-4 py-2.5 font-semibold">备注</th>
</tr>
</thead>
<tbody>
{indexes.map(index => (
<tr key={index.name} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2.5 font-mono text-blue-400">{index.name}</td>
<td className="px-4 py-2.5 font-mono text-xs">{index.columns.join(', ') || '-'}</td>
<td className="px-4 py-2.5">
<span className={`text-xs px-1.5 py-0.5 rounded ${index.nonUnique === 0 ? 'bg-cyan-500/15 text-cyan-400' : 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
{index.nonUnique === 0 ? '唯一' : '普通'}
</span>
</td>
<td className="px-4 py-2.5 text-xs">{index.type}</td>
<td className="px-4 py-2.5 text-xs text-[var(--dg-text-muted)]">{index.comment}</td>
</tr>
))}
{indexes.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-10 text-[var(--dg-text-muted)]">暂无索引信息</td>
</tr>
)}
</tbody>
</table>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
<Code2 className="w-3.5 h-3.5" />
CREATE TABLE 语句
</span>
<button
onClick={() => tableDetail?.ddl && navigator.clipboard.writeText(tableDetail.ddl)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
复制
</button>
</div>
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 overflow-x-auto">
{tableDetail?.ddl ? (
<pre
className="text-sm font-mono whitespace-pre-wrap leading-relaxed"
dangerouslySetInnerHTML={{ __html: highlightDDL(tableDetail.ddl) }}
/>
) : (
<pre className="text-sm font-mono whitespace-pre-wrap text-[var(--dg-text-muted)]">-- 暂无 DDL 信息</pre>
)}
</div>
</div>
)}
</CardContent>
</Card>
)}
</section>
</div>
);
}
+11 -3
View File
@@ -38,6 +38,10 @@ interface Props {
mode?: 'edit' | 'clone';
}
function toBool(value: unknown): boolean {
return value === true || value === 'true' || value === 1 || value === '1';
}
/* ------------------------------------------------------------------ */
/* Reusable Edit Instance Dialog */
/* ------------------------------------------------------------------ */
@@ -50,7 +54,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
const [database, setDatabase] = useState(instance.database || '');
const [env, setEnv] = useState(instance.env || 'development');
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
const [clusterMode, setClusterMode] = useState(instance.cluster_mode === true);
const [clusterMode, setClusterMode] = useState(toBool(instance.cluster_mode));
const [workflowId, setWorkflowId] = useState<number | null>(instance.workflow_id || null);
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
// excluded_databases: comma-separated input
@@ -74,7 +78,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
setDatabase(instance.database || '');
setEnv(instance.env || 'development');
setAccessMode(instance.access_mode || 'readwrite');
setClusterMode(instance.cluster_mode === true);
setClusterMode(toBool(instance.cluster_mode));
setWorkflowId(instance.workflow_id || null);
setExcludedDbs(
Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '')
@@ -155,7 +159,11 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
const res = mode === 'edit' && !password
? await instanceApi.testConnection(instance.id)
: await instanceApi.testNewConnection(payload);
setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean });
const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean };
if (instance.db_type === 'redis' && result.success) {
setClusterMode(toBool(result.cluster_mode));
}
setTestResult(result);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail
|| (e as { response?: { data?: { message?: string } } })?.response?.data?.message
+4 -3
View File
@@ -47,6 +47,7 @@ export default function Layout() {
const themeOption = useMemo(() => THEME_OPTIONS.find(o => o.id === theme), [theme]);
const themeIcon = useMemo(() => {
if (!themeOption) return <Monitor className="w-4 h-4" />;
if (themeOption.isDark === 'system') return <Monitor className="w-4 h-4" />;
if (themeOption.isDark) return <Moon className="w-4 h-4" />;
return <Sun className="w-4 h-4" />;
}, [themeOption]);
@@ -94,13 +95,13 @@ export default function Layout() {
+ 'hover:ml-0.5')
}
>
{/* Active indicator bar - gradient with glow */}
{/* Active indicator bar */}
{isActive && (
<div
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r animate-[slideInLeft_200ms_ease-out]"
style={{
background: 'linear-gradient(180deg, var(--dg-blue), var(--dg-purple, var(--dg-blue)))',
boxShadow: '0 0 8px 1px var(--dg-blue), 0 0 16px 2px color-mix(in srgb, var(--dg-blue) 40%, transparent)',
background: 'var(--dg-blue)',
boxShadow: '0 0 0 1px color-mix(in srgb, var(--dg-blue) 30%, transparent)',
}}
/>
)}
+4 -4
View File
@@ -1,4 +1,4 @@
import { useAuth, actionPermissionMap } from '@/hooks/useAuth';
import { useAuth } from '@/hooks/useAuth';
import type { ReactNode } from 'react';
interface ActionConfig {
@@ -17,11 +17,11 @@ interface PageHeaderProps {
}
export default function PageHeader({ title, description, action, secondaryAction, children }: PageHeaderProps) {
const { user, hasPermission } = useAuth();
const { hasPermission, hasActionPermission } = useAuth();
const canShowAction = !action?.permission
|| hasPermission(action.permission)
|| (actionPermissionMap[action.permission] || []).includes(user.role);
|| hasActionPermission(action.permission);
return (
<div className="flex items-center justify-between">
@@ -43,7 +43,7 @@ export default function PageHeader({ title, description, action, secondaryAction
{canShowAction && action && (
<button
onClick={action.onClick}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{(() => { const Icon = action.icon; return <Icon className="w-4 h-4" />; })()}
{action.label}
+1 -1
View File
@@ -231,7 +231,7 @@ export default function AuroraView() {
letterSpacing: 0.3,
}}
>
企业级数据库审核与合规管理
企业级数据审核与变更管理平台
</p>
</div>
+2 -2
View File
@@ -373,7 +373,7 @@ export default function CosmosView() {
letterSpacing: '0.3px',
}}
>
企业级数据库审核与合规管理
企业级数据审核与变更管理平台
</p>
</div>
@@ -527,7 +527,7 @@ export default function CosmosView() {
letterSpacing: '0.3px',
}}
>
&copy; 2025 OpsxSQL — 企业级数据库安全合规
&copy; 2025 OpsxSQL — 企业级数据审核与变更管理平台
</p>
</div>
@@ -117,7 +117,7 @@ export default function GradientMeshView(): React.JSX.Element {
OpsxSQL
</h1>
<p style={{ fontSize: 14, color: '#6b7280', margin: 0, fontWeight: 400 }}>
企业级数据库审核与合规管理
企业级数据审核与变更管理平台
</p>
</div>
@@ -288,7 +288,7 @@ export default function InkWashView() {
letterSpacing: 0.5,
}}
>
企业级数据库审核与合规管理
企业级数据审核与变更管理平台
</p>
</div>
@@ -195,7 +195,7 @@ export default function MinimalDark() {
OpsxSQL
</h1>
<p style={{ color: 'rgba(255,255,255,0.4)', fontSize: 14, marginBottom: 36 }}>
企业级数据库审核与合规管理
企业级数据审核与变更管理平台
</p>
{/* Form */}
@@ -79,7 +79,7 @@ export default function NeonCityView() {
OpsxSQL
</h1>
<p style={{ fontSize: 14, color: '#64748b', margin: 0, fontWeight: 400 }}>
企业级数据库审核与合规管理
企业级数据审核与变更管理平台
</p>
</div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useLogin } from '@/hooks/useLogin';
import { useTheme } from '@/hooks/useTheme';
import { Shield, Database, Code, GitBranch, Bell, History, UserCog, Sparkles, Sun, Moon } from 'lucide-react';
import { Shield, Database, Code, GitBranch, Bell, History, UserCog, Sparkles, Sun, Moon, Monitor } from 'lucide-react';
export default function SidePanel() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
@@ -11,7 +11,7 @@ export default function SidePanel() {
{ icon: Code, title: '自助查询', desc: '在线SQL编辑器,语法高亮,查询历史' },
{ icon: GitBranch, title: '数据变更审批', desc: '工单提交、多级审核、执行确认' },
{ icon: Bell, title: '审核规则引擎', desc: '语法/性能/安全/命名,9大类规则' },
{ icon: History, title: '完整审计追踪', desc: '操作可追溯,查询历史、慢日志分析' },
{ icon: History, title: '完整审计追踪', desc: '操作可追溯,查询历史、执行记录' },
{ icon: UserCog, title: '角色权限管理', desc: 'RBAC 权限体系,admin/DBA/开发者/审计' },
];
@@ -26,7 +26,7 @@ export default function SidePanel() {
onClick={toggleTheme}
className="absolute top-5 right-5 z-20 w-10 h-10 rounded-xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
{(theme as string) === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{theme === 'system' ? <Monitor className="w-5 h-5" /> : theme === 'ocean-deep' || theme === 'pine-green' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
</button>
{/* 左侧 */}
+31 -16
View File
@@ -1,24 +1,39 @@
export const menuSections = [
{ id: 'workspace', label: '工作台' },
{ id: 'admin', label: '管理' },
{ id: 'system', label: '系统' },
{ id: 'change', label: '数据变更' },
{ id: 'query', label: '数据查询' },
{ id: 'asset', label: '数据资产' },
{ id: 'governance', label: '审批治理' },
{ id: 'access', label: '权限与组织' },
{ id: 'system', label: '系统治理' },
];
export const menuItems = [
// 工作台 — 日常操作
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard.view' },
{ id: 'queries', label: '查询数据', icon: 'Code2', section: 'workspace', roles: ['admin', 'dba', 'developer'], permission: 'queries.execute' },
{ id: 'tickets', label: '数据变更', icon: 'FileEdit', section: 'workspace', roles: ['admin', 'dba', 'developer'], permission: 'tickets.manage' },
// 工作台
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard' },
// 管理 — 数据源 + 权限
{ id: 'instances', label: '数据源', icon: 'Database', section: 'admin', roles: ['admin', 'dba'], permission: 'instances.manage' },
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'admin', roles: ['admin', 'dba'], permission: 'workflow.manage' },
{ id: 'users', label: '用户管理', icon: 'Users', section: 'admin', roles: ['admin'], permission: 'users.manage' },
{ id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'admin', roles: ['admin'], permission: 'roles.manage' },
{ id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'admin', roles: ['admin'], permission: 'instance-groups.manage' },
// 数据变更
{ id: 'tickets', label: '变更工单', icon: 'FileEdit', section: 'change', roles: ['admin', 'dba', 'developer'], permission: 'tickets' },
{ id: 'auto-task', label: '自动执行', icon: 'Timer', section: 'change', roles: ['admin'], permission: 'auto-task' },
// 系统
{ id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba', 'auditor'], permission: 'audit-logs.view' },
{ id: 'auto-task', label: '自动任务', icon: 'Timer', section: 'system', roles: ['admin'], permission: 'auto-task.manage' },
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings.manage' },
// 数据查询
{ id: 'queries', label: 'SQL 查询', icon: 'Code2', section: 'query', roles: ['admin', 'dba', 'developer'], permission: 'queries' },
// 数据资产
{ id: 'instances', label: '数据源实例', icon: 'Database', section: 'asset', roles: ['admin', 'dba'], permission: 'instances' },
// 审批治理
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'governance', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'workflow' },
{ id: 'audit-rules', label: '审核规则', icon: 'ShieldCheck', section: 'governance', roles: ['admin', 'dba', 'auditor'], permission: 'audit-rules' },
// 权限与组织
{ id: 'users', label: '用户管理', icon: 'Users', section: 'access', roles: ['admin'], permission: 'users' },
{ id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'access', roles: ['admin'], permission: 'roles' },
{ id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'access', roles: ['admin'], permission: 'instance-groups' },
// 系统治理
{ id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba', 'auditor'], permission: 'audit-logs' },
{ id: 'notifications', label: '通知渠道', icon: 'Bell', section: 'system', roles: ['admin'], permission: 'notifications' },
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'], permission: 'announcement' },
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings' },
];
+22 -6
View File
@@ -113,17 +113,24 @@ const routePermissionMap: Record<string, UserRole[]> = {
dashboard: ['admin', 'dba', 'developer', 'auditor'],
instances: ['admin', 'dba'],
'instance-groups': ['admin'],
'data-dictionary': ['admin', 'dba', 'developer'],
tickets: ['admin', 'dba', 'developer'],
queries: ['admin', 'dba', 'developer'],
roles: ['admin'],
users: ['admin'],
'audit-rules': ['admin', 'dba', 'auditor'],
'audit-logs': ['admin', 'dba', 'auditor'],
workflow: ['admin', 'dba', 'developer', 'auditor'],
'auto-task': ['admin'],
notifications: ['admin'],
announcement: ['admin'],
settings: ['admin'],
};
const permissionAliasMap: Record<string, string[]> = {
instances: ['datasource'],
settings: ['config-platform'],
};
export const actionPermissionMap: Record<string, UserRole[]> = {
'ticket.create': ['admin', 'dba', 'developer'],
'ticket.approve': ['admin', 'dba', 'developer'],
@@ -314,17 +321,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return cache.menu.get(menuId)!;
}
const allowedRoles = routePermissionMap[menuId];
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
const allowedRoles = menuItem?.roles || routePermissionMap[menuId];
if (!allowedRoles) {
cache.menu.set(menuId, false);
return false;
}
const result = allowedRoles.includes(user.role);
const permissionKeys = [
menuId,
menuItem?.permission,
...((permissionAliasMap[menuId] || [])),
...((menuItem?.permission && permissionAliasMap[menuItem.permission]) || []),
].filter(Boolean) as string[];
const customPermission = permissionKeys.find(key => user.customPermissions && key in user.customPermissions);
const result = allowedRoles.includes(user.role)
&& (customPermission ? Boolean(user.customPermissions?.[customPermission]) : true);
cache.menu.set(menuId, result);
cache.timestamp = Date.now();
return result;
}, [user.role, isLoggedIn, isCacheValid]);
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
@@ -446,8 +462,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, [user, clearPermissionCache, emitPermissionChange]);
const visibleMenuItems = useMemo(() => {
return menuItems.filter(item => item.roles.includes(user.role));
}, [user.role]);
return menuItems.filter(item => item.roles.includes(user.role) && hasPermission(item.id));
}, [user.role, hasPermission]);
/** 检查是否拥有指定角色或以上级别权限 */
const hasRoleLevel = useCallback((minRole: UserRole): boolean => {
+3 -1
View File
@@ -35,7 +35,9 @@ export function useLogin(): LoginState {
const { data: prefs } = await api.get<{ theme?: string; login_style?: string }>('/user/preferences');
if (prefs?.theme) {
const t = prefs.theme;
if (t === 'dark' || t === 'light') {
if (t === 'system') {
setTheme('system');
} else if (t === 'dark' || t === 'light') {
setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional');
} else if (THEME_OPTIONS.some(o => o.id === t)) {
setTheme(t as Theme);
+22 -9
View File
@@ -1,30 +1,30 @@
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
export type Theme = 'ocean-deep' | 'light-professional' | 'midnight-purple' | 'pine-green' | 'orange-vitality';
export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green';
export interface ThemeOption {
id: Theme;
name: string;
description: string;
isDark: boolean;
isDark: boolean | 'system';
primaryColor: string;
}
export const THEME_OPTIONS: ThemeOption[] = [
{ id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' },
{ id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' },
{ id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' },
{ id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' },
{ id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' },
{ id: 'system', name: '跟随系统', description: '自动匹配系统深色或灰白浅色', isDark: 'system', primaryColor: '#2563eb' },
{ id: 'light-professional', name: '灰白', description: '克制清爽的生产浅色主题', isDark: false, primaryColor: '#2563eb' },
{ id: 'ocean-deep', name: '深色', description: '低亮度、高对比的专业暗色主题', isDark: true, primaryColor: '#3b82f6' },
{ id: 'pine-green', name: '墨绿', description: '偏运维场景的深色备用主题', isDark: true, primaryColor: '#22c55e' },
];
const THEME_ORDER: Theme[] = THEME_OPTIONS.map(o => o.id);
const DEFAULT_THEME: Theme = 'ocean-deep';
const DEFAULT_THEME: Theme = 'system';
// Backward compatibility: map old theme names to new ones
function migrateLegacyTheme(value: string | null): Theme | null {
if (value === 'dark') return 'ocean-deep';
if (value === 'light') return 'light-professional';
if (value === 'midnight-purple' || value === 'orange-vitality') return 'ocean-deep';
return null;
}
@@ -51,8 +51,15 @@ function getInitialTheme(): Theme {
return DEFAULT_THEME;
}
function resolveSystemTheme(): Exclude<Theme, 'system'> {
if (typeof window === 'undefined') return 'ocean-deep';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'ocean-deep' : 'light-professional';
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme);
const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme;
document.documentElement.setAttribute('data-theme', resolvedTheme);
document.documentElement.setAttribute('data-theme-choice', theme);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
@@ -60,6 +67,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
applyTheme(theme);
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => applyTheme('system');
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, [theme]);
const toggleTheme = useCallback(() => {
+18 -18
View File
@@ -46,14 +46,14 @@
--dg-green: #3fb950;
--dg-red: #f85149;
--dg-orange: #f0883e;
--dg-purple: #8957e5;
--dg-purple: #7c8aa5;
--dg-cyan: #39c5cf;
--dg-yellow: #d29922;
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
--dg-gradient-start: #2563eb;
--dg-gradient-end: #9333ea;
--dg-gradient-end: #0ea5e9;
--dg-gradient-text-start: #60a5fa;
--dg-gradient-text-end: #c084fc;
--dg-gradient-text-end: #93c5fd;
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
}
@@ -99,21 +99,21 @@
--dg-green: #3fb950;
--dg-red: #f85149;
--dg-orange: #f0883e;
--dg-purple: #8957e5;
--dg-purple: #7c8aa5;
--dg-cyan: #39c5cf;
--dg-yellow: #d29922;
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
--dg-gradient-start: #2563eb;
--dg-gradient-end: #9333ea;
--dg-gradient-end: #0ea5e9;
--dg-gradient-text-start: #60a5fa;
--dg-gradient-text-end: #c084fc;
--dg-gradient-text-end: #93c5fd;
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
}
/* ===== Theme 2: Light Professional (浅色商务) ===== */
[data-theme="light-professional"],
[data-theme="light"] {
--background: 0 0% 100%;
--background: 220 14% 97%;
--foreground: 220 14% 10%;
--card: 0 0% 100%;
--card-foreground: 220 14% 10%;
@@ -133,7 +133,7 @@
--input: 220 13% 91%;
--ring: 217 91% 60%;
--radius: 0.625rem;
--sidebar-background: 0 0% 98%;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 220 14% 20%;
--sidebar-primary: 217 91% 60%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -141,12 +141,12 @@
--sidebar-accent-foreground: 220 14% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217 91% 60%;
--dg-bg-primary: #ffffff;
--dg-bg-secondary: #f8f9fb;
--dg-bg-tertiary: #f0f1f5;
--dg-bg-hover: #e8eaef;
--dg-border: #e2e5eb;
--dg-border-light: #eaecf0;
--dg-bg-primary: #f5f6f8;
--dg-bg-secondary: #ffffff;
--dg-bg-tertiary: #eef1f5;
--dg-bg-hover: #e6e9ef;
--dg-border: #dfe3ea;
--dg-border-light: #edf0f4;
--dg-text-primary: #1a1d23;
--dg-text-secondary: #5c6370;
--dg-text-muted: #9ca3af;
@@ -154,14 +154,14 @@
--dg-green: #16a34a;
--dg-red: #dc2626;
--dg-orange: #ea580c;
--dg-purple: #7c3aed;
--dg-purple: #64748b;
--dg-cyan: #0891b2;
--dg-yellow: #ca8a04;
--dg-card-hover-border: rgba(37, 99, 235, 0.3);
--dg-gradient-start: #2563eb;
--dg-gradient-end: #7c3aed;
--dg-gradient-end: #0ea5e9;
--dg-gradient-text-start: #1d4ed8;
--dg-gradient-text-end: #6d28d9;
--dg-gradient-text-end: #0369a1;
--dg-input-focus-ring: rgba(37, 99, 235, 0.15);
}
@@ -455,7 +455,7 @@
@apply dg-badge bg-yellow-500/10 text-yellow-400;
}
.dg-badge-purple {
@apply dg-badge bg-purple-500/10 text-purple-400;
@apply dg-badge bg-cyan-500/10 text-cyan-400;
}
.dg-badge-orange {
@apply dg-badge bg-orange-500/10 text-orange-400;
+29 -23
View File
@@ -1,39 +1,45 @@
import type { PermissionNode } from '@/types';
export const permissionTreeData: PermissionNode[] = [
{ id: 'dashboard', label: '仪表盘(查看统计、图表)' },
{ id: 'datasource', label: '数据源管理', children: [
{ id: 'workspace', label: '工作台', children: [
{ id: 'dashboard', label: '运营总览(查看统计、待办、风险趋势)' },
]},
{ id: 'change', label: '数据变更', children: [
{ id: 'tickets', label: '变更工单(创建、查看、审批、执行)' },
{ id: 'ticket_create', label: '创建工单' },
{ id: 'ticket_approve', label: '审批工单' },
{ id: 'ticket_execute', label: '执行工单' },
{ id: 'ticket_rollback', label: '回滚工单' },
{ id: 'auto-task', label: '自动执行任务' },
]},
{ id: 'query', label: '数据查询', children: [
{ id: 'queries', label: 'SQL 查询(查看日志、新建查询、导出)' },
{ id: 'query_create', label: '执行查询' },
{ id: 'query_export', label: '导出结果' },
{ id: 'query_view_all', label: '查看全部用户查询' },
]},
{ id: 'datasource', label: '数据资产', children: [
{ id: 'instances', label: '数据源实例(查看、添加、编辑、删除)' },
{ id: 'mysql', label: 'MySQL 实例(查看、添加、编辑、删除)' },
{ id: 'postgres', label: 'PostgreSQL 实例(查看、添加、编辑、删除)' },
{ id: 'redis', label: 'Redis 实例(查看、添加、编辑、删除)' },
{ id: 'mongo', label: 'MongoDB 实例(查看、添加、编辑、删除)' },
]},
{ id: 'tickets', label: '数据变更工单(创建、查看、审批、执行)', children: [
{ id: 'ticket_create', label: '创建工单' },
{ id: 'ticket_approve', label: '审批工单' },
{ id: 'ticket_execute', label: '执行工单' },
{ id: 'ticket_rollback', label: '回滚工单' },
{ id: 'governance', label: '审批治理', children: [
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
{ id: 'audit-rules', label: '审核规则(风险规则、禁用语句、影响行数)' },
]},
{ id: 'queries', label: '查询数据(查看日志、新建查询、导出)', children: [
{ id: 'query_create', label: '执行查询' },
{ id: 'query_export', label: '导出结果' },
{ id: 'query_view_all', label: '查看全部用户查询' },
]},
{ id: 'permission', label: '权限管理', children: [
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
{ id: 'permission', label: '权限与组织', children: [
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
{ id: 'instance-groups', label: '权限组管理(创建、编辑、分配)' },
]},
{ id: 'system', label: '系统配置', children: [
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
{ id: 'system', label: '系统治理', children: [
{ id: 'audit-logs', label: '审计日志(查看操作记录)' },
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
{ id: 'auto-task', label: '自动任务' },
{ id: 'announcement', label: '公告管理' },
]},
{ id: 'config', label: '配置平台', children: [
{ id: 'config-platform', label: '全局配置(消息推送/LDAP/查询限制/工单规则)' },
{ id: 'notifications', label: '通知渠道(配置 Webhook、测试)' },
{ id: 'announcement', label: '公告管理' },
{ id: 'settings', label: '系统设置(外观、LDAP、平台参数)' },
{ id: 'config-platform', label: '全局配置(消息推送/LDAP/查询限制/工单规则)' },
]},
];
@@ -53,7 +59,7 @@ export const statusColorMap: Record<string, string> = {
reviewing: 'bg-blue-500/15 text-blue-400',
approved: 'bg-green-500/15 text-green-400',
rejected: 'bg-red-500/15 text-red-400',
executing: 'bg-purple-500/15 text-purple-400',
executing: 'bg-cyan-500/15 text-cyan-400',
success: 'bg-green-500/15 text-green-400',
failed: 'bg-red-500/15 text-red-400',
rollback: 'bg-orange-500/15 text-orange-400',
+10 -6
View File
@@ -32,7 +32,9 @@ export default function AppearanceSettings() {
const prefs = res.data;
if (prefs?.theme) {
const t = prefs.theme;
if (t === 'dark' || t === 'light') {
if (t === 'system') {
setTheme('system');
} else if (t === 'dark' || t === 'light') {
setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional');
} else if (THEME_OPTIONS.some(o => o.id === t)) {
setTheme(t as Theme);
@@ -87,8 +89,8 @@ export default function AppearanceSettings() {
{/* ===== Theme Mode Section ===== */}
<div>
<h3 className="text-sm font-semibold mb-1">界面配色</h3>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">选择界面配色方案,即时预览效果</p>
<div className="grid grid-cols-5 gap-3">
<p className="text-xs text-[var(--dg-text-muted)] mb-4">推荐使用跟随系统;浅色为灰白基调,暗色降低眩光,适合长期值守</p>
<div className="grid grid-cols-4 gap-3">
{THEME_OPTIONS.map(opt => (
<button
key={opt.id}
@@ -101,7 +103,9 @@ export default function AppearanceSettings() {
<div
className="w-full h-8 rounded-lg mb-3"
style={{
background: opt.isDark
background: opt.isDark === 'system'
? 'linear-gradient(135deg, #f8f9fb 0%, #e8eaef 48%, #161b22 52%, #0d1117 100%)'
: opt.isDark
? `linear-gradient(135deg, ${opt.primaryColor} 0%, color-mix(in srgb, ${opt.primaryColor} 40%, #1a1a2e) 50%, #0d1117 100%)`
: `linear-gradient(135deg, ${opt.primaryColor} 0%, color-mix(in srgb, ${opt.primaryColor} 60%, #ffffff) 50%, #f8fafc 100%)`,
}}
@@ -134,7 +138,7 @@ export default function AppearanceSettings() {
onClick={() => handleLoginStyleChange(opt.id)}
className={`group relative rounded-xl p-4 text-left transition-all ${
loginStyle === opt.id
? 'ring-2 ring-indigo-500/50 bg-[var(--dg-bg-tertiary)]'
? 'ring-2 ring-blue-500/50 bg-[var(--dg-bg-tertiary)]'
: 'bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
@@ -331,7 +335,7 @@ export default function AppearanceSettings() {
{/* Active check mark */}
{loginStyle === opt.id && (
<div className="absolute top-2 right-2 w-5 h-5 rounded-full bg-indigo-500 flex items-center justify-center">
<div className="absolute top-2 right-2 w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
+2 -2
View File
@@ -37,7 +37,7 @@ const ACTION_COLORS: Record<string, string> = {
create: 'bg-blue-500/15 text-blue-400',
approve: 'bg-yellow-500/15 text-yellow-400',
execute: 'bg-green-500/15 text-green-400',
update: 'bg-purple-500/15 text-purple-400',
update: 'bg-cyan-500/15 text-cyan-400',
delete: 'bg-red-500/15 text-red-400',
login: 'bg-emerald-500/15 text-emerald-400',
login_failed: 'bg-red-500/15 text-red-400',
@@ -283,7 +283,7 @@ export default function AuditLogsPage() {
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="w-4 h-4 text-purple-400" />
<Clock className="w-4 h-4 text-cyan-400" />
操作趋势(最近10天)
</CardTitle>
</CardHeader>
+3 -3
View File
@@ -49,7 +49,7 @@ const DB_TABS: DbTab[] = [
const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType; color: string }> = {
DDL: { label: 'DDL 规则', icon: Table, color: 'text-amber-400 bg-amber-500/15' },
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-cyan-400 bg-cyan-500/15' },
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
security: { label: '安全规则', icon: AlertTriangle, color: 'text-red-400 bg-red-500/15' },
performance: { label: '性能规则', icon: Info, color: 'text-cyan-400 bg-cyan-500/15' },
@@ -375,10 +375,10 @@ export default function AuditRulesPage() {
{/* Pattern */}
{rule.pattern && (
<div className="flex items-start gap-2">
<FileSearch className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
<FileSearch className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">匹配模式</span>
<code className="block mt-0.5 text-xs font-mono bg-[var(--dg-bg-primary)] px-2 py-1 rounded text-purple-300">
<code className="block mt-0.5 text-xs font-mono bg-[var(--dg-bg-primary)] px-2 py-1 rounded text-cyan-300">
{rule.pattern}
</code>
</div>
+7 -7
View File
@@ -186,7 +186,7 @@ export default function ConfigPlatformPage() {
</div>
<button
onClick={() => handleSave('notification')}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{savedTab === 'notification' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{savedTab === 'notification' ? '已保存' : '保存配置'}
@@ -283,7 +283,7 @@ export default function ConfigPlatformPage() {
</div>
<button
onClick={() => handleSave('ldap')}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{savedTab === 'ldap' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{savedTab === 'ldap' ? '已保存' : '保存配置'}
@@ -294,8 +294,8 @@ export default function ConfigPlatformPage() {
<CardContent className="p-5">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<UserSearch className="w-5 h-5 text-purple-400" />
<div className="w-10 h-10 rounded-lg bg-cyan-500/15 flex items-center justify-center">
<UserSearch className="w-5 h-5 text-cyan-400" />
</div>
<div>
<h3 className="font-semibold">启用 LDAP 认证</h3>
@@ -423,7 +423,7 @@ export default function ConfigPlatformPage() {
</div>
<button
onClick={() => handleSave('query')}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{savedTab === 'query' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{savedTab === 'query' ? '已保存' : '保存配置'}
@@ -581,7 +581,7 @@ export default function ConfigPlatformPage() {
</div>
<button
onClick={() => handleSave('ticket')}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{savedTab === 'ticket' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{savedTab === 'ticket' ? '已保存' : '保存配置'}
@@ -641,7 +641,7 @@ export default function ConfigPlatformPage() {
</div>
<button
onClick={() => handleSave('ddl')}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
{savedTab === 'ddl' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{savedTab === 'ddl' ? '已保存' : '保存配置'}
+2 -2
View File
@@ -165,7 +165,7 @@ export default function Dashboard() {
},
{
title: '数据源总数', value: stats.instance_total, icon: Database,
color: 'text-purple-400', bg: 'bg-purple-400/10',
color: 'text-cyan-400', bg: 'bg-cyan-400/10',
subtitle: `${stats.instance_online} 个在线 · ${stats.user_total} 个用户`,
},
];
@@ -174,7 +174,7 @@ export default function Dashboard() {
const quickActions = [
{ label: '新建工单', icon: FileText, onClick: () => navigate('/tickets/create'), color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
{ label: '新建查询', icon: Search, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-cyan-400', bg: 'bg-cyan-400/10', border: 'border-cyan-400/20' },
];
+5 -5
View File
@@ -62,7 +62,7 @@ function getTypeIcon(type: string): { icon: React.ElementType; color: string } {
return { icon: AlignLeft, color: 'text-green-400' };
}
if (lower.includes('date') || lower.includes('time') || lower.includes('timestamp')) {
return { icon: Info, color: 'text-purple-400' };
return { icon: Info, color: 'text-cyan-400' };
}
if (lower.includes('json')) {
return { icon: FileJson, color: 'text-yellow-400' };
@@ -85,7 +85,7 @@ function highlightDDL(ddl: string): string {
keywords.forEach(kw => {
const regex = new RegExp(`\\b${kw}\\b`, 'gi');
highlighted = highlighted.replace(regex, `<span class="text-purple-400 font-semibold">${kw}</span>`);
highlighted = highlighted.replace(regex, `<span class="text-cyan-400 font-semibold">${kw}</span>`);
});
highlighted = highlighted.replace(/'[^']*'/g, '<span class="text-green-400">$&</span>');
@@ -430,7 +430,7 @@ export default function DataDictionaryPage() {
<CardTitle className="text-base font-semibold">{selectedTable}</CardTitle>
{tableDetail && (
<>
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 flex items-center gap-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 flex items-center gap-1">
<Gauge className="w-3 h-3" />
{tableDetail.table_size}
</span>
@@ -531,7 +531,7 @@ export default function DataDictionaryPage() {
</td>
<td className="px-4 py-2.5">
{col.Key ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 font-medium">
<span className="text-xs px-1.5 py-0.5 rounded bg-cyan-500/15 text-cyan-400 font-medium">
{col.Key}
</span>
) : (
@@ -586,7 +586,7 @@ export default function DataDictionaryPage() {
<td className="px-4 py-2.5">
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
idxType === 'PRIMARY' || idxType === 'UNIQUE'
? 'bg-purple-500/15 text-purple-400'
? 'bg-cyan-500/15 text-cyan-400'
: 'bg-green-500/15 text-green-400'
}`}>
{idxType}
+6 -42
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Database, Table2, Users, Cpu, Play, RefreshCw } from 'lucide-react';
import { ArrowLeft, Database, Table2, Users, Cpu, Play } from 'lucide-react';
import { instanceDetailApi } from '@/api/instanceDetail';
import { instanceApi } from '@/api/instance';
import { queryApi } from '@/api/query';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import DataDictionaryPanel from '@/components/DataDictionaryPanel';
interface InstanceInfo {
id: number; name: string; db_type: string; host: string; port: number;
@@ -22,7 +23,6 @@ export default function InstanceDetailPage() {
var [activeTab, setActiveTab] = useState<TabKey>('overview');
var [databases, setDatabases] = useState<string[]>([]);
var [selectedDb, setSelectedDb] = useState('');
var [tables, setTables] = useState<any[]>([]);
var [users, setUsers] = useState<any[]>([]);
var [processes, setProcesses] = useState<any[]>([]);
var [sqlText, setSqlText] = useState('');
@@ -32,7 +32,7 @@ export default function InstanceDetailPage() {
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
{ key: 'overview', label: '概览', icon: Database },
{ key: 'tables', label: '表管理', icon: Table2 },
{ key: 'tables', label: '表结构', icon: Table2 },
{ key: 'query', label: 'SQL查询', icon: Play },
{ key: 'users', label: '用户权限', icon: Users },
{ key: 'process', label: '进程列表', icon: Cpu },
@@ -45,7 +45,7 @@ export default function InstanceDetailPage() {
}, [instanceId]);
useEffect(function () {
if (activeTab === 'overview' || activeTab === 'tables') {
if (activeTab === 'overview' || activeTab === 'tables' || activeTab === 'query') {
instanceDetailApi.listDatabases(instanceId).then(function (res: any) {
var dbs = res.databases || res || [];
setDatabases(Array.isArray(dbs) ? dbs : []);
@@ -64,14 +64,6 @@ export default function InstanceDetailPage() {
}
}, [activeTab, selectedDb, instanceId]);
useEffect(function () {
if (activeTab === 'tables' && selectedDb) {
instanceDetailApi.listTables(instanceId, selectedDb).then(function (res: any) {
setTables(res.tables || res || []);
}).catch(function () { setTables([]); });
}
}, [activeTab, selectedDb, instanceId]);
var handleQuery = function () {
if (!sqlText.trim()) return;
setLoading(true);
@@ -136,7 +128,7 @@ export default function InstanceDetailPage() {
<div className="grid grid-cols-3 gap-4">
<div className="dg-card p-4"><div className="text-2xl font-bold text-blue-400">{databases.length}</div><div className="text-xs text-[var(--dg-text-muted)]">数据库</div></div>
<div className="dg-card p-4"><div className="text-2xl font-bold text-green-400">{instance.status === 'connected' ? '在线' : '离线'}</div><div className="text-xs text-[var(--dg-text-muted)]">状态</div></div>
<div className="dg-card p-4"><div className="text-2xl font-bold text-purple-400">{instance.version || '-'}</div><div className="text-xs text-[var(--dg-text-muted)]">版本</div></div>
<div className="dg-card p-4"><div className="text-2xl font-bold text-cyan-400">{instance.version || '-'}</div><div className="text-xs text-[var(--dg-text-muted)]">版本</div></div>
</div>
<div className="dg-card p-4">
<h3 className="text-sm font-semibold mb-3">数据库列表</h3>
@@ -151,35 +143,7 @@ export default function InstanceDetailPage() {
{/* 表管理 */}
{activeTab === 'tables' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">选择数据库</option>
{databases.map(function (db) { return <option key={db} value={db}>{db}</option>; })}
</select>
<button onClick={function () { if (selectedDb) setActiveTab('tables'); }} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]"><RefreshCw className="w-4 h-4" /></button>
</div>
{tables.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-muted)]">选择数据库查看表列表</div> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">表名</th><th className="px-4 py-2">引擎</th><th className="px-4 py-2">行数</th><th className="px-4 py-2">大小</th><th className="px-4 py-2">注释</th>
</tr></thead>
<tbody>
{tables.map(function (t: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 font-medium">{t.Name || t.name}</td>
<td className="px-4 py-2">{t.Engine || t.engine || '-'}</td>
<td className="px-4 py-2">{t.Rows || t.rows || '-'}</td>
<td className="px-4 py-2">{t.Data_length ? (t.Data_length / 1024 / 1024).toFixed(2) + ' MB' : '-'}</td>
<td className="px-4 py-2 text-[var(--dg-text-muted)]">{t.Comment || t.comment || '-'}</td>
</tr>;
})}
</tbody>
</table>
</div>
)}
</div>
<DataDictionaryPanel instanceId={instanceId} />
)}
{/* SQL查询 */}
+11 -3
View File
@@ -66,7 +66,7 @@ const envColor: Record<string, string> = {
staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
development: 'bg-green-500/10 text-green-400 border-green-500/20',
test: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
uat: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
uat: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
};
const statusLabel: Record<string, { text: string; online: boolean }> = {
@@ -1074,7 +1074,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
{ label: '连接数', value: metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: 'QPS', value: metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '延迟', value: metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' },
{ label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
].map(m => (
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
<div className={`w-8 h-8 rounded-lg ${m.bg} flex items-center justify-center mx-auto mb-2`}>
@@ -1164,6 +1164,10 @@ const formSteps = [
{ label: '高级配置', icon: Activity },
];
function toBool(value: unknown): boolean {
return value === true || value === 'true' || value === 1 || value === '1';
}
function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
defaultType: string; onCancel: () => void; onCreated: () => void;
}) {
@@ -1244,7 +1248,11 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
cluster_mode: isRedis ? redisClusterMode : undefined,
env: (finalEnv || 'development') as string, access_mode: accessMode,
});
setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean });
const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean };
if (isRedis && result.success) {
setRedisClusterMode(toBool(result.cluster_mode));
}
setTestResult(result);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败';
setTestResult({ success: false, message: msg });
+11 -1
View File
@@ -9,6 +9,7 @@ import CosmosView from '@/components/login/CosmosView';
import InkWashView from '@/components/login/InkWashView';
import NeonCityView from '@/components/login/NeonCityView';
import GradientMeshView from '@/components/login/GradientMeshView';
import { THEME_OPTIONS, type Theme, useTheme } from '@/hooks/useTheme';
export type LoginStyle = 'sidepanel' | 'split' | 'centered' | 'fullimage' | 'minimal' | 'aurora' | 'cosmos' | 'inkwash' | 'neoncity' | 'gradientmesh';
@@ -41,6 +42,7 @@ export const LOGIN_STYLE_OPTIONS: { id: LoginStyle; name: string; desc: string }
export default function LoginPage() {
const [style, setStyle] = useState<LoginStyle>('sidepanel');
const [ready, setReady] = useState(false);
const { setTheme } = useTheme();
useEffect(() => {
// Try backend first (cross-browser), fallback to localStorage
@@ -48,6 +50,14 @@ export default function LoginPage() {
.then(r => r.json())
.then(data => {
const s = data?.login_style as LoginStyle;
const t = data?.theme as string | undefined;
if (t === 'system') {
setTheme('system');
} else if (t === 'dark' || t === 'light') {
setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional');
} else if (t && THEME_OPTIONS.some(option => option.id === t)) {
setTheme(t as Theme);
}
if (s && s in LOGIN_STYLES) {
setStyle(s);
} else {
@@ -60,7 +70,7 @@ export default function LoginPage() {
if (stored && stored in LOGIN_STYLES) setStyle(stored);
})
.finally(() => setReady(true));
}, []);
}, [setTheme]);
if (!ready) return null; // avoid flash
+1 -1
View File
@@ -204,7 +204,7 @@ export default function MongoDBDetailPage() {
</td>
<td className="px-4 py-2.5">
<span className={'px-2 py-0.5 rounded text-xs font-medium '
+ (colType === 'view' ? 'bg-purple-500/10 text-purple-400' : 'bg-blue-500/10 text-blue-400')}>
+ (colType === 'view' ? 'bg-cyan-500/10 text-cyan-400' : 'bg-blue-500/10 text-blue-400')}>
{colType}
</span>
</td>
+337 -69
View File
@@ -1,11 +1,68 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search } from 'lucide-react';
import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search, Save, Copy, Code2, Table2, Trash2, FileText } from 'lucide-react';
import { redisApi } from '@/api/redis';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { FormDialog } from '@/components/FormDialog';
import { toast } from '@/hooks/useToast';
type TabKey = 'keys' | 'monitor' | 'cli' | 'perf';
type ValueMode = 'table' | 'json' | 'raw';
interface RedisKeyDetail {
key: string;
name?: string;
type: string;
ttl: number;
value: any;
}
function ttlMsToSeconds(ttl: number | undefined): number {
if (ttl == null || ttl < 0) return -1;
return Math.max(1, Math.ceil(ttl / 1000));
}
function formatTTL(ttl: number | undefined): string {
if (ttl == null || ttl < 0) return '永不过期';
if (ttl === 0) return '即将过期';
var seconds = Math.ceil(ttl / 1000);
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
function normalizeZSetRows(value: any): { member: string; score: number }[] {
if (!Array.isArray(value)) return [];
return value.map(function (row: any) {
return {
member: String(row?.member ?? row?.Member ?? ''),
score: Number(row?.score ?? row?.Score ?? 0),
};
});
}
function normalizeValueForEdit(type: string, value: any): any {
if (type === 'zset') return normalizeZSetRows(value);
return value;
}
function stringifyValue(type: string, value: any): string {
if (type === 'string') return typeof value === 'string' ? value : String(value ?? '');
return JSON.stringify(normalizeValueForEdit(type, value), null, 2);
}
function parseEditorValue(type: string, text: string): any {
if (type === 'string') return text;
return JSON.parse(text || (type === 'hash' ? '{}' : '[]'));
}
function tryFormatJsonText(text: string): string | null {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return null;
}
}
export default function RedisDetailPage() {
var params = useParams<{ id: string }>();
@@ -18,7 +75,13 @@ export default function RedisDetailPage() {
var [keyPattern, setKeyPattern] = useState('*');
var [keyType, setKeyType] = useState('');
var [keys, setKeys] = useState<any[]>([]);
var [selectedKey, setSelectedKey] = useState<any>(null);
var [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
var [detailLoading, setDetailLoading] = useState(false);
var [valueMode, setValueMode] = useState<ValueMode>('table');
var [valueSearch, setValueSearch] = useState('');
var [editValue, setEditValue] = useState('');
var [editTtl, setEditTtl] = useState(-1);
var [savingKey, setSavingKey] = useState(false);
var [showAddKey, setShowAddKey] = useState(false);
var [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 });
var [cmdInput, setCmdInput] = useState('');
@@ -48,21 +111,83 @@ export default function RedisDetailPage() {
}).catch(function () { setKeys([]); }).finally(function () { setLoading(false); });
};
var loadKeyDetail = function (keyName: string) {
setDetailLoading(true);
redisApi.getKeyDetail(instanceId, keyName, selectedDb).then(function (res: any) {
var detail = res as RedisKeyDetail;
setSelectedKey(detail);
setEditValue(stringifyValue(detail.type, detail.value));
setEditTtl(ttlMsToSeconds(detail.ttl));
setValueMode(detail.type === 'string' ? 'json' : 'table');
setValueSearch('');
}).catch(function () {
toast('error', '加载 Redis Key 详情失败');
}).finally(function () {
setDetailLoading(false);
});
};
var handleAddKey = function () {
redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: newKey.value, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () {
var parsedValue: any;
try {
parsedValue = parseEditorValue(newKey.type, newKey.value);
} catch {
toast('error', newKey.type === 'hash' ? 'Hash 值需要 JSON Object' : '该类型的值需要 JSON Array');
return;
}
redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: parsedValue, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () {
setShowAddKey(false);
setNewKey({ key: '', value: '', type: 'string', ttl: -1 });
fetchKeys();
toast('success', 'Key 已新增');
}).catch(function () {});
};
var handleDeleteKey = function (keyName: string) {
setDeleteConfirm({msg: '确定删除该键?', onConfirm: async () => {
redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { fetchKeys(); }).catch(function () {});
redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () {
if ((selectedKey?.key || selectedKey?.name) === keyName) setSelectedKey(null);
fetchKeys();
toast('success', 'Key 已删除');
}).catch(function () {});
}});
return;
};
var handleCopyValue = function () {
if (!selectedKey) return;
navigator.clipboard?.writeText(editValue).then(function () {
toast('success', '已复制当前值');
}).catch(function () {
toast('warning', '复制失败,请手动复制');
});
};
var handleSaveKey = function () {
if (!selectedKey) return;
var keyName = selectedKey.key || selectedKey.name || '';
var parsedValue: any;
try {
parsedValue = parseEditorValue(selectedKey.type, editValue);
} catch (e: any) {
toast('error', '值不是合法的 ' + (selectedKey.type === 'hash' ? 'JSON Object' : 'JSON Array'));
return;
}
setSavingKey(true);
redisApi.updateKey(instanceId, keyName, {
db: selectedDb,
type: selectedKey.type,
value: parsedValue,
ttl: editTtl > 0 ? editTtl : undefined,
}).then(function () {
toast('success', 'Key 已保存');
loadKeyDetail(keyName);
fetchKeys();
}).catch(function () {}).finally(function () {
setSavingKey(false);
});
};
var handleCommand = function () {
if (!cmdInput.trim()) return;
var cmd = cmdInput;
@@ -82,6 +207,106 @@ export default function RedisDetailPage() {
}
}, [activeTab, instanceId]);
var renderStructuredValue = function () {
if (!selectedKey) return null;
var type = selectedKey.type;
var value = normalizeValueForEdit(type, selectedKey.value);
var search = valueSearch.trim().toLowerCase();
if (type === 'hash') {
var entries = Object.entries(value || {}).filter(function (entry) {
return !search || entry[0].toLowerCase().includes(search) || String(entry[1]).toLowerCase().includes(search);
});
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
<th className="w-16 px-3 py-2 font-medium">#</th>
<th className="w-[32%] px-3 py-2 font-medium">Field</th>
<th className="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
{entries.map(function (entry, index) {
var pretty = typeof entry[1] === 'string' ? (tryFormatJsonText(entry[1]) || entry[1]) : JSON.stringify(entry[1], null, 2);
return (
<tr key={entry[0]} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index + 1}</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-primary)]">{entry[0]}</td>
<td className="px-3 py-2">
<pre className="max-h-28 overflow-auto whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] px-2 py-1.5 font-mono text-xs text-[var(--dg-text-secondary)]">{pretty}</pre>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
if (type === 'list' || type === 'set') {
var list = Array.isArray(value) ? value.filter(function (item) {
return !search || String(item).toLowerCase().includes(search);
}) : [];
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
<th className="w-20 px-3 py-2 font-medium">Index</th>
<th className="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
{list.map(function (item, index) {
var pretty = typeof item === 'string' ? (tryFormatJsonText(item) || item) : JSON.stringify(item, null, 2);
return (
<tr key={index} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index}</td>
<td className="px-3 py-2"><pre className="max-h-28 overflow-auto whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] px-2 py-1.5 font-mono text-xs text-[var(--dg-text-secondary)]">{pretty}</pre></td>
</tr>
);
})}
</tbody>
</table>
);
}
if (type === 'zset') {
var zRows = normalizeZSetRows(value).filter(function (row) {
return !search || row.member.toLowerCase().includes(search) || String(row.score).includes(search);
});
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
<th className="w-20 px-3 py-2 font-medium">#</th>
<th className="px-3 py-2 font-medium">Member</th>
<th className="w-32 px-3 py-2 font-medium">Score</th>
</tr>
</thead>
<tbody>
{zRows.map(function (row, index) {
return (
<tr key={index} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index + 1}</td>
<td className="px-3 py-2 font-mono text-xs">{row.member}</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">{row.score}</td>
</tr>
);
})}
</tbody>
</table>
);
}
var formatted = tryFormatJsonText(String(value ?? ''));
return (
<pre className="min-h-[240px] whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] p-4 font-mono text-xs text-[var(--dg-text-secondary)]">
{formatted || String(value ?? '')}
</pre>
);
};
var infoSections = Object.entries(info).filter(function (e) { return typeof e[1] === 'object' && e[1] !== null; });
var infoScalars = Object.entries(info).filter(function (e) { return typeof e[1] !== 'object'; });
@@ -123,60 +348,119 @@ export default function RedisDetailPage() {
{/* 键管理 */}
{activeTab === 'keys' && (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
</select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} className="w-full pl-10 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="pattern, 如 user:*" />
<div className="grid min-h-[620px] grid-cols-[360px_minmax(0,1fr)] gap-4">
<div className="dg-card flex min-h-0 flex-col overflow-hidden">
<div className="border-b border-[var(--dg-border)] p-3">
<div className="mb-3 grid grid-cols-[1fr_auto] gap-2">
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); setSelectedKey(null); }} className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm">
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB{i}</option>; })}
</select>
<button onClick={function () { setShowAddKey(true); }} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-[var(--dg-blue)] px-3 text-sm font-medium text-white hover:opacity-90"><Plus className="h-4 w-4" />新建</button>
</div>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} onKeyDown={function (e) { if (e.key === 'Enter') fetchKeys(); }} className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm" placeholder="pattern, 如 MARKET:*" />
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm">
<option value="">全部类型</option>
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
</select>
<button onClick={fetchKeys} disabled={loading} className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"><RefreshCw className={'h-4 w-4 ' + (loading ? 'animate-spin' : '')} /></button>
</div>
</div>
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-3 py-2 text-xs text-[var(--dg-text-muted)]">
<span>Keys</span>
<span>{keys.length} 条</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{loading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 8 }, function (_, i) {
return <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />;
})}
</div>
) : keys.length === 0 ? (
<div className="px-6 py-16 text-center text-sm text-[var(--dg-text-muted)]">点击刷新加载键列表</div>
) : keys.map(function (k: any, i: number) {
var keyName = k.key || k.name || k;
var active = (selectedKey?.key || selectedKey?.name) === keyName;
return (
<button key={keyName + i} onClick={function () { loadKeyDetail(keyName); }}
className={'block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ' + (active ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-hover)]')}>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
<span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{k.type || '-'}</span>
</div>
<div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL: {formatTTL(k.ttl)}</div>
</button>
);
})}
</div>
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">全部类型</option>
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
</select>
<button onClick={fetchKeys} disabled={loading} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"><RefreshCw className="w-4 h-4" /></button>
<button onClick={function () { setShowAddKey(true); }} className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm flex items-center gap-1"><Plus className="w-4 h-4" />新增</button>
</div>
{/* 键列表 */}
<div className="overflow-x-auto">
{loading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }, function (_, i) {
return <div key={i} className="flex items-center gap-4">
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-16" />
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-12" />
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-20" />
</div>;
})}
<div className="text-center text-xs text-[var(--dg-text-muted)] pt-2">加载中...</div>
<div className="dg-card min-w-0 overflow-hidden">
{!selectedKey ? (
<div className="flex h-full min-h-[520px] flex-col items-center justify-center text-center">
<FileText className="mb-3 h-10 w-10 text-[var(--dg-text-muted)]" />
<div className="text-sm font-medium">选择一个 Key 查看详情</div>
<div className="mt-1 text-xs text-[var(--dg-text-muted)]">支持结构化查看、格式化 JSON、编辑值和 TTL</div>
</div>
) : keys.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">点击刷新加载键列表</div>
) : (
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">键名</th><th className="px-4 py-2">类型</th><th className="px-4 py-2">TTL</th><th className="px-4 py-2">操作</th>
</tr></thead>
<tbody>
{keys.map(function (k: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 font-mono text-xs">{k.key || k.name || k}</td>
<td className="px-4 py-2">{k.type || '-'}</td>
<td className="px-4 py-2">{k.ttl != null ? k.ttl : '-'}</td>
<td className="px-4 py-2 flex gap-2">
<button onClick={function () {
redisApi.getKeyDetail(instanceId, k.key || k.name || k, selectedDb).then(function (res: any) { setSelectedKey(res); }).catch(function () {});
}} className="text-xs text-blue-400 hover:text-blue-300">查看</button>
<button onClick={function () { handleDeleteKey(k.key || k.name || k); }} className="text-xs text-red-400 hover:text-red-300">删除</button>
</td>
</tr>;
})}
</tbody>
</table>
<div className="flex h-full min-h-[620px] flex-col">
<div className="border-b border-[var(--dg-border)] p-4">
<div className="flex items-center gap-3">
<span className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-2 text-xs font-semibold uppercase text-cyan-400">{selectedKey.type}</span>
<input readOnly value={selectedKey.key || selectedKey.name || ''} className="h-10 min-w-0 flex-1 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm text-[var(--dg-text-primary)]" />
<div className="flex h-10 overflow-hidden rounded-md border border-[var(--dg-border)]">
<span className="flex items-center border-r border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 text-xs text-[var(--dg-text-muted)]">TTL</span>
<input type="number" value={editTtl} onChange={function (e) { setEditTtl(Number(e.target.value)); }} className="w-24 bg-[var(--dg-bg-primary)] px-3 text-sm outline-none" />
</div>
<button onClick={handleCopyValue} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]" title="复制值"><Copy className="h-4 w-4" /></button>
<button onClick={handleSaveKey} disabled={savingKey} className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--dg-blue)] px-4 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"><Save className="h-4 w-4" />保存</button>
<button onClick={function () { handleDeleteKey(selectedKey.key || selectedKey.name || ''); }} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20" title="删除 Key"><Trash2 className="h-4 w-4" /></button>
</div>
</div>
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-4 py-3">
<div className="flex rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-0.5">
{[
{ key: 'table' as ValueMode, label: '结构', icon: Table2 },
{ key: 'json' as ValueMode, label: '格式化', icon: Code2 },
{ key: 'raw' as ValueMode, label: '编辑', icon: FileText },
].map(function (mode) {
var Icon = mode.icon;
return <button key={mode.key} onClick={function () { setValueMode(mode.key); }}
className={'inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs transition-colors ' + (valueMode === mode.key ? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
<Icon className="h-3.5 w-3.5" />{mode.label}
</button>;
})}
</div>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input value={valueSearch} onChange={function (e) { setValueSearch(e.target.value); }} className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" placeholder="搜索字段或值" />
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4 scrollbar-thin">
{detailLoading ? (
<div className="h-72 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
) : valueMode === 'raw' ? (
<textarea value={editValue} onChange={function (e) { setEditValue(e.target.value); }} spellCheck={false}
className="min-h-[480px] w-full resize-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]" />
) : valueMode === 'json' ? (
<pre className="min-h-[480px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{selectedKey.type === 'string' ? (tryFormatJsonText(String(selectedKey.value ?? '')) || String(selectedKey.value ?? '')) : JSON.stringify(normalizeValueForEdit(selectedKey.type, selectedKey.value), null, 2)}
</pre>
) : (
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
{renderStructuredValue()}
</div>
)}
</div>
</div>
)}
</div>
</div>
@@ -264,22 +548,6 @@ export default function RedisDetailPage() {
</div>
)}
{/* 键详情弹窗 */}
{selectedKey && (
<FormDialog open={!!selectedKey} onClose={function () { setSelectedKey(null); }} title="键详情" width="max-w-lg">
<div className="space-y-3">
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">键名:</span><span className="font-mono">{selectedKey.key || selectedKey.name}</span></div>
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">类型:</span><span>{selectedKey.type}</span></div>
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">TTL:</span><span>{selectedKey.ttl != null ? selectedKey.ttl + 's' : '-'}</span></div>
<div className="text-sm"><span className="text-[var(--dg-text-muted)]">值:</span>
<pre className="mt-1 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono max-h-60 overflow-y-auto">
{typeof selectedKey.value === 'object' ? JSON.stringify(selectedKey.value, null, 2) : String(selectedKey.value || '-')}
</pre>
</div>
</div>
</FormDialog>
)}
{/* 新增键弹窗 */}
<FormDialog open={showAddKey} onClose={() => setShowAddKey(false)} title="新增键" width="max-w-md"
footer={<>
+8 -8
View File
@@ -29,7 +29,7 @@ const ROLES: RolePreset[] = [
color: 'text-amber-400',
bg: 'bg-amber-500/10',
ring: 'ring-amber-500/20',
permissions: ['dashboard', 'datasource', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'permission', 'roles', 'users', 'instance-groups', 'system', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config', 'config-platform', 'notifications'],
permissions: ['workspace', 'dashboard', 'change', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'auto-task', 'query', 'queries', 'query_create', 'query_export', 'query_view_all', 'datasource', 'instances', 'mysql', 'postgres', 'redis', 'mongo', 'governance', 'workflow', 'audit-rules', 'permission', 'users', 'roles', 'instance-groups', 'system', 'audit-logs', 'notifications', 'announcement', 'settings', 'config-platform'],
},
{
key: 'dba',
@@ -39,7 +39,7 @@ const ROLES: RolePreset[] = [
color: 'text-blue-400',
bg: 'bg-blue-500/10',
ring: 'ring-blue-500/20',
permissions: ['dashboard', 'datasource', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'system', 'audit-rules', 'audit-logs', 'workflow'],
permissions: ['workspace', 'dashboard', 'change', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'query', 'queries', 'query_create', 'query_export', 'query_view_all', 'datasource', 'instances', 'mysql', 'postgres', 'redis', 'mongo', 'governance', 'workflow', 'audit-rules', 'system', 'audit-logs'],
},
{
key: 'developer',
@@ -49,17 +49,17 @@ const ROLES: RolePreset[] = [
color: 'text-emerald-400',
bg: 'bg-emerald-500/10',
ring: 'ring-emerald-500/20',
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
permissions: ['workspace', 'dashboard', 'change', 'tickets', 'ticket_create', 'query', 'queries', 'query_create', 'query_export'],
},
{
key: 'auditor',
name: '审计员',
description: '查看查询日志、审计规则、审批流程记录',
description: '查看审计日志、审核规则和审批流程记录',
icon: Eye,
color: 'text-violet-400',
bg: 'bg-violet-500/10',
ring: 'ring-violet-500/20',
permissions: ['dashboard', 'tickets', 'queries', 'query_view_all', 'system', 'audit-rules', 'audit-logs', 'workflow'],
color: 'text-cyan-400',
bg: 'bg-cyan-500/10',
ring: 'ring-cyan-500/20',
permissions: ['workspace', 'dashboard', 'governance', 'workflow', 'audit-rules', 'system', 'audit-logs'],
},
];
+1 -1
View File
@@ -449,7 +449,7 @@ export default function TicketCreatePage() {
<div className="flex items-center justify-end">
<button onClick={handleSubmit}
disabled={submitting || !validated}
className="flex items-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all">
className="flex items-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-[var(--dg-blue)] rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all">
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
提交审批
</button>
+6 -6
View File
@@ -63,7 +63,7 @@ interface RelatedTicket {
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
EXECUTE_TICKET: { icon: Play, color: 'text-purple-400', bg: 'bg-purple-500/15' },
EXECUTE_TICKET: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
@@ -359,7 +359,7 @@ export default function TicketDetailPage() {
<button
onClick={() => handleAction('execute')}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all"
className="flex items-center gap-2 px-4 py-2.5 bg-cyan-500/15 border border-cyan-500/30 rounded-lg text-sm font-medium text-cyan-400 hover:bg-cyan-500/25 transition-all"
>
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
执行
@@ -411,7 +411,7 @@ export default function TicketDetailPage() {
const stepAccent: Record<string, string> = {
'提交': 'text-blue-400',
'审核': 'text-amber-400',
'执行': 'text-purple-400',
'执行': 'text-cyan-400',
};
const getAccent = (name: string) => {
@@ -778,7 +778,7 @@ export default function TicketDetailPage() {
{ticket.rollback_sql && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
<RotateCcw className="w-4 h-4 text-cyan-400" /> 回滚 SQL
</h3>
<SqlEditor
value={ticket.rollback_sql}
@@ -799,7 +799,7 @@ export default function TicketDetailPage() {
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
</h3>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
@@ -822,7 +822,7 @@ export default function TicketDetailPage() {
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
</h3>
{/* Status badge + key metrics */}
+5 -5
View File
@@ -121,7 +121,7 @@ const displayStatusColorMap: Record<string, string> = {
reviewing: 'bg-blue-500/15 text-blue-400',
approved: 'bg-blue-500/15 text-blue-400',
rejected: 'bg-red-500/15 text-red-400',
executing: 'bg-purple-500/15 text-purple-400',
executing: 'bg-cyan-500/15 text-cyan-400',
success: 'bg-green-500/15 text-green-400',
failed: 'bg-red-500/15 text-red-400',
rollback: 'bg-orange-500/15 text-orange-400',
@@ -494,7 +494,7 @@ export default function TicketsPage() {
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="dg-btn dg-btn-sm dg-btn-ghost text-purple-400 hover:text-purple-300"
className="dg-btn dg-btn-sm dg-btn-ghost text-cyan-400 hover:text-cyan-300"
>
<Play className="w-3.5 h-3.5" />
执行
@@ -675,7 +675,7 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
<button
onClick={handleExecute}
disabled={loading}
className="dg-btn dg-btn-sm bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-500/25 disabled:opacity-50"
className="dg-btn dg-btn-sm bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-50"
>
<Play className="w-3.5 h-3.5" /> 执行
</button>
@@ -758,7 +758,7 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
{execResult && (
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
</h3>
{/* Summary row */}
@@ -833,7 +833,7 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
{!execResult && ticket.execute_result && (
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
</h3>
<div className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
ticket.status === 'failed' ? 'bg-red-500/5 border border-red-500/20 text-red-400' : 'bg-[var(--dg-bg-tertiary)]'
+3 -3
View File
@@ -51,7 +51,7 @@ const roleNameMap: Record<string, string> = {
};
const roleTagStyle: Record<string, string> = {
admin: 'bg-purple-500/15 text-purple-400',
admin: 'bg-cyan-500/15 text-cyan-400',
dba: 'bg-blue-500/15 text-blue-400',
developer: 'bg-green-500/15 text-green-400',
auditor: 'bg-orange-500/15 text-orange-400',
@@ -722,7 +722,7 @@ function EditUserModal({ user, onClose, onUpdated }: { user: UserItem; onClose:
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
<div className="w-10 h-10 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div>
@@ -856,7 +856,7 @@ function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => voi
{/* User Info Card */}
<div className="flex items-center gap-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-xl mb-5">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-lg font-semibold">
<div className="w-14 h-14 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-lg font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div className="flex-1 min-w-0">
+3 -3
View File
@@ -61,10 +61,10 @@ const STEP_TYPE_LABELS: Record<string, string> = {
const STEP_TYPE_COLORS: Record<string, string> = {
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
serial: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
serial: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
parallel: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
any_one: 'bg-green-500/15 text-green-400 border-green-500/30',
audit: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
audit: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
};
@@ -621,7 +621,7 @@ export default function WorkflowPage() {
{ label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: items.filter((w) => w.is_active).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '默认流程', value: items.filter((w) => w.is_default).length, icon: Star, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
{ label: '总步骤数', value: items.reduce((acc, w) => acc + (w.steps?.length || 0), 0), icon: Users, color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ label: '总步骤数', value: items.reduce((acc, w) => acc + (w.steps?.length || 0), 0), icon: Users, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">