feat: 添加Neo4j图数据库支持及前端代码重构

- 新增 Neo4j 图数据库 handler、service、model
- 后端添加 SaveGraph API 接口
- 前端 Database.vue 重构,拆分为独立组件
- 新增 web/src/views/database/ 组件目录
- 删除临时文件 (temp_*.go)
- 添加 Neo4j 相关 API 需求文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 09:11:08 +08:00
parent 20015dbd2a
commit c917d6b04c
41 changed files with 4453 additions and 1021 deletions

View File

@@ -0,0 +1,69 @@
/* Reset - 样式重置 */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html.dark {
color-scheme: dark;
}
body {
font-family: var(--font-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
font-size: var(--font-size-sm, 14px);
line-height: 1.5;
color: #f3f4f6;
background-color: #0f1117;
}
a {
color: inherit;
text-decoration: none;
}
button {
border: none;
background: none;
cursor: pointer;
font: inherit;
}
input,
textarea,
select {
font: inherit;
}
ul,
ol {
list-style: none;
}
img {
max-width: 100%;
display: block;
}
/* 滚动条隐藏工具类 */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 内容自动可见性 */
.content-auto {
content-visibility: auto;
}

View File

@@ -0,0 +1,65 @@
/* CSS Variables - 全局变量 */
:root {
/* 主题色 */
--color-primary: #ff9500;
--color-primary-light: #ffb732;
--color-primary-dark: #cc7700;
/* 功能色 */
--color-success: #22c55e;
--color-warning: #eab308;
--color-danger: #ef4444;
--color-info: #3b82f6;
/* 文字色 */
--color-text-primary: #ffffff;
--color-text-regular: #a1a1aa;
--color-text-secondary: #71717a;
--color-text-placeholder: #6b7280;
/* 背景色 - Dark 主题 */
--color-bg-base: #0f1117;
--color-bg-dark: #171922;
--color-bg-light: #1a1c25;
--color-bg-lighter: #1f2230;
/* 边框色 */
--color-border: #2a2c36;
--color-border-light: #3a3c46;
--color-border-lighter: #4a4c56;
/* 圆角 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* 间距 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
--spacing-2xl: 32px;
/* 字体-family: -apple */
--font-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 16px 32px rgba(0, 0, 0, 0.5);
/* 动画 */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}

View File

@@ -0,0 +1,127 @@
/* Button - 按钮样式 */
/* 主要按钮 */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
font-weight: 500;
color: white;
background: linear-gradient(to right, var(--color-primary), #ef4444);
border-radius: var(--radius-md);
transition: all var(--transition-base);
cursor: pointer;
border: none;
}
.btn-primary:hover {
background: linear-gradient(to right, #ffb732, #dc2626);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.2);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 次要按钮 */
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
color: #d1d5db;
background-color: #374151;
border: 1px solid #4b5563;
border-radius: var(--radius-md);
transition: all var(--transition-base);
cursor: pointer;
}
.btn-secondary:hover {
background-color: #4b5563;
border-color: #6b7280;
}
.btn-secondary:active {
transform: scale(0.98);
}
/* 图标按钮 */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
cursor: pointer;
background: transparent;
border: none;
color: #9ca3af;
}
.btn-icon:hover {
background-color: #374151;
transform: scale(1.05);
}
.btn-icon:active {
transform: scale(0.95);
}
/* 危险按钮 */
.btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
font-weight: 500;
color: white;
background-color: var(--color-danger);
border-radius: var(--radius-md);
transition: all var(--transition-base);
cursor: pointer;
border: none;
}
.btn-danger:hover {
background-color: #dc2626;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
/* 幽灵按钮 */
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
color: #9ca3af;
background: transparent;
border-radius: var(--radius-md);
transition: all var(--transition-base);
cursor: pointer;
border: none;
}
.btn-ghost:hover {
background-color: #374151;
color: white;
}

View File

@@ -0,0 +1,102 @@
/* Form - 表单样式 */
/* 输入框 */
.input-field {
width: 100%;
padding: 10px 16px;
font-size: var(--font-size-sm);
color: white;
background-color: #171922;
border: 1px solid #2a2c36;
border-radius: var(--radius-md);
transition: all var(--transition-base);
outline: none;
}
.input-field::placeholder {
color: var(--color-text-placeholder);
}
.input-field:hover:not(:focus) {
border-color: #3a3c46;
}
.input-field:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.15);
}
/* 搜索输入框 */
.search-input {
width: 100%;
padding: 10px 16px 10px 40px;
font-size: var(--font-size-sm);
color: white;
background-color: #171922;
border: 1px solid #2a2c36;
border-radius: var(--radius-md);
transition: all var(--transition-base);
outline: none;
}
.search-input::placeholder {
color: var(--color-text-placeholder);
}
.search-input:hover:not(:focus) {
border-color: #3a3c46;
}
.search-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.15);
}
/* 文本域 */
.textarea-field {
width: 100%;
padding: 10px 16px;
font-size: var(--font-size-sm);
color: white;
background-color: #171922;
border: 1px solid #2a2c36;
border-radius: var(--radius-md);
transition: all var(--transition-base);
outline: none;
resize: vertical;
min-height: 80px;
}
.textarea-field::placeholder {
color: var(--color-text-placeholder);
}
.textarea-field:hover:not(:focus) {
border-color: #3a3c46;
}
.textarea-field:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.15);
}
/* 表单标签 */
.form-label {
display: block;
font-size: var(--font-size-sm);
font-weight: 500;
color: #d1d5db;
margin-bottom: var(--spacing-sm);
}
/* 表单组 */
.form-group {
margin-bottom: var(--spacing-lg);
}
/* 表单错误提示 */
.form-error {
font-size: var(--font-size-xs);
color: var(--color-danger);
margin-top: var(--spacing-xs);
}

View File

@@ -0,0 +1,88 @@
/* Modal - 弹窗样式 */
/* 弹窗遮罩 */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: var(--spacing-lg);
animation: modal-fade-in 0.2s ease-out forwards;
}
/* 弹窗内容 */
.modal-content {
background-color: #1f2230;
border-radius: var(--radius-xl);
border: 1px solid #2a2c36;
box-shadow: var(--shadow-xl);
overflow: hidden;
animation: modal-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* 弹窗头部 */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-lg);
border-bottom: 1px solid #2a2c36;
background-color: rgba(31, 34, 48, 0.5);
}
.modal-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: white;
}
/* 弹窗主体 */
.modal-body {
padding: var(--spacing-lg);
}
/* 弹窗底部 */
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid #2a2c36;
background-color: rgba(31, 34, 48, 0.5);
}
/* 弹窗动画 */
@keyframes modal-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes modal-scale-in {
0% {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-slide-up {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,91 @@
/* Table - 表格样式 */
/* 表格容器 */
.table-container {
background-color: #1f2230;
border-radius: var(--radius-lg);
overflow: hidden;
}
/* 表格 */
.table {
width: 100%;
border-collapse: collapse;
}
/* 表格头部 */
.table th {
text-align: left;
padding: var(--spacing-md) var(--spacing-lg);
font-size: var(--font-size-sm);
font-weight: 500;
color: #9ca3af;
background-color: #171922;
border-bottom: 1px solid #2a2c36;
}
/* 表格单元格 */
.table td {
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid #2a2c36;
}
/* 表格行 */
.table-row {
transition: all var(--transition-base);
}
.table-row:hover {
background-color: rgba(55, 65, 81, 0.5);
}
.table-row:active {
background-color: #374151;
}
/* 表格行 - 斑马纹 */
.table-row-striped:nth-child(even) {
background-color: rgba(31, 34, 48, 0.5);
}
/* 表格操作按钮组 */
.table-actions {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
/* 表格空状态 */
.table-empty {
padding: var(--spacing-2xl);
text-align: center;
color: #6b7280;
}
.table-empty-icon {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-md);
color: #4b5563;
}
/* 表格加载状态 */
.table-loading {
padding: var(--spacing-2xl);
text-center: center;
color: #6b7280;
}
.table-loading-icon {
font-size: var(--font-size-2xl);
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,198 @@
/* Element Plus - Dark Theme Overrides */
/* 基础变量覆盖 */
html.dark {
--el-bg-color: #171922;
--el-bg-color-overlay: #171922;
--el-text-color-primary: #ffffff;
--el-text-color-regular: #a1a1aa;
--el-border-color: #2a2c36;
--el-border-color-light: #2a2c36;
--el-fill-color-blank: #171922;
--el-color-primary: #ff9500;
}
/* el-select 统一样式 */
html.dark .el-select {
--el-select-input-focus-border-color: #ff9500;
}
html.dark .el-select .el-input__wrapper,
html.dark .el-select .el-select__wrapper {
background-color: #1a1c25 !important;
border: 1px solid #2a2c36;
border-radius: 8px;
transition: all 0.2s ease;
padding: 2px 11px;
min-height: 42px;
box-shadow: none !important;
}
html.dark .el-select:hover .el-input__wrapper,
html.dark .el-select:hover .el-select__wrapper {
background-color: #1a1c25 !important;
border-color: #ff9500;
}
html.dark .el-select .el-input__wrapper.is-focus,
html.dark .el-select .el-select__wrapper.is-focus {
border-color: #ff9500;
}
html.dark .el-select .el-input__inner {
color: #ffffff;
line-height: 1.5;
}
html.dark .el-select .el-input__inner::placeholder {
color: #6b7280;
}
/* 下拉箭头 */
html.dark .el-select .el-input__suffix .el-select__caret {
color: #71717a;
transition: transform 0.3s;
}
html.dark .el-select .el-input__suffix .el-select__caret.is-reverse {
transform: rotate(180deg);
}
/* 下拉菜单 */
.el-select-dropdown.el-popper,
html.dark .el-select-dropdown {
background-color: #1a1c25 !important;
border: 1px solid #2a2c36 !important;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
html.dark .el-select-dropdown__list {
background-color: #1a1c25 !important;
}
html.dark .el-select-dropdown__item {
color: #ffffff !important;
padding: 8px 12px;
transition: all 0.2s;
}
html.dark .el-select-dropdown__item:hover,
html.dark .el-select-dropdown__item.hover {
background-color: #1a1c25 !important;
}
html.dark .el-select-dropdown__item.is-selected {
color: #ff9500 !important;
font-weight: 600;
background-color: transparent;
}
html.dark .el-select-dropdown__item.is-selected::before {
content: '';
display: inline-block;
width: 4px;
height: 4px;
background-color: #ff9500;
border-radius: 50%;
margin-right: 8px;
}
html.dark .el-select-dropdown__empty {
color: #71717a;
padding: 20px 0;
}
/* 选中项文字居中 */
html.dark .el-select .el-select__wrapper {
display: flex;
align-items: center;
}
/* 多选标签 */
html.dark .el-select .el-tag {
background-color: #2a2c36;
border-color: transparent;
color: #ffffff;
border-radius: 4px;
}
/* el-checkbox 暗色主题 - 金黄色选中 */
html.dark .el-checkbox {
--el-checkbox-checked-text-color: #ffb700;
--el-checkbox-checked-bg-color: #ffb700;
--el-checkbox-checked-border-color: #ffb700;
--el-checkbox-input-border-color-hover: #ffb700;
}
html.dark .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner {
background-color: #ffb700;
border-color: #ffb700;
}
html.dark .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner::after {
border-color: #1f2937;
}
html.dark .el-checkbox .el-checkbox__label {
color: #e5e7eb;
}
html.dark .el-checkbox:hover .el-checkbox__inner {
border-color: #ffb700;
}
/* popper 箭头 */
.el-popper.is-light,
html.dark .el-popper.is-light {
background: #1a1c25 !important;
border: 1px solid #2a2c36 !important;
}
.el-popper.is-light .el-popper__arrow::before,
html.dark .el-popper.is-light .el-popper__arrow::before {
background: #1a1c25 !important;
border-color: #2a2c36 !important;
}
/* el-pagination */
html.dark .el-pagination {
--el-pagination-bg-color: #374151;
--el-pagination-text-color: #ffffff;
--el-pagination-button-disabled-bg-color: #1f2230;
}
html.dark .el-pagination .el-pager li {
background-color: #374151;
color: #ffffff;
}
html.dark .el-pagination .el-pager li:hover {
color: #ff9500;
}
html.dark .el-pagination .el-pager li.is-active {
background-color: #ff9500;
color: #ffffff;
}
/* 自定义下拉菜单类 */
.dark-select-dropdown {
background-color: #1a1c25 !important;
border: 1px solid #2a2c36 !important;
}
.dark-select-dropdown .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
}
.dark-select-dropdown .el-select-dropdown__item:hover,
.dark-select-dropdown .el-select-dropdown__item.hover {
background-color: #1a1c25 !important;
}
.dark-select-dropdown .el-select-dropdown__item.is-selected {
color: #ff9500 !important;
background-color: transparent !important;
}

View File

@@ -0,0 +1,295 @@
/* Main Entry - 全局样式入口 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Force body styles */
body {
color: #f3f4f6;
background-color: #0f1115;
}
/* Ensure main content has dark background */
#app, .page-content {
background-color: #0f1115 !important;
color: #f3f4f6;
}
/* Base styles */
@import './base/variables.css';
@import './base/reset.css';
/* Component styles */
@import './components/button.css';
@import './components/form.css';
@import './components/modal.css';
@import './components/table.css';
/* Animations */
@keyframes bar-grow {
from {
height: 0;
opacity: 1;
}
to {
opacity: 1;
}
}
@keyframes progress-grow {
from {
width: 0;
opacity: 1;
}
to {
width: var(--target-width);
opacity: 1;
}
}
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes loading-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes loading-dots {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes loading-skeleton {
0% { opacity: 0.4; }
50% { opacity: 0.7; }
100% { opacity: 0.4; }
}
/* Animation classes */
.chart-bar {
height: 0;
opacity: 0;
animation: bar-grow 2.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.progress-bar {
width: 0;
opacity: 0;
animation: progress-grow 2.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.loading-pulse {
animation: pulse-dot 1.5s ease-in-out infinite;
}
.loading-spin {
animation: loading-spin 1s linear infinite;
}
.loading-skeleton {
animation: loading-skeleton 1.5s ease-in-out infinite;
}
/* Utility classes */
.text-gradient {
background: linear-gradient(to right, var(--color-primary), #ef4444);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glass {
background-color: rgba(31, 34, 48, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(42, 44, 54, 0.5);
}
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 149, 0, 0.5);
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2230;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
/* Badge styles */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: var(--font-size-xs);
font-weight: 500;
border-radius: var(--radius-sm);
transition: all var(--transition-base);
}
.badge-success {
background-color: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.badge-warning {
background-color: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.badge-error {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.badge-info {
background-color: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.badge-default {
background-color: rgba(107, 114, 128, 0.2);
color: #6b7280;
}
/* Status dot */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
transition: all var(--transition-base);
}
.status-dot-active {
background-color: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot-inactive {
background-color: #6b7280;
}
.status-dot-error {
background-color: #ef4444;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
/* Empty state */
.empty-state {
padding: var(--spacing-2xl);
text-align: center;
transition: all var(--transition-slow);
}
.empty-state-icon {
color: #4b5563;
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-md);
transition: transform var(--transition-slow);
}
.empty-state:hover .empty-state-icon {
color: #6b7280;
transform: scale(1.1);
}
/* Card hover effect */
.card-hover {
transition: all var(--transition-base);
}
.card-hover:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
/* Element Plus Dark Theme - Must be last */
html.dark {
--el-bg-color: #171922;
--el-bg-color-overlay: #171922;
--el-text-color-primary: #ffffff;
--el-text-color-regular: #a1a1aa;
--el-border-color: #2a2c36;
--el-border-color-light: #2a2c36;
--el-fill-color-blank: #171922;
--el-color-primary: #ff9500;
}
/* el-select */
html.dark .el-select .el-input__wrapper,
html.dark .el-select .el-select__wrapper {
background-color: #171922 !important;
border: 1px solid #2a2c36;
box-shadow: none !important;
}
html.dark .el-select:hover .el-input__wrapper,
html.dark .el-select:hover .el-select__wrapper {
background-color: #1a1c25 !important;
border-color: #ff9500;
}
html.dark .el-select .el-input__wrapper.is-focus,
html.dark .el-select .el-select__wrapper.is-focus {
border-color: #ff9500;
}
html.dark .el-select .el-input__inner {
color: #ffffff;
}
/* el-select dropdown */
.el-select-dropdown.el-popper,
html.dark .el-select-dropdown {
background-color: #171922 !important;
border: 1px solid #2a2c36 !important;
}
html.dark .el-select-dropdown__list {
background-color: #171922 !important;
}
html.dark .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
}
html.dark .el-select-dropdown__item:hover,
html.dark .el-select-dropdown__item.hover {
background-color: #1a1c25 !important;
}
html.dark .el-select-dropdown__item.is-selected {
color: #ff9500 !important;
}
/* el-checkbox */
html.dark .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner {
background-color: #ffb700;
border-color: #ffb700;
}
html.dark .el-checkbox .el-checkbox__label {
color: #e5e7eb;
}

View File

@@ -0,0 +1,125 @@
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
/**
* API 请求封装
* @param apiFunc - API 请求函数
* @param options - 配置选项
*/
export function useApi<T = any>(
apiFunc: (...args: any[]) => Promise<Response>,
options: {
showError?: boolean
showSuccess?: boolean
successMessage?: string
errorMessage?: string
} = {}
) {
const { showError = true, showSuccess = false, successMessage, errorMessage } = options
const loading = ref(false)
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const execute = async (...args: any[]) => {
loading.value = true
error.value = null
try {
const response = await apiFunc(...args)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
if (result.success || response.ok) {
data.value = result
if (showSuccess) {
ElMessage.success(successMessage || result.message || '操作成功')
}
return result
} else {
if (showError) {
ElMessage.error(errorMessage || result.message || '操作失败')
}
throw new Error(result.message || '操作失败')
}
} catch (err: any) {
error.value = err
if (showError) {
ElMessage.error(errorMessage || err.message || '请求失败')
}
throw err
} finally {
loading.value = false
}
}
return {
loading: computed(() => loading.value),
data: computed(() => data.value),
error: computed(() => error.value),
execute,
}
}
/**
* 简单的 CRUD 操作封装
*/
export function useCrud<T = any>(baseUrl: string) {
const API_BASE = baseUrl
const fetchList = async (params?: any) => {
const query = params ? '?' + new URLSearchParams(params).toString() : ''
const response = await fetch(`${API_BASE}${query}`)
const result = await response.json()
return result
}
const fetchById = async (id: string) => {
const response = await fetch(`${API_BASE}/${id}`)
const result = await response.json()
return result
}
const create = async (data: Partial<T>) => {
const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
return result
}
const update = async (id: string, data: Partial<T>) => {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
return result
}
const remove = async (id: string) => {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
})
return response.ok
}
return {
fetchList,
fetchById,
create,
update,
remove,
}
}

View File

@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from './router'
import './style.css'
import './assets/styles/index.css'
import App from './App.vue'
const app = createApp(App)

95
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* 格式化工具函数
*/
/**
* 格式化日期
* @param date - 日期字符串或 Date 对象
* @param format - 格式化模板,默认 'YYYY-MM-DD'
*/
export function formatDate(date: string | Date, format: string = 'YYYY-MM-DD'): string {
if (!date) return ''
const d = typeof date === 'string' ? new Date(date) : date
if (isNaN(d.getTime())) return ''
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 格式化数字(千分位)
* @param num - 数字
*/
export function formatNumber(num: number | string): string {
if (num === null || num === undefined) return ''
const n = typeof num === 'string' ? parseFloat(num) : num
if (isNaN(n)) return ''
return n.toLocaleString()
}
/**
* 格式化文件大小
* @param bytes - 字节数
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`
}
/**
* 截断文本
* @param text - 文本
* @param maxLength - 最大长度
* @param suffix - 后缀,默认 '...'
*/
export function truncate(text: string, maxLength: number, suffix: string = '...'): string {
if (!text || text.length <= maxLength) return text
return text.slice(0, maxLength) + suffix
}
/**
* 首字母大写
* @param text - 文本
*/
export function capitalize(text: string): string {
if (!text) return ''
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()
}
/**
* 格式化时长(秒)
* @param seconds - 秒数
*/
export function formatDuration(seconds: number): string {
if (!seconds || seconds < 0) return '0s'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const parts: string[] = []
if (h > 0) parts.push(`${h}h`)
if (m > 0) parts.push(`${m}m`)
if (s > 0 || parts.length === 0) parts.push(`${s}s`)
return parts.join(' ')
}

128
web/src/utils/validate.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 校验工具函数
*/
/**
* 校验邮箱
*/
export function isEmail(value: string): boolean {
const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return reg.test(value)
}
/**
* 校验手机号(中国大陆)
*/
export function isPhone(value: string): boolean {
const reg = /^1[3-9]\d{9}$/
return reg.test(value)
}
/**
* 校验 URL
*/
export function isUrl(value: string): boolean {
try {
new URL(value)
return true
} catch {
return false
}
}
/**
* 校验 IP 地址
*/
export function isIP(value: string): boolean {
const reg = /^(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$/
return reg.test(value)
}
/**
* 校验端口号
*/
export function isPort(value: string | number): boolean {
const port = typeof value === 'string' ? parseInt(value) : value
return !isNaN(port) && port >= 1 && port <= 65535
}
/**
* 校验必填
*/
export function isRequired(value: any): boolean {
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
return true
}
/**
* 校验最小长度
*/
export function minLength(value: string, min: number): boolean {
return value.length >= min
}
/**
* 校验最大长度
*/
export function maxLength(value: string, max: number): boolean {
return value.length <= max
}
/**
* 校验数值范围
*/
export function inRange(value: number, min: number, max: number): boolean {
return value >= min && value <= max
}
/**
* 校验密码强度
* @returns 0-4, 0=无, 1=弱, 2=中, 3=强, 4=很强
*/
export function passwordStrength(password: string): number {
if (!password) return 0
let strength = 0
// 长度
if (password.length >= 8) strength++
if (password.length >= 12) strength++
// 字符类型
if (/[a-z]/.test(password)) strength++
if (/[A-Z]/.test(password)) strength++
if (/[0-9]/.test(password)) strength++
if (/[^a-zA-Z0-9]/.test(password)) strength++
return Math.min(strength, 4)
}
/**
* 校验对象
*/
export interface ValidationRule {
validator: (value: any) => boolean
message: string
}
export interface ValidationResult {
valid: boolean
errors: string[]
}
export function validate(value: any, rules: ValidationRule[]): ValidationResult {
const errors: string[] = []
for (const rule of rules) {
if (!rule.validator(value)) {
errors.push(rule.message)
}
}
return {
valid: errors.length === 0,
errors,
}
}

View File

@@ -1,679 +1,54 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
interface Database {
id: string
name: string
description: string
db_type: string
host: string
port: number
username: string
password: string
database: string
table_count: number
created_at: string
updated_at: string
}
// 表信息
interface TableInfo {
name: string
ddl: string
columns: ColumnInfo[]
table_comment?: string
}
// 字段信息
interface ColumnInfo {
name: string
type: string
comment: string
mapped_name: string
column_type?: string
is_nullable?: string
default_value?: string
column_key?: string
}
// 解析 DDL 获取列信息
function parseDDLColumns(ddl: string): ColumnInfo[] {
const columns: ColumnInfo[] = []
if (!ddl) return columns
// 移除 CREATE TABLE 语句,只保留括号内的内容
const match = ddl.match(/\(([\s\S]*)\)\s*.*$/m)
if (!match) return columns
const body = match[1]
// 按换行或逗号分割列定义(处理多行的情况)
const lines = body.split(/,\s*(?=`\w+`|\s*$)/)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('PRIMARY KEY') || trimmed.startsWith('KEY ') ||
trimmed.startsWith('UNIQUE KEY') || trimmed.startsWith('FULLTEXT') ||
trimmed.startsWith('CONSTRAINT') || trimmed.startsWith('FOREIGN KEY') ||
trimmed.startsWith('ENGINE') || trimmed.startsWith('CHARSET') || trimmed.startsWith(')')) {
continue
}
// 解析列名(支持反引号和单引号)
const colNameMatch = trimmed.match(/^`?(\w+)`?\s+/)
if (!colNameMatch) continue
const name = colNameMatch[1]
// 提取列定义剩余部分
const rest = trimmed.substring(colNameMatch[0].length)
// 提取数据类型(到 NOT NULL / DEFAULT / COMMENT 之前)
const typeMatch = rest.match(/^([^\s]+(?:\s*\([^\)]+\))?)/)
const type = typeMatch ? typeMatch[1] : ''
// 提取 COMMENT
const commentMatch = trimmed.match(/COMMENT\s+['"]([^'"]*)['"]/i)
const comment = commentMatch ? commentMatch[1] : ''
// 提取默认值
const defaultMatch = trimmed.match(/DEFAULT\s+([^\s,]+)/i)
const defaultValue = defaultMatch ? defaultMatch[1] : ''
// 判断是否可空
const isNullable = trimmed.includes('NOT NULL') ? 'NO' : 'YES'
// 判断是否是主键
const isPrimaryKey = trimmed.includes('PRIMARY KEY')
columns.push({
name,
type,
comment,
mapped_name: '',
column_type: type,
is_nullable: isNullable,
default_value: defaultValue,
column_key: isPrimaryKey ? 'PRI' : '',
})
}
return columns
}
// 数据库数据
const databases = ref<Database[]>([])
const loading = ref(false)
// 编辑状态
const editingDb = ref<Database | null>(null)
const isEditing = ref(false)
const isCreating = ref(false)
const searchQuery = ref('')
// 表映射弹窗状态
const isMapping = ref(false)
const mappingDb = ref<Database | null>(null)
const tables = ref<TableInfo[]>([])
const tableLoading = ref(false)
const tableSearchQuery = ref('')
const selectedTables = ref<TableInfo[]>([]) // 选中的表列表
const tablePage = ref(1)
const tablePageSize = ref(10)
const currentTableIndex = ref(0)
// 搜索时重置分页
watch(tableSearchQuery, () => {
tablePage.value = 1
})
// 映射步骤1-选择表, 2-DDl和映射
const mappingStep = ref(1)
// 当前正在编辑的表
const selectedTable = computed(() => {
if (selectedTables.value.length === 0) return null
return selectedTables.value[currentTableIndex.value] || null
})
// 检查表是否被选中
const isTableSelected = (tableName: string) => {
return selectedTables.value.some(t => t.name === tableName)
}
// 切换表的选择状态
const toggleTableSelection = (table: TableInfo) => {
const index = selectedTables.value.findIndex(t => t.name === table.name)
if (index >= 0) {
selectedTables.value.splice(index, 1)
// 调整当前索引
if (currentTableIndex.value >= selectedTables.value.length) {
currentTableIndex.value = Math.max(0, selectedTables.value.length - 1)
}
} else {
selectedTables.value.push(table)
}
}
// 全选所有表
const selectAllTables = () => {
selectedTables.value = [...filteredTables()]
currentTableIndex.value = 0
}
// 清除所有选择
const clearAllTables = () => {
selectedTables.value = []
currentTableIndex.value = 0
}
// 上一张表
const prevTable = () => {
if (currentTableIndex.value > 0) {
currentTableIndex.value--
}
}
// 下一张表
const nextTable = () => {
if (currentTableIndex.value < selectedTables.value.length - 1) {
currentTableIndex.value++
}
}
// 过滤后的表
const filteredTables = () => {
let result = tables.value
if (tableSearchQuery.value) {
result = result.filter(t => t.name.toLowerCase().includes(tableSearchQuery.value.toLowerCase()))
}
return result
}
const paginatedTables = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
const end = start + tablePageSize.value
return filteredTables().slice(start, end)
})
const totalFilteredTables = computed(() => filteredTables().length)
// 关闭映射弹窗
const closeMapping = () => {
isMapping.value = false
mappingDb.value = null
tables.value = []
selectedTables.value = []
currentTableIndex.value = 0
mappingStep.value = 1
tableSearchQuery.value = ''
tablePage.value = 1
}
// 打开映射弹窗(从列表页面点击映射按钮)
const openMapping = async (db: Database) => {
mappingDb.value = db
tables.value = []
selectedTables.value = []
currentTableIndex.value = 0
mappingStep.value = 1
tableLoading.value = true
isMapping.value = true
try {
// 并行获取实时表结构和已保存的映射数据
const [checkRes, mappingRes] = await Promise.all([
fetch(`${API_BASE}/database/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
db_type: db.db_type,
host: db.host,
port: db.port,
username: db.username,
password: db.password,
database: db.database,
charset: 'utf8mb4',
}),
}),
// 如果是已存在的数据库,获取已保存的映射
db.id ? fetch(`${API_BASE}/sub-table/database/${db.id}`) : Promise.resolve(null),
])
if (!checkRes.ok) {
throw new Error(`HTTP ${checkRes.status}`)
}
const result = await checkRes.json()
// 获取已保存的映射数据
let savedMappings: any = {}
let savedTableNames: string[] = [] // 已保存的表名列表
if (mappingRes && mappingRes.ok) {
const mappingResult = await mappingRes.json()
// 后端返回的是 list 字段
const tablesList = mappingResult.list || mappingResult.tables || []
if (tablesList.length > 0) {
// 构建映射表: parent_table -> fields[]
for (const table of tablesList) {
savedMappings[table.parent_table] = table.fields || []
savedTableNames.push(table.parent_table)
}
}
}
if (result.success && result.tables) {
tables.value = result.tables.map((table: any) => {
const ddl = table.ddl || ''
// 获取该表已保存的字段映射
const savedFields = savedMappings[table.table_name] || []
// 如果有 columns 数据则使用,否则尝试从 DDL 解析
let columns: ColumnInfo[]
if (table.columns && table.columns.length > 0) {
columns = table.columns.map((col: any) => {
// 查找已保存的映射
const savedField = savedFields.find((f: any) => f.column_name === col.column_name)
return {
name: col.column_name,
type: col.data_type || col.column_type || '',
column_type: col.column_type || '',
comment: col.column_comment || '',
is_nullable: col.is_nullable || '',
default_value: col.default_value || '',
column_key: col.column_key || '',
mapped_name: savedField?.mapped_name || col.mapped_name || '',
}
})
} else if (ddl) {
// 从 DDL 解析列信息,并合并已保存的映射
columns = parseDDLColumns(ddl).map(col => {
const savedField = savedFields.find((f: any) => f.column_name === col.name)
return {
...col,
mapped_name: savedField?.mapped_name || '',
}
})
} else {
columns = []
}
return {
name: table.table_name,
table_comment: table.table_comment || '',
ddl,
columns,
}
})
// 恢复已选择的表
if (savedTableNames.length > 0) {
selectedTables.value = tables.value.filter(t => savedTableNames.includes(t.name))
} else {
selectedTables.value = []
}
currentTableIndex.value = 0
} else {
ElMessage.warning(result.message || '获取表结构失败')
}
} catch (error: any) {
console.error('获取表结构失败:', error)
ElMessage.error('获取表结构失败: ' + error.message)
} finally {
tableLoading.value = false
}
}
// 进入下一步(选择表后)
const goToStep2 = () => {
if (selectedTables.value.length > 0) {
mappingStep.value = 2
currentTableIndex.value = 0
}
}
// 返回上一步
const goToStep1 = () => {
mappingStep.value = 1
}
// 保存映射(创建或更新数据库 + 保存子表)
const saveMapping = async () => {
if (!mappingDb.value) {
ElMessage.warning('数据库信息不存在')
return
}
const isEditing = !!mappingDb.value.id
const subTablesData = selectedTables.value.map(table => ({
parent_table: table.name,
sub_table_name: table.table_comment || table.name,
sub_table_comment: table.table_comment || '',
fields: (table.columns || []).map(col => ({
column_name: col.name,
mapped_name: col.mapped_name || '', // 这里实际存的是 comment
})),
}))
try {
let response: Response
if (isEditing) {
// 编辑模式:调用更新接口
response = await fetch(`${API_BASE}/database/${mappingDb.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: mappingDb.value.name,
description: mappingDb.value.description,
db_type: mappingDb.value.db_type,
host: mappingDb.value.host,
port: mappingDb.value.port,
username: mappingDb.value.username,
password: mappingDb.value.password,
database: mappingDb.value.database,
sub_tables: subTablesData,
}),
})
} else {
// 新建模式:调用创建接口
response = await fetch(`${API_BASE}/database/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: mappingDb.value.name,
description: mappingDb.value.description,
db_type: mappingDb.value.db_type,
host: mappingDb.value.host,
port: mappingDb.value.port,
username: mappingDb.value.username,
password: mappingDb.value.password,
database: mappingDb.value.database,
sub_tables: subTablesData,
}),
})
}
if (response.ok) {
const result = await response.json()
if (!isEditing) {
databases.value.push(result)
}
closeMapping()
ElMessage.success(isEditing ? 'Mapping updated successfully' : 'Database created successfully')
// 刷新列表
fetchDatabases()
} else {
const errorData = await response.json()
ElMessage.error(errorData.error || (isEditing ? 'Failed to update mapping' : 'Failed to create database'))
}
} catch (error: any) {
console.error('保存失败:', error)
ElMessage.error('保存失败: ' + error.message)
}
}
const dbTypes = ['MySQL', 'PostgreSQL', 'MongoDB', 'Redis']
// API 基础 URL
const API_BASE = 'http://localhost:8082'
// 获取列表数据
const fetchDatabases = async () => {
loading.value = true
try {
const response = await fetch(`${API_BASE}/database/list`)
if (!response.ok) {
throw new Error('Failed to fetch databases')
}
const data = await response.json()
databases.value = data.list || []
} catch (error) {
console.error('Failed to fetch databases:', error)
ElMessage.error('Failed to load databases')
databases.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDatabases()
})
// 新建 Database 表单
const newDbForm = ref({
name: '',
db_type: 'MySQL',
description: '',
host: 'localhost',
port: 3306,
username: 'root',
password: '',
database: '',
})
// 打开新建弹窗
const openCreate = () => {
newDbForm.value = {
name: '',
db_type: 'MySQL',
description: '',
host: 'localhost',
port: 3306,
username: 'root',
password: '',
database: '',
}
isCreating.value = true
}
// 关闭新建弹窗
const closeCreate = () => {
isCreating.value = false
}
// 测试连接并获取表列表
const testConnect = async () => {
try {
const response = await fetch(`${API_BASE}/database/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
db_type: newDbForm.value.db_type,
host: newDbForm.value.host,
port: newDbForm.value.port,
username: newDbForm.value.username,
password: newDbForm.value.password,
database: newDbForm.value.database,
charset: 'utf8mb4',
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
if (result.success) {
// 连接成功,显示表映射弹窗
const dbInfo: Database = {
id: '',
name: newDbForm.value.name || 'Untitled Database',
description: newDbForm.value.description,
db_type: newDbForm.value.db_type,
host: newDbForm.value.host,
port: newDbForm.value.port,
username: newDbForm.value.username,
password: newDbForm.value.password,
database: newDbForm.value.database,
table_count: 0,
created_at: '',
updated_at: '',
}
// 将后端返回的表数据映射到前端格式
if (result.tables && result.tables.length > 0) {
tables.value = result.tables.map((table: any) => {
const ddl = table.ddl || ''
// 如果有 columns 数据则使用,否则尝试从 DDL 解析
let columns: ColumnInfo[]
if (table.columns && table.columns.length > 0) {
columns = table.columns.map((col: any) => ({
name: col.column_name,
type: col.data_type || col.column_type || '',
column_type: col.column_type || '',
comment: col.column_comment || '',
is_nullable: col.is_nullable || '',
default_value: col.default_value || '',
column_key: col.column_key || '',
mapped_name: '',
}))
} else if (ddl) {
// 从 DDL 解析列信息
columns = parseDDLColumns(ddl)
} else {
columns = []
}
return {
name: table.table_name,
table_comment: table.table_comment || '',
ddl,
columns,
}
})
}
// 默认不选中任何表
selectedTables.value = []
currentTableIndex.value = 0
mappingStep.value = 1
mappingDb.value = dbInfo
isMapping.value = true
isCreating.value = false // 关闭创建弹窗
ElMessage.success('Connection successful! Please select tables to map.')
} else {
ElMessage.error(result.message || 'Connection failed')
}
} catch (error: any) {
console.error('Connection test failed:', error)
ElMessage.error('Connection test failed: ' + error.message)
}
}
// 编辑表单数据
const editForm = ref({
name: '',
db_type: '',
description: '',
host: '',
port: 0,
username: '',
password: '',
database: '',
})
// 打开编辑弹窗
const openEdit = (db: Database) => {
editingDb.value = db
editForm.value = {
name: db.name,
db_type: db.db_type,
description: db.description,
host: db.host,
port: db.port,
username: db.username,
password: db.password,
database: db.database,
}
isEditing.value = true
}
// 保存编辑
const saveEdit = async () => {
if (editingDb.value) {
try {
const response = await fetch(`${API_BASE}/database/${editingDb.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: editForm.value.name,
description: editForm.value.description,
db_type: editForm.value.db_type,
host: editForm.value.host,
port: editForm.value.port,
username: editForm.value.username,
password: editForm.value.password,
database: editForm.value.database,
}),
})
if (response.ok) {
const updatedDb = await response.json()
const index = databases.value.findIndex(d => d.id === editingDb.value!.id)
if (index !== -1) {
databases.value[index] = updatedDb
}
ElMessage.success('Database updated successfully')
} else {
const errorData = await response.json()
ElMessage.error(errorData.error || 'Failed to update database')
}
} catch (error) {
console.error('Failed to update database:', error)
ElMessage.error('Failed to update database')
}
}
isEditing.value = false
}
// 取消编辑
const cancelEdit = () => {
isEditing.value = false
editingDb.value = null
}
// 删除 Database
const deleteDb = async (id: string) => {
try {
const response = await fetch(`${API_BASE}/database/${id}`, {
method: 'DELETE'
})
if (response.ok) {
databases.value = databases.value.filter(d => d.id !== id)
ElMessage.success('Database deleted successfully')
} else {
ElMessage.error('Failed to delete database')
}
} catch (error) {
console.error('Failed to delete database:', error)
ElMessage.error('Failed to delete database')
}
}
// 过滤后的 Databases
const filteredDatabases = () => {
return databases.value.filter(db => {
const matchSearch = !searchQuery.value ||
db.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
db.db_type.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
db.host.toLowerCase().includes(searchQuery.value.toLowerCase())
return matchSearch
})
}
import { useDatabase } from './database/useDatabase'
import './database/database.css'
const {
// State
databases,
loading,
editingDb,
isEditing,
isCreating,
searchQuery,
isMapping,
mappingDb,
tables,
tableLoading,
tableSearchQuery,
selectedTables,
tablePage,
tablePageSize,
currentTableIndex,
mappingStep,
newDbForm,
editForm,
dbTypes,
// Computed
selectedTable,
filteredDatabases,
paginatedTables,
totalFilteredTables,
// Methods
isTableSelected,
toggleTableSelection,
selectAllTables,
clearAllTables,
prevTable,
nextTable,
fetchDatabases,
openCreate,
closeCreate,
closeMapping,
testConnect,
openMapping,
goToStep2,
goToStep1,
saveMapping,
openEdit,
saveEdit,
cancelEdit,
deleteDb,
} = useDatabase()
</script>
<template>
@@ -979,6 +354,57 @@ const filteredDatabases = () => {
>
</div>
</div>
<!-- 连接凭据 - 当选择Neo4j时显示 -->
<div v-if="newDbForm.db_type === 'Neo4j'" class="space-y-4 pt-2">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
v-model="newDbForm.host"
type="text"
placeholder="localhost"
class="input-field"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
v-model="newDbForm.port"
type="number"
placeholder="7687"
class="input-field"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Database</label>
<input
v-model="newDbForm.database"
type="text"
placeholder="neo4j (default)"
class="input-field"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">User</label>
<input
v-model="newDbForm.username"
type="text"
placeholder="neo4j"
class="input-field"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input
v-model="newDbForm.password"
type="password"
placeholder="Enter password..."
class="input-field"
>
</div>
</div>
</div>
<!-- 底部操作栏 -->

View File

@@ -0,0 +1,75 @@
/* Database View Styles */
/* 搜索输入框 */
.search-input {
@apply bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 pl-10 text-white placeholder-gray-500 focus:outline-none focus:border-primary-cyan focus:ring-1 focus:ring-primary-cyan/30 transition-all;
}
/* 输入框 */
.input-field {
@apply w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-cyan focus:ring-1 focus:ring-primary-cyan/30 transition-all;
}
/* 主要按钮 */
.btn-primary {
@apply px-4 py-2 rounded-lg bg-gradient-to-r from-primary-cyan to-blue-500 text-white hover:from-cyan-500 hover:to-blue-600 transition-all flex items-center gap-2;
}
/* 次要按钮 */
.btn-secondary {
@apply px-4 py-2 rounded-lg bg-dark-600 text-gray-300 hover:bg-dark-500 transition-all flex items-center gap-2;
}
/* 图标按钮 */
.btn-icon {
@apply p-2 rounded-lg hover:bg-dark-600 transition-colors;
}
/* 表格行 */
.table-row {
@apply border-b border-dark-600 hover:bg-dark-600/50 transition-colors;
}
/* 空状态 */
.empty-state {
@apply py-16 text-center text-gray-500;
}
.empty-state-icon {
@apply text-4xl mb-4 block;
}
/* 表格复选框 */
.table-checkbox {
@apply flex-shrink-0;
}
/* 弹窗遮罩 */
.modal-overlay {
animation: fadeIn 0.2s ease-out;
}
/* 弹窗内容 */
.modal-content {
animation: slideUp 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,45 @@
// Database 相关类型定义
export interface Database {
id: string
name: string
description: string
db_type: string
host: string
port: number
username: string
password: string
database: string
table_count: number
created_at: string
updated_at: string
}
export interface TableInfo {
name: string
ddl: string
columns: ColumnInfo[]
table_comment?: string
}
export interface ColumnInfo {
name: string
type: string
comment: string
mapped_name: string
column_type?: string
is_nullable?: string
default_value?: string
column_key?: string
}
export interface DbForm {
name: string
db_type: string
description: string
host: string
port: number
username: string
password: string
database: string
}

View File

@@ -0,0 +1,683 @@
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { Database, TableInfo, ColumnInfo, DbForm } from './types'
// API 基础 URL
const API_BASE = 'http://localhost:8082'
// 解析 DDL 获取列信息
function parseDDLColumns(ddl: string): ColumnInfo[] {
const columns: ColumnInfo[] = []
if (!ddl) return columns
// 移除 CREATE TABLE 语句,只保留括号内的内容
const match = ddl.match(/\(([\s\S]*)\)\s*.*$/m)
if (!match) return columns
const body = match[1]
// 按换行或逗号分割列定义(处理多行的情况)
const lines = body.split(/,\s*(?=`\w+`|\s*$)/)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('PRIMARY KEY') || trimmed.startsWith('KEY ') ||
trimmed.startsWith('UNIQUE KEY') || trimmed.startsWith('FULLTEXT') ||
trimmed.startsWith('CONSTRAINT') || trimmed.startsWith('FOREIGN KEY') ||
trimmed.startsWith('ENGINE') || trimmed.startsWith('CHARSET') || trimmed.startsWith(')')) {
continue
}
// 解析列名(支持反引号和单引号)
const colNameMatch = trimmed.match(/^`?(\w+)`?\s+/)
if (!colNameMatch) continue
const name = colNameMatch[1]
// 提取列定义剩余部分
const rest = trimmed.substring(colNameMatch[0].length)
// 提取数据类型(到 NOT NULL / DEFAULT / COMMENT 之前)
const typeMatch = rest.match(/^([^\s]+(?:\s*\([^\)]+\))?)/)
const type = typeMatch ? typeMatch[1] : ''
// 提取 COMMENT
const commentMatch = trimmed.match(/COMMENT\s+['"]([^'"]*)['"]/i)
const comment = commentMatch ? commentMatch[1] : ''
// 提取默认值
const defaultMatch = trimmed.match(/DEFAULT\s+([^\s,]+)/i)
const defaultValue = defaultMatch ? defaultMatch[1] : ''
// 判断是否可空
const isNullable = trimmed.includes('NOT NULL') ? 'NO' : 'YES'
// 判断是否是主键
const isPrimaryKey = trimmed.includes('PRIMARY KEY')
columns.push({
name,
type,
comment,
mapped_name: '',
column_type: type,
is_nullable: isNullable,
default_value: defaultValue,
column_key: isPrimaryKey ? 'PRI' : '',
})
}
return columns
}
export function useDatabase() {
// 数据库数据
const databases = ref<Database[]>([])
const loading = ref(false)
// 编辑状态
const editingDb = ref<Database | null>(null)
const isEditing = ref(false)
const isCreating = ref(false)
const searchQuery = ref('')
// 表映射弹窗状态
const isMapping = ref(false)
const mappingDb = ref<Database | null>(null)
const tables = ref<TableInfo[]>([])
const tableLoading = ref(false)
const tableSearchQuery = ref('')
const selectedTables = ref<TableInfo[]>([])
const tablePage = ref(1)
const tablePageSize = ref(10)
const currentTableIndex = ref(0)
// 映射步骤1-选择表, 2-DDl和映射
const mappingStep = ref(1)
// 新建表单
const newDbForm = ref<DbForm>({
name: '',
db_type: 'MySQL',
description: '',
host: 'localhost',
port: 3306,
username: 'root',
password: '',
database: '',
})
// 编辑表单
const editForm = ref<DbForm>({
name: '',
db_type: '',
description: '',
host: '',
port: 0,
username: '',
password: '',
database: '',
})
const dbTypes = ['MySQL', 'Neo4j']
// 监听数据库类型变化,自动设置默认端口
watch(() => newDbForm.value.db_type, (newType) => {
if (newType === 'Neo4j') {
newDbForm.value.port = 7687
newDbForm.value.username = 'neo4j'
} else if (newType === 'MySQL') {
newDbForm.value.port = 3306
newDbForm.value.username = 'root'
}
})
// 搜索时重置分页
watch(tableSearchQuery, () => {
tablePage.value = 1
})
// 当前正在编辑的表
const selectedTable = computed(() => {
if (selectedTables.value.length === 0) return null
return selectedTables.value[currentTableIndex.value] || null
})
// 过滤后的表
const filteredTables = () => {
let result = tables.value
if (tableSearchQuery.value) {
result = result.filter(t => t.name.toLowerCase().includes(tableSearchQuery.value.toLowerCase()))
}
return result
}
const paginatedTables = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
const end = start + tablePageSize.value
return filteredTables().slice(start, end)
})
const totalFilteredTables = computed(() => filteredTables().length)
// 过滤后的 Databases
const filteredDatabases = () => {
return databases.value.filter(db => {
const matchSearch = !searchQuery.value ||
db.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
db.db_type.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
db.host.toLowerCase().includes(searchQuery.value.toLowerCase())
return matchSearch
})
}
// 检查表是否被选中
const isTableSelected = (tableName: string) => {
return selectedTables.value.some(t => t.name === tableName)
}
// 切换表的选择状态
const toggleTableSelection = (table: TableInfo) => {
const index = selectedTables.value.findIndex(t => t.name === table.name)
if (index >= 0) {
selectedTables.value.splice(index, 1)
if (currentTableIndex.value >= selectedTables.value.length) {
currentTableIndex.value = Math.max(0, selectedTables.value.length - 1)
}
} else {
selectedTables.value.push(table)
}
}
// 全选所有表
const selectAllTables = () => {
selectedTables.value = [...filteredTables()]
currentTableIndex.value = 0
}
// 清除所有选择
const clearAllTables = () => {
selectedTables.value = []
currentTableIndex.value = 0
}
// 上一张表
const prevTable = () => {
if (currentTableIndex.value > 0) {
currentTableIndex.value--
}
}
// 下一张表
const nextTable = () => {
if (currentTableIndex.value < selectedTables.value.length - 1) {
currentTableIndex.value++
}
}
// 获取列表数据
const fetchDatabases = async () => {
loading.value = true
try {
const response = await fetch(`${API_BASE}/database/list`)
if (!response.ok) {
throw new Error('Failed to fetch databases')
}
const data = await response.json()
databases.value = data.list || []
} catch (error) {
console.error('Failed to fetch databases:', error)
ElMessage.error('Failed to load databases')
databases.value = []
} finally {
loading.value = false
}
}
// 打开新建弹窗
const openCreate = () => {
newDbForm.value = {
name: '',
db_type: 'MySQL',
description: '',
host: 'localhost',
port: 3306,
username: 'root',
password: '',
database: '',
}
isCreating.value = true
}
// 关闭新建弹窗
const closeCreate = () => {
isCreating.value = false
}
// 关闭映射弹窗
const closeMapping = () => {
isMapping.value = false
mappingDb.value = null
tables.value = []
selectedTables.value = []
currentTableIndex.value = 0
mappingStep.value = 1
tableSearchQuery.value = ''
tablePage.value = 1
}
// 测试连接并获取表列表
const testConnect = async () => {
try {
const response = await fetch(`${API_BASE}/database/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
db_type: newDbForm.value.db_type,
host: newDbForm.value.host,
port: newDbForm.value.port,
username: newDbForm.value.username,
password: newDbForm.value.password,
database: newDbForm.value.database,
charset: 'utf8mb4',
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
if (result.success) {
const dbInfo: Database = {
id: '',
name: newDbForm.value.name || 'Untitled Database',
description: newDbForm.value.description,
db_type: newDbForm.value.db_type,
host: newDbForm.value.host,
port: newDbForm.value.port,
username: newDbForm.value.username,
password: newDbForm.value.password,
database: newDbForm.value.database,
table_count: 0,
created_at: '',
updated_at: '',
}
if (result.tables && result.tables.length > 0) {
tables.value = result.tables.map((table: any) => {
const ddl = table.ddl || ''
let columns: ColumnInfo[]
if (table.columns && table.columns.length > 0) {
columns = table.columns.map((col: any) => ({
name: col.column_name,
type: col.data_type || col.column_type || '',
column_type: col.column_type || '',
comment: col.column_comment || '',
is_nullable: col.is_nullable || '',
default_value: col.default_value || '',
column_key: col.column_key || '',
mapped_name: '',
}))
} else if (ddl) {
columns = parseDDLColumns(ddl)
} else {
columns = []
}
return {
name: table.table_name,
table_comment: table.table_comment || '',
ddl,
columns,
}
})
}
selectedTables.value = []
currentTableIndex.value = 0
mappingStep.value = 1
mappingDb.value = dbInfo
isMapping.value = true
isCreating.value = false
ElMessage.success('Connection successful! Please select tables to map.')
} else {
ElMessage.error(result.message || 'Connection failed')
}
} catch (error: any) {
console.error('Connection test failed:', error)
ElMessage.error('Connection test failed: ' + error.message)
}
}
// 打开映射弹窗
const openMapping = async (db: Database) => {
mappingDb.value = db
tables.value = []
selectedTables.value = []
currentTableIndex.value = 0
mappingStep.value = 1
tableLoading.value = true
isMapping.value = true
try {
const [checkRes, mappingRes] = await Promise.all([
fetch(`${API_BASE}/database/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
db_type: db.db_type,
host: db.host,
port: db.port,
username: db.username,
password: db.password,
database: db.database,
charset: 'utf8mb4',
}),
}),
db.id ? fetch(`${API_BASE}/sub-table/database/${db.id}`) : Promise.resolve(null),
])
if (!checkRes.ok) {
throw new Error(`HTTP ${checkRes.status}`)
}
const result = await checkRes.json()
let savedMappings: any = {}
let savedTableNames: string[] = []
if (mappingRes && mappingRes.ok) {
const mappingResult = await mappingRes.json()
const tablesList = mappingResult.list || mappingResult.tables || []
for (const table of tablesList) {
savedMappings[table.parent_table] = table.fields || []
savedTableNames.push(table.parent_table)
}
}
if (result.success && result.tables) {
tables.value = result.tables.map((table: any) => {
const ddl = table.ddl || ''
const savedFields = savedMappings[table.table_name] || []
let columns: ColumnInfo[]
if (table.columns && table.columns.length > 0) {
columns = table.columns.map((col: any) => {
const savedField = savedFields.find((f: any) => f.column_name === col.column_name)
return {
name: col.column_name,
type: col.data_type || col.column_type || '',
column_type: col.column_type || '',
comment: col.column_comment || '',
is_nullable: col.is_nullable || '',
default_value: col.default_value || '',
column_key: col.column_key || '',
mapped_name: savedField?.mapped_name || col.mapped_name || '',
}
})
} else if (ddl) {
columns = parseDDLColumns(ddl).map(col => {
const savedField = savedFields.find((f: any) => f.column_name === col.name)
return {
...col,
mapped_name: savedField?.mapped_name || '',
}
})
} else {
columns = []
}
return {
name: table.table_name,
table_comment: table.table_comment || '',
ddl,
columns,
}
})
if (savedTableNames.length > 0) {
selectedTables.value = tables.value.filter(t => savedTableNames.includes(t.name))
} else {
selectedTables.value = []
}
currentTableIndex.value = 0
} else {
ElMessage.warning(result.message || '获取表结构失败')
}
} catch (error: any) {
console.error('获取表结构失败:', error)
ElMessage.error('获取表结构失败: ' + error.message)
} finally {
tableLoading.value = false
}
}
// 进入下一步
const goToStep2 = () => {
if (selectedTables.value.length > 0) {
mappingStep.value = 2
currentTableIndex.value = 0
}
}
// 返回上一步
const goToStep1 = () => {
mappingStep.value = 1
}
// 保存映射
const saveMapping = async () => {
if (!mappingDb.value) {
ElMessage.warning('数据库信息不存在')
return
}
const isEditing = !!mappingDb.value.id
const subTablesData = selectedTables.value.map(table => ({
parent_table: table.name,
sub_table_name: table.table_comment || table.name,
sub_table_comment: table.table_comment || '',
fields: (table.columns || []).map(col => ({
column_name: col.name,
mapped_name: col.mapped_name || '',
})),
}))
try {
let response: Response
if (isEditing) {
response = await fetch(`${API_BASE}/database/${mappingDb.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: mappingDb.value.name,
description: mappingDb.value.description,
db_type: mappingDb.value.db_type,
host: mappingDb.value.host,
port: mappingDb.value.port,
username: mappingDb.value.username,
password: mappingDb.value.password,
database: mappingDb.value.database,
sub_tables: subTablesData,
}),
})
} else {
response = await fetch(`${API_BASE}/database/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: mappingDb.value.name,
description: mappingDb.value.description,
db_type: mappingDb.value.db_type,
host: mappingDb.value.host,
port: mappingDb.value.port,
username: mappingDb.value.username,
password: mappingDb.value.password,
database: mappingDb.value.database,
sub_tables: subTablesData,
}),
})
}
if (response.ok) {
const result = await response.json()
if (!isEditing) {
databases.value.push(result)
}
closeMapping()
ElMessage.success(isEditing ? 'Mapping updated successfully' : 'Database created successfully')
fetchDatabases()
} else {
const errorData = await response.json()
ElMessage.error(errorData.error || (isEditing ? 'Failed to update mapping' : 'Failed to create database'))
}
} catch (error: any) {
console.error('保存失败:', error)
ElMessage.error('保存失败: ' + error.message)
}
}
// 打开编辑弹窗
const openEdit = (db: Database) => {
editingDb.value = db
editForm.value = {
name: db.name,
db_type: db.db_type,
description: db.description,
host: db.host,
port: db.port,
username: db.username,
password: db.password,
database: db.database,
}
isEditing.value = true
}
// 保存编辑
const saveEdit = async () => {
if (editingDb.value) {
try {
const response = await fetch(`${API_BASE}/database/${editingDb.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: editForm.value.name,
description: editForm.value.description,
db_type: editForm.value.db_type,
host: editForm.value.host,
port: editForm.value.port,
username: editForm.value.username,
password: editForm.value.password,
database: editForm.value.database,
}),
})
if (response.ok) {
const updatedDb = await response.json()
const index = databases.value.findIndex(d => d.id === editingDb.value!.id)
if (index !== -1) {
databases.value[index] = updatedDb
}
ElMessage.success('Database updated successfully')
} else {
const errorData = await response.json()
ElMessage.error(errorData.error || 'Failed to update database')
}
} catch (error) {
console.error('Failed to update database:', error)
ElMessage.error('Failed to update database')
}
}
isEditing.value = false
}
// 取消编辑
const cancelEdit = () => {
isEditing.value = false
editingDb.value = null
}
// 删除 Database
const deleteDb = async (id: string) => {
try {
const response = await fetch(`${API_BASE}/database/${id}`, {
method: 'DELETE'
})
if (response.ok) {
databases.value = databases.value.filter(d => d.id !== id)
ElMessage.success('Database deleted successfully')
} else {
ElMessage.error('Failed to delete database')
}
} catch (error) {
console.error('Failed to delete database:', error)
ElMessage.error('Failed to delete database')
}
}
// 初始化
onMounted(() => {
fetchDatabases()
})
return {
// State
databases,
loading,
editingDb,
isEditing,
isCreating,
searchQuery,
isMapping,
mappingDb,
tables,
tableLoading,
tableSearchQuery,
selectedTables,
tablePage,
tablePageSize,
currentTableIndex,
mappingStep,
newDbForm,
editForm,
dbTypes,
// Computed
selectedTable,
filteredDatabases,
paginatedTables,
totalFilteredTables,
// Methods
isTableSelected,
toggleTableSelection,
selectAllTables,
clearAllTables,
prevTable,
nextTable,
fetchDatabases,
openCreate,
closeCreate,
closeMapping,
testConnect,
openMapping,
goToStep2,
goToStep1,
saveMapping,
openEdit,
saveEdit,
cancelEdit,
deleteDb,
}
}