feat: enhance redis and production IA
This commit is contained in:
1 parent
644981eef4
commit
58bbc394e9
76 files changed
+1702
-721
No files matched your search
@@ -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 +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';
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './auth';
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
import request from './request';
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () => request.get<any, any>('/dashboard/stats'),
|
||||
};
|
||||
export * from './dashboard/dashboard';
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from '../request';
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () => request.get<any, any>('/dashboard/stats'),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dashboard';
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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,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,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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,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,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,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,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,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 +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';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './query';
|
||||
|
||||
@@ -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,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 } }),
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './notification';
|
||||
export * from './user';
|
||||
export * from './workflow';
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 +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';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ticket';
|
||||
|
||||
@@ -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,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,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function AuroraView() {
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
企业级数据审核与变更管理平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ export default function CosmosView() {
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
企业级数据库审核与合规管理
|
||||
企业级数据审核与变更管理平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -527,7 +527,7 @@ export default function CosmosView() {
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
© 2025 OpsxSQL — 企业级数据库安全合规
|
||||
© 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 左侧 */}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' ? '已保存' : '保存配置'}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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查询 */}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={<>
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)]'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user