update
This commit is contained in:
1 parent
f5cf3f4440
commit
0fcdbc5f6c
29 files changed
+773
-264
No files matched your search
@@ -35,18 +35,19 @@ function PageFallback() {
|
||||
}
|
||||
|
||||
function RouteGuard() {
|
||||
const { isLoggedIn, hasPermission } = useAuth();
|
||||
const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth();
|
||||
const location = useLocation();
|
||||
const path = location.pathname.split('/')[1] || 'dashboard';
|
||||
const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record<string, string>)[path] || path;
|
||||
const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries');
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// 检查当前路径是否有权限访问
|
||||
if (permissionPath !== 'dashboard' && !hasPermission(permissionPath)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
if (!hasPermission(permissionPath)) {
|
||||
return <Navigate to={fallbackPath} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
@@ -54,9 +55,9 @@ function RouteGuard() {
|
||||
|
||||
// 登录页守卫:已登录用户自动跳转到仪表盘
|
||||
function LoginGuard() {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const { isLoggedIn, visibleMenuItems } = useAuth();
|
||||
if (isLoggedIn) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
return <Navigate to={'/' + (visibleMenuItems[0]?.id || 'queries')} replace />;
|
||||
}
|
||||
return <LoginPage />;
|
||||
}
|
||||
@@ -68,7 +69,7 @@ function AppRoutes() {
|
||||
<Route path="/login" element={<LoginGuard />} />
|
||||
<Route element={<RouteGuard />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route index element={<Navigate to="/queries" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||
@@ -88,7 +89,7 @@ function AppRoutes() {
|
||||
<Route path="notifications" element={<NotificationPage />} />
|
||||
<Route path="announcement" element={<AnnouncementPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/queries" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Layout() {
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
||||
{/* 侧边栏 — z-[200] 永远在弹框之上 */}
|
||||
<aside className="relative z-[200] flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
||||
<aside className="relative z-[200] hidden md:flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
||||
{/* Logo */}
|
||||
<div className="h-14 flex items-center px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -126,18 +126,18 @@ export default function Layout() {
|
||||
{/* 主内容区 */}
|
||||
<main id="app-main" className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* 顶部栏 — z-[200] 永远在弹框之上 */}
|
||||
<header className="relative z-[200] h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
|
||||
<header className="relative z-[200] h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-3 md:px-5 flex-shrink-0">
|
||||
{/* Breadcrumb area */}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-sm min-w-0">
|
||||
<span className="text-[var(--dg-text-muted)] font-medium">OpsxSQL</span>
|
||||
{breadcrumbInfo.sectionLabel && (
|
||||
<>
|
||||
<span className="hidden sm:contents">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||
<span className="text-[var(--dg-text-muted)] opacity-70">{breadcrumbInfo.sectionLabel}</span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{breadcrumbInfo.pageLabel}</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">{breadcrumbInfo.pageLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
@@ -169,7 +169,7 @@ export default function Layout() {
|
||||
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||
{user.avatar}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
||||
<span className="hidden sm:inline text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors duration-200"
|
||||
@@ -182,11 +182,34 @@ export default function Layout() {
|
||||
</header>
|
||||
|
||||
{/* 页面内容 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-3 md:p-5 pb-20 md:pb-5">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-[210] md:hidden border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]/95 backdrop-blur">
|
||||
<div className="flex overflow-x-auto px-2 py-1.5 gap-1 scrollbar-thin">
|
||||
{visibleMenuItems.map(item => {
|
||||
const Icon = iconMap[item.icon] || Database;
|
||||
const isActive = currentPath === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate('/' + item.id)}
|
||||
className={`min-w-[70px] flex flex-col items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--dg-blue)]/12 text-[var(--dg-blue)]'
|
||||
: 'text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="max-w-[64px] truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Keyframe animation for active indicator */}
|
||||
<style>{`
|
||||
@keyframes slideInLeft {
|
||||
|
||||
@@ -24,17 +24,17 @@ export default function PageHeader({ title, description, action, secondaryAction
|
||||
|| hasActionPermission(action.permission);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold">{title}</h1>
|
||||
{description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2 sm:gap-3 flex-wrap">
|
||||
{secondaryAction && (
|
||||
<button
|
||||
onClick={secondaryAction.onClick}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
|
||||
className="flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
|
||||
>
|
||||
{(() => { const Icon = secondaryAction.icon; return <Icon className="w-4 h-4" />; })()}
|
||||
{secondaryAction.label}
|
||||
@@ -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-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
||||
className="flex items-center gap-2 px-3 sm: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}
|
||||
|
||||
@@ -148,9 +148,17 @@ const appTheme = EditorView.theme({
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--dg-text-primary)',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
||||
lineHeight: '1.55',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--dg-text-primary)',
|
||||
borderLeftWidth: '1.5px',
|
||||
height: '1.25em !important',
|
||||
marginTop: '0.15em',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
'.cm-line': {
|
||||
lineHeight: '1.55',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
|
||||
@@ -448,4 +456,4 @@ const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(({
|
||||
);
|
||||
});
|
||||
|
||||
export default SqlEditor;
|
||||
export default SqlEditor;
|
||||
@@ -16,7 +16,7 @@ export default function SidePanel() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-[var(--dg-bg-primary)] relative overflow-hidden">
|
||||
<div className="min-h-screen flex flex-col md:flex-row bg-[var(--dg-bg-primary)] relative overflow-hidden">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_20%_20%,rgba(88,166,255,0.08)_0%,transparent_50%),radial-gradient(ellipse_at_80%_80%,rgba(137,87,229,0.08)_0%,transparent_50%)]" />
|
||||
@@ -24,29 +24,29 @@ export default function SidePanel() {
|
||||
{/* 主题切换 */}
|
||||
<button
|
||||
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"
|
||||
className="absolute top-4 right-4 md:top-5 md: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 === '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>
|
||||
|
||||
{/* 左侧 */}
|
||||
<div className="flex-1 flex flex-col justify-center px-12 relative z-10">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 flex flex-col justify-center px-5 pt-12 pb-4 md:px-12 md:py-0 relative z-10">
|
||||
<div className="mb-5 md:mb-8">
|
||||
<div className="flex items-center gap-3 mb-4 md:mb-5">
|
||||
<div className="w-11 h-11 rounded-xl dg-gradient-br flex items-center justify-center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold dg-gradient-text">
|
||||
<h1 className="text-3xl md:text-4xl font-bold dg-gradient-text">
|
||||
OpsxSQL
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-base text-[var(--dg-text-secondary)] leading-relaxed">
|
||||
<p className="text-sm md:text-base text-[var(--dg-text-secondary)] leading-relaxed">
|
||||
企业级数据审核与变更管理平台,支持多数据库类型,<br />
|
||||
提供完善的 SQL 审核、审批流程和审计追踪
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="hidden sm:grid grid-cols-2 gap-3">
|
||||
{features.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5 p-3 rounded-xl bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] hover:border-blue-500/50 hover:bg-[var(--dg-bg-secondary)] transition-all group">
|
||||
<f.icon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform" />
|
||||
@@ -60,7 +60,7 @@ export default function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* 右侧 */}
|
||||
<div className="w-[400px] flex flex-col justify-center px-12 bg-[var(--dg-bg-secondary)]/80 backdrop-blur-xl border-l border-[var(--dg-border)] relative z-10">
|
||||
<div className="w-full md:w-[400px] flex flex-col justify-center px-5 pb-8 md:px-12 md:py-0 bg-[var(--dg-bg-secondary)]/80 backdrop-blur-xl md:border-l border-[var(--dg-border)] relative z-10">
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-5 h-5 text-blue-400" />
|
||||
|
||||
@@ -10,7 +10,7 @@ export const menuSections = [
|
||||
|
||||
export const menuItems = [
|
||||
// 工作台
|
||||
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard' },
|
||||
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'auditor'], permission: 'dashboard' },
|
||||
|
||||
// 数据变更
|
||||
{ id: 'tickets', label: '变更工单', icon: 'FileEdit', section: 'change', roles: ['admin', 'dba', 'developer'], permission: 'tickets' },
|
||||
|
||||
@@ -27,23 +27,25 @@ function endEllipsis(value: string, maxLength: number): string {
|
||||
return value.slice(0, Math.max(0, maxLength - 3)) + '...';
|
||||
}
|
||||
|
||||
const HEADER_KEY_MAX_LENGTH = 96;
|
||||
|
||||
export function RedisHashQueryResult({ rows, rawKey, rawType, rawTtl, command, onView }: RedisHashQueryResultProps) {
|
||||
var redisKey = String(rawKey || parseRedisKeyFromCommand(command) || '');
|
||||
var redisKeyLabel = redisKey ? middleEllipsis(redisKey, 20) : '-';
|
||||
var redisKeyLabel = redisKey ? middleEllipsis(redisKey, HEADER_KEY_MAX_LENGTH) : '-';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
|
||||
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-semibold text-blue-400">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
|
||||
<span className="shrink-0 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-semibold text-blue-400">
|
||||
{String(rawType || 'hash').replace(/^./, function (c) { return c.toUpperCase(); })}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 max-w-[220px] flex-1 truncate rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-1.5 font-mono text-[var(--dg-text-primary)]"
|
||||
className="min-w-0 max-w-full overflow-hidden rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-1.5 font-mono text-[var(--dg-text-primary)]"
|
||||
title={redisKey}
|
||||
>
|
||||
{redisKeyLabel}
|
||||
</span>
|
||||
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-mono text-[var(--dg-text-muted)]">
|
||||
<span className="shrink-0 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-mono text-[var(--dg-text-muted)]">
|
||||
TTL {String(rawTtl ?? '-1')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { menuItems } from '@/constants/menu';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
const defaultUser = {
|
||||
id: 0,
|
||||
@@ -131,6 +132,8 @@ const permissionAliasMap: Record<string, string[]> = {
|
||||
settings: ['config-platform'],
|
||||
};
|
||||
|
||||
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
|
||||
|
||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||
'ticket.create': ['admin', 'dba', 'developer'],
|
||||
'ticket.approve': ['admin', 'dba', 'developer'],
|
||||
@@ -191,6 +194,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
} catch { /* ignore */ }
|
||||
return true;
|
||||
});
|
||||
const [hasWritableDataSourceAccess, setHasWritableDataSourceAccess] = useState(false);
|
||||
|
||||
/** 权限缓存引用 */
|
||||
const permissionCacheRef = useRef<PermissionCache>({
|
||||
@@ -216,6 +220,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
permissionCacheRef.current.timestamp = 0;
|
||||
}, []);
|
||||
|
||||
const hasWritableAccessInUserPayload = useCallback((u: AuthUser): boolean => {
|
||||
return Boolean(u.dataSourcePermissions?.some(ds =>
|
||||
ds.actions.some(action => ['write', 'delete', 'readwrite'].includes(String(action)))
|
||||
));
|
||||
}, []);
|
||||
|
||||
/** 触发权限变更事件 */
|
||||
const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
|
||||
listenersRef.current.forEach(listener => listener(updatedUser));
|
||||
@@ -239,6 +249,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [clearPermissionCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
setHasWritableDataSourceAccess(false);
|
||||
clearPermissionCache();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role === 'admin' || user.role === 'dba') {
|
||||
setHasWritableDataSourceAccess(true);
|
||||
clearPermissionCache();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role !== 'developer') {
|
||||
setHasWritableDataSourceAccess(false);
|
||||
clearPermissionCache();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
instanceApi.accessible('write')
|
||||
.then((instances) => {
|
||||
if (cancelled) return;
|
||||
setHasWritableDataSourceAccess((instances || []).length > 0);
|
||||
clearPermissionCache();
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setHasWritableDataSourceAccess(hasWritableAccessInUserPayload(user));
|
||||
clearPermissionCache();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, user.id, user.role, user.permissionsUpdatedAt, hasWritableAccessInUserPayload, clearPermissionCache]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await authApi.login(username, password);
|
||||
@@ -327,6 +374,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
cache.menu.set(menuId, false);
|
||||
return false;
|
||||
}
|
||||
const targetMenuId = menuItem?.id || menuId;
|
||||
if (user.role === 'developer' && dataChangePermissionIds.has(targetMenuId) && !hasWritableDataSourceAccess) {
|
||||
cache.menu.set(menuId, false);
|
||||
cache.timestamp = Date.now();
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionKeys = [
|
||||
menuId,
|
||||
@@ -340,7 +393,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
cache.menu.set(menuId, result);
|
||||
cache.timestamp = Date.now();
|
||||
return result;
|
||||
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
|
||||
}, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]);
|
||||
|
||||
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
|
||||
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
|
||||
@@ -351,6 +404,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return cache.action.get(action)!;
|
||||
}
|
||||
|
||||
if (user.role === 'developer' && ['ticket.create', 'ticket.approve'].includes(String(action)) && !hasWritableDataSourceAccess) {
|
||||
cache.action.set(action, false);
|
||||
cache.timestamp = Date.now();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 优先检查自定义权限
|
||||
if (user.customPermissions && action in user.customPermissions) {
|
||||
const customResult = user.customPermissions[action];
|
||||
@@ -369,7 +428,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
cache.action.set(action, result);
|
||||
cache.timestamp = Date.now();
|
||||
return result;
|
||||
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
|
||||
}, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]);
|
||||
|
||||
/** 数据源级权限检查 */
|
||||
const hasDataSourcePermission = useCallback((
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useLogin(): LoginState {
|
||||
}
|
||||
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
|
||||
} catch { /* ignore */ }
|
||||
navigate('/dashboard');
|
||||
navigate('/queries');
|
||||
} else {
|
||||
setError('用户名或密码错误');
|
||||
}
|
||||
|
||||
@@ -17,11 +17,20 @@ interface AuditLog {
|
||||
user_id: number;
|
||||
username: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: number | null;
|
||||
resource_type?: string;
|
||||
resource_id?: number | null;
|
||||
target_type?: string;
|
||||
target_id?: number | null;
|
||||
detail: string | null;
|
||||
ip_address: string | null;
|
||||
created_at: string | null;
|
||||
result_status?: string | null;
|
||||
query_content?: string | null;
|
||||
db_type?: string | null;
|
||||
database_name?: string | null;
|
||||
instance_name?: string | null;
|
||||
execution_time_ms?: number | null;
|
||||
rows_affected?: number | null;
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
@@ -41,6 +50,8 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
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',
|
||||
query: 'bg-indigo-500/15 text-indigo-400',
|
||||
query_failed: 'bg-red-500/15 text-red-400',
|
||||
cancel: 'bg-orange-500/15 text-orange-400',
|
||||
reject: 'bg-red-500/15 text-red-400',
|
||||
};
|
||||
@@ -55,6 +66,8 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
reject: '拒绝',
|
||||
login: '登录',
|
||||
login_failed: '登录失败',
|
||||
query: '查询',
|
||||
query_failed: '查询失败',
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
|
||||
@@ -72,6 +85,7 @@ const TARGET_ICONS: Record<string, React.ElementType> = {
|
||||
notification: AlertTriangle,
|
||||
workflow: FileText,
|
||||
system_setting: AlertTriangle,
|
||||
query: Search,
|
||||
other: FileText,
|
||||
};
|
||||
|
||||
@@ -82,9 +96,9 @@ const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#e
|
||||
function exportLogsToCSV(logs: AuditLog[]) {
|
||||
const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间'];
|
||||
const rows = logs.map(l => [
|
||||
l.id, l.username, ACTION_LABELS[l.action] || l.action, l.resource_type,
|
||||
l.resource_id || '', l.detail || '', l.ip_address || '',
|
||||
l.action.includes('fail') || l.action === 'reject' ? '失败' : '成功',
|
||||
l.id, l.username, ACTION_LABELS[l.action] || l.action, getTargetType(l),
|
||||
getTargetId(l) || '', l.detail || '', l.ip_address || '',
|
||||
getResultStatus(l) === 'failed' ? '失败' : '成功',
|
||||
l.created_at || '',
|
||||
]);
|
||||
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
@@ -97,6 +111,19 @@ function exportLogsToCSV(logs: AuditLog[]) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function getTargetType(log: AuditLog): string {
|
||||
return log.target_type || log.resource_type || 'other';
|
||||
}
|
||||
|
||||
function getTargetId(log: AuditLog): number | null {
|
||||
return log.target_id ?? log.resource_id ?? null;
|
||||
}
|
||||
|
||||
function getResultStatus(log: AuditLog): string {
|
||||
if (log.result_status) return log.result_status;
|
||||
return log.action.includes('fail') || log.action === 'reject' ? 'failed' : 'success';
|
||||
}
|
||||
|
||||
/* ───────── Main Component ───────── */
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
@@ -142,8 +169,8 @@ export default function AuditLogsPage() {
|
||||
// Client-side filtering for advanced filters
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter(log => {
|
||||
if (filterTarget && log.resource_type !== filterTarget) return false;
|
||||
if (filterStatus && log.action.includes('fail') !== (filterStatus === 'failed')) return false;
|
||||
if (filterTarget && getTargetType(log) !== filterTarget) return false;
|
||||
if (filterStatus && getResultStatus(log) !== filterStatus) return false;
|
||||
if (filterDateRange && log.created_at) {
|
||||
const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000);
|
||||
if (filterDateRange === '1d' && days > 1) return false;
|
||||
@@ -187,7 +214,7 @@ export default function AuditLogsPage() {
|
||||
const statusDistribution = useMemo(() => {
|
||||
let success = 0, failed = 0;
|
||||
logs.forEach(l => {
|
||||
if (l.action.includes('fail') || l.action === 'reject') failed++;
|
||||
if (getResultStatus(l) === 'failed') failed++;
|
||||
else success++;
|
||||
});
|
||||
return [
|
||||
@@ -353,6 +380,7 @@ export default function AuditLogsPage() {
|
||||
<option value="instance">实例</option>
|
||||
<option value="user">用户</option>
|
||||
<option value="auth">认证</option>
|
||||
<option value="query">查询</option>
|
||||
<option value="workflow">审批流</option>
|
||||
<option value="notification">通知</option>
|
||||
</select>
|
||||
@@ -439,10 +467,12 @@ export default function AuditLogsPage() {
|
||||
) : (
|
||||
paginatedLogs.map(log => {
|
||||
const isExpanded = expandedId === log.id;
|
||||
const isFailed = log.action.includes('fail') || log.action === 'reject';
|
||||
const targetType = getTargetType(log);
|
||||
const targetId = getTargetId(log);
|
||||
const isFailed = getResultStatus(log) === 'failed';
|
||||
const statusConfig = isFailed ? STATUS_CONFIG.failed : STATUS_CONFIG.success;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const TargetIcon = TARGET_ICONS[log.resource_type] || FileText;
|
||||
const TargetIcon = TARGET_ICONS[targetType] || FileText;
|
||||
return (
|
||||
<Fragment key={log.id}>
|
||||
<tr
|
||||
@@ -471,8 +501,8 @@ export default function AuditLogsPage() {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-[var(--dg-text-secondary)]">{log.resource_type}</span>
|
||||
{log.resource_id && <span className="text-[var(--dg-text-muted)] text-xs">#{log.resource_id}</span>}
|
||||
<span className="text-[var(--dg-text-secondary)]">{targetType}</span>
|
||||
{targetId && <span className="text-[var(--dg-text-muted)] text-xs">#{targetId}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono">
|
||||
@@ -497,6 +527,11 @@ export default function AuditLogsPage() {
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">操作详情</div>
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">{log.detail || '无详情'}</p>
|
||||
{log.query_content && (
|
||||
<pre className="mt-3 max-h-32 overflow-auto rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 text-xs font-mono whitespace-pre-wrap text-[var(--dg-text-secondary)]">
|
||||
{log.query_content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -509,12 +544,36 @@ export default function AuditLogsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div>
|
||||
<div className="text-sm font-mono">{log.resource_id || 'N/A'}</div>
|
||||
<div className="text-sm font-mono">{targetId || 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div>
|
||||
<div className="text-sm">{formatDate(log.created_at)}</div>
|
||||
</div>
|
||||
{log.instance_name && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">实例</div>
|
||||
<div className="text-sm">{log.instance_name}</div>
|
||||
</div>
|
||||
)}
|
||||
{log.database_name && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">数据库</div>
|
||||
<div className="text-sm">{log.database_name}</div>
|
||||
</div>
|
||||
)}
|
||||
{log.execution_time_ms != null && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">耗时</div>
|
||||
<div className="text-sm">{log.execution_time_ms}ms</div>
|
||||
</div>
|
||||
)}
|
||||
{log.rows_affected != null && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">返回行数</div>
|
||||
<div className="text-sm">{log.rows_affected}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -53,12 +53,14 @@ type ViewMode = 'grid' | 'list';
|
||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||
mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
||||
postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||
postgresql: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||
pg: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||
redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
||||
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||
};
|
||||
|
||||
const envLabel: Record<string, string> = {
|
||||
production: '生产', staging: '预发', development: '开发', test: '测试', uat: 'UAT',
|
||||
production: '生产', staging: '预发', development: '开发', test: '测试', testing: '测试', uat: 'UAT',
|
||||
};
|
||||
|
||||
const envColor: Record<string, string> = {
|
||||
@@ -66,6 +68,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',
|
||||
testing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
uat: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
|
||||
};
|
||||
|
||||
@@ -82,6 +85,24 @@ function getStatusInfo(status: string) {
|
||||
return statusLabel[status] || { text: '未知', online: false };
|
||||
}
|
||||
|
||||
function isOfflineStatus(status: string): boolean {
|
||||
return status === 'offline' || status === 'disconnected';
|
||||
}
|
||||
|
||||
function matchesStatusFilter(status: string, filterStatus: string): boolean {
|
||||
if (!filterStatus) return true;
|
||||
const info = getStatusInfo(status);
|
||||
if (filterStatus === 'online') return info.online;
|
||||
if (filterStatus === 'offline') return isOfflineStatus(status);
|
||||
return status === filterStatus;
|
||||
}
|
||||
|
||||
function matchesEnvFilter(env: string, filterEnv: string): boolean {
|
||||
if (!filterEnv) return true;
|
||||
if (filterEnv === 'test') return env === 'test' || env === 'testing';
|
||||
return env === filterEnv;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Health Score */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -176,9 +197,9 @@ export default function InstancePage() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return instances.filter(i => {
|
||||
if (activeType !== 'all' && i.db_type !== activeType) return false;
|
||||
if (filterStatus && i.status !== filterStatus) return false;
|
||||
if (filterEnv && i.env !== filterEnv) return false;
|
||||
if (activeType !== 'all' && (activeType === 'postgres' ? !['postgres', 'postgresql', 'pg'].includes(i.db_type) : i.db_type !== activeType)) return false;
|
||||
if (!matchesStatusFilter(i.status, filterStatus)) return false;
|
||||
if (!matchesEnvFilter(i.env, filterEnv)) return false;
|
||||
if (searchQuery && !i.name.toLowerCase().includes(searchQuery.toLowerCase()) && !i.host.includes(searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -186,7 +207,7 @@ export default function InstancePage() {
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
online: filtered.filter(i => getStatusInfo(i.status).online).length,
|
||||
offline: filtered.filter(i => !getStatusInfo(i.status).online).length,
|
||||
offline: filtered.filter(i => isOfflineStatus(i.status)).length,
|
||||
total: filtered.length,
|
||||
}), [filtered]);
|
||||
|
||||
@@ -310,6 +331,7 @@ export default function InstancePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="数据源管理"
|
||||
@@ -738,6 +760,15 @@ export default function InstancePage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={!!deleteConfirm}
|
||||
title={deleteConfirm?.msg || '确认操作'}
|
||||
variant="danger"
|
||||
confirmText="确定"
|
||||
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1191,8 +1222,6 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
const [error, setError] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
|
||||
|
||||
const isRedis = dbType === 'redis';
|
||||
const needUsername = !isRedis; // Redis username is optional (6.0+ ACL)
|
||||
const needDatabase = !isRedis;
|
||||
@@ -1679,14 +1708,6 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
<ConfirmDialog
|
||||
open={!!deleteConfirm}
|
||||
title={deleteConfirm?.msg || '确认操作'}
|
||||
variant="danger"
|
||||
confirmText="确定"
|
||||
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,8 @@ export default function LoginPage() {
|
||||
|
||||
if (!ready) return null; // avoid flash
|
||||
|
||||
const Component = LOGIN_STYLES[style] || SidePanel;
|
||||
const Component = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||
? SidePanel
|
||||
: (LOGIN_STYLES[style] || SidePanel);
|
||||
return <Component />;
|
||||
}
|
||||
@@ -13,12 +13,15 @@ import SqlEditor from '@/components/SqlEditor';
|
||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { redisApi } from '@/api/redis';
|
||||
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
|
||||
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
|
||||
import type { RedisLineEditorState } from '@/features/redis/types';
|
||||
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
|
||||
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
||||
import type { QueryHistoryItem } from '@/features/query/types';
|
||||
@@ -41,6 +44,21 @@ interface QueryResult {
|
||||
masked_columns?: string[];
|
||||
}
|
||||
|
||||
interface RedisKeyInfo {
|
||||
key?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
interface RedisKeyDetail {
|
||||
key: string;
|
||||
name?: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
type SortState = { column: string | null; direction: SortDirection };
|
||||
|
||||
@@ -94,6 +112,13 @@ export default function QueriesPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
|
||||
const [redisPattern, setRedisPattern] = useState('');
|
||||
const [redisType, setRedisType] = useState('');
|
||||
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
|
||||
const [redisSelectedKey, setRedisSelectedKey] = useState<RedisKeyDetail | null>(null);
|
||||
const [redisLoading, setRedisLoading] = useState(false);
|
||||
const [redisDetailLoading, setRedisDetailLoading] = useState(false);
|
||||
const [redisValueSearch, setRedisValueSearch] = useState('');
|
||||
|
||||
// -- Pagination --
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
@@ -163,6 +188,54 @@ export default function QueriesPage() {
|
||||
const dbType = normalizeDbType(selectedInstance?.db_type);
|
||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
||||
|
||||
useEffect(() => {
|
||||
setRedisKeys([]);
|
||||
setRedisSelectedKey(null);
|
||||
setRedisValueSearch('');
|
||||
}, [instanceId, database]);
|
||||
|
||||
const handleRedisSearch = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
const pattern = redisPattern.trim();
|
||||
if (!pattern) {
|
||||
setRedisKeys([]);
|
||||
setRedisSelectedKey(null);
|
||||
return;
|
||||
}
|
||||
setRedisLoading(true);
|
||||
try {
|
||||
const res = await redisApi.getKeys(Number(instanceId), {
|
||||
db: Number(database || 0),
|
||||
pattern,
|
||||
type: redisType || undefined,
|
||||
count: 80,
|
||||
});
|
||||
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
|
||||
} catch {
|
||||
setRedisKeys([]);
|
||||
} finally {
|
||||
setRedisLoading(false);
|
||||
}
|
||||
}, [instanceId, database, redisPattern, redisType]);
|
||||
|
||||
const loadRedisKeyDetail = useCallback(async (keyName: string) => {
|
||||
if (!instanceId) return;
|
||||
setRedisDetailLoading(true);
|
||||
try {
|
||||
const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0));
|
||||
setRedisSelectedKey(detail as RedisKeyDetail);
|
||||
setRedisValueSearch('');
|
||||
} catch {
|
||||
setRedisSelectedKey(null);
|
||||
} finally {
|
||||
setRedisDetailLoading(false);
|
||||
}
|
||||
}, [instanceId, database]);
|
||||
|
||||
const copyRedisText = useCallback((text: string) => {
|
||||
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
|
||||
if (/^\d+$/.test(database)) return;
|
||||
@@ -331,19 +404,19 @@ export default function QueriesPage() {
|
||||
}, [history, favFilter, searchHistory]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3rem)] gap-0">
|
||||
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
||||
{/* ====== 主区域 ====== */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
||||
|
||||
{/* -- 顶栏: 实例+库选择 -- */}
|
||||
<div className="shrink-0 px-5 pt-4 pb-2">
|
||||
<div className="shrink-0 px-0 md:px-5 pt-1 md:pt-4 pb-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-full md:min-w-0 flex-wrap md:flex-nowrap">
|
||||
{selectedInstance && dbTypeIcon[dbType] && (
|
||||
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
<select value={instanceId} onChange={e => setInstanceId(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[200px]">
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
|
||||
<option value="">选择实例</option>
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
@@ -354,12 +427,12 @@ export default function QueriesPage() {
|
||||
|
||||
{dbType === 'redis' ? (
|
||||
<select value={database || '0'} onChange={e => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-24">
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
|
||||
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<select value={database} onChange={e => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[160px]">
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
|
||||
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
|
||||
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
@@ -367,7 +440,7 @@ export default function QueriesPage() {
|
||||
</div>
|
||||
|
||||
{/* 右侧工具按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
|
||||
{instanceId && (
|
||||
<button onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
@@ -387,7 +460,7 @@ export default function QueriesPage() {
|
||||
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
||||
</button>
|
||||
<button onClick={handleExecute} disabled={executing || !instanceId}
|
||||
className="dg-btn dg-btn-primary">
|
||||
className="dg-btn dg-btn-primary ml-auto md:ml-0">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{executing ? '执行中...' : '执行'}
|
||||
</button>
|
||||
@@ -409,11 +482,11 @@ export default function QueriesPage() {
|
||||
)}
|
||||
|
||||
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
|
||||
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex gap-0 overflow-visible md:overflow-hidden">
|
||||
{/* Schema 侧边栏(左侧,独立) */}
|
||||
{showSidebar && instanceId && (
|
||||
<>
|
||||
<div className="shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
||||
<div className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||
clusterMode={selectedInstance?.cluster_mode}
|
||||
onInsert={(text) => {
|
||||
@@ -428,7 +501,7 @@ export default function QueriesPage() {
|
||||
</div>
|
||||
<div
|
||||
ref={sidebarDragRef}
|
||||
className="w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
|
||||
className="hidden md:block w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||
@@ -438,8 +511,126 @@ export default function QueriesPage() {
|
||||
|
||||
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{dbType === 'redis' && instanceId && (
|
||||
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${showSidebar ? 'md:hidden' : ''}`}>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="border-b border-[var(--dg-border)] px-3 py-2">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<KeyRound className="h-4 w-4 text-red-400" />
|
||||
Redis 键值可视化
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_130px_auto] lg:w-[620px]">
|
||||
<div className="relative">
|
||||
<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={redisPattern}
|
||||
onChange={e => setRedisPattern(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRedisSearch(); }}
|
||||
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="输入 key/pattern,* 扫描全部"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={redisType}
|
||||
onChange={e => setRedisType(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={handleRedisSearch} disabled={redisLoading || !redisPattern.trim()} className="dg-btn dg-btn-primary h-9 disabled:opacity-40">
|
||||
{redisLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-[520px] min-h-[260px] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="min-h-[180px] overflow-y-auto border-b border-[var(--dg-border)] lg:border-b-0 lg:border-r">
|
||||
<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>{redisKeys.length} 条</span>
|
||||
</div>
|
||||
{redisLoading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }, (_, i) => <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />)}
|
||||
</div>
|
||||
) : redisKeys.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||
) : redisKeys.map((item, i) => {
|
||||
const keyName = item.key || item.name || '';
|
||||
const active = (redisSelectedKey?.key || redisSelectedKey?.name) === keyName;
|
||||
return (
|
||||
<button
|
||||
key={keyName + i}
|
||||
onClick={() => loadRedisKeyDetail(keyName)}
|
||||
className={`block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ${active ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
|
||||
{item.type && <span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{item.type}</span>}
|
||||
</div>
|
||||
{typeof item.ttl === 'number' && <div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL {formatTTL(item.ttl)}</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-auto">
|
||||
{redisDetailLoading ? (
|
||||
<div className="m-4 h-52 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
) : redisSelectedKey ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col">
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--dg-border)] px-3 py-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{redisSelectedKey.key || redisSelectedKey.name}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--dg-text-muted)]">
|
||||
<span className="rounded bg-red-500/10 px-1.5 py-0.5 uppercase text-red-400">{redisSelectedKey.type}</span>
|
||||
<span>TTL {formatTTL(redisSelectedKey.ttl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full sm: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={redisValueSearch} onChange={e => setRedisValueSearch(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-3">
|
||||
{redisSelectedKey.type === 'string' ? (
|
||||
<pre className="min-h-[180px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||
{typeof redisSelectedKey.value === 'string' ? (tryFormatJsonText(redisSelectedKey.value) || redisSelectedKey.value) : JSON.stringify(redisSelectedKey.value, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
|
||||
<RedisStructuredValue
|
||||
type={redisSelectedKey.type}
|
||||
value={redisSelectedKey.value}
|
||||
redisKey={redisSelectedKey.key || redisSelectedKey.name || ''}
|
||||
search={redisValueSearch}
|
||||
onCopy={copyRedisText}
|
||||
onInspect={(title, value) => setRedisLineViewer({ open: true, field: title, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||
onEditHashLine={(field, value) => setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||
onDeleteHashLine={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[260px] items-center justify-center px-5 text-center text-sm text-[var(--dg-text-muted)]">选择左侧 Key 查看结构化值;命令行查询仍可在下方执行</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SQL 编辑器 */}
|
||||
<div className="shrink-0 p-4 pb-2">
|
||||
<div className="shrink-0 p-0 md:p-4 pb-2">
|
||||
<div className="dg-card overflow-hidden flex flex-col">
|
||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||
language={editorLang} height="200px" onExecute={handleExecute}
|
||||
@@ -449,7 +640,7 @@ export default function QueriesPage() {
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
@@ -472,7 +663,7 @@ export default function QueriesPage() {
|
||||
|
||||
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
||||
{result && (
|
||||
<div className="flex-1 min-h-0 px-4 pb-4 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 px-0 md:px-4 pb-4 overflow-hidden flex flex-col">
|
||||
<div className="dg-card overflow-hidden flex-1 flex flex-col">
|
||||
{/* 结果标题栏 + 折叠按钮 */}
|
||||
<div
|
||||
@@ -565,7 +756,7 @@ export default function QueriesPage() {
|
||||
/>
|
||||
) : (
|
||||
/* Standard table for other results */
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function RedisDetailPage() {
|
||||
var [infoSearch, setInfoSearch] = useState('');
|
||||
var [activeTab, setActiveTab] = useState<TabKey>('keys');
|
||||
var [selectedDb, setSelectedDb] = useState(0);
|
||||
var [keyPattern, setKeyPattern] = useState('*');
|
||||
var [keyPattern, setKeyPattern] = useState('');
|
||||
var [keyType, setKeyType] = useState('');
|
||||
var [keys, setKeys] = useState<any[]>([]);
|
||||
var [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
|
||||
@@ -77,6 +77,11 @@ export default function RedisDetailPage() {
|
||||
}, [instanceId]);
|
||||
|
||||
var fetchKeys = function () {
|
||||
if (!keyPattern.trim()) {
|
||||
setKeys([]);
|
||||
setSelectedKey(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) {
|
||||
setKeys(res.keys || res || []);
|
||||
@@ -317,7 +322,7 @@ export default function RedisDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Tab */}
|
||||
<div className="flex gap-0 border-b border-[var(--dg-border)]">
|
||||
<div className="flex gap-0 overflow-x-auto border-b border-[var(--dg-border)]">
|
||||
{tabs.map(function (tab) {
|
||||
var Icon = tab.icon;
|
||||
return <button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
||||
@@ -330,7 +335,7 @@ export default function RedisDetailPage() {
|
||||
|
||||
{/* 键管理 */}
|
||||
{activeTab === 'keys' && (
|
||||
<div className="grid min-h-[620px] grid-cols-[360px_minmax(0,1fr)] gap-4">
|
||||
<div className="grid min-h-[620px] grid-cols-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<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">
|
||||
@@ -341,7 +346,7 @@ export default function RedisDetailPage() {
|
||||
</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:*" />
|
||||
<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="输入 key/pattern,* 扫描全部" />
|
||||
</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">
|
||||
@@ -365,7 +370,7 @@ export default function RedisDetailPage() {
|
||||
})}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-[var(--dg-text-muted)]">点击刷新加载键列表</div>
|
||||
<div className="px-6 py-16 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||
) : keys.map(function (k: any, i: number) {
|
||||
var keyName = k.key || k.name || k;
|
||||
var active = (selectedKey?.key || selectedKey?.name) === keyName;
|
||||
|
||||
@@ -44,12 +44,12 @@ const ROLES: RolePreset[] = [
|
||||
{
|
||||
key: 'developer',
|
||||
name: '开发人员',
|
||||
description: '提交 SQL 工单、执行自助查询、导出结果',
|
||||
description: '默认执行自助查询;授权写入数据源后可提交变更工单',
|
||||
icon: Code,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10',
|
||||
ring: 'ring-emerald-500/20',
|
||||
permissions: ['workspace', 'dashboard', 'change', 'tickets', 'ticket_create', 'query', 'queries', 'query_create', 'query_export'],
|
||||
permissions: ['query', 'queries', 'query_create', 'query_export'],
|
||||
},
|
||||
{
|
||||
key: 'auditor',
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import PlatformConfigPage from './PlatformConfigPage';
|
||||
import AuditRulesPage from './AuditRulesPage';
|
||||
import AnnouncementPage from './AnnouncementPage';
|
||||
import NotificationPage from './NotificationPage';
|
||||
import LdapConfigPage from './LdapConfigPage';
|
||||
import AppearanceSettings from './AppearanceSettings';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'appearance', label: '外观设置', icon: '🎨' },
|
||||
{ id: 'platform', label: '平台配置', icon: '⚙️' },
|
||||
{ id: 'rules', label: '审核规则', icon: '🛡️' },
|
||||
{ id: 'notification', label: '消息推送', icon: '🔔' },
|
||||
{ id: 'ldap', label: 'LDAP', icon: '🔐' },
|
||||
] as const;
|
||||
|
||||
@@ -23,7 +18,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">系统设置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理平台外观、配置、审核规则、消息推送和 LDAP 认证</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理平台外观、配置和 LDAP 认证</p>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
@@ -48,8 +43,6 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||
{activeTab === 'platform' && <PlatformConfigPage />}
|
||||
{activeTab === 'rules' && <AuditRulesPage />}
|
||||
{activeTab === 'notification' && <NotificationPage />}
|
||||
{activeTab === 'ldap' && <LdapConfigPage />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -271,26 +271,26 @@ export default function TicketCreatePage() {
|
||||
const [showBrowser, setShowBrowser] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3rem)] overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-5 space-y-4">
|
||||
<div className="min-h-[calc(100vh-3rem)] overflow-y-auto">
|
||||
<div className="mx-auto max-w-6xl px-3 py-4 space-y-4 sm:px-5 lg:px-6">
|
||||
|
||||
{/* ====== 顶部:返回 + 工单标题 ====== */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<button onClick={() => navigate('/tickets')}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
className="w-8 h-8 shrink-0 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
title="返回工单列表">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium focus:border-blue-400 focus:outline-none transition-colors"
|
||||
className="w-full min-w-0 flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium focus:border-blue-400 focus:outline-none transition-colors"
|
||||
placeholder="输入工单标题" />
|
||||
</div>
|
||||
|
||||
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="grid grid-cols-1 gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 md:grid-cols-[minmax(180px,1fr)_minmax(140px,220px)_auto_auto] md:items-center">
|
||||
{/* 实例 */}
|
||||
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[180px] disabled:opacity-50">
|
||||
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50">
|
||||
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={String(inst.id)}>
|
||||
@@ -302,23 +302,23 @@ export default function TicketCreatePage() {
|
||||
{/* 数据库 */}
|
||||
{isRedis ? (
|
||||
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-24">
|
||||
className="h-10 w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
|
||||
</select>
|
||||
) : databases.length > 0 ? (
|
||||
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[150px] disabled:opacity-50">
|
||||
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50">
|
||||
<option value="">{loadingDatabases ? '加载中...' : '选择数据库 *'}</option>
|
||||
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[150px] disabled:opacity-50"
|
||||
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50"
|
||||
placeholder={instanceId ? '数据库名' : '先选实例'} />
|
||||
)}
|
||||
|
||||
{/* 定时执行 */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none text-xs text-[var(--dg-text-muted)]">
|
||||
<label className="flex h-10 items-center gap-1.5 cursor-pointer select-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-blue-500 focus:ring-blue-500 focus:ring-offset-0" />
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
@@ -327,7 +327,7 @@ export default function TicketCreatePage() {
|
||||
{enableSchedule && (
|
||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
|
||||
step="1" min={new Date().toISOString().slice(0, 16)}
|
||||
className="px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs focus:border-blue-400 focus:outline-none" />
|
||||
className="h-10 w-full px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs focus:border-blue-400 focus:outline-none md:col-span-2" />
|
||||
)}
|
||||
{enableSchedule && scheduledAt && (
|
||||
<span className="text-xs font-semibold text-red-500">
|
||||
@@ -335,24 +335,19 @@ export default function TicketCreatePage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 浏览面板 */}
|
||||
{instanceId && (
|
||||
<button onClick={() => setShowBrowser(!showBrowser)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
||||
showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
||||
<div className="flex flex-wrap gap-2 md:col-span-4">
|
||||
{instanceId && (
|
||||
<button onClick={() => setShowBrowser(!showBrowser)}
|
||||
className={`dg-btn dg-btn-sm ${showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'dg-btn-ghost'}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-blue-400 disabled:opacity-30">
|
||||
<Sparkles className="w-3 h-3" /> 格式化
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 格式化 */}
|
||||
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors disabled:opacity-30">
|
||||
<Sparkles className="w-3 h-3" /> 格式化
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ====== 错误提示 ====== */}
|
||||
@@ -363,10 +358,10 @@ export default function TicketCreatePage() {
|
||||
)}
|
||||
|
||||
{/* ====== 编辑器 + 浏览面板 ====== */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row">
|
||||
{/* 浏览面板 */}
|
||||
{showBrowser && instanceId && (
|
||||
<div className="w-[280px] shrink-0">
|
||||
<div className="min-h-[260px] xl:w-[300px] xl:shrink-0">
|
||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||
clusterMode={selectedInstance?.cluster_mode}
|
||||
onInsert={handleSchemaInsert} singleDb />
|
||||
@@ -377,7 +372,7 @@ export default function TicketCreatePage() {
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className="dg-card overflow-hidden flex flex-col">
|
||||
{/* 编辑器顶栏:标题 + SQL检测按钮 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<div className="flex flex-col gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-xs font-medium text-[var(--dg-text-muted)]">SQL 变更语句</span>
|
||||
<button onClick={handleValidate}
|
||||
disabled={validating || !sqlContent.trim() || !instanceId || !database}
|
||||
@@ -390,12 +385,12 @@ export default function TicketCreatePage() {
|
||||
height="320px"
|
||||
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
|
||||
{/* 编辑器底栏 */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
||||
<div className="flex flex-col gap-1 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
|
||||
{sqlContent.length} 字符
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{validated && <span className="flex items-center gap-1 text-green-400"><Check className="w-3 h-3" />检测通过</span>}
|
||||
<span>Ctrl+Enter 格式化</span>
|
||||
</div>
|
||||
@@ -419,7 +414,7 @@ export default function TicketCreatePage() {
|
||||
{auditResult.findings && auditResult.findings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{auditResult.findings.map((f, idx) => (
|
||||
<div key={idx} className={'flex items-center gap-2 px-3 py-2 rounded-lg text-xs ' + (LEVEL_COLOR[f.level] || '')}>
|
||||
<div key={idx} className={'flex flex-col gap-1 px-3 py-2 rounded-lg text-xs sm:flex-row sm:items-center sm:gap-2 ' + (LEVEL_COLOR[f.level] || '')}>
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
@@ -449,7 +444,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-[var(--dg-blue)] rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all">
|
||||
className="flex w-full items-center justify-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 sm:w-auto">
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
提交审批
|
||||
</button>
|
||||
|
||||
@@ -277,7 +277,7 @@ export default function TicketDetailPage() {
|
||||
const renderActionButtons = () => {
|
||||
if (showRollbackConfirm) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
||||
@@ -323,7 +323,7 @@ export default function TicketDetailPage() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 w-full">
|
||||
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={comment}
|
||||
@@ -603,7 +603,7 @@ export default function TicketDetailPage() {
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start sm:items-center gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
|
||||
@@ -612,7 +612,7 @@ export default function TicketDetailPage() {
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
{ticket.title}
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||
{statusMap[ticket.status] || ticket.status}
|
||||
@@ -660,13 +660,13 @@ export default function TicketDetailPage() {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
||||
className="flex flex-col sm:flex-row sm:justify-between gap-1 sm:gap-3 py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
||||
>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
<span className="text-sm font-medium break-words sm:text-right">{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function TicketsPage() {
|
||||
/>
|
||||
|
||||
{/* Status Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
|
||||
{stats === null ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="dg-card-section animate-pulse">
|
||||
@@ -314,8 +314,8 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 lg:flex-row lg:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="dg-input w-full pl-10 pr-4 py-2.5"
|
||||
@@ -329,13 +329,35 @@ export default function TicketsPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 lg:pb-0">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'executing', label: '执行中' },
|
||||
{ key: 'success', label: '完成' },
|
||||
{ key: 'failed', label: '失败' },
|
||||
{ key: 'rejected', label: '拒绝' },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.key || 'all'}
|
||||
onClick={() => { setStatusFilter(item.key); setPage(1); }}
|
||||
className={`dg-btn dg-btn-sm whitespace-nowrap ${statusFilter === item.key ? 'dg-btn-primary' : 'dg-btn-ghost'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setSortAsc(!sortAsc)} className="dg-btn dg-btn-sm dg-btn-ghost justify-center whitespace-nowrap">
|
||||
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||
{sortAsc ? '最早优先' : '最新优先'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ticket Table */}
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="p-0">
|
||||
{loading && tickets.length === 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="dg-table-header">
|
||||
@@ -360,7 +382,7 @@ export default function TicketsPage() {
|
||||
) : tickets.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="dg-table-header">
|
||||
@@ -524,11 +546,121 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tickets.length > 0 && (
|
||||
<div className="md:hidden divide-y divide-[var(--dg-border)]">
|
||||
{sortedTickets.map(ticket => (
|
||||
<div key={ticket.id} className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button onClick={() => openDetail(ticket.id)} className="min-w-0 text-left">
|
||||
<div className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</div>
|
||||
<div className="mt-1 text-sm font-medium text-[var(--dg-text-primary)] line-clamp-2">{ticket.title}</div>
|
||||
</button>
|
||||
<span className={`dg-badge shrink-0 ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||||
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||
<div className="text-[var(--dg-text-muted)]">实例</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 min-w-0">
|
||||
<span className={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>{ticket.db_type.toUpperCase()}</span>
|
||||
<span className="truncate">{ticket.instance_name || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||
<div className="text-[var(--dg-text-muted)]">提交人</div>
|
||||
<div className="mt-1 truncate">{ticket.applicant_name || '-'}</div>
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||
<div className="text-[var(--dg-text-muted)]">待处理</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{ticket.pending_steps?.length > 0 ? ticket.pending_steps.map(ps => (
|
||||
<div key={ps.step_number} className="flex gap-1.5">
|
||||
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
|
||||
<span className="font-medium">{ps.approver_names.join(', ')}</span>
|
||||
</div>
|
||||
)) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-[var(--dg-text-muted)]">
|
||||
创建于 {new Date(ticket.create_time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => openDetail(ticket.id)} className="dg-btn dg-btn-sm dg-btn-ghost">
|
||||
<Eye className="w-3.5 h-3.5" /> 查看
|
||||
</button>
|
||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
|
||||
fetchTickets(); fetchStats();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || '审批失败';
|
||||
alert(msg);
|
||||
}
|
||||
}}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-green-400 hover:text-green-300"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
|
||||
fetchTickets(); fetchStats();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || '操作失败';
|
||||
alert(msg);
|
||||
}
|
||||
}}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-red-400 hover:text-red-300"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" /> 拒绝
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canExecute && ticket.is_executor && ticket.status === 'approved' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ticketApi.execute(ticket.id);
|
||||
fetchTickets(); fetchStats();
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-cyan-400 hover:text-cyan-300"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> 执行
|
||||
</button>
|
||||
)}
|
||||
{currentUser?.id === ticket.applicant_id && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved') && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await ticketApi.cancel(ticket.id);
|
||||
fetchTickets(); fetchStats();
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-amber-400 hover:text-amber-300"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" /> 撤销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-4 border-t border-[var(--dg-border)]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
@@ -642,17 +774,17 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
|
||||
<div
|
||||
className="w-full max-h-[85vh] overflow-hidden flex flex-col"
|
||||
className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 md:px-6 py-4 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
||||
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||||
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<button
|
||||
@@ -687,10 +819,10 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||
|
||||
{/* Status Flow */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between overflow-x-auto pb-1">
|
||||
{statusDisplay.map((step, idx) => {
|
||||
const StepIcon = step.icon;
|
||||
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
|
||||
@@ -728,7 +860,7 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
||||
{[
|
||||
{ label: '提交人', value: ticket.applicant_name || '-' },
|
||||
{ label: '当前审核人', value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-') },
|
||||
@@ -737,9 +869,9 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between py-1.5">
|
||||
<div key={idx} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
<span className="text-xs font-medium">{item.value}</span>
|
||||
<span className="text-xs font-medium text-right break-words">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -941,4 +1073,4 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center justify-between px-4 sm:px-5 py-4">
|
||||
<h2 className="text-base font-semibold">{isEdit ? '编辑审批流程' : '新建审批流程'}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -361,7 +361,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
<div className="px-4 sm:px-5 pb-5 space-y-4">
|
||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||
|
||||
<div>
|
||||
@@ -376,9 +376,9 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
||||
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
{STEP_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
@@ -400,12 +400,12 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
return (
|
||||
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
{/* Step header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap sm:flex-nowrap">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
|
||||
</div>
|
||||
<input
|
||||
className="flex-1 px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
|
||||
className="flex-1 min-w-[160px] px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={step.name}
|
||||
onChange={(e) => updateStep(idx, { name: e.target.value })}
|
||||
placeholder="步骤名称"
|
||||
@@ -413,7 +413,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
|
||||
{typeConf.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 ml-1">
|
||||
<div className="flex items-center gap-0.5 sm:ml-1">
|
||||
<button
|
||||
onClick={() => moveStep(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
@@ -442,7 +442,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
|
||||
{/* User selection — submit 步骤不需要选人 */}
|
||||
{step.step_type !== 'submit' && (
|
||||
<div className="pl-9 mt-2">
|
||||
<div className="sm:pl-9 mt-2">
|
||||
<UserDropdown
|
||||
users={getEligibleUsers(step.step_type)}
|
||||
selectedIds={step.approver_ids}
|
||||
@@ -468,14 +468,14 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4">
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-5 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEdit ? '保存' : '创建'}
|
||||
@@ -590,12 +590,12 @@ export default function WorkflowPage() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">审批流程配置</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold">审批流程配置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多级审批流程,支持审核阶段叠加和多人执行</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditorWorkflow(null)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新建流程
|
||||
@@ -644,7 +644,7 @@ export default function WorkflowPage() {
|
||||
return (
|
||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
||||
@@ -731,7 +731,7 @@ export default function WorkflowPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 ml-4 shrink-0">
|
||||
<div className="flex items-center gap-1 lg:ml-4 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={() => setEditorWorkflow(wf)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors"
|
||||
@@ -772,8 +772,8 @@ export default function WorkflowPage() {
|
||||
{/* Preview Modal */}
|
||||
{previewWorkflow && (
|
||||
<ModalOverlay open={!!previewWorkflow} onClose={() => setPreviewWorkflow(null)} width="max-w-2xl">
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-4 sm:p-6 w-full mx-2 sm:mx-4 max-h-[90vh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex items-start sm:items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold">{previewWorkflow.name}</h3>
|
||||
{previewWorkflow.is_default && (
|
||||
@@ -793,7 +793,7 @@ export default function WorkflowPage() {
|
||||
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
||||
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
||||
return (
|
||||
<div key={step.id || i} className="flex items-start gap-4">
|
||||
<div key={step.id || i} className="flex items-start gap-3 sm:gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
||||
<StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} />
|
||||
</div>
|
||||
@@ -815,7 +815,7 @@ export default function WorkflowPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
||||
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="hidden sm:block w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user