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:
69
web/src/assets/styles/base/reset.css
Normal file
69
web/src/assets/styles/base/reset.css
Normal 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;
|
||||
}
|
||||
65
web/src/assets/styles/base/variables.css
Normal file
65
web/src/assets/styles/base/variables.css
Normal 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;
|
||||
}
|
||||
127
web/src/assets/styles/components/button.css
Normal file
127
web/src/assets/styles/components/button.css
Normal 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;
|
||||
}
|
||||
102
web/src/assets/styles/components/form.css
Normal file
102
web/src/assets/styles/components/form.css
Normal 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);
|
||||
}
|
||||
88
web/src/assets/styles/components/modal.css
Normal file
88
web/src/assets/styles/components/modal.css
Normal 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);
|
||||
}
|
||||
}
|
||||
91
web/src/assets/styles/components/table.css
Normal file
91
web/src/assets/styles/components/table.css
Normal 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);
|
||||
}
|
||||
}
|
||||
198
web/src/assets/styles/element/index.css
Normal file
198
web/src/assets/styles/element/index.css
Normal 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;
|
||||
}
|
||||
295
web/src/assets/styles/index.css
Normal file
295
web/src/assets/styles/index.css
Normal 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;
|
||||
}
|
||||
125
web/src/composables/useApi.ts
Normal file
125
web/src/composables/useApi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
95
web/src/utils/format.ts
Normal 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
128
web/src/utils/validate.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
|
||||
75
web/src/views/database/database.css
Normal file
75
web/src/views/database/database.css
Normal 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);
|
||||
}
|
||||
}
|
||||
45
web/src/views/database/types.ts
Normal file
45
web/src/views/database/types.ts
Normal 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
|
||||
}
|
||||
683
web/src/views/database/useDatabase.ts
Normal file
683
web/src/views/database/useDatabase.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user