refactor: Flatten directory structure
Move project files from uzdb/ subdirectory to root directory for cleaner project structure.
Changes:
- Move frontend/ to root
- Move internal/ to root
- Move build/ to root
- Move all config files (go.mod, wails.json, etc.) to root
- Remove redundant uzdb/ subdirectory nesting
Project structure is now:
├── frontend/ # React application
├── internal/ # Go backend
├── build/ # Wails build assets
├── doc/ # Design documentation
├── main.go # Entry point
└── ...
🤖 Generated with Qoder
This commit is contained in:
105
frontend/src/App.css
Normal file
105
frontend/src/App.css
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* App Component Styles
|
||||
*/
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View Tabs */
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-4) 0;
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.view-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* View Content */
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Global scrollbar improvements */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Focus visible improvements */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.view-tabs,
|
||||
.layout-menubar,
|
||||
.layout-toolbar,
|
||||
.layout-sidebar,
|
||||
.layout-statusbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
307
frontend/src/App.tsx
Normal file
307
frontend/src/App.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* uzdb - Database Management Tool
|
||||
*
|
||||
* Main application component integrating all UI components.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import './index.css';
|
||||
|
||||
// Import components
|
||||
import AppLayout from './components/Layout/AppLayout';
|
||||
import MenuBar from './components/MenuBar/MenuBar';
|
||||
import ToolBar from './components/Layout/ToolBar';
|
||||
import StatusBar from './components/Layout/StatusBar';
|
||||
import ConnectionPanel, { DatabaseConnection } from './components/Sidebar/ConnectionPanel';
|
||||
import QueryEditor, { QueryTab, QueryResult } from './components/MainArea/QueryEditor';
|
||||
import DataGrid from './components/MainArea/DataGrid';
|
||||
import TableStructure from './components/MainArea/TableStructure';
|
||||
|
||||
// Import mock data
|
||||
import { mockConnections } from './mock/connections';
|
||||
import {
|
||||
mockQueryResults,
|
||||
mockQueryTabs,
|
||||
mockDataGridColumns,
|
||||
mockDataGridRows,
|
||||
mockTableColumns,
|
||||
mockIndexes,
|
||||
mockForeignKeys,
|
||||
mockTableInfo,
|
||||
} from './mock/queryResults';
|
||||
|
||||
type MainView = 'query' | 'data' | 'structure';
|
||||
|
||||
function App() {
|
||||
// Application state
|
||||
const [connections] = useState<DatabaseConnection[]>(mockConnections);
|
||||
const [activeConnectionId, setActiveConnectionId] = useState<string>('conn-1');
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('conn-1');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// Query editor state
|
||||
const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs);
|
||||
const [activeTabId, setActiveTabId] = useState<string>('tab-1');
|
||||
const [queryResults, setQueryResults] = useState<QueryResult | null>(null);
|
||||
const [isQueryLoading, setIsQueryLoading] = useState(false);
|
||||
|
||||
// Main view state
|
||||
const [mainView, setMainView] = useState<MainView>('query');
|
||||
|
||||
// Handler: Connection click
|
||||
const handleConnectionClick = useCallback((connection: DatabaseConnection) => {
|
||||
setSelectedConnectionId(connection.id);
|
||||
if (connection.status === 'disconnected') {
|
||||
console.log(`Connecting to ${connection.name}...`);
|
||||
// In real app: call backend to connect
|
||||
} else if (connection.status === 'connected' || connection.status === 'active') {
|
||||
setActiveConnectionId(connection.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handler: New connection
|
||||
const handleNewConnection = useCallback(() => {
|
||||
console.log('Opening new connection dialog...');
|
||||
// In real app: open connection dialog
|
||||
}, []);
|
||||
|
||||
// Handler: Table double-click
|
||||
const handleTableDoubleClick = useCallback((
|
||||
table: any,
|
||||
schema: any,
|
||||
connection: DatabaseConnection
|
||||
) => {
|
||||
console.log(`Opening table ${table.name} from schema ${schema.name}`);
|
||||
setMainView('data');
|
||||
}, []);
|
||||
|
||||
// Handler: Query execution
|
||||
const handleExecuteQuery = useCallback((query: string) => {
|
||||
setIsQueryLoading(true);
|
||||
console.log('Executing query:', query);
|
||||
|
||||
// Simulate async query execution
|
||||
setTimeout(() => {
|
||||
setQueryResults(mockQueryResults);
|
||||
setIsQueryLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Handler: Tab content change
|
||||
const handleContentChange = useCallback((tabId: string, content: string) => {
|
||||
setQueryTabs(prev => prev.map(tab =>
|
||||
tab.id === tabId ? { ...tab, content, isDirty: true } : tab
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Handler: Save query
|
||||
const handleSaveQuery = useCallback((tabId: string) => {
|
||||
console.log('Saving query:', tabId);
|
||||
setQueryTabs(prev => prev.map(tab =>
|
||||
tab.id === tabId ? { ...tab, isDirty: false } : tab
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Handler: Close tab
|
||||
const handleCloseTab = useCallback((tabId: string) => {
|
||||
setQueryTabs(prev => prev.filter(tab => tab.id !== tabId));
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(queryTabs[0]?.id || '');
|
||||
}
|
||||
}, [activeTabId, queryTabs]);
|
||||
|
||||
// Handler: New tab
|
||||
const handleNewTab = useCallback(() => {
|
||||
const newTab: QueryTab = {
|
||||
id: `tab-${Date.now()}`,
|
||||
title: 'untitled.sql',
|
||||
content: '',
|
||||
isDirty: false,
|
||||
};
|
||||
setQueryTabs(prev => [...prev, newTab]);
|
||||
setActiveTabId(newTab.id);
|
||||
}, []);
|
||||
|
||||
// Handler: Format SQL
|
||||
const handleFormatSQL = useCallback(() => {
|
||||
console.log('Formatting SQL...');
|
||||
// In real app: format SQL using sql-formatter
|
||||
}, []);
|
||||
|
||||
// Handler: Menu item click
|
||||
const handleMenuItemClick = useCallback((menuId: string, itemId: string) => {
|
||||
console.log(`Menu "${menuId}" -> Item "${itemId}"`);
|
||||
|
||||
switch (itemId) {
|
||||
case 'new-connection':
|
||||
handleNewConnection();
|
||||
break;
|
||||
case 'run-query':
|
||||
if (activeTabId) {
|
||||
const tab = queryTabs.find(t => t.id === activeTabId);
|
||||
if (tab) handleExecuteQuery(tab.content);
|
||||
}
|
||||
break;
|
||||
case 'toggle-sidebar':
|
||||
setSidebarCollapsed(prev => !prev);
|
||||
break;
|
||||
default:
|
||||
console.log('Unhandled menu item:', itemId);
|
||||
}
|
||||
}, [activeTabId, queryTabs, handleNewConnection, handleExecuteQuery]);
|
||||
|
||||
// Get active connection
|
||||
const activeConnection = connections.find(c => c.id === activeConnectionId);
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onSidebarToggle={setSidebarCollapsed}
|
||||
menuBar={
|
||||
<MenuBar
|
||||
title="uzdb"
|
||||
onMenuItemClick={handleMenuItemClick}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
<ToolBar
|
||||
buttons={[
|
||||
{
|
||||
id: 'run',
|
||||
icon: '▶',
|
||||
label: 'Run',
|
||||
tooltip: 'Execute query (Ctrl+Enter)',
|
||||
onClick: () => {
|
||||
if (activeTabId) {
|
||||
const tab = queryTabs.find(t => t.id === activeTabId);
|
||||
if (tab) handleExecuteQuery(tab.content);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'save',
|
||||
icon: '💾',
|
||||
label: 'Save',
|
||||
tooltip: 'Save query (Ctrl+S)',
|
||||
onClick: () => activeTabId && handleSaveQuery(activeTabId),
|
||||
disabled: !queryTabs.find(t => t.id === activeTabId)?.isDirty,
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
icon: '📤',
|
||||
label: 'Export',
|
||||
tooltip: 'Export results',
|
||||
disabled: !queryResults,
|
||||
},
|
||||
{
|
||||
id: 'find',
|
||||
icon: '🔍',
|
||||
label: 'Find',
|
||||
tooltip: 'Find in query (Ctrl+F)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<ConnectionPanel
|
||||
connections={connections}
|
||||
selectedConnectionId={selectedConnectionId}
|
||||
activeConnectionId={activeConnectionId}
|
||||
onConnectionClick={handleConnectionClick}
|
||||
onNewConnection={handleNewConnection}
|
||||
onTableDoubleClick={handleTableDoubleClick}
|
||||
/>
|
||||
}
|
||||
mainContent={
|
||||
<div className="main-content">
|
||||
{/* View tabs */}
|
||||
<div className="view-tabs">
|
||||
<button
|
||||
className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('query')}
|
||||
>
|
||||
📑 SQL Editor
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'data' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('data')}
|
||||
>
|
||||
📊 Data Grid
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'structure' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('structure')}
|
||||
>
|
||||
📋 Table Structure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main view content */}
|
||||
<div className="view-content">
|
||||
{mainView === 'query' && (
|
||||
<QueryEditor
|
||||
tabs={queryTabs}
|
||||
activeTabId={activeTabId}
|
||||
results={queryResults}
|
||||
isLoading={isQueryLoading}
|
||||
onTabClick={setActiveTabId}
|
||||
onCloseTab={handleCloseTab}
|
||||
onNewTab={handleNewTab}
|
||||
onContentChange={handleContentChange}
|
||||
onExecute={handleExecuteQuery}
|
||||
onSave={handleSaveQuery}
|
||||
onFormat={handleFormatSQL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mainView === 'data' && (
|
||||
<DataGrid
|
||||
columns={mockDataGridColumns}
|
||||
rows={mockDataGridRows}
|
||||
totalRows={1247}
|
||||
pagination={{ currentPage: 1, pageSize: 25, totalRows: 1247 }}
|
||||
selectable
|
||||
editable
|
||||
tableName="users"
|
||||
schemaName="public"
|
||||
onRefresh={() => console.log('Refreshing data...')}
|
||||
onExport={() => console.log('Exporting data...')}
|
||||
onAddRow={() => console.log('Adding row...')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mainView === 'structure' && (
|
||||
<TableStructure
|
||||
tableName="users"
|
||||
schemaName="public"
|
||||
connectionName={activeConnection?.name}
|
||||
columns={mockTableColumns}
|
||||
indexes={mockIndexes}
|
||||
foreignKeys={mockForeignKeys}
|
||||
tableInfo={mockTableInfo}
|
||||
onViewData={() => setMainView('data')}
|
||||
onEditTable={() => console.log('Edit table...')}
|
||||
onRefresh={() => console.log('Refreshing structure...')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
statusBar={
|
||||
<StatusBar
|
||||
connectionInfo={activeConnection?.status === 'active'
|
||||
? `✓ Connected to ${activeConnection.name}`
|
||||
: 'Ready'
|
||||
}
|
||||
queryInfo={queryResults ? `${queryResults.rowCount} rows in ${queryResults.executionTime}s` : undefined}
|
||||
statusType={queryResults?.error ? 'error' : queryResults ? 'success' : 'normal'}
|
||||
encoding="UTF-8"
|
||||
lineEnding="LF"
|
||||
editorMode="ins"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
93
frontend/src/assets/fonts/OFL.txt
Normal file
93
frontend/src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/images/logo-universal.png
Normal file
BIN
frontend/src/assets/images/logo-universal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
134
frontend/src/components/Layout/AppLayout.css
Normal file
134
frontend/src/components/Layout/AppLayout.css
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* AppLayout Component Styles
|
||||
*/
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Menu Bar */
|
||||
.layout-menubar {
|
||||
flex-shrink: 0;
|
||||
height: var(--menubar-height);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.layout-toolbar {
|
||||
flex-shrink: 0;
|
||||
height: var(--toolbar-height);
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Main area (sidebar + workspace) */
|
||||
.layout-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.layout-sidebar {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: width var(--transition-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.layout-sidebar.collapsed {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.layout-resize-handle {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.layout-resize-handle:hover {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.layout-resize-handle:active {
|
||||
background-color: var(--primary-active);
|
||||
}
|
||||
|
||||
/* Sidebar toggle button */
|
||||
.layout-sidebar-toggle {
|
||||
width: 48px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Workspace */
|
||||
.layout-workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.layout-statusbar {
|
||||
flex-shrink: 0;
|
||||
height: var(--statusbar-height);
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.layout-sidebar:not(.collapsed) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.layout-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for sidebar transitions */
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar:not(.collapsed) {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
155
frontend/src/components/Layout/AppLayout.tsx
Normal file
155
frontend/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* AppLayout Component
|
||||
*
|
||||
* Main application layout with menu bar, toolbar, sidebar, main content area, and status bar.
|
||||
* Based on layout-design.md "整体布局架构"
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import './AppLayout.css';
|
||||
|
||||
export interface AppLayoutProps {
|
||||
/** Menu bar component */
|
||||
menuBar?: React.ReactNode;
|
||||
/** Toolbar component */
|
||||
toolbar?: React.ReactNode;
|
||||
/** Sidebar/connection panel component */
|
||||
sidebar?: React.ReactNode;
|
||||
/** Main content area component */
|
||||
mainContent?: React.ReactNode;
|
||||
/** Status bar component */
|
||||
statusBar?: React.ReactNode;
|
||||
/** Sidebar collapsed state */
|
||||
sidebarCollapsed?: boolean;
|
||||
/** Handler when sidebar collapse state changes */
|
||||
onSidebarToggle?: (collapsed: boolean) => void;
|
||||
/** Children (alternative to explicit props) */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppLayout - Main application shell component
|
||||
*/
|
||||
export const AppLayout: React.FC<AppLayoutProps> = ({
|
||||
menuBar,
|
||||
toolbar,
|
||||
sidebar,
|
||||
mainContent,
|
||||
statusBar,
|
||||
sidebarCollapsed = false,
|
||||
onSidebarToggle,
|
||||
children,
|
||||
}) => {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(240);
|
||||
const [isCollapsed, setIsCollapsed] = useState(sidebarCollapsed);
|
||||
|
||||
// Handle sidebar resize start
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
// Handle mouse move for resizing
|
||||
useCallback(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const newWidth = Math.max(180, Math.min(400, e.clientX));
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
// Toggle sidebar collapse
|
||||
const toggleSidebar = useCallback(() => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newCollapsed);
|
||||
onSidebarToggle?.(newCollapsed);
|
||||
}, [isCollapsed, onSidebarToggle]);
|
||||
|
||||
// Expose toggle function via custom event for keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
const handleToggleRequest = () => toggleSidebar();
|
||||
window.addEventListener('toggle-sidebar', handleToggleRequest);
|
||||
return () => window.removeEventListener('toggle-sidebar', handleToggleRequest);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* Menu Bar */}
|
||||
{menuBar && <div className="layout-menubar">{menuBar}</div>}
|
||||
|
||||
{/* Toolbar */}
|
||||
{toolbar && <div className="layout-toolbar">{toolbar}</div>}
|
||||
|
||||
{/* Main content area (sidebar + workspace) */}
|
||||
<div className="layout-main">
|
||||
{/* Sidebar */}
|
||||
{sidebar && (
|
||||
<>
|
||||
<div
|
||||
className={`layout-sidebar ${isCollapsed ? 'collapsed' : ''}`}
|
||||
style={
|
||||
isCollapsed
|
||||
? undefined
|
||||
: { width: sidebarWidth, flex: 'none' }
|
||||
}
|
||||
>
|
||||
{sidebar}
|
||||
</div>
|
||||
|
||||
{/* Resize handle */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className="layout-resize-handle"
|
||||
onMouseDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
title="Drag to resize sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collapse toggle button (when collapsed) */}
|
||||
{isCollapsed && sidebar && (
|
||||
<div className="layout-sidebar-toggle">
|
||||
<button
|
||||
className="btn-icon toggle-button"
|
||||
onClick={toggleSidebar}
|
||||
title="Expand sidebar"
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Workspace (main content) */}
|
||||
<div className="layout-workspace">
|
||||
{mainContent || children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
{statusBar && <div className="layout-statusbar">{statusBar}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
120
frontend/src/components/Layout/StatusBar.css
Normal file
120
frontend/src/components/Layout/StatusBar.css
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* StatusBar Component Styles
|
||||
*/
|
||||
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--statusbar-height);
|
||||
padding: 0 var(--space-4);
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: var(--text-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status type variations */
|
||||
.statusbar.status-normal {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statusbar.status-success {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.statusbar.status-warning {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.statusbar.status-error {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.statusbar.status-info {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* Left section */
|
||||
.statusbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.query-info {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Right section */
|
||||
.statusbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.status-button {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.status-button:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.editor-mode {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.statusbar {
|
||||
padding: 0 var(--space-2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusbar-left {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.statusbar-right {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-button {
|
||||
padding: var(--space-1);
|
||||
}
|
||||
}
|
||||
126
frontend/src/components/Layout/StatusBar.tsx
Normal file
126
frontend/src/components/Layout/StatusBar.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* StatusBar Component
|
||||
*
|
||||
* Bottom status bar showing connection info, encoding, and editor mode.
|
||||
* Based on layout-design.md section "状态栏设计规范"
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './StatusBar.css';
|
||||
|
||||
export type StatusType = 'normal' | 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
export interface StatusBarProps {
|
||||
/** Connection status message */
|
||||
connectionInfo?: string;
|
||||
/** Query execution info */
|
||||
queryInfo?: string;
|
||||
/** Encoding format */
|
||||
encoding?: string;
|
||||
/** Line ending type */
|
||||
lineEnding?: 'LF' | 'CRLF';
|
||||
/** Editor mode */
|
||||
editorMode?: 'ins' | 'ovr';
|
||||
/** Status type for coloring */
|
||||
statusType?: StatusType;
|
||||
/** Handler when encoding is clicked */
|
||||
onEncodingClick?: () => void;
|
||||
/** Handler when line ending is clicked */
|
||||
onLineEndingClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusBar component
|
||||
*/
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({
|
||||
connectionInfo,
|
||||
queryInfo,
|
||||
encoding = 'UTF-8',
|
||||
lineEnding = 'LF',
|
||||
editorMode = 'ins',
|
||||
statusType = 'normal',
|
||||
onEncodingClick,
|
||||
onLineEndingClick,
|
||||
}) => {
|
||||
// Get status class based on type
|
||||
const getStatusClass = (): string => {
|
||||
switch (statusType) {
|
||||
case 'success':
|
||||
return 'status-success';
|
||||
case 'warning':
|
||||
return 'status-warning';
|
||||
case 'error':
|
||||
return 'status-error';
|
||||
case 'info':
|
||||
return 'status-info';
|
||||
default:
|
||||
return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = (): string => {
|
||||
switch (statusType) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
case 'error':
|
||||
return '✕';
|
||||
case 'info':
|
||||
return 'ℹ';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`statusbar ${getStatusClass()}`}>
|
||||
<div className="statusbar-left">
|
||||
{/* Status icon and message */}
|
||||
{getStatusIcon() && (
|
||||
<span className="status-icon" role="status" aria-label={statusType}>
|
||||
{getStatusIcon()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Connection info */}
|
||||
{connectionInfo && (
|
||||
<span className="status-item connection-info">{connectionInfo}</span>
|
||||
)}
|
||||
|
||||
{/* Query info (takes priority over connection info when present) */}
|
||||
{queryInfo && (
|
||||
<span className="status-item query-info">{queryInfo}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="statusbar-right">
|
||||
{/* Encoding */}
|
||||
<button
|
||||
className="status-button"
|
||||
onClick={onEncodingClick}
|
||||
title="Change encoding"
|
||||
>
|
||||
{encoding}
|
||||
</button>
|
||||
|
||||
{/* Line ending */}
|
||||
<button
|
||||
className="status-button"
|
||||
onClick={onLineEndingClick}
|
||||
title="Change line ending"
|
||||
>
|
||||
{lineEnding}
|
||||
</button>
|
||||
|
||||
{/* Editor mode */}
|
||||
<span className="status-item editor-mode" title={editorMode === 'ins' ? 'Insert' : 'Overwrite'}>
|
||||
{editorMode}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBar;
|
||||
124
frontend/src/components/Layout/ToolBar.css
Normal file
124
frontend/src/components/Layout/ToolBar.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ToolBar Component Styles
|
||||
*/
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--toolbar-height);
|
||||
padding: 0 var(--space-4);
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-button:hover:not(.disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.toolbar-button.disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.button-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Connection indicator */
|
||||
.toolbar-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.toolbar-connection:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connection-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.connection-status-dot.connected {
|
||||
background-color: var(--success);
|
||||
box-shadow: 0 0 4px var(--success);
|
||||
}
|
||||
|
||||
.connection-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
padding: 0 var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.button-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
104
frontend/src/components/Layout/ToolBar.tsx
Normal file
104
frontend/src/components/Layout/ToolBar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ToolBar Component
|
||||
*
|
||||
* Quick access toolbar with common actions.
|
||||
* Based on layout-design.md toolbar specifications
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './ToolBar.css';
|
||||
|
||||
export interface ToolButton {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface ToolBarProps {
|
||||
/** Toolbar buttons */
|
||||
buttons?: ToolButton[];
|
||||
/** Additional content (e.g., search box) */
|
||||
children?: React.ReactNode;
|
||||
/** Handler when button is clicked */
|
||||
onButtonClick?: (buttonId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default toolbar buttons
|
||||
*/
|
||||
const defaultButtons: ToolButton[] = [
|
||||
{
|
||||
id: 'run',
|
||||
icon: '▶',
|
||||
label: 'Run',
|
||||
tooltip: 'Execute query (Ctrl+Enter)',
|
||||
},
|
||||
{
|
||||
id: 'save',
|
||||
icon: '💾',
|
||||
label: 'Save',
|
||||
tooltip: 'Save query (Ctrl+S)',
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
icon: '📤',
|
||||
label: 'Export',
|
||||
tooltip: 'Export results',
|
||||
},
|
||||
{
|
||||
id: 'find',
|
||||
icon: '🔍',
|
||||
label: 'Find',
|
||||
tooltip: 'Find in query (Ctrl+F)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* ToolBar component
|
||||
*/
|
||||
export const ToolBar: React.FC<ToolBarProps> = ({
|
||||
buttons = defaultButtons,
|
||||
children,
|
||||
onButtonClick,
|
||||
}) => {
|
||||
const handleButtonClick = (button: ToolButton) => {
|
||||
button.onClick?.();
|
||||
onButtonClick?.(button.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="toolbar" role="toolbar" aria-label="Main toolbar">
|
||||
<div className="toolbar-buttons">
|
||||
{buttons.map((button) => (
|
||||
<button
|
||||
key={button.id}
|
||||
className={`toolbar-button ${button.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleButtonClick(button)}
|
||||
disabled={button.disabled}
|
||||
title={button.tooltip}
|
||||
aria-label={button.label}
|
||||
>
|
||||
<span className="button-icon">{button.icon}</span>
|
||||
<span className="button-label">{button.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{children && <div className="toolbar-content">{children}</div>}
|
||||
|
||||
<div className="toolbar-spacer" />
|
||||
|
||||
{/* Connection indicator */}
|
||||
<div className="toolbar-connection">
|
||||
<span className="connection-status-dot connected"></span>
|
||||
<span className="connection-name">🗄️ MySQL @ localhost</span>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolBar;
|
||||
330
frontend/src/components/MainArea/DataGrid.css
Normal file
330
frontend/src/components/MainArea/DataGrid.css
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* DataGrid Component Styles
|
||||
*/
|
||||
|
||||
.data-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.data-grid-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-grid-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.table-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-grid-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.data-grid-filters {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.filter-input:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Table Wrapper */
|
||||
.data-grid-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Data Table */
|
||||
.data-grid-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.data-grid-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.data-grid-table th {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.data-grid-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-grid-table th.sortable:hover {
|
||||
background-color: var(--border);
|
||||
}
|
||||
|
||||
.th-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-grid-table td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.data-grid-table tbody tr {
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.data-grid-table tbody tr:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.data-grid-table tbody tr.selected {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Special Columns */
|
||||
.select-column,
|
||||
.action-column {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.select-cell,
|
||||
.action-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.select-cell input[type="checkbox"],
|
||||
.action-cell input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editable-cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editable-cell:hover {
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.null-value {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Cell Editor */
|
||||
.cell-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.cell-editor input[type="text"],
|
||||
.cell-editor input[type="number"],
|
||||
.cell-editor input[type="datetime-local"] {
|
||||
flex: 1;
|
||||
padding: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cell-editor-number {
|
||||
width: 100px !important;
|
||||
}
|
||||
|
||||
.cell-editor-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* Loading & Empty States */
|
||||
.loading-cell,
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
padding: var(--space-10);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.data-grid-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
|
||||
.page-size-selector label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-size-selector select {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.page-size-selector select:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-size-selector select:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.data-grid-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.data-grid-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
495
frontend/src/components/MainArea/DataGrid.tsx
Normal file
495
frontend/src/components/MainArea/DataGrid.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* DataGrid Component
|
||||
*
|
||||
* Data table viewer with sorting, filtering, and inline editing capabilities.
|
||||
* Based on layout-design.md section "数据浏览模块"
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import './DataGrid.css';
|
||||
|
||||
export interface Column {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export interface DataRow {
|
||||
id: string | number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PaginationState {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
columnId: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface FilterState {
|
||||
columnId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DataGridProps {
|
||||
/** Table/column data */
|
||||
columns: Column[];
|
||||
rows: DataRow[];
|
||||
/** Total row count (for pagination) */
|
||||
totalRows?: number;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Enable row selection */
|
||||
selectable?: boolean;
|
||||
/** Enable inline editing */
|
||||
editable?: boolean;
|
||||
/** Current pagination state */
|
||||
pagination?: PaginationState;
|
||||
/** Current sort state */
|
||||
sort?: SortState;
|
||||
/** Active filters */
|
||||
filters?: FilterState[];
|
||||
/** Handler when page changes */
|
||||
onPageChange?: (page: number) => void;
|
||||
/** Handler when page size changes */
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
/** Handler when sort changes */
|
||||
onSortChange?: (sort: SortState) => void;
|
||||
/** Handler when filter changes */
|
||||
onFilterChange?: (filter: FilterState) => void;
|
||||
/** Handler when row is selected */
|
||||
onRowSelect?: (rowIds: (string | number)[]) => void;
|
||||
/** Handler when cell is edited */
|
||||
onCellEdit?: (rowId: string | number, columnId: string, value: any) => void;
|
||||
/** Handler when add row is requested */
|
||||
onAddRow?: () => void;
|
||||
/** Handler when delete rows is requested */
|
||||
onDeleteRows?: (rowIds: (string | number)[]) => void;
|
||||
/** Handler when refresh is requested */
|
||||
onRefresh?: () => void;
|
||||
/** Handler when export is requested */
|
||||
onExport?: () => void;
|
||||
/** Table name for display */
|
||||
tableName?: string;
|
||||
/** Schema name for display */
|
||||
schemaName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell Editor component based on data type
|
||||
*/
|
||||
interface CellEditorProps {
|
||||
value: any;
|
||||
column: Column;
|
||||
onSave: (value: any) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CellEditor: React.FC<CellEditorProps> = ({ value, column, onSave, onCancel }) => {
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(editValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// Render appropriate editor based on column type
|
||||
const renderEditor = () => {
|
||||
const type = column.type?.toUpperCase() || '';
|
||||
|
||||
if (type.includes('BOOL')) {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editValue}
|
||||
onChange={(e) => setEditValue(e.target.checked)}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.includes('INT') || type.includes('DECIMAL') || type.includes('FLOAT')) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={editValue ?? ''}
|
||||
onChange={(e) => setEditValue(e.target.value ? Number(e.target.value) : null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
className="cell-editor-number"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.includes('DATE') || type.includes('TIME')) {
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editValue ? new Date(editValue).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: text input
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={editValue ?? ''}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
className="cell-editor-text"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cell-editor">
|
||||
{renderEditor()}
|
||||
<div className="cell-editor-actions">
|
||||
<button className="btn-icon" onClick={() => onSave(editValue)} title="Save">
|
||||
✓
|
||||
</button>
|
||||
<button className="btn-icon" onClick={onCancel} title="Cancel">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* DataGrid component
|
||||
*/
|
||||
export const DataGrid: React.FC<DataGridProps> = ({
|
||||
columns,
|
||||
rows,
|
||||
totalRows,
|
||||
isLoading = false,
|
||||
selectable = true,
|
||||
editable = true,
|
||||
pagination = { currentPage: 1, pageSize: 25, totalRows: 0 },
|
||||
sort,
|
||||
filters = [],
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onSortChange,
|
||||
onFilterChange,
|
||||
onRowSelect,
|
||||
onCellEdit,
|
||||
onAddRow,
|
||||
onDeleteRows,
|
||||
onRefresh,
|
||||
onExport,
|
||||
tableName,
|
||||
schemaName,
|
||||
}) => {
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowId: string | number;
|
||||
columnId: string;
|
||||
} | null>(null);
|
||||
const [localFilters, setLocalFilters] = useState<FilterState[]>(filters);
|
||||
|
||||
// Calculate total pages
|
||||
const totalPages = Math.ceil((totalRows || rows.length) / pagination.pageSize);
|
||||
|
||||
// Handle header click for sorting
|
||||
const handleHeaderClick = (columnId: string) => {
|
||||
if (!onSortChange) return;
|
||||
|
||||
if (sort?.columnId === columnId) {
|
||||
// Toggle direction or remove sort
|
||||
if (sort.direction === 'asc') {
|
||||
onSortChange({ columnId, direction: 'desc' });
|
||||
} else {
|
||||
onSortChange({ columnId: '', direction: 'asc' });
|
||||
}
|
||||
} else {
|
||||
onSortChange({ columnId, direction: 'asc' });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle row selection
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
const allIds = rows.map((r) => r.id);
|
||||
setSelectedRows(new Set(allIds));
|
||||
onRowSelect?.(allIds);
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
onRowSelect?.([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRow = (rowId: string | number) => {
|
||||
const newSelected = new Set(selectedRows);
|
||||
if (newSelected.has(rowId)) {
|
||||
newSelected.delete(rowId);
|
||||
} else {
|
||||
newSelected.add(rowId);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
onRowSelect?.(Array.from(newSelected));
|
||||
};
|
||||
|
||||
// Handle cell edit
|
||||
const handleCellDoubleClick = (rowId: string | number, columnId: string) => {
|
||||
if (editable) {
|
||||
setEditingCell({ rowId, columnId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellSave = (value: any) => {
|
||||
if (editingCell) {
|
||||
onCellEdit?.(editingCell.rowId, editingCell.columnId, value);
|
||||
setEditingCell(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellCancel = () => {
|
||||
setEditingCell(null);
|
||||
};
|
||||
|
||||
// Handle filter change
|
||||
const handleFilterChange = (columnId: string, value: string) => {
|
||||
const newFilters = localFilters.filter((f) => f.columnId !== columnId);
|
||||
if (value) {
|
||||
newFilters.push({ columnId, value });
|
||||
}
|
||||
setLocalFilters(newFilters);
|
||||
onFilterChange?.({ columnId, value });
|
||||
};
|
||||
|
||||
// Get sort icon
|
||||
const getSortIcon = (columnId: string) => {
|
||||
if (!sort || sort.columnId !== columnId) return '⇅';
|
||||
return sort.direction === 'asc' ? '↑' : '↓';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="data-grid">
|
||||
{/* Toolbar */}
|
||||
<div className="data-grid-toolbar">
|
||||
<div className="data-grid-info">
|
||||
{tableName && (
|
||||
<span className="table-name">
|
||||
📋 {schemaName && `${schemaName}.`}{tableName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="data-grid-actions">
|
||||
<button className="btn btn-primary" onClick={onAddRow} disabled={isLoading}>
|
||||
✏️ Add Row
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onDeleteRows?.(Array.from(selectedRows))}
|
||||
disabled={selectedRows.size === 0 || isLoading}
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onRefresh} disabled={isLoading}>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onExport} disabled={isLoading}>
|
||||
📤 Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="data-grid-filters">
|
||||
{columns.map((column) => (
|
||||
<input
|
||||
key={column.id}
|
||||
type="text"
|
||||
className="filter-input"
|
||||
placeholder={`Filter ${column.name}...`}
|
||||
style={{ width: column.width || 150 }}
|
||||
value={localFilters.find((f) => f.columnId === column.id)?.value || ''}
|
||||
onChange={(e) => handleFilterChange(column.id, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="data-grid-wrapper">
|
||||
<table className="data-grid-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th className="select-column">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.size === rows.length && rows.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
style={{ width: column.width }}
|
||||
className={column.sortable ? 'sortable' : ''}
|
||||
onClick={() => column.sortable && handleHeaderClick(column.id)}
|
||||
>
|
||||
<div className="th-content">
|
||||
<span>{column.name}</span>
|
||||
{column.sortable && (
|
||||
<span className="sort-icon">{getSortIcon(column.id)}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{editable && <th className="action-column">✎</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 2 : 1)} className="loading-cell">
|
||||
<div className="loading-spinner" />
|
||||
<span>Loading data...</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 2 : 1)} className="empty-cell">
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`${selectedRows.has(row.id) ? 'selected' : ''}`}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(row.id)}
|
||||
onChange={() => handleSelectRow(row.id)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.id}
|
||||
className={editable ? 'editable-cell' : ''}
|
||||
onDoubleClick={() => handleCellDoubleClick(row.id, column.id)}
|
||||
>
|
||||
{editingCell?.rowId === row.id &&
|
||||
editingCell?.columnId === column.id ? (
|
||||
<CellEditor
|
||||
value={row[column.id]}
|
||||
column={column}
|
||||
onSave={handleCellSave}
|
||||
onCancel={handleCellCancel}
|
||||
/>
|
||||
) : (
|
||||
<span className="cell-value">
|
||||
{row[column.id] === null ? (
|
||||
<span className="null-value">NULL</span>
|
||||
) : (
|
||||
String(row[column.id])
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{editable && (
|
||||
<td className="action-cell">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleCellDoubleClick(row.id, columns[0].id)}
|
||||
title="Edit row"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="data-grid-pagination">
|
||||
<div className="pagination-info">
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="selected-info">Selected: {selectedRows.size} rows</span>
|
||||
)}
|
||||
<span className="total-info">Total: {totalRows || rows.length} rows</span>
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onPageChange?.(1)}
|
||||
disabled={pagination.currentPage === 1}
|
||||
title="First page"
|
||||
>
|
||||
◀◀
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onPageChange?.(pagination.currentPage - 1)}
|
||||
disabled={pagination.currentPage === 1}
|
||||
title="Previous page"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="page-indicator">
|
||||
{pagination.currentPage} / {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onPageChange?.(pagination.currentPage + 1)}
|
||||
disabled={pagination.currentPage >= totalPages}
|
||||
title="Next page"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onPageChange?.(totalPages)}
|
||||
disabled={pagination.currentPage >= totalPages}
|
||||
title="Last page"
|
||||
>
|
||||
▶▶
|
||||
</button>
|
||||
<div className="page-size-selector">
|
||||
<label>Per page:</label>
|
||||
<select
|
||||
value={pagination.pageSize}
|
||||
onChange={(e) => onPageSizeChange?.(Number(e.target.value))}
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={250}>250</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGrid;
|
||||
347
frontend/src/components/MainArea/QueryEditor.css
Normal file
347
frontend/src/components/MainArea/QueryEditor.css
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* QueryEditor Component Styles
|
||||
*/
|
||||
|
||||
.query-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.query-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--tab-height);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 var(--space-2);
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.query-tab-list {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.query-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.query-tab:hover {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.query-tab.active {
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.query-tab.dirty .tab-title::after {
|
||||
content: ' ●';
|
||||
color: var(--primary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.query-tab:hover .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.new-tab-btn {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.query-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connection-select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.connection-select:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connection-select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Editor Container */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-right: 1px solid var(--border);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.sql-editor {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.sql-editor::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cursor-position {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Results Panel */
|
||||
.results-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.results-message {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.results-message.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.results-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
background-color: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.results-table tr:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.results-limit-notice {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
background-color: var(--bg-warning);
|
||||
}
|
||||
|
||||
.results-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.results-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message {
|
||||
padding: var(--space-4);
|
||||
background-color: #fef2f2;
|
||||
border-left: 3px solid var(--error);
|
||||
margin: var(--space-3);
|
||||
}
|
||||
|
||||
.error-message pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--error);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
z-index: 100;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay span {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
301
frontend/src/components/MainArea/QueryEditor.tsx
Normal file
301
frontend/src/components/MainArea/QueryEditor.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* QueryEditor Component
|
||||
*
|
||||
* SQL query editor with syntax highlighting and execution controls.
|
||||
* Based on layout-design.md section "SQL 编辑器模块"
|
||||
*/
|
||||
|
||||
import React, { useState, KeyboardEvent } from 'react';
|
||||
import './QueryEditor.css';
|
||||
|
||||
export interface QueryTab {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
isDirty?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
columns: string[];
|
||||
rows: any[][];
|
||||
rowCount?: number;
|
||||
executionTime?: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface QueryEditorProps {
|
||||
/** Current query tabs */
|
||||
tabs?: QueryTab[];
|
||||
/** Active tab ID */
|
||||
activeTabId?: string;
|
||||
/** Query results */
|
||||
results?: QueryResult | null;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Handler when tab is clicked */
|
||||
onTabClick?: (tabId: string) => void;
|
||||
/** Handler when tab is closed */
|
||||
onCloseTab?: (tabId: string) => void;
|
||||
/** Handler when new tab is requested */
|
||||
onNewTab?: () => void;
|
||||
/** Handler when query content changes */
|
||||
onContentChange?: (tabId: string, content: string) => void;
|
||||
/** Handler when query is executed */
|
||||
onExecute?: (query: string) => void;
|
||||
/** Handler when query is saved */
|
||||
onSave?: (tabId: string) => void;
|
||||
/** Handler when format is requested */
|
||||
onFormat?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryEditor component
|
||||
*/
|
||||
export const QueryEditor: React.FC<QueryEditorProps> = ({
|
||||
tabs = [],
|
||||
activeTabId,
|
||||
results,
|
||||
isLoading = false,
|
||||
onTabClick,
|
||||
onCloseTab,
|
||||
onNewTab,
|
||||
onContentChange,
|
||||
onExecute,
|
||||
onSave,
|
||||
onFormat,
|
||||
}) => {
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
|
||||
|
||||
// Get active tab
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Ctrl+Enter to execute
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onExecute?.(editorContent);
|
||||
}
|
||||
|
||||
// Ctrl+S to save
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (activeTabId) {
|
||||
onSave?.(activeTabId);
|
||||
}
|
||||
}
|
||||
|
||||
// Tab key for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newValue =
|
||||
editorContent.substring(0, start) + ' ' + editorContent.substring(end);
|
||||
setEditorContent(newValue);
|
||||
// Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Update cursor position
|
||||
const handleCursorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
|
||||
const lines = content.substring(0, cursorPos).split('\n');
|
||||
const line = lines.length;
|
||||
const column = lines[lines.length - 1].length + 1;
|
||||
|
||||
setCursorPosition({ line, column });
|
||||
setEditorContent(content);
|
||||
onContentChange?.(activeTabId || 'default', content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="query-editor">
|
||||
{/* Tab Bar */}
|
||||
<div className="query-tabs">
|
||||
<div className="query-tab-list">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${
|
||||
tab.isDirty ? 'dirty' : ''
|
||||
}`}
|
||||
onClick={() => onTabClick?.(tab.id)}
|
||||
>
|
||||
<span className="tab-icon">📑</span>
|
||||
<span className="tab-title">{tab.title}</span>
|
||||
{tab.isDirty && <span className="tab-dirty-indicator">●</span>}
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab?.(tab.id);
|
||||
}}
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon new-tab-btn"
|
||||
onClick={onNewTab}
|
||||
title="New query tab"
|
||||
aria-label="New query tab"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor Toolbar */}
|
||||
<div className="query-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
className="btn btn-primary btn-run"
|
||||
onClick={() => onExecute?.(editorContent)}
|
||||
disabled={isLoading || !editorContent.trim()}
|
||||
>
|
||||
▶ Run
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onFormat}
|
||||
disabled={isLoading || !editorContent.trim()}
|
||||
>
|
||||
✨ Format
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => activeTabId && onSave?.(activeTabId)}
|
||||
disabled={isLoading || !activeTab?.isDirty}
|
||||
>
|
||||
💾 Save
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={isLoading || !results}>
|
||||
📤 Export
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={isLoading}>
|
||||
🔍 Find
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-spacer" />
|
||||
<div className="toolbar-group">
|
||||
<select className="connection-select" disabled={isLoading}>
|
||||
<option>🗄️ MySQL @ localhost</option>
|
||||
<option>🐘 PostgreSQL @ prod-db</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL Editor */}
|
||||
<div className="editor-container">
|
||||
<div className="line-numbers">
|
||||
{editorContent.split('\n').map((_, index) => (
|
||||
<div key={index} className="line-number">
|
||||
{index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="sql-editor"
|
||||
value={activeTab?.content || editorContent}
|
||||
onChange={handleCursorChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="-- Write your SQL query here... -- Example: SELECT * FROM users; "
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
aria-label="SQL query editor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cursor position indicator */}
|
||||
<div className="cursor-position">
|
||||
Ln {cursorPosition.line}, Col {cursorPosition.column}
|
||||
</div>
|
||||
|
||||
{/* Results Panel */}
|
||||
{results && (
|
||||
<div className="results-panel">
|
||||
<div className="results-header">
|
||||
<h4 className="results-title">Results</h4>
|
||||
{results.message && (
|
||||
<span className={`results-message ${results.error ? 'error' : 'success'}`}>
|
||||
{results.error ? '✕' : '✓'} {results.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.error ? (
|
||||
<div className="error-message">
|
||||
<pre>{results.error}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-table-wrapper">
|
||||
<table className="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{results.columns.map((col, index) => (
|
||||
<th key={index}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.rows.slice(0, 100).map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex}>{cell === null ? 'NULL' : String(cell)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{results.rows.length > 100 && (
|
||||
<div className="results-limit-notice">
|
||||
Showing 100 of {results.rows.length} rows
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results footer */}
|
||||
<div className="results-footer">
|
||||
<span className="results-info">
|
||||
{results.rowCount || results.rows.length} rows
|
||||
{results.executionTime && ` in ${results.executionTime}s`}
|
||||
</span>
|
||||
<div className="results-actions">
|
||||
<button className="btn-icon" title="Copy results">
|
||||
📋
|
||||
</button>
|
||||
<button className="btn-icon" title="Export">
|
||||
📤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span>Executing query...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
||||
279
frontend/src/components/MainArea/TableStructure.css
Normal file
279
frontend/src/components/MainArea/TableStructure.css
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* TableStructure Component Styles
|
||||
*/
|
||||
|
||||
.table-structure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.structure-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.structure-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.table-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.structure-heading {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.structure-meta {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.structure-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Tab Navigation */
|
||||
.structure-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-4) 0;
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.structure-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.structure-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.structure-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.structure-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Structure Table */
|
||||
.structure-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.structure-table th {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-table td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.structure-table tbody tr:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.column-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.column-type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.column-default,
|
||||
.column-extra {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.index-name,
|
||||
.fk-name {
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Key Badges */
|
||||
.key-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.key-badge.pk {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.key-badge.uk {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tab Placeholder */
|
||||
.tab-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-10);
|
||||
color: var(--text-muted);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.tab-placeholder p {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.structure-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.structure-meta {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.structure-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.structure-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.structure-table {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.structure-table th,
|
||||
.structure-table td {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
}
|
||||
347
frontend/src/components/MainArea/TableStructure.tsx
Normal file
347
frontend/src/components/MainArea/TableStructure.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* TableStructure Component
|
||||
*
|
||||
* Table structure viewer showing columns, indexes, foreign keys, and table info.
|
||||
* Based on layout-design.md section "表结构查看模块"
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import './TableStructure.css';
|
||||
|
||||
export interface TableColumn {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
isPrimaryKey?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
export interface Index {
|
||||
name: string;
|
||||
type: string;
|
||||
columns: string[];
|
||||
isUnique: boolean;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export interface ForeignKey {
|
||||
name: string;
|
||||
column: string;
|
||||
referencesTable: string;
|
||||
referencesColumn: string;
|
||||
onUpdate?: string;
|
||||
onDelete?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
engine?: string;
|
||||
collation?: string;
|
||||
rowCount?: number;
|
||||
size?: string;
|
||||
autoIncrement?: number;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export type StructureTab = 'data' | 'structure' | 'indexes' | 'foreignKeys' | 'triggers';
|
||||
|
||||
export interface TableStructureProps {
|
||||
/** Table name */
|
||||
tableName: string;
|
||||
/** Schema name */
|
||||
schemaName?: string;
|
||||
/** Connection name */
|
||||
connectionName?: string;
|
||||
/** Column definitions */
|
||||
columns?: TableColumn[];
|
||||
/** Indexes */
|
||||
indexes?: Index[];
|
||||
/** Foreign keys */
|
||||
foreignKeys?: ForeignKey[];
|
||||
/** Table metadata */
|
||||
tableInfo?: TableInfo;
|
||||
/** Active tab */
|
||||
activeTab?: StructureTab;
|
||||
/** Handler when tab changes */
|
||||
onTabChange?: (tab: StructureTab) => void;
|
||||
/** Handler when viewing data is requested */
|
||||
onViewData?: () => void;
|
||||
/** Handler when editing table is requested */
|
||||
onEditTable?: () => void;
|
||||
/** Handler when refreshing is requested */
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns Tab Content
|
||||
*/
|
||||
const ColumnsTab: React.FC<{ columns?: TableColumn[] }> = ({ columns = [] }) => {
|
||||
return (
|
||||
<div className="structure-section">
|
||||
<h4 className="section-title">Columns ({columns.length})</h4>
|
||||
<table className="structure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Null</th>
|
||||
<th>Key</th>
|
||||
<th>Default</th>
|
||||
<th>Extra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="empty-cell">
|
||||
No columns found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
columns.map((col, index) => (
|
||||
<tr key={index}>
|
||||
<td className="column-name">{col.name}</td>
|
||||
<td className="column-type">{col.type}</td>
|
||||
<td className="text-center">{col.nullable ? '✓' : '❌'}</td>
|
||||
<td className="text-center">
|
||||
{col.isPrimaryKey && <span className="key-badge pk">PK</span>}
|
||||
{col.isUnique && <span className="key-badge uk">UK</span>}
|
||||
</td>
|
||||
<td className="column-default">
|
||||
{col.defaultValue !== undefined ? String(col.defaultValue) : '-'}
|
||||
</td>
|
||||
<td className="column-extra">{col.extra || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Indexes Tab Content
|
||||
*/
|
||||
const IndexesTab: React.FC<{ indexes?: Index[] }> = ({ indexes = [] }) => {
|
||||
return (
|
||||
<div className="structure-section">
|
||||
<h4 className="section-title">Indexes ({indexes.length})</h4>
|
||||
<table className="structure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Columns</th>
|
||||
<th>Unique</th>
|
||||
<th>Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{indexes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="empty-cell">
|
||||
No indexes found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
indexes.map((idx, index) => (
|
||||
<tr key={index}>
|
||||
<td className="index-name">{idx.name}</td>
|
||||
<td>{idx.type}</td>
|
||||
<td>{idx.columns.join(', ')}</td>
|
||||
<td className="text-center">{idx.isUnique ? '✓' : ''}</td>
|
||||
<td>{idx.method}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Foreign Keys Tab Content
|
||||
*/
|
||||
const ForeignKeysTab: React.FC<{ foreignKeys?: ForeignKey[] }> = ({
|
||||
foreignKeys = [],
|
||||
}) => {
|
||||
return (
|
||||
<div className="structure-section">
|
||||
<h4 className="section-title">Foreign Keys ({foreignKeys.length})</h4>
|
||||
<table className="structure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Column</th>
|
||||
<th>References</th>
|
||||
<th>On Update</th>
|
||||
<th>On Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreignKeys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="empty-cell">
|
||||
No foreign keys found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
foreignKeys.map((fk, index) => (
|
||||
<tr key={index}>
|
||||
<td className="fk-name">{fk.name}</td>
|
||||
<td>{fk.column}</td>
|
||||
<td>
|
||||
{fk.referencesTable}({fk.referencesColumn})
|
||||
</td>
|
||||
<td>{fk.onUpdate || '-'}</td>
|
||||
<td>{fk.onDelete || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Table Info Section
|
||||
*/
|
||||
const TableInfoSection: React.FC<{ info?: TableInfo }> = ({ info }) => {
|
||||
if (!info) return null;
|
||||
|
||||
const infoItems = [
|
||||
{ label: 'Engine', value: info.engine },
|
||||
{ label: 'Collation', value: info.collation },
|
||||
{ label: 'Rows', value: info.rowCount?.toLocaleString() },
|
||||
{ label: 'Size', value: info.size },
|
||||
{ label: 'Auto Increment', value: info.autoIncrement?.toLocaleString() },
|
||||
{ label: 'Comment', value: info.comment },
|
||||
].filter((item) => item.value !== undefined);
|
||||
|
||||
return (
|
||||
<div className="structure-section">
|
||||
<h4 className="section-title">Table Info</h4>
|
||||
<div className="info-grid">
|
||||
{infoItems.map((item) => (
|
||||
<div key={item.label} className="info-item">
|
||||
<span className="info-label">{item.label}:</span>
|
||||
<span className="info-value">{item.value || '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main TableStructure component
|
||||
*/
|
||||
export const TableStructure: React.FC<TableStructureProps> = ({
|
||||
tableName,
|
||||
schemaName,
|
||||
connectionName,
|
||||
columns = [],
|
||||
indexes = [],
|
||||
foreignKeys = [],
|
||||
tableInfo,
|
||||
activeTab = 'structure',
|
||||
onTabChange,
|
||||
onViewData,
|
||||
onEditTable,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [localTab, setLocalTab] = useState<StructureTab>(activeTab);
|
||||
|
||||
const handleTabChange = (tab: StructureTab) => {
|
||||
setLocalTab(tab);
|
||||
onTabChange?.(tab);
|
||||
};
|
||||
|
||||
const tabs: { id: StructureTab; label: string; icon: string }[] = [
|
||||
{ id: 'data', label: 'Data', icon: '📊' },
|
||||
{ id: 'structure', label: 'Structure', icon: '📋' },
|
||||
{ id: 'indexes', label: 'Indexes', icon: '🔖' },
|
||||
{ id: 'foreignKeys', label: 'Foreign Keys', icon: '🔗' },
|
||||
{ id: 'triggers', label: 'Triggers', icon: '⚡' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="table-structure">
|
||||
{/* Header */}
|
||||
<div className="structure-header">
|
||||
<div className="structure-title">
|
||||
<span className="table-icon">📋</span>
|
||||
<h3 className="structure-heading">
|
||||
Table Structure: {tableName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="structure-meta">
|
||||
{schemaName && <span className="meta-item">Schema: {schemaName}</span>}
|
||||
{connectionName && (
|
||||
<span className="meta-item">Connection: {connectionName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="structure-actions">
|
||||
<button className="btn btn-primary" onClick={onViewData}>
|
||||
View Data
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onEditTable}>
|
||||
Edit Table
|
||||
</button>
|
||||
<button className="btn-icon" onClick={onRefresh} title="Refresh">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="structure-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`structure-tab ${localTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
>
|
||||
<span className="tab-icon">{tab.icon}</span>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="structure-content">
|
||||
{localTab === 'data' && (
|
||||
<div className="tab-placeholder">
|
||||
<p>Data view is available in the DataGrid component</p>
|
||||
<button className="btn btn-primary" onClick={onViewData}>
|
||||
Open Data View
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localTab === 'structure' && (
|
||||
<>
|
||||
<ColumnsTab columns={columns} />
|
||||
<TableInfoSection info={tableInfo} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{localTab === 'indexes' && <IndexesTab indexes={indexes} />}
|
||||
|
||||
{localTab === 'foreignKeys' && <ForeignKeysTab foreignKeys={foreignKeys} />}
|
||||
|
||||
{localTab === 'triggers' && (
|
||||
<div className="tab-placeholder">
|
||||
<p>No triggers defined for this table</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableStructure;
|
||||
170
frontend/src/components/MenuBar/MenuBar.css
Normal file
170
frontend/src/components/MenuBar/MenuBar.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* MenuBar Component Styles
|
||||
*/
|
||||
|
||||
.menubar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--menubar-height);
|
||||
padding: 0 var(--space-2);
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menubar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* Application title */
|
||||
.menubar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-right: var(--space-4);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.menubar-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.menubar-app-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.menubar-nav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.menubar-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menubar-button {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.menubar-button:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.menubar-button.active {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.menubar-button:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-2) 0;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.menu-item:hover:not(.disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.menu-item.disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-item-shortcut {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.menu-separator {
|
||||
height: 1px;
|
||||
margin: var(--space-2) 0;
|
||||
background-color: var(--border);
|
||||
}
|
||||
|
||||
.menubar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.menubar-app-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menubar-title {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.menubar-button {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
267
frontend/src/components/MenuBar/MenuBar.tsx
Normal file
267
frontend/src/components/MenuBar/MenuBar.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* MenuBar Component
|
||||
*
|
||||
* Top application menu bar with File, Edit, View, Query, Tools, Help menus.
|
||||
* Based on layout-design.md menu specifications
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import './MenuBar.css';
|
||||
|
||||
/**
|
||||
* Menu item structure
|
||||
*/
|
||||
export interface MenuItem {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Display label */
|
||||
label?: string;
|
||||
/** Keyboard shortcut */
|
||||
shortcut?: string;
|
||||
/** Whether item is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether item is a separator */
|
||||
separator?: boolean;
|
||||
/** Submenu items */
|
||||
submenu?: MenuItem[];
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu definition structure
|
||||
*/
|
||||
export interface MenuDefinition {
|
||||
/** Menu label (e.g., "File", "Edit") */
|
||||
label: string;
|
||||
/** Menu items */
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface MenuBarProps {
|
||||
/** List of menu definitions */
|
||||
menus?: MenuDefinition[];
|
||||
/** Application title */
|
||||
title?: string;
|
||||
/** Handler when menu item is clicked */
|
||||
onMenuItemClick?: (menuId: string, itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default menu definitions based on layout-design.md
|
||||
*/
|
||||
const defaultMenus: MenuDefinition[] = [
|
||||
{
|
||||
label: 'File',
|
||||
items: [
|
||||
{ id: 'new-connection', label: 'New Connection', shortcut: 'Ctrl+N' },
|
||||
{ id: 'open-file', label: 'Open File...', shortcut: 'Ctrl+O' },
|
||||
{ separator: true, id: 'sep-1' },
|
||||
{ id: 'save', label: 'Save', shortcut: 'Ctrl+S' },
|
||||
{ id: 'save-as', label: 'Save As...', shortcut: 'Ctrl+Shift+S' },
|
||||
{ separator: true, id: 'sep-2' },
|
||||
{ id: 'export', label: 'Export', shortcut: 'Ctrl+E' },
|
||||
{ id: 'import', label: 'Import', shortcut: 'Ctrl+I' },
|
||||
{ separator: true, id: 'sep-3' },
|
||||
{ id: 'close-tab', label: 'Close Tab', shortcut: 'Ctrl+W' },
|
||||
{ id: 'exit', label: 'Exit', shortcut: 'Alt+F4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
items: [
|
||||
{ id: 'undo', label: 'Undo', shortcut: 'Ctrl+Z' },
|
||||
{ id: 'redo', label: 'Redo', shortcut: 'Ctrl+Y' },
|
||||
{ separator: true, id: 'sep-4' },
|
||||
{ id: 'cut', label: 'Cut', shortcut: 'Ctrl+X' },
|
||||
{ id: 'copy', label: 'Copy', shortcut: 'Ctrl+C' },
|
||||
{ id: 'paste', label: 'Paste', shortcut: 'Ctrl+V' },
|
||||
{ separator: true, id: 'sep-5' },
|
||||
{ id: 'find', label: 'Find', shortcut: 'Ctrl+F' },
|
||||
{ id: 'replace', label: 'Replace', shortcut: 'Ctrl+H' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
items: [
|
||||
{ id: 'refresh', label: 'Refresh', shortcut: 'F5' },
|
||||
{ separator: true, id: 'sep-6' },
|
||||
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', shortcut: 'Ctrl+B' },
|
||||
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'Ctrl++' },
|
||||
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'Ctrl+-' },
|
||||
{ id: 'reset-zoom', label: 'Reset Zoom', shortcut: 'Ctrl+0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Query',
|
||||
items: [
|
||||
{ id: 'run-query', label: 'Run Query', shortcut: 'Ctrl+Enter' },
|
||||
{ id: 'explain-query', label: 'Explain Query', shortcut: 'Ctrl+Shift+E' },
|
||||
{ separator: true, id: 'sep-7' },
|
||||
{ id: 'format-sql', label: 'Format SQL', shortcut: 'Ctrl+Shift+F' },
|
||||
{ id: 'query-history', label: 'Query History', shortcut: 'Ctrl+Shift+H' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
items: [
|
||||
{ id: 'data-export', label: 'Data Export Wizard...' },
|
||||
{ id: 'data-import', label: 'Data Import Wizard...' },
|
||||
{ separator: true, id: 'sep-8' },
|
||||
{ id: 'connection-manager', label: 'Connection Manager...' },
|
||||
{ id: 'preferences', label: 'Preferences', shortcut: 'Ctrl+,' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
items: [
|
||||
{ id: 'documentation', label: 'Documentation', shortcut: 'F1' },
|
||||
{ id: 'check-updates', label: 'Check for Updates...' },
|
||||
{ separator: true, id: 'sep-9' },
|
||||
{ id: 'about', label: 'About uzdb' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Dropdown menu component
|
||||
*/
|
||||
interface MenuDropdownProps {
|
||||
menu: MenuDefinition;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onItemClick: (itemId: string) => void;
|
||||
}
|
||||
|
||||
const MenuDropdown: React.FC<MenuDropdownProps> = ({
|
||||
menu,
|
||||
isOpen,
|
||||
onClose,
|
||||
onItemClick,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="menu-dropdown" ref={menuRef}>
|
||||
{menu.items.map((item) => {
|
||||
if (item.separator) {
|
||||
return <div key={item.id} className="menu-separator" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`menu-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => !item.disabled && onItemClick(item.id)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<span className="menu-item-label">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="menu-item-shortcut">{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main MenuBar component
|
||||
*/
|
||||
export const MenuBar: React.FC<MenuBarProps> = ({
|
||||
menus = defaultMenus,
|
||||
title = 'uzdb',
|
||||
onMenuItemClick,
|
||||
}) => {
|
||||
const [openMenuIndex, setOpenMenuIndex] = useState<number | null>(null);
|
||||
|
||||
const handleMenuClick = (index: number, menu: MenuDefinition) => {
|
||||
if (openMenuIndex === index) {
|
||||
setOpenMenuIndex(null);
|
||||
} else {
|
||||
setOpenMenuIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (menuLabel: string, itemId: string) => {
|
||||
console.log(`Menu "${menuLabel}" -> Item "${itemId}"`);
|
||||
onMenuItemClick?.(menuLabel.toLowerCase(), itemId);
|
||||
setOpenMenuIndex(null);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setOpenMenuIndex(null);
|
||||
};
|
||||
|
||||
// Handle escape key to close menu
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpenMenuIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="menubar">
|
||||
<div className="menubar-left">
|
||||
{/* Application icon/title */}
|
||||
<div className="menubar-title">
|
||||
<span className="menubar-icon">🗄️</span>
|
||||
<span className="menubar-app-name">{title}</span>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<nav className="menubar-nav" role="menubar" aria-label="Application menu">
|
||||
{menus.map((menu, index) => (
|
||||
<div key={menu.label} className="menubar-item">
|
||||
<button
|
||||
className={`menubar-button ${openMenuIndex === index ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(index, menu)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenuIndex === index}
|
||||
>
|
||||
{menu.label}
|
||||
</button>
|
||||
<MenuDropdown
|
||||
menu={menu}
|
||||
isOpen={openMenuIndex === index}
|
||||
onClose={handleCloseMenu}
|
||||
onItemClick={(itemId) => handleMenuItemClick(menu.label, itemId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="menubar-right">
|
||||
{/* Placeholder for user account, theme toggle, etc. */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuBar;
|
||||
235
frontend/src/components/Sidebar/ConnectionPanel.css
Normal file
235
frontend/src/components/Sidebar/ConnectionPanel.css
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* ConnectionPanel Component Styles
|
||||
*/
|
||||
|
||||
.connection-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-min-width);
|
||||
max-width: var(--sidebar-max-width);
|
||||
background-color: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.connection-panel-collapsed {
|
||||
width: 48px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.connection-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-panel-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Connection List */
|
||||
.connection-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Connection Item */
|
||||
.connection-item {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.connection-item:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.connection-item.selected {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.connection-item.active {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
box-shadow: inset 2px 0 0 var(--primary);
|
||||
}
|
||||
|
||||
.connection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.connection-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.db-type-icon {
|
||||
font-size: var(--text-sm);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
/* Tree Node */
|
||||
.tree-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
gap: var(--space-1);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.tree-node-content:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.tree-node-content.active {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
transition: transform var(--transition-fast) var(--ease-in-out);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-toggle-placeholder {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
font-size: var(--text-sm);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tree-node-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.connection-panel-footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background-color: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-new-connection {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Collapsed state */
|
||||
.collapsed-connections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsed-connection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed-connection-item:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Context Menu (placeholder for future implementation) */
|
||||
.connection-context-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-2) 0;
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
transition: background-color var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
background-color: var(--border);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
434
frontend/src/components/Sidebar/ConnectionPanel.tsx
Normal file
434
frontend/src/components/Sidebar/ConnectionPanel.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* ConnectionPanel Component
|
||||
*
|
||||
* Left sidebar panel displaying database connections and schema tree.
|
||||
* Based on layout-design.md section "左侧连接面板设计"
|
||||
*/
|
||||
|
||||
import React, { useState, KeyboardEvent } from 'react';
|
||||
import { StatusIndicator, StatusType } from '../common/StatusIndicator';
|
||||
import './ConnectionPanel.css';
|
||||
|
||||
/**
|
||||
* Database connection data structure
|
||||
*/
|
||||
export interface DatabaseConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'mysql' | 'postgresql' | 'sqlite' | 'mariadb';
|
||||
host?: string;
|
||||
port?: number;
|
||||
status: StatusType;
|
||||
databases?: Schema[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema/database structure
|
||||
*/
|
||||
export interface Schema {
|
||||
id: string;
|
||||
name: string;
|
||||
tables?: Table[];
|
||||
views?: View[];
|
||||
functions?: Function[];
|
||||
procedures?: Procedure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Table structure
|
||||
*/
|
||||
export interface Table {
|
||||
id: string;
|
||||
name: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* View structure
|
||||
*/
|
||||
export interface View {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function structure
|
||||
*/
|
||||
export interface Function {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procedure structure
|
||||
*/
|
||||
export interface Procedure {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ConnectionPanelProps {
|
||||
/** List of database connections */
|
||||
connections: DatabaseConnection[];
|
||||
/** Currently selected connection ID */
|
||||
selectedConnectionId?: string;
|
||||
/** Currently active connection ID */
|
||||
activeConnectionId?: string;
|
||||
/** Handler when connection is clicked */
|
||||
onConnectionClick?: (connection: DatabaseConnection) => void;
|
||||
/** Handler when new connection is requested */
|
||||
onNewConnection?: () => void;
|
||||
/** Handler when connection context menu is requested */
|
||||
onContextMenu?: (connection: DatabaseConnection, event: React.MouseEvent) => void;
|
||||
/** Handler when table is double-clicked */
|
||||
onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void;
|
||||
/** Collapsed state */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree node item component for rendering hierarchical data
|
||||
*/
|
||||
interface TreeNodeProps {
|
||||
label: string;
|
||||
icon: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
onToggle?: () => void;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
label,
|
||||
icon,
|
||||
expanded,
|
||||
onClick,
|
||||
onToggle,
|
||||
children,
|
||||
level,
|
||||
isActive = false,
|
||||
}) => {
|
||||
const hasChildren = children !== undefined && children !== null;
|
||||
|
||||
return (
|
||||
<div className="tree-node">
|
||||
<div
|
||||
className={`tree-node-content ${isActive ? 'active' : ''}`}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
onClick={onClick}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{hasChildren && (
|
||||
<span
|
||||
className={`tree-toggle ${expanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle?.();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
)}
|
||||
{!hasChildren && <span className="tree-toggle-placeholder" />}
|
||||
<span className="tree-icon">{icon}</span>
|
||||
<span className="tree-label">{label}</span>
|
||||
</div>
|
||||
{expanded && children && (
|
||||
<div className="tree-node-children" role="group">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ConnectionItem component renders a single connection with its schema tree
|
||||
*/
|
||||
interface ConnectionItemProps {
|
||||
connection: DatabaseConnection;
|
||||
isActive: boolean;
|
||||
isSelected: boolean;
|
||||
expandedSchemas: Set<string>;
|
||||
expandedTables: Set<string>;
|
||||
onToggleSchema: (schemaId: string) => void;
|
||||
onToggleTable: (tableId: string) => void;
|
||||
onClick: () => void;
|
||||
onContextMenu: (event: React.MouseEvent) => void;
|
||||
onTableDoubleClick: (table: Table, schema: Schema) => void;
|
||||
}
|
||||
|
||||
const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
||||
connection,
|
||||
isActive,
|
||||
isSelected,
|
||||
expandedSchemas,
|
||||
expandedTables,
|
||||
onToggleSchema,
|
||||
onToggleTable,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onTableDoubleClick,
|
||||
}) => {
|
||||
// Get database type icon
|
||||
const getDbTypeIcon = (): string => {
|
||||
switch (connection.type) {
|
||||
case 'mysql':
|
||||
return '🗄️';
|
||||
case 'postgresql':
|
||||
return '🐘';
|
||||
case 'sqlite':
|
||||
return '◪';
|
||||
case 'mariadb':
|
||||
return '🗄️';
|
||||
default:
|
||||
return '🗄️';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`connection-item ${isActive ? 'active' : ''} ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
role="treeitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Connection header */}
|
||||
<div className="connection-header">
|
||||
<StatusIndicator status={connection.status} />
|
||||
<span className="db-type-icon">{getDbTypeIcon()}</span>
|
||||
<span className="connection-name">{connection.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Schema tree - only show if connected and has databases */}
|
||||
{(connection.status === 'connected' || connection.status === 'active') &&
|
||||
connection.databases &&
|
||||
connection.databases.map((schema) => {
|
||||
const isSchemaExpanded = expandedSchemas.has(schema.id);
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
key={schema.id}
|
||||
label={schema.name}
|
||||
icon="📊"
|
||||
level={1}
|
||||
expanded={isSchemaExpanded}
|
||||
onToggle={() => onToggleSchema(schema.id)}
|
||||
>
|
||||
{/* Tables */}
|
||||
{schema.tables && schema.tables.length > 0 && (
|
||||
<div>
|
||||
{schema.tables.map((table) => {
|
||||
const isTableExpanded = expandedTables.has(table.id);
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
key={table.id}
|
||||
label={table.name}
|
||||
icon="📋"
|
||||
level={2}
|
||||
expanded={isTableExpanded}
|
||||
onToggle={() => onToggleTable(table.id)}
|
||||
onClick={() => onTableDoubleClick(table, schema)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Views count */}
|
||||
{schema.views && schema.views.length > 0 && (
|
||||
<TreeNode
|
||||
label={`views (${schema.views.length})`}
|
||||
icon="👁️"
|
||||
level={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Functions count */}
|
||||
{schema.functions && schema.functions.length > 0 && (
|
||||
<TreeNode
|
||||
label={`functions (${schema.functions.length})`}
|
||||
icon="⚡"
|
||||
level={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Procedures count */}
|
||||
{schema.procedures && schema.procedures.length > 0 && (
|
||||
<TreeNode
|
||||
label={`procedures (${schema.procedures.length})`}
|
||||
icon="📝"
|
||||
level={2}
|
||||
/>
|
||||
)}
|
||||
</TreeNode>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main ConnectionPanel component
|
||||
*/
|
||||
export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
|
||||
connections,
|
||||
selectedConnectionId,
|
||||
activeConnectionId,
|
||||
onConnectionClick,
|
||||
onNewConnection,
|
||||
onContextMenu,
|
||||
onTableDoubleClick,
|
||||
collapsed = false,
|
||||
}) => {
|
||||
// Track expanded schemas and tables
|
||||
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
|
||||
// Toggle schema expansion
|
||||
const handleToggleSchema = (schemaId: string) => {
|
||||
setExpandedSchemas((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(schemaId)) {
|
||||
next.delete(schemaId);
|
||||
} else {
|
||||
next.add(schemaId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle table expansion
|
||||
const handleToggleTable = (tableId: string) => {
|
||||
setExpandedTables((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tableId)) {
|
||||
next.delete(tableId);
|
||||
} else {
|
||||
next.add(tableId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
// TODO: Implement arrow key navigation
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
// Navigate up
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
// Navigate down
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
// Expand node or connect
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
// Collapse node
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
// Connect/confirm
|
||||
break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
// Toggle expand/collapse
|
||||
break;
|
||||
case 'F2':
|
||||
e.preventDefault();
|
||||
// Rename (not implemented)
|
||||
break;
|
||||
case 'Delete':
|
||||
e.preventDefault();
|
||||
// Delete connection (not implemented)
|
||||
break;
|
||||
case 'F5':
|
||||
e.preventDefault();
|
||||
// Refresh schema (not implemented)
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="connection-panel-collapsed">
|
||||
<div className="collapsed-connections">
|
||||
{connections.map((conn) => (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="collapsed-connection-item"
|
||||
title={conn.name}
|
||||
onClick={() => onConnectionClick?.(conn)}
|
||||
>
|
||||
<StatusIndicator status={conn.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="connection-panel" onKeyDown={handleKeyDown}>
|
||||
{/* Panel Header */}
|
||||
<div className="connection-panel-header">
|
||||
<h3 className="connection-panel-title">
|
||||
🗄️ Connections
|
||||
</h3>
|
||||
<button className="btn-icon" title="Settings" aria-label="Connection settings">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection List */}
|
||||
<div className="connection-list" role="tree" aria-label="Database connections">
|
||||
{connections.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No connections yet</p>
|
||||
</div>
|
||||
) : (
|
||||
connections.map((connection) => (
|
||||
<ConnectionItem
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
isActive={connection.id === activeConnectionId}
|
||||
isSelected={connection.id === selectedConnectionId}
|
||||
expandedSchemas={expandedSchemas}
|
||||
expandedTables={expandedTables}
|
||||
onToggleSchema={handleToggleSchema}
|
||||
onToggleTable={handleToggleTable}
|
||||
onClick={() => onConnectionClick?.(connection)}
|
||||
onContextMenu={(e) => onContextMenu?.(connection, e)}
|
||||
onTableDoubleClick={(table, schema) =>
|
||||
onTableDoubleClick?.(table, schema, connection)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Connection Button */}
|
||||
<div className="connection-panel-footer">
|
||||
<button className="btn btn-primary btn-new-connection" onClick={onNewConnection}>
|
||||
+ New Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionPanel;
|
||||
73
frontend/src/components/common/StatusIndicator.css
Normal file
73
frontend/src/components/common/StatusIndicator.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* StatusIndicator Component Styles
|
||||
*/
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--space-2);
|
||||
transition: all var(--transition-normal) var(--ease-in-out);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Connected state - Green with glow */
|
||||
.status-indicator.status-connected {
|
||||
background-color: var(--success);
|
||||
box-shadow: 0 0 4px var(--success);
|
||||
}
|
||||
|
||||
/* Active state - Blue with pulse animation */
|
||||
.status-indicator.status-active {
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Disconnected state - Gray with border */
|
||||
.status-indicator.status-disconnected {
|
||||
background-color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Connecting state - Orange spinning animation */
|
||||
.status-indicator.status-connecting {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--warning);
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Error state - Red with help cursor */
|
||||
.status-indicator.status-error {
|
||||
background-color: var(--error);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Hover effect for clickable indicators */
|
||||
.status-indicator[tabindex]:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.status-indicator[tabindex]:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
93
frontend/src/components/common/StatusIndicator.tsx
Normal file
93
frontend/src/components/common/StatusIndicator.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* StatusIndicator Component
|
||||
*
|
||||
* Displays connection status with appropriate color and animation.
|
||||
* Based on layout-design.md section "连接状态指示器"
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './StatusIndicator.css';
|
||||
|
||||
/**
|
||||
* Connection status types
|
||||
*/
|
||||
export type StatusType =
|
||||
| 'connected' // Green - Connection successful and available
|
||||
| 'active' // Blue - Currently in use with pulse effect
|
||||
| 'disconnected' // Gray - Saved but not connected
|
||||
| 'connecting' // Orange - Establishing connection (spinning)
|
||||
| 'error'; // Red - Connection failed
|
||||
|
||||
export interface StatusIndicatorProps {
|
||||
/** Current connection status */
|
||||
status: StatusType;
|
||||
/** Optional tooltip text shown on hover */
|
||||
tooltip?: string;
|
||||
/** Additional CSS class name */
|
||||
className?: string;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusIndicator component renders a colored dot indicating connection state
|
||||
*/
|
||||
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||
status,
|
||||
tooltip,
|
||||
className = '',
|
||||
onClick,
|
||||
}) => {
|
||||
// Map status to CSS class
|
||||
const getStatusClass = (): string => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'status-connected';
|
||||
case 'active':
|
||||
return 'status-active';
|
||||
case 'disconnected':
|
||||
return 'status-disconnected';
|
||||
case 'connecting':
|
||||
return 'status-connecting';
|
||||
case 'error':
|
||||
return 'status-error';
|
||||
default:
|
||||
return 'status-disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
// Get aria label for accessibility
|
||||
const getAriaLabel = (): string => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'active':
|
||||
return 'Active connection';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'error':
|
||||
return 'Connection error';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`status-indicator ${getStatusClass()} ${className}`}
|
||||
role="status"
|
||||
aria-label={getAriaLabel()}
|
||||
title={tooltip}
|
||||
onClick={onClick}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusIndicator;
|
||||
63
frontend/src/components/index.ts
Normal file
63
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* uzdb Frontend Components
|
||||
*
|
||||
* Central export file for all components.
|
||||
*/
|
||||
|
||||
// Common components
|
||||
export { StatusIndicator } from './common/StatusIndicator';
|
||||
export type { StatusIndicatorProps, StatusType } from './common/StatusIndicator';
|
||||
|
||||
// Layout components
|
||||
export { AppLayout } from './Layout/AppLayout';
|
||||
export type { AppLayoutProps } from './Layout/AppLayout';
|
||||
|
||||
export { StatusBar } from './Layout/StatusBar';
|
||||
export type { StatusBarProps, StatusType as StatusBarStatusType } from './Layout/StatusBar';
|
||||
|
||||
export { ToolBar } from './Layout/ToolBar';
|
||||
export type { ToolBarProps, ToolButton } from './Layout/ToolBar';
|
||||
|
||||
// MenuBar components
|
||||
export { MenuBar } from './MenuBar/MenuBar';
|
||||
export type { MenuBarProps, MenuItem, MenuDefinition } from './MenuBar/MenuBar';
|
||||
|
||||
// Sidebar components
|
||||
export { ConnectionPanel } from './Sidebar/ConnectionPanel';
|
||||
export type {
|
||||
ConnectionPanelProps,
|
||||
DatabaseConnection,
|
||||
Schema,
|
||||
Table,
|
||||
View,
|
||||
Function,
|
||||
Procedure,
|
||||
} from './Sidebar/ConnectionPanel';
|
||||
|
||||
// MainArea components
|
||||
export { QueryEditor } from './MainArea/QueryEditor';
|
||||
export type {
|
||||
QueryEditorProps,
|
||||
QueryTab,
|
||||
QueryResult,
|
||||
} from './MainArea/QueryEditor';
|
||||
|
||||
export { DataGrid } from './MainArea/DataGrid';
|
||||
export type {
|
||||
DataGridProps,
|
||||
Column,
|
||||
DataRow,
|
||||
PaginationState,
|
||||
SortState,
|
||||
FilterState,
|
||||
} from './MainArea/DataGrid';
|
||||
|
||||
export { TableStructure } from './MainArea/TableStructure';
|
||||
export type {
|
||||
TableStructureProps,
|
||||
TableColumn,
|
||||
Index,
|
||||
ForeignKey,
|
||||
TableInfo,
|
||||
StructureTab,
|
||||
} from './MainArea/TableStructure';
|
||||
364
frontend/src/index.css
Normal file
364
frontend/src/index.css
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* uzdb Global Styles & CSS Variables
|
||||
* Based on design-system.md specifications
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS Custom Properties (Design Tokens)
|
||||
============================================ */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--primary-active: #1d4ed8;
|
||||
|
||||
/* Semantic Colors */
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #06b6d4;
|
||||
|
||||
/* Neutral Colors */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
/* Database Type Colors */
|
||||
--db-mysql: #00758f;
|
||||
--db-postgresql: #336791;
|
||||
--db-sqlite: #003b57;
|
||||
--db-mariadb: #003541;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 12px;
|
||||
--text-sm: 14px;
|
||||
--text-base: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 24px;
|
||||
|
||||
/* Spacing (4px grid) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
/* Layout Dimensions */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-min-width: 180px;
|
||||
--sidebar-max-width: 400px;
|
||||
--menubar-height: 32px;
|
||||
--toolbar-height: 40px;
|
||||
--statusbar-height: 28px;
|
||||
--tab-height: 36px;
|
||||
--row-height: 36px;
|
||||
--header-height: 40px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 100ms;
|
||||
--transition-normal: 150ms;
|
||||
--transition-slow: 300ms;
|
||||
--ease-in-out: ease-in-out;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Reset & Base Styles
|
||||
============================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Typography
|
||||
============================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-2xl); }
|
||||
h2 { font-size: var(--text-xl); }
|
||||
h3 { font-size: var(--text-lg); }
|
||||
h4 { font-size: var(--text-base); }
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Focus States (Accessibility)
|
||||
============================================ */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Button Styles
|
||||
============================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal) var(--ease-in-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background-color: var(--primary-active);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Input Styles
|
||||
============================================ */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Scrollbar Styles
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Animation Keyframes
|
||||
============================================ */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utility Classes
|
||||
============================================ */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container!)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
134
frontend/src/mock/connections.ts
Normal file
134
frontend/src/mock/connections.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Mock Database Connections Data
|
||||
*
|
||||
* Sample data for development and testing purposes.
|
||||
*/
|
||||
|
||||
import { DatabaseConnection } from '../components/Sidebar/ConnectionPanel';
|
||||
|
||||
/**
|
||||
* Sample database connections
|
||||
*/
|
||||
export const mockConnections: DatabaseConnection[] = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'MySQL @ localhost',
|
||||
type: 'mysql',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
status: 'active',
|
||||
databases: [
|
||||
{
|
||||
id: 'schema-1',
|
||||
name: 'public',
|
||||
tables: [
|
||||
{ id: 'table-1', name: 'users', schema: 'public' },
|
||||
{ id: 'table-2', name: 'products', schema: 'public' },
|
||||
{ id: 'table-3', name: 'orders', schema: 'public' },
|
||||
{ id: 'table-4', name: 'order_items', schema: 'public' },
|
||||
{ id: 'table-5', name: 'categories', schema: 'public' },
|
||||
],
|
||||
views: [
|
||||
{ id: 'view-1', name: 'active_users' },
|
||||
{ id: 'view-2', name: 'product_stats' },
|
||||
],
|
||||
functions: [
|
||||
{ id: 'func-1', name: 'calculate_total' },
|
||||
{ id: 'func-2', name: 'get_user_role' },
|
||||
],
|
||||
procedures: [
|
||||
{ id: 'proc-1', name: 'sp_create_order' },
|
||||
{ id: 'proc-2', name: 'sp_update_inventory' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schema-2',
|
||||
name: 'information_schema',
|
||||
tables: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
name: 'PostgreSQL @ prod-db',
|
||||
type: 'postgresql',
|
||||
host: 'prod-db.example.com',
|
||||
port: 5432,
|
||||
status: 'connected',
|
||||
databases: [
|
||||
{
|
||||
id: 'schema-3',
|
||||
name: 'public',
|
||||
tables: [
|
||||
{ id: 'table-6', name: 'customers', schema: 'public' },
|
||||
{ id: 'table-7', name: 'transactions', schema: 'public' },
|
||||
{ id: 'table-8', name: 'audit_log', schema: 'public' },
|
||||
],
|
||||
views: [{ id: 'view-3', name: 'customer_summary' }],
|
||||
functions: [{ id: 'func-3', name: 'generate_report' }],
|
||||
procedures: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'conn-3',
|
||||
name: 'SQLite @ local',
|
||||
type: 'sqlite',
|
||||
status: 'disconnected',
|
||||
databases: [],
|
||||
},
|
||||
{
|
||||
id: 'conn-4',
|
||||
name: 'MariaDB @ staging',
|
||||
type: 'mariadb',
|
||||
host: 'staging.example.com',
|
||||
port: 3306,
|
||||
status: 'error',
|
||||
databases: [],
|
||||
},
|
||||
{
|
||||
id: 'conn-5',
|
||||
name: 'MySQL @ dev-server',
|
||||
type: 'mysql',
|
||||
host: 'dev.example.com',
|
||||
port: 3306,
|
||||
status: 'connecting',
|
||||
databases: [],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get connection by ID
|
||||
*/
|
||||
export const getConnectionById = (id: string): DatabaseConnection | undefined => {
|
||||
return mockConnections.find((conn) => conn.id === id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get connections by status
|
||||
*/
|
||||
export const getConnectionsByStatus = (status: DatabaseConnection['status']): DatabaseConnection[] => {
|
||||
return mockConnections.filter((conn) => conn.status === status);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new mock connection (for testing)
|
||||
*/
|
||||
export const addMockConnection = (connection: DatabaseConnection): void => {
|
||||
mockConnections.push(connection);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update connection status (for testing)
|
||||
*/
|
||||
export const updateConnectionStatus = (
|
||||
id: string,
|
||||
status: DatabaseConnection['status']
|
||||
): void => {
|
||||
const conn = mockConnections.find((c) => c.id === id);
|
||||
if (conn) {
|
||||
conn.status = status;
|
||||
}
|
||||
};
|
||||
|
||||
export default mockConnections;
|
||||
232
frontend/src/mock/queryResults.ts
Normal file
232
frontend/src/mock/queryResults.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Mock Query Results Data
|
||||
*
|
||||
* Sample data for development and testing purposes.
|
||||
*/
|
||||
|
||||
import { QueryResult, QueryTab } from '../components/MainArea/QueryEditor';
|
||||
import { TableColumn, Index, ForeignKey, TableInfo } from '../components/MainArea/TableStructure';
|
||||
import { Column, DataRow } from '../components/MainArea/DataGrid';
|
||||
|
||||
/**
|
||||
* Sample query results
|
||||
*/
|
||||
export const mockQueryResults: QueryResult = {
|
||||
columns: ['id', 'name', 'email', 'created_at', 'active', 'role'],
|
||||
rows: [
|
||||
[1, 'Alice Johnson', 'alice@example.com', '2024-01-15 10:30:00', true, 'admin'],
|
||||
[2, 'Bob Smith', 'bob@example.com', '2024-01-16 14:22:00', true, 'user'],
|
||||
[3, 'Carol Williams', 'carol@example.com', '2024-01-17 09:15:00', false, 'user'],
|
||||
[4, 'David Brown', 'david@example.com', '2024-01-18 16:45:00', true, 'moderator'],
|
||||
[5, 'Eve Davis', 'eve@example.com', '2024-01-19 11:00:00', true, 'user'],
|
||||
[6, 'Frank Miller', 'frank@example.com', '2024-01-20 08:30:00', true, 'user'],
|
||||
[7, 'Grace Wilson', 'grace@example.com', '2024-01-21 13:20:00', true, 'user'],
|
||||
[8, 'Henry Moore', 'henry@example.com', '2024-01-22 15:10:00', false, 'user'],
|
||||
[9, 'Ivy Taylor', 'ivy@example.com', '2024-01-23 10:45:00', true, 'admin'],
|
||||
[10, 'Jack Anderson', 'jack@example.com', '2024-01-24 12:00:00', true, 'user'],
|
||||
],
|
||||
rowCount: 127,
|
||||
executionTime: 0.045,
|
||||
message: '127 rows affected',
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample error query result
|
||||
*/
|
||||
export const mockErrorResult: QueryResult = {
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: "ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SELEC * FROM users' at line 1",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample query tabs
|
||||
*/
|
||||
export const mockQueryTabs: QueryTab[] = [
|
||||
{
|
||||
id: 'tab-1',
|
||||
title: 'query_1.sql',
|
||||
content: `-- Get active users with their orders
|
||||
SELECT
|
||||
u.id,
|
||||
u.name,
|
||||
u.email,
|
||||
COUNT(o.id) as order_count
|
||||
FROM users u
|
||||
LEFT JOIN orders o ON u.id = o.user_id
|
||||
WHERE u.active = true
|
||||
AND u.created_at >= '2024-01-01'
|
||||
GROUP BY u.id, u.name, u.email
|
||||
HAVING COUNT(o.id) > 0
|
||||
ORDER BY order_count DESC
|
||||
LIMIT 100;`,
|
||||
isDirty: false,
|
||||
},
|
||||
{
|
||||
id: 'tab-2',
|
||||
title: 'unsaved_query.sql',
|
||||
content: `SELECT * FROM products WHERE price > 100;`,
|
||||
isDirty: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample table columns
|
||||
*/
|
||||
export const mockTableColumns: TableColumn[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'INT',
|
||||
nullable: false,
|
||||
isPrimaryKey: true,
|
||||
extra: 'auto_increment',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'VARCHAR(100)',
|
||||
nullable: false,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'VARCHAR(255)',
|
||||
nullable: false,
|
||||
isUnique: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'TIMESTAMP',
|
||||
nullable: true,
|
||||
defaultValue: 'NOW()',
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'BOOLEAN',
|
||||
nullable: false,
|
||||
defaultValue: 'TRUE',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'ENUM(\'user\', \'admin\', \'moderator\')',
|
||||
nullable: false,
|
||||
defaultValue: "'user'",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample indexes
|
||||
*/
|
||||
export const mockIndexes: Index[] = [
|
||||
{
|
||||
name: 'PRIMARY',
|
||||
type: 'BTREE',
|
||||
columns: ['id'],
|
||||
isUnique: true,
|
||||
method: 'BTREE',
|
||||
},
|
||||
{
|
||||
name: 'idx_email',
|
||||
type: 'BTREE',
|
||||
columns: ['email'],
|
||||
isUnique: true,
|
||||
method: 'BTREE',
|
||||
},
|
||||
{
|
||||
name: 'idx_created_at',
|
||||
type: 'BTREE',
|
||||
columns: ['created_at'],
|
||||
isUnique: false,
|
||||
method: 'BTREE',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample foreign keys
|
||||
*/
|
||||
export const mockForeignKeys: ForeignKey[] = [
|
||||
{
|
||||
name: 'fk_user_role',
|
||||
column: 'role_id',
|
||||
referencesTable: 'roles',
|
||||
referencesColumn: 'id',
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
{
|
||||
name: 'fk_user_department',
|
||||
column: 'department_id',
|
||||
referencesTable: 'departments',
|
||||
referencesColumn: 'id',
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'RESTRICT',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample table info
|
||||
*/
|
||||
export const mockTableInfo: TableInfo = {
|
||||
engine: 'InnoDB',
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
rowCount: 1247,
|
||||
size: '256 KB',
|
||||
autoIncrement: 1248,
|
||||
comment: 'User accounts table',
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample data grid columns
|
||||
*/
|
||||
export const mockDataGridColumns: Column[] = [
|
||||
{ id: 'id', name: 'ID', type: 'INT', width: 80, sortable: true },
|
||||
{ id: 'name', name: 'Name', type: 'VARCHAR', width: 200, sortable: true, editable: true },
|
||||
{ id: 'email', name: 'Email', type: 'VARCHAR', width: 250, sortable: true, editable: true },
|
||||
{ id: 'created_at', name: 'Created At', type: 'TIMESTAMP', width: 180, sortable: true },
|
||||
{ id: 'active', name: 'Active', type: 'BOOLEAN', width: 80, sortable: true, editable: true },
|
||||
{ id: 'role', name: 'Role', type: 'ENUM', width: 120, sortable: true, editable: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Sample data grid rows
|
||||
*/
|
||||
export const mockDataGridRows: DataRow[] = [
|
||||
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', created_at: '2024-01-15', active: true, role: 'admin' },
|
||||
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', created_at: '2024-01-16', active: true, role: 'user' },
|
||||
{ id: 3, name: 'Carol Williams', email: 'carol@example.com', created_at: '2024-01-17', active: false, role: 'user' },
|
||||
{ id: 4, name: 'David Brown', email: 'david@example.com', created_at: '2024-01-18', active: true, role: 'moderator' },
|
||||
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', created_at: '2024-01-19', active: true, role: 'user' },
|
||||
{ id: 6, name: 'Frank Miller', email: 'frank@example.com', created_at: '2024-01-20', active: true, role: 'user' },
|
||||
{ id: 7, name: 'Grace Wilson', email: 'grace@example.com', created_at: '2024-01-21', active: true, role: 'user' },
|
||||
{ id: 8, name: 'Henry Moore', email: 'henry@example.com', created_at: '2024-01-22', active: false, role: 'user' },
|
||||
{ id: 9, name: 'Ivy Taylor', email: 'ivy@example.com', created_at: '2024-01-23', active: true, role: 'admin' },
|
||||
{ id: 10, name: 'Jack Anderson', email: 'jack@example.com', created_at: '2024-01-24', active: true, role: 'user' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate more rows for testing pagination
|
||||
*/
|
||||
export const generateMockRows = (count: number): DataRow[] => {
|
||||
const roles = ['user', 'admin', 'moderator'];
|
||||
const firstNames = ['Alice', 'Bob', 'Carol', 'David', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'];
|
||||
const lastNames = ['Johnson', 'Smith', 'Williams', 'Brown', 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor', 'Anderson'];
|
||||
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `${firstNames[i % firstNames.length]} ${lastNames[i % lastNames.length]}`,
|
||||
email: `user${i + 1}@example.com`,
|
||||
created_at: `2024-01-${((i % 31) + 1).toString().padStart(2, '0')}`,
|
||||
active: i % 5 !== 0,
|
||||
role: roles[i % roles.length],
|
||||
}));
|
||||
};
|
||||
|
||||
export default {
|
||||
mockQueryResults,
|
||||
mockErrorResult,
|
||||
mockQueryTabs,
|
||||
mockTableColumns,
|
||||
mockIndexes,
|
||||
mockForeignKeys,
|
||||
mockTableInfo,
|
||||
mockDataGridColumns,
|
||||
mockDataGridRows,
|
||||
};
|
||||
40
frontend/src/style.css
Normal file
40
frontend/src/style.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* uzdb Global Styles
|
||||
*
|
||||
* Base styles for the application.
|
||||
* Additional styles are in index.css
|
||||
*/
|
||||
|
||||
html {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Prevent text selection in UI elements */
|
||||
.menubar,
|
||||
.toolbar,
|
||||
.statusbar,
|
||||
.view-tabs,
|
||||
.query-tabs {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection in content areas */
|
||||
.sql-editor,
|
||||
.data-grid-table,
|
||||
.structure-table {
|
||||
user-select: text;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user