feat(dbms): 支持MySQL数据库连接管理功能
- 添加MySQL数据库类型的连接支持,默认端口为3306,用户名为root - 实现Oracle与MySQL连接类型的差异化表单验证逻辑 - 更新连接树组件以区分不同数据库类型的显示方式 - 集成备份恢复任务面板,支持双标签页切换功能 - 优化表格排序功能,增强连接列表的可操作性 - 调整任务面板布局,改进用户体验和界面交互 - 新增连接对话框中数据库类型的选择与初始化逻辑
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
docs/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
out/
|
out/
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill'
|
|||||||
|
|
||||||
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
silentStatusError?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -109,6 +110,10 @@ class RequestHttp {
|
|||||||
}
|
}
|
||||||
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
|
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
|
||||||
if (data.code && data.code !== ResultEnum.SUCCESS) {
|
if (data.code && data.code !== ResultEnum.SUCCESS) {
|
||||||
|
if ((response.config as CustomAxiosRequestConfig).silentStatusError) {
|
||||||
|
return Promise.reject(data)
|
||||||
|
}
|
||||||
|
|
||||||
if (data.message.includes('&')) {
|
if (data.message.includes('&')) {
|
||||||
let formattedMessage = data.message.split('&').join('<br>')
|
let formattedMessage = data.message.split('&').join('<br>')
|
||||||
if (data.message.includes(':')) {
|
if (data.message.includes(':')) {
|
||||||
@@ -147,7 +152,9 @@ class RequestHttp {
|
|||||||
if (error.message.indexOf('timeout') !== -1) ElMessage.error('请求超时!请您稍后重试')
|
if (error.message.indexOf('timeout') !== -1) ElMessage.error('请求超时!请您稍后重试')
|
||||||
if (error.message.indexOf('Network Error') !== -1) ElMessage.error('网络错误!请您稍后重试')
|
if (error.message.indexOf('Network Error') !== -1) ElMessage.error('网络错误!请您稍后重试')
|
||||||
// 根据服务器响应的错误状态码,做不同的处理
|
// 根据服务器响应的错误状态码,做不同的处理
|
||||||
if (response) checkStatus(response.status)
|
if (response && !(error.config as CustomAxiosRequestConfig | undefined)?.silentStatusError) {
|
||||||
|
checkStatus(response.status)
|
||||||
|
}
|
||||||
// 服务器结果都没有返回(可能服务器错误可能客户端断网),断网处理:可以跳转到断网页面
|
// 服务器结果都没有返回(可能服务器错误可能客户端断网),断网处理:可以跳转到断网页面
|
||||||
if (!window.navigator.onLine) router.replace('/500')
|
if (!window.navigator.onLine) router.replace('/500')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import http from '@/api'
|
import http from '@/api'
|
||||||
|
import type { CustomAxiosRequestConfig } from '@/api'
|
||||||
import type { Dbms } from '@/api/system/dbms/interface'
|
import type { Dbms } from '@/api/system/dbms/interface'
|
||||||
|
|
||||||
export const getDbmsOverview = () => {
|
export const getDbmsOverview = () => {
|
||||||
return http.get<Dbms.Overview>('/database/overview', {}, { loading: false })
|
return http.get<Dbms.Overview>('/database/overview', {}, { loading: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDbmsConnectionList = (params: Dbms.ConnectionListParams) => {
|
export const getDbmsConnectionList = (params: Dbms.ConnectionListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||||
return http.post<Dbms.ConnectionPageData>('/database/connections/list', params, { loading: false })
|
return http.post<Dbms.ConnectionPageData>('/database/connections/list', params, { loading: false, ...config })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addDbmsConnection = (params: Dbms.ConnectionPayload) => {
|
export const addDbmsConnection = (params: Dbms.ConnectionPayload) => {
|
||||||
@@ -25,24 +26,32 @@ export const testDbmsConnection = (params: Dbms.TestConnectionParams) => {
|
|||||||
return http.post<Dbms.TestConnectionResult>('/database/connections/test', params)
|
return http.post<Dbms.TestConnectionResult>('/database/connections/test', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDbmsTableList = (params: Dbms.TableListParams) => {
|
export const getDbmsTableList = (params: Dbms.TableListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||||
return http.post<Dbms.TableRecord[]>('/database/connections/tables', params)
|
return http.post<Dbms.TableRecord[]>('/database/connections/tables', params, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createDbmsBackupTask = (params: Dbms.CreateBackupParams) => {
|
export const createDbmsBackupTask = (params: Dbms.CreateBackupParams) => {
|
||||||
return http.post<Dbms.TaskCreateResult>('/database/backups/create', params)
|
return http.post<Dbms.TaskCreateResult>('/database/backups/create', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDbmsBackupTaskList = (params: Dbms.TaskListParams) => {
|
export const getDbmsBackupTaskList = (params: Dbms.TaskListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||||
return http.post<Dbms.TaskPageData>('/database/backups/tasks/list', params, { loading: false })
|
return http.post<Dbms.TaskPageData>('/database/backups/tasks/list', params, { loading: false, ...config })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDbmsBackupTaskStatus = (taskId: string) => {
|
export const getDbmsBackupTaskStatus = (taskId: string) => {
|
||||||
return http.get<Dbms.TaskRecord>('/database/backups/tasks/status', { taskId }, { loading: false })
|
return http.get<Dbms.TaskRecord>('/database/backups/tasks/status', { taskId }, { loading: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDbmsBackupFileList = (params: Dbms.FileListParams) => {
|
export const stopDbmsBackupTask = (params: Dbms.StopBackupTaskParams) => {
|
||||||
return http.post<Dbms.BackupFilePageData>('/database/backups/files/list', params, { loading: false })
|
return http.post<boolean>('/database/backups/tasks/stop', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restartDbmsBackupTask = (params: Dbms.RestartBackupTaskParams) => {
|
||||||
|
return http.post<Dbms.TaskCreateResult>('/database/backups/tasks/restart', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDbmsBackupFileList = (params: Dbms.FileListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||||
|
return http.post<Dbms.BackupFilePageData>('/database/backups/files/list', params, { loading: false, ...config })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createDbmsRestoreTask = (params: Dbms.CreateRestoreParams) => {
|
export const createDbmsRestoreTask = (params: Dbms.CreateRestoreParams) => {
|
||||||
@@ -53,6 +62,10 @@ export const getDbmsRestoreTaskStatus = (taskId: string) => {
|
|||||||
return http.get<Dbms.TaskRecord>('/database/restores/tasks/status', { taskId }, { loading: false })
|
return http.get<Dbms.TaskRecord>('/database/restores/tasks/status', { taskId }, { loading: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getDbmsRestoreTaskList = (params: Dbms.TaskListParams, config: Partial<CustomAxiosRequestConfig> = {}) => {
|
||||||
|
return http.post<Dbms.TaskPageData>('/database/restores/tasks/list', params, { loading: false, ...config })
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteDbmsBackupFile = (params: Dbms.DeleteBackupFileParams) => {
|
export const deleteDbmsBackupFile = (params: Dbms.DeleteBackupFileParams) => {
|
||||||
return http.post<boolean>('/database/delete/backup-file', params)
|
return http.post<boolean>('/database/delete/backup-file', params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ export namespace Dbms {
|
|||||||
dbType: DbType
|
dbType: DbType
|
||||||
host: string
|
host: string
|
||||||
port: number
|
port: number
|
||||||
connectType: ConnectType
|
connectType?: ConnectType | null
|
||||||
serviceName?: string | null
|
serviceName?: string | null
|
||||||
sid?: string | null
|
sid?: string | null
|
||||||
|
databaseName?: string | null
|
||||||
schemaName?: string | null
|
schemaName?: string | null
|
||||||
username: string
|
username: string
|
||||||
savePassword: 0 | 1
|
savePassword: 0 | 1
|
||||||
@@ -53,9 +54,10 @@ export namespace Dbms {
|
|||||||
dbType: DbType
|
dbType: DbType
|
||||||
host: string
|
host: string
|
||||||
port: number
|
port: number
|
||||||
connectType: ConnectType
|
connectType?: ConnectType | null
|
||||||
serviceName?: string | null
|
serviceName?: string | null
|
||||||
sid?: string | null
|
sid?: string | null
|
||||||
|
databaseName?: string | null
|
||||||
schemaName?: string | null
|
schemaName?: string | null
|
||||||
username: string
|
username: string
|
||||||
password?: string | null
|
password?: string | null
|
||||||
@@ -90,6 +92,11 @@ export namespace Dbms {
|
|||||||
export interface TableRecord {
|
export interface TableRecord {
|
||||||
owner: string
|
owner: string
|
||||||
tableName: string
|
tableName: string
|
||||||
|
autoIncrement?: number | string | null
|
||||||
|
updateTime?: string | null
|
||||||
|
dataLength?: number | string | null
|
||||||
|
engine?: string | null
|
||||||
|
tableRows?: number | string | null
|
||||||
comments?: string | null
|
comments?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +129,15 @@ export namespace Dbms {
|
|||||||
taskStatus: TaskStatus
|
taskStatus: TaskStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StopBackupTaskParams {
|
||||||
|
taskId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestartBackupTaskParams {
|
||||||
|
taskId: string
|
||||||
|
temporaryPassword?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskListParams extends ReqPage {
|
export interface TaskListParams extends ReqPage {
|
||||||
connectionId?: string
|
connectionId?: string
|
||||||
taskStatus?: TaskStatus | ''
|
taskStatus?: TaskStatus | ''
|
||||||
@@ -134,6 +150,7 @@ export namespace Dbms {
|
|||||||
dbType: DbType
|
dbType: DbType
|
||||||
operationType: OperationType
|
operationType: OperationType
|
||||||
backupStrategy?: BackupStrategy | null
|
backupStrategy?: BackupStrategy | null
|
||||||
|
restoreMode?: RestoreMode | null
|
||||||
taskStatus: TaskStatus
|
taskStatus: TaskStatus
|
||||||
schemaName?: string | null
|
schemaName?: string | null
|
||||||
targetNamesJson?: string | null
|
targetNamesJson?: string | null
|
||||||
@@ -163,6 +180,7 @@ export namespace Dbms {
|
|||||||
backupMode?: BackupMode | null
|
backupMode?: BackupMode | null
|
||||||
fileName: string
|
fileName: string
|
||||||
filePath?: string | null
|
filePath?: string | null
|
||||||
|
metadataFilePath?: string | null
|
||||||
logFileName?: string | null
|
logFileName?: string | null
|
||||||
logFilePath?: string | null
|
logFilePath?: string | null
|
||||||
fileSize?: number | null
|
fileSize?: number | null
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const getSeriesTimeRange = () => {
|
|||||||
let maxTime = Number.NEGATIVE_INFINITY
|
let maxTime = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
seriesList.forEach((series: { data?: unknown[] }) => {
|
seriesList.forEach((series: { data?: unknown[] }) => {
|
||||||
;(series.data || []).forEach(point => {
|
(series.data || []).forEach(point => {
|
||||||
const timestamp = resolveTimeValue(point)
|
const timestamp = resolveTimeValue(point)
|
||||||
|
|
||||||
if (timestamp === undefined) return
|
if (timestamp === undefined) return
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const expectedPaths = [
|
|||||||
'/systemMonitor/databaseMonitor/index',
|
'/systemMonitor/databaseMonitor/index',
|
||||||
'/systemMonitor/database-monitor',
|
'/systemMonitor/database-monitor',
|
||||||
'/systemMonitor/database-monitor/index',
|
'/systemMonitor/database-monitor/index',
|
||||||
|
'/system-ops/dbms/index',
|
||||||
'/system-ops/database-monitor',
|
'/system-ops/database-monitor',
|
||||||
'/system-ops/database-monitor/index'
|
'/system-ops/database-monitor/index'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export const staticRouter: RouteRecordRaw[] = [
|
|||||||
'/systemMonitor/databaseMonitor/index',
|
'/systemMonitor/databaseMonitor/index',
|
||||||
'/systemMonitor/database-monitor',
|
'/systemMonitor/database-monitor',
|
||||||
'/systemMonitor/database-monitor/index',
|
'/systemMonitor/database-monitor/index',
|
||||||
|
'/system-ops/dbms/index',
|
||||||
'/system-ops/database-monitor',
|
'/system-ops/database-monitor',
|
||||||
'/system-ops/database-monitor/index'
|
'/system-ops/database-monitor/index'
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<el-input v-model="form.connectionName" clearable />
|
<el-input v-model="form.connectionName" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="连接类型">
|
<el-form-item v-if="selectedDbType === 'ORACLE'" label="连接类型">
|
||||||
<el-select model-value="Basic" disabled>
|
<el-select model-value="Basic" disabled>
|
||||||
<el-option label="Basic" value="Basic" />
|
<el-option label="Basic" value="Basic" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -39,6 +39,13 @@
|
|||||||
<el-input v-model.number="form.port" />
|
<el-input v-model.number="form.port" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<template v-if="selectedDbType === 'MYSQL'">
|
||||||
|
<el-form-item label="数据库名" prop="databaseName">
|
||||||
|
<el-input v-model="form.databaseName" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<el-form-item v-if="form.connectType === 'SERVICE_NAME'" label="服务名" prop="serviceName">
|
<el-form-item v-if="form.connectType === 'SERVICE_NAME'" label="服务名" prop="serviceName">
|
||||||
<el-input v-model="form.serviceName" clearable />
|
<el-input v-model="form.serviceName" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -53,6 +60,7 @@
|
|||||||
<el-radio value="SID">SID</el-radio>
|
<el-radio value="SID">SID</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item label="用户名" prop="username">
|
||||||
<el-input v-model="form.username" clearable />
|
<el-input v-model="form.username" clearable />
|
||||||
@@ -114,9 +122,54 @@ const formRules: FormRules<DbmsConnectionFormModel> = {
|
|||||||
connectionName: [{ required: true, message: '请输入连接名称', trigger: 'blur' }],
|
connectionName: [{ required: true, message: '请输入连接名称', trigger: 'blur' }],
|
||||||
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
|
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
|
||||||
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
|
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
|
||||||
connectType: [{ required: true, message: '请选择连接类型', trigger: 'change' }],
|
connectType: [
|
||||||
serviceName: [{ required: true, message: '请输入服务名', trigger: 'blur' }],
|
{
|
||||||
sid: [{ required: true, message: '请输入 SID', trigger: 'blur' }],
|
validator: (_rule, value, callback) => {
|
||||||
|
if (selectedDbType.value === 'ORACLE' && !value) {
|
||||||
|
callback(new Error('请选择连接类型'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
serviceName: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (selectedDbType.value === 'ORACLE' && form.connectType === 'SERVICE_NAME' && !String(value || '').trim()) {
|
||||||
|
callback(new Error('请输入服务名'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sid: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (selectedDbType.value === 'ORACLE' && form.connectType === 'SID' && !String(value || '').trim()) {
|
||||||
|
callback(new Error('请输入 SID'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
databaseName: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (selectedDbType.value === 'MYSQL' && !String(value || '').trim()) {
|
||||||
|
callback(new Error('请输入数据库名'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +186,7 @@ const resetForm = () => {
|
|||||||
const open = (nextMode: 'add' | 'edit', record?: Dbms.ConnectionRecord, dbType: Dbms.DbType = 'ORACLE') => {
|
const open = (nextMode: 'add' | 'edit', record?: Dbms.ConnectionRecord, dbType: Dbms.DbType = 'ORACLE') => {
|
||||||
mode.value = nextMode
|
mode.value = nextMode
|
||||||
selectedDbType.value = record?.dbType || dbType
|
selectedDbType.value = record?.dbType || dbType
|
||||||
applyForm(createConnectionForm(record))
|
applyForm(createConnectionForm(record, selectedDbType.value))
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tree-search-row">
|
<div class="tree-search-row">
|
||||||
<el-input v-model="keyword" clearable placeholder="搜索连接、Schema" />
|
<el-input v-model="keyword" clearable placeholder="搜索连接或 Schema" />
|
||||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
@node-click="handleNodeClick"
|
@node-click="handleNodeClick"
|
||||||
>
|
>
|
||||||
<template #default="{ data }">
|
<template #default="{ data }">
|
||||||
<div class="tree-node" :class="`is-${data.type}`">
|
<div class="tree-node" :class="`is-${data.type}`" @dblclick.stop="handleNodeDoubleClick(data)">
|
||||||
<span class="node-main">
|
<span class="node-main">
|
||||||
<el-icon :class="['node-icon', `is-${data.type}`]">
|
<el-icon :class="['node-icon', `is-${data.type}`]">
|
||||||
<component :is="resolveNodeIcon(data.type)" />
|
<component :is="resolveNodeIcon(data.type)" />
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { ArrowLeft, ArrowRight, Coin, Collection, Folder, Refresh, View } from '@element-plus/icons-vue'
|
import { ArrowLeft, ArrowRight, Coin, Collection, Refresh } from '@element-plus/icons-vue'
|
||||||
import type { Dbms } from '@/api/system/dbms/interface'
|
import type { Dbms } from '@/api/system/dbms/interface'
|
||||||
import type { DbmsTreeNode, DbmsTreeNodeType, DbmsWorkspaceSection } from './types'
|
import type { DbmsTreeNode, DbmsTreeNodeType, DbmsWorkspaceSection } from './types'
|
||||||
|
|
||||||
@@ -68,48 +68,44 @@ const emit = defineEmits<{
|
|||||||
refresh: []
|
refresh: []
|
||||||
selectConnection: [connection: Dbms.ConnectionRecord]
|
selectConnection: [connection: Dbms.ConnectionRecord]
|
||||||
selectSection: [section: DbmsWorkspaceSection]
|
selectSection: [section: DbmsWorkspaceSection]
|
||||||
|
connect: [connection: Dbms.ConnectionRecord]
|
||||||
|
openTables: [connection: Dbms.ConnectionRecord]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const nodeIcons: Record<DbmsTreeNodeType, Component> = {
|
const nodeIcons: Record<DbmsTreeNodeType, Component> = {
|
||||||
connection: Coin,
|
connection: Coin,
|
||||||
schema: Collection,
|
schema: Collection,
|
||||||
tableGroup: Folder,
|
tableGroup: Collection,
|
||||||
viewGroup: View
|
viewGroup: Collection
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveNodeIcon = (type: DbmsTreeNodeType) => nodeIcons[type]
|
const resolveNodeIcon = (type: DbmsTreeNodeType) => nodeIcons[type]
|
||||||
|
|
||||||
const treeData = computed<DbmsTreeNode[]>(() =>
|
const treeData = computed<DbmsTreeNode[]>(() =>
|
||||||
props.connections.map(connection => {
|
props.connections.map(connection => {
|
||||||
const schemaLabel = connection.schemaName || connection.username || '默认 Schema'
|
const schemaLabel =
|
||||||
|
connection.dbType === 'MYSQL'
|
||||||
|
? connection.databaseName?.trim()
|
||||||
|
: connection.schemaName?.trim() || connection.username?.trim()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `connection-${connection.id}`,
|
id: `connection-${connection.id}`,
|
||||||
label: connection.connectionName,
|
label: connection.connectionName,
|
||||||
type: 'connection',
|
type: 'connection',
|
||||||
connection,
|
connection,
|
||||||
children: [
|
section: 'overview',
|
||||||
|
children: schemaLabel
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
id: `schema-${connection.id}`,
|
id: `schema-${connection.id}`,
|
||||||
label: schemaLabel,
|
label: schemaLabel,
|
||||||
type: 'schema',
|
type: 'schema',
|
||||||
connection,
|
connection,
|
||||||
children: [
|
section: 'overview'
|
||||||
{
|
|
||||||
id: `tables-${connection.id}`,
|
|
||||||
label: '表',
|
|
||||||
type: 'tableGroup',
|
|
||||||
connection
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `views-${connection.id}`,
|
|
||||||
label: '视图',
|
|
||||||
type: 'viewGroup',
|
|
||||||
connection
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -117,12 +113,15 @@ const treeData = computed<DbmsTreeNode[]>(() =>
|
|||||||
const filterTree = (nodes: DbmsTreeNode[], value: string): DbmsTreeNode[] => {
|
const filterTree = (nodes: DbmsTreeNode[], value: string): DbmsTreeNode[] => {
|
||||||
if (!value) return nodes
|
if (!value) return nodes
|
||||||
const normalizedValue = value.toLowerCase()
|
const normalizedValue = value.toLowerCase()
|
||||||
|
|
||||||
return nodes
|
return nodes
|
||||||
.map(node => {
|
.map(node => {
|
||||||
const children = node.children ? filterTree(node.children, value) : []
|
const children = node.children ? filterTree(node.children, value) : []
|
||||||
|
|
||||||
if (node.label.toLowerCase().includes(normalizedValue) || children.length) {
|
if (node.label.toLowerCase().includes(normalizedValue) || children.length) {
|
||||||
return { ...node, children }
|
return { ...node, children }
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
.filter(Boolean) as DbmsTreeNode[]
|
.filter(Boolean) as DbmsTreeNode[]
|
||||||
@@ -134,13 +133,19 @@ const handleNodeClick = (node: DbmsTreeNode) => {
|
|||||||
if (node.connection) {
|
if (node.connection) {
|
||||||
emit('selectConnection', node.connection)
|
emit('selectConnection', node.connection)
|
||||||
}
|
}
|
||||||
if (node.type === 'tableGroup') {
|
|
||||||
emit('selectSection', 'tables')
|
emit('selectSection', node.section || 'overview')
|
||||||
} else if (node.type === 'viewGroup') {
|
}
|
||||||
emit('selectSection', 'views')
|
|
||||||
} else {
|
const handleNodeDoubleClick = (node: DbmsTreeNode) => {
|
||||||
emit('selectSection', 'overview')
|
if (!node.connection) return
|
||||||
|
|
||||||
|
if (node.type === 'schema') {
|
||||||
|
emit('openTables', node.connection)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit('connect', node.connection)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -44,28 +44,28 @@
|
|||||||
@current-change="handleCurrentChange"
|
@current-change="handleCurrentChange"
|
||||||
@row-dblclick="row => emit('edit', row)"
|
@row-dblclick="row => emit('edit', row)"
|
||||||
>
|
>
|
||||||
<el-table-column prop="connectionName" label="连接名称" min-width="150" fixed="left" show-overflow-tooltip />
|
<el-table-column prop="connectionName" label="连接名称" min-width="150" fixed="left" show-overflow-tooltip sortable />
|
||||||
<el-table-column prop="host" label="主机" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="host" label="主机" min-width="140" show-overflow-tooltip sortable />
|
||||||
<el-table-column prop="port" label="端口" width="90" align="center" />
|
<el-table-column prop="port" label="端口" width="90" align="center" sortable />
|
||||||
<el-table-column prop="connectType" label="连接类型" min-width="120" align="center" />
|
<el-table-column prop="connectType" label="连接类型" min-width="120" align="center" sortable />
|
||||||
<el-table-column prop="schemaName" label="Schema" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="schemaName" label="Schema" min-width="120" show-overflow-tooltip sortable />
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip sortable />
|
||||||
<el-table-column prop="directoryName" label="Directory" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="directoryName" label="Directory" min-width="140" show-overflow-tooltip sortable />
|
||||||
<el-table-column label="保存密码" width="100" align="center">
|
<el-table-column label="保存密码" width="100" align="center" sort-by="savePassword" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.savePassword === 1 ? 'success' : 'info'" effect="light">
|
<el-tag :type="row.savePassword === 1 ? 'success' : 'info'" effect="light">
|
||||||
{{ row.savePassword === 1 ? '是' : '否' }}
|
{{ row.savePassword === 1 ? '是' : '否' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="测试状态" width="110" align="center">
|
<el-table-column label="测试状态" width="110" align="center" sort-by="lastTestStatus" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getConnectionStatusMeta(row.lastTestStatus).type" effect="light">
|
<el-tag :type="getConnectionStatusMeta(row.lastTestStatus).type" effect="light">
|
||||||
{{ getConnectionStatusMeta(row.lastTestStatus).label }}
|
{{ getConnectionStatusMeta(row.lastTestStatus).label }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="lastTestMessage" label="测试消息" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="lastTestMessage" label="测试消息" min-width="180" show-overflow-tooltip sortable />
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link :icon="EditPen" @click="emit('edit', row)">编辑</el-button>
|
<el-button type="primary" link :icon="EditPen" @click="emit('edit', row)">编辑</el-button>
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="card dbms-task-card">
|
<section class="card dbms-task-card">
|
||||||
<div class="card-header">
|
<div class="card-body">
|
||||||
<div class="selected-info">
|
<div class="task-panel-topbar">
|
||||||
<div class="section-title">备份与恢复</div>
|
<div v-if="selectedConnection" class="task-panel-connection">
|
||||||
<div class="section-description">
|
<span class="task-panel-connection-label">数据库连接信息:</span>
|
||||||
当前连接:{{ selectedConnection?.connectionName || '未选择' }}
|
<span class="task-panel-connection-title" :title="connectionTitle">{{ connectionTitle }}</span>
|
||||||
<span v-if="selectedConnection"> / {{ selectedConnection.host }}:{{ selectedConnection.port }}</span>
|
<el-button type="primary" plain :icon="Connection" @click="emit('edit-connection', selectedConnection)">
|
||||||
</div>
|
连接信息
|
||||||
</div>
|
|
||||||
<el-button type="primary" plain :icon="Refresh" :disabled="!selectedConnection" @click="emit('load-tables')">
|
|
||||||
加载表
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<el-tabs v-model="activeTab" class="operation-tabs">
|
<el-tabs v-model="activeTab" class="operation-tabs">
|
||||||
<el-tab-pane label="创建备份" name="backup">
|
<el-tab-pane label="创建备份" name="backup">
|
||||||
<el-form ref="backupFormRef" :model="backupForm" :rules="backupRules" label-width="112px">
|
<el-form ref="backupFormRef" :model="backupForm" :rules="backupRules" label-width="112px">
|
||||||
<div class="form-grid">
|
<div class="backup-form-grid form-grid" :class="backupModeLayoutClass">
|
||||||
<el-form-item label="备份策略">
|
<el-form-item label="备份策略">
|
||||||
<el-select v-model="backupForm.backupStrategy">
|
<el-select v-model="backupForm.backupStrategy" disabled>
|
||||||
<el-option label="DATA_PUMP" value="DATA_PUMP" />
|
<el-option label="DATA_PUMP" value="DATA_PUMP" />
|
||||||
<el-option label="JDBC_EXPORT" value="JDBC_EXPORT" />
|
<el-option label="JDBC_EXPORT" value="JDBC_EXPORT" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Schema">
|
<el-form-item :label="schemaFieldLabel">
|
||||||
<el-input v-model="backupForm.schemaName" :placeholder="selectedConnection?.schemaName || '默认 Schema'" clearable />
|
<el-input v-model="backupForm.schemaName" :placeholder="schemaFieldPlaceholder" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备份模式">
|
<el-form-item label="备份模式">
|
||||||
<el-select v-model="backupForm.backupMode">
|
<el-select v-model="backupForm.backupMode">
|
||||||
@@ -37,21 +33,9 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Directory">
|
|
||||||
<el-input
|
|
||||||
v-model="backupForm.directoryName"
|
|
||||||
:placeholder="selectedConnection?.directoryName || 'DATA_PUMP_DIR'"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="backupForm.backupMode === 'TIME_RANGE'" label="时间字段" prop="timeColumn">
|
<el-form-item v-if="backupForm.backupMode === 'TIME_RANGE'" label="时间字段" prop="timeColumn">
|
||||||
<el-input v-model="backupForm.timeColumn" clearable />
|
<el-input v-model="backupForm.timeColumn" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="backupForm.backupMode === 'SIZE_SPLIT'" label="文件上限">
|
|
||||||
<el-input-number v-model="backupForm.maxFileSizeMb" :min="1" controls-position="right" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form-item v-if="backupForm.backupMode === 'TIME_RANGE'" label="时间范围" prop="timeRange">
|
<el-form-item v-if="backupForm.backupMode === 'TIME_RANGE'" label="时间范围" prop="timeRange">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="backupForm.timeRange"
|
v-model="backupForm.timeRange"
|
||||||
@@ -62,49 +46,82 @@
|
|||||||
end-placeholder="结束时间"
|
end-placeholder="结束时间"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="表选择">
|
<el-form-item v-if="backupForm.backupMode === 'SIZE_SPLIT'" label="文件上限">
|
||||||
|
<div class="input-with-unit">
|
||||||
|
<el-input-number v-model="backupForm.maxFileSizeMb" :min="1" controls-position="right" />
|
||||||
|
<span class="input-unit">MB</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="backup-table-select" label="表选择" prop="targetNames">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="backupForm.targetNames"
|
v-model="backupForm.targetNames"
|
||||||
multiple
|
multiple
|
||||||
filterable
|
filterable
|
||||||
collapse-tags
|
collapse-tags
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="2"
|
||||||
|
popper-class="dbms-table-select-popper"
|
||||||
placeholder="不选则按后端默认范围"
|
placeholder="不选则按后端默认范围"
|
||||||
:loading="tableLoading"
|
:loading="tableLoading"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="table-select-header">
|
||||||
|
<el-button link type="primary" :disabled="!tables.length" @click="handleSelectAllTables">
|
||||||
|
全选
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="primary" :disabled="!backupForm.targetNames.length" @click="handleClearTables">
|
||||||
|
全不选
|
||||||
|
</el-button>
|
||||||
|
<span class="table-select-count">
|
||||||
|
{{ backupForm.targetNames.length }}/{{ tables.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="table in tables"
|
v-for="table in tables"
|
||||||
:key="table.tableName"
|
:key="table.tableName"
|
||||||
:label="table.comments ? `${table.tableName} / ${table.comments}` : table.tableName"
|
:label="table.comments ? `${table.tableName} / ${table.comments}` : table.tableName"
|
||||||
:value="table.tableName"
|
:value="table.tableName"
|
||||||
|
>
|
||||||
|
<div class="table-option">
|
||||||
|
<el-checkbox
|
||||||
|
class="table-option-checkbox"
|
||||||
|
:model-value="backupForm.targetNames.includes(table.tableName)"
|
||||||
/>
|
/>
|
||||||
|
<span class="table-option-label">
|
||||||
|
{{ table.comments ? `${table.tableName} / ${table.comments}` : table.tableName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="临时密码">
|
<div class="backup-table-action">
|
||||||
<el-input v-model="backupForm.temporaryPassword" type="password" show-password clearable />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<el-button type="primary" :icon="Promotion" :disabled="!selectedConnection" @click="handleBackupSubmit">
|
<el-button type="primary" :icon="Promotion" :disabled="!selectedConnection" @click="handleBackupSubmit">
|
||||||
创建备份任务
|
开始备份
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="创建恢复" name="restore">
|
<el-tab-pane label="创建恢复" name="restore">
|
||||||
<el-form ref="restoreFormRef" :model="restoreForm" :rules="restoreRules" label-width="112px">
|
<el-form ref="restoreFormRef" :model="restoreForm" :rules="restoreRules" label-width="112px">
|
||||||
|
<div class="restore-form-grid form-grid">
|
||||||
<el-form-item label="备份文件" prop="backupFileId">
|
<el-form-item label="备份文件" prop="backupFileId">
|
||||||
<el-select v-model="restoreForm.backupFileId" filterable placeholder="请选择备份文件">
|
<el-select v-model="restoreForm.backupFileId" filterable placeholder="请选择已完成备份任务">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="file in files"
|
v-for="option in restoreBackupOptions"
|
||||||
:key="file.id"
|
:key="option.backupFileId"
|
||||||
:label="file.fileName"
|
:label="option.taskNo"
|
||||||
:value="file.id"
|
:value="option.backupFileId"
|
||||||
/>
|
>
|
||||||
|
<div class="restore-backup-option">
|
||||||
|
<span class="restore-backup-task-no">{{ option.taskNo }}</span>
|
||||||
|
<span class="restore-backup-file-path">{{ option.filePath || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid">
|
|
||||||
<el-form-item label="恢复模式">
|
<el-form-item label="恢复模式">
|
||||||
<el-select v-model="restoreForm.restoreMode">
|
<el-select v-model="restoreForm.restoreMode">
|
||||||
<el-option
|
<el-option
|
||||||
@@ -115,36 +132,52 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="目标 Schema">
|
<el-form-item :label="targetSchemaFieldLabel">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="restoreForm.targetSchemaName"
|
v-model="restoreForm.targetSchemaName"
|
||||||
:placeholder="selectedConnection?.schemaName || '默认 Schema'"
|
:placeholder="targetSchemaFieldPlaceholder"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
|
||||||
<el-form-item label="临时密码">
|
|
||||||
<el-input v-model="restoreForm.temporaryPassword" type="password" show-password clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="isOverwriteRestoreMode(restoreForm.restoreMode)" label="覆盖确认" prop="overwriteConfirmText">
|
<el-form-item v-if="isOverwriteRestoreMode(restoreForm.restoreMode)" label="覆盖确认" prop="overwriteConfirmText">
|
||||||
<el-input v-model="restoreForm.overwriteConfirmText" placeholder="请输入:确认覆盖" clearable />
|
<el-checkbox v-model="overwriteConfirmChecked">我确认清空或覆盖目标数据</el-checkbox>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="restore-table-action">
|
||||||
<div class="form-actions">
|
|
||||||
<el-button type="primary" :icon="Promotion" :disabled="!selectedConnection" @click="handleRestoreSubmit">
|
<el-button type="primary" :icon="Promotion" :disabled="!selectedConnection" @click="handleRestoreSubmit">
|
||||||
创建恢复任务
|
开始恢复
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
|
<DbmsTaskStatusCard
|
||||||
|
class="embedded-status-card"
|
||||||
|
:operation-type="currentOperationType"
|
||||||
|
:tasks="currentTasks"
|
||||||
|
:files="files"
|
||||||
|
:task-query="currentTaskQuery"
|
||||||
|
:task-loading="currentTaskLoading"
|
||||||
|
:task-total="currentTaskTotal"
|
||||||
|
:task-page-num="currentTaskPageNum"
|
||||||
|
:task-page-size="currentTaskPageSize"
|
||||||
|
@refresh-tasks="handleRefreshCurrentTasks"
|
||||||
|
@search-tasks="handleSearchCurrentTasks"
|
||||||
|
@check-task="row => emit('check-task', row)"
|
||||||
|
@stop-task="row => emit('stop-task', row)"
|
||||||
|
@restart-task="row => emit('restart-task', row)"
|
||||||
|
@delete-task="row => emit('delete-task', row)"
|
||||||
|
@task-page-change="handleCurrentTaskPageChange"
|
||||||
|
@task-size-change="handleCurrentTaskSizeChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { Promotion, Refresh } from '@element-plus/icons-vue'
|
import { Connection, Promotion } from '@element-plus/icons-vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import type { Dbms } from '@/api/system/dbms/interface'
|
import type { Dbms } from '@/api/system/dbms/interface'
|
||||||
import {
|
import {
|
||||||
@@ -155,8 +188,9 @@ import {
|
|||||||
type DbmsBackupFormModel,
|
type DbmsBackupFormModel,
|
||||||
type DbmsRestoreFormModel
|
type DbmsRestoreFormModel
|
||||||
} from '../utils/taskPayload'
|
} from '../utils/taskPayload'
|
||||||
import { backupModeLabels, restoreModeLabels } from '../utils/normalize'
|
import { backupModeLabels, OVERWRITE_CONFIRM_TEXT, restoreModeLabels } from '../utils/normalize'
|
||||||
import type { BackupSubmitPayload, RestoreSubmitPayload } from './types'
|
import DbmsTaskStatusCard from './DbmsTaskStatusCard.vue'
|
||||||
|
import type { BackupSubmitPayload, DbmsFileQuery, DbmsTaskQuery, RestoreSubmitPayload } from './types'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'DbmsTaskPanel'
|
name: 'DbmsTaskPanel'
|
||||||
@@ -166,13 +200,48 @@ const props = defineProps<{
|
|||||||
selectedConnection: Dbms.ConnectionRecord | null
|
selectedConnection: Dbms.ConnectionRecord | null
|
||||||
tables: Dbms.TableRecord[]
|
tables: Dbms.TableRecord[]
|
||||||
files: Dbms.BackupFileRecord[]
|
files: Dbms.BackupFileRecord[]
|
||||||
|
tasks: Dbms.TaskRecord[]
|
||||||
|
restoreTasks: Dbms.TaskRecord[]
|
||||||
|
taskQuery: DbmsTaskQuery
|
||||||
|
restoreTaskQuery: DbmsTaskQuery
|
||||||
|
fileQuery: DbmsFileQuery
|
||||||
tableLoading: boolean
|
tableLoading: boolean
|
||||||
|
taskLoading: boolean
|
||||||
|
restoreTaskLoading: boolean
|
||||||
|
fileLoading: boolean
|
||||||
|
taskTotal: number
|
||||||
|
restoreTaskTotal: number
|
||||||
|
taskPageNum: number
|
||||||
|
taskPageSize: number
|
||||||
|
restoreTaskPageNum: number
|
||||||
|
restoreTaskPageSize: number
|
||||||
|
fileTotal: number
|
||||||
|
filePageNum: number
|
||||||
|
filePageSize: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'load-tables': []
|
'load-tables': []
|
||||||
backup: [payload: BackupSubmitPayload]
|
backup: [payload: BackupSubmitPayload]
|
||||||
restore: [payload: RestoreSubmitPayload]
|
restore: [payload: RestoreSubmitPayload]
|
||||||
|
'refresh-tasks': []
|
||||||
|
'refresh-restore-tasks': []
|
||||||
|
'refresh-files': []
|
||||||
|
'search-tasks': [query: DbmsTaskQuery]
|
||||||
|
'search-restore-tasks': [query: DbmsTaskQuery]
|
||||||
|
'search-files': [query: DbmsFileQuery]
|
||||||
|
'check-task': [row: Dbms.TaskRecord]
|
||||||
|
'stop-task': [row: Dbms.TaskRecord]
|
||||||
|
'restart-task': [row: Dbms.TaskRecord]
|
||||||
|
'delete-task': [row: Dbms.TaskRecord]
|
||||||
|
'delete-file': [row: Dbms.BackupFileRecord]
|
||||||
|
'edit-connection': [row: Dbms.ConnectionRecord]
|
||||||
|
'task-page-change': [page: number]
|
||||||
|
'task-size-change': [size: number]
|
||||||
|
'restore-task-page-change': [page: number]
|
||||||
|
'restore-task-size-change': [size: number]
|
||||||
|
'file-page-change': [page: number]
|
||||||
|
'file-size-change': [size: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeTab = ref('backup')
|
const activeTab = ref('backup')
|
||||||
@@ -180,8 +249,55 @@ const backupFormRef = ref<FormInstance>()
|
|||||||
const restoreFormRef = ref<FormInstance>()
|
const restoreFormRef = ref<FormInstance>()
|
||||||
const backupForm = reactive<DbmsBackupFormModel>(createBackupForm())
|
const backupForm = reactive<DbmsBackupFormModel>(createBackupForm())
|
||||||
const restoreForm = reactive<DbmsRestoreFormModel>(createRestoreForm())
|
const restoreForm = reactive<DbmsRestoreFormModel>(createRestoreForm())
|
||||||
|
const overwriteConfirmChecked = ref(false)
|
||||||
|
const isMysqlConnection = computed(() => props.selectedConnection?.dbType === 'MYSQL')
|
||||||
|
const currentOperationType = computed(() => (activeTab.value === 'restore' ? 'RESTORE' : 'BACKUP'))
|
||||||
|
const currentTasks = computed(() => (activeTab.value === 'restore' ? props.restoreTasks : props.tasks))
|
||||||
|
const currentTaskQuery = computed(() => (activeTab.value === 'restore' ? props.restoreTaskQuery : props.taskQuery))
|
||||||
|
const currentTaskLoading = computed(() => (activeTab.value === 'restore' ? props.restoreTaskLoading : props.taskLoading))
|
||||||
|
const currentTaskTotal = computed(() => (activeTab.value === 'restore' ? props.restoreTaskTotal : props.taskTotal))
|
||||||
|
const currentTaskPageNum = computed(() => (activeTab.value === 'restore' ? props.restoreTaskPageNum : props.taskPageNum))
|
||||||
|
const currentTaskPageSize = computed(() => (activeTab.value === 'restore' ? props.restoreTaskPageSize : props.taskPageSize))
|
||||||
|
const restoreBackupOptions = computed(() =>
|
||||||
|
props.tasks
|
||||||
|
.filter(task => task.operationType === 'BACKUP' && task.taskStatus === 'SUCCESS')
|
||||||
|
.map(task => {
|
||||||
|
const file = props.files.find(file => file.taskId === task.id)
|
||||||
|
|
||||||
|
return file
|
||||||
|
? {
|
||||||
|
backupFileId: file.id,
|
||||||
|
taskNo: task.taskNo,
|
||||||
|
filePath: file.filePath || file.fileName || ''
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ backupFileId: string; taskNo: string; filePath: string }>
|
||||||
|
)
|
||||||
|
const backupModeLayoutClass = computed(() => ({
|
||||||
|
'is-time-range': backupForm.backupMode === 'TIME_RANGE',
|
||||||
|
'is-size-split': backupForm.backupMode === 'SIZE_SPLIT'
|
||||||
|
}))
|
||||||
|
const connectionTitle = computed(() => {
|
||||||
|
const connection = props.selectedConnection
|
||||||
|
if (!connection) return ''
|
||||||
|
const databaseName =
|
||||||
|
connection.dbType === 'MYSQL'
|
||||||
|
? connection.databaseName || connection.schemaName || connection.connectionName
|
||||||
|
: connection.schemaName || connection.databaseName || connection.connectionName
|
||||||
|
return `${databaseName}(${connection.host}:${connection.port})`
|
||||||
|
})
|
||||||
|
const schemaFieldLabel = computed(() => (isMysqlConnection.value ? '数据库名' : 'Schema'))
|
||||||
|
const schemaFieldPlaceholder = computed(() =>
|
||||||
|
isMysqlConnection.value ? props.selectedConnection?.databaseName || '默认数据库名' : props.selectedConnection?.schemaName || '默认 Schema'
|
||||||
|
)
|
||||||
|
const targetSchemaFieldLabel = computed(() => (isMysqlConnection.value ? '目标数据库' : '目标 Schema'))
|
||||||
|
const targetSchemaFieldPlaceholder = computed(() =>
|
||||||
|
isMysqlConnection.value ? props.selectedConnection?.databaseName || '默认数据库名' : props.selectedConnection?.schemaName || '默认 Schema'
|
||||||
|
)
|
||||||
|
|
||||||
const backupRules: FormRules<DbmsBackupFormModel> = {
|
const backupRules: FormRules<DbmsBackupFormModel> = {
|
||||||
|
targetNames: [{ required: true, type: 'array', min: 1, message: '请选择备份表', trigger: 'change' }],
|
||||||
timeColumn: [{ required: true, message: '请输入时间字段', trigger: 'blur' }],
|
timeColumn: [{ required: true, message: '请输入时间字段', trigger: 'blur' }],
|
||||||
timeRange: [{ required: true, message: '请选择时间范围', trigger: 'change' }]
|
timeRange: [{ required: true, message: '请选择时间范围', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
@@ -202,12 +318,67 @@ const restoreRules: FormRules<DbmsRestoreFormModel> = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectAllTables = () => {
|
||||||
|
backupForm.targetNames = props.tables.map(table => table.tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearTables = () => {
|
||||||
|
backupForm.targetNames = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefreshCurrentTasks = () => {
|
||||||
|
if (activeTab.value === 'restore') {
|
||||||
|
emit('refresh-restore-tasks')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('refresh-tasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchCurrentTasks = (query: DbmsTaskQuery) => {
|
||||||
|
if (activeTab.value === 'restore') {
|
||||||
|
emit('search-restore-tasks', query)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('search-tasks', query)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentTaskPageChange = (page: number) => {
|
||||||
|
if (activeTab.value === 'restore') {
|
||||||
|
emit('restore-task-page-change', page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('task-page-change', page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentTaskSizeChange = (size: number) => {
|
||||||
|
if (activeTab.value === 'restore') {
|
||||||
|
emit('restore-task-size-change', size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('task-size-change', size)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(overwriteConfirmChecked, checked => {
|
||||||
|
restoreForm.overwriteConfirmText = checked ? OVERWRITE_CONFIRM_TEXT : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => restoreForm.restoreMode,
|
||||||
|
restoreMode => {
|
||||||
|
if (!isOverwriteRestoreMode(restoreMode)) {
|
||||||
|
overwriteConfirmChecked.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.selectedConnection,
|
() => props.selectedConnection,
|
||||||
connection => {
|
connection => {
|
||||||
backupForm.schemaName = connection?.schemaName || ''
|
backupForm.schemaName = connection?.dbType === 'MYSQL' ? connection?.databaseName || '' : connection?.schemaName || ''
|
||||||
|
// 备份恢复统一使用 JDBC_EXPORT,Oracle 与 MySQL 保持同一套导出恢复链路。
|
||||||
|
backupForm.backupStrategy = 'JDBC_EXPORT'
|
||||||
backupForm.directoryName = connection?.directoryName || 'DATA_PUMP_DIR'
|
backupForm.directoryName = connection?.directoryName || 'DATA_PUMP_DIR'
|
||||||
restoreForm.targetSchemaName = connection?.schemaName || ''
|
restoreForm.targetSchemaName = connection?.dbType === 'MYSQL' ? connection?.databaseName || '' : connection?.schemaName || ''
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -228,46 +399,32 @@ const handleRestoreSubmit = async () => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.dbms-task-card {
|
.dbms-task-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
border: 1px solid var(--el-border-color-light);
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-info {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-description {
|
|
||||||
margin-top: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 12px;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-tabs :deep(.el-tabs__header) {
|
||||||
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation-tabs :deep(.el-select),
|
.operation-tabs :deep(.el-select),
|
||||||
@@ -275,9 +432,49 @@ const handleRestoreSubmit = async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.embedded-status-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-panel-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 34px;
|
||||||
|
margin-bottom: -34px;
|
||||||
|
padding-right: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-panel-connection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-panel-connection-label {
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-panel-connection-title {
|
||||||
|
display: block;
|
||||||
|
max-width: 360px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 0 12px;
|
gap: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +482,113 @@ const handleRestoreSubmit = async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.input-with-unit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-unit {
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions,
|
||||||
|
.backup-table-action,
|
||||||
|
.restore-table-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-table-select {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-table-action {
|
||||||
|
grid-column: 3;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-form-grid.is-size-split .backup-table-action {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-table-action {
|
||||||
|
grid-column: 3;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-select-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-select-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-option-checkbox {
|
||||||
|
flex: none;
|
||||||
|
height: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-option-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-backup-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-backup-task-no {
|
||||||
|
flex: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-backup-file-path {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-table-select {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.dbms-table-select-popper .el-select-dropdown__wrap {
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -18,35 +18,105 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-body">
|
<div class="table-body">
|
||||||
<el-table :data="tasks" border stripe height="100%" v-loading="taskLoading">
|
<el-table :data="tasks" border stripe height="100%" v-loading="taskLoading">
|
||||||
<el-table-column prop="taskNo" label="任务号" min-width="180" fixed="left" show-overflow-tooltip />
|
<template v-if="operationType === 'RESTORE'">
|
||||||
<el-table-column label="类型" width="90" align="center">
|
<el-table-column prop="taskNo" label="恢复任务号" min-width="180" fixed="left" show-overflow-tooltip sortable />
|
||||||
<template #default="{ row }">{{ getOperationTypeLabel(row.operationType) }}</template>
|
<el-table-column prop="schemaName" label="目标数据库名" min-width="140" show-overflow-tooltip sortable />
|
||||||
|
<el-table-column label="恢复模式" min-width="130" show-overflow-tooltip sortable>
|
||||||
|
<template #default="{ row }">{{ formatRestoreMode(row.restoreMode) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column prop="progressPercent" label="进度" width="150" align="center" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center" sort-by="taskStatus" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getTaskStatusMeta(row.taskStatus).type" effect="light">
|
<el-tag :type="getTaskStatusMeta(row.taskStatus).type" effect="light">
|
||||||
{{ getTaskStatusMeta(row.taskStatus).label }}
|
{{ getTaskStatusMeta(row.taskStatus).label }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="progressPercent" label="进度" width="150" align="center">
|
<el-table-column label="开始时间" min-width="170" sort-by="startedAt" sortable>
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.startedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结束时间" min-width="170" sort-by="finishedAt" sortable>
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.finishedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="resultMessage" label="结果消息" min-width="220" show-overflow-tooltip sortable />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="task-actions">
|
||||||
|
<el-button type="primary" link :icon="Refresh" @click="emit('check-task', row)">刷新</el-button>
|
||||||
|
<el-button type="primary" link :icon="Delete" @click="emit('delete-task', row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table-column prop="taskNo" label="任务号" min-width="180" fixed="left" show-overflow-tooltip sortable />
|
||||||
|
<el-table-column prop="schemaName" label="数据库名" min-width="120" show-overflow-tooltip sortable />
|
||||||
|
<el-table-column label="对象" min-width="160" show-overflow-tooltip sort-by="targetNamesJson" sortable>
|
||||||
|
<template #default="{ row }">{{ parseJsonArrayText(row.targetNamesJson) || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="progressPercent" label="进度" width="150" align="center" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8" />
|
<el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="schemaName" label="Schema" min-width="120" show-overflow-tooltip />
|
<el-table-column label="状态" width="100" align="center" sort-by="taskStatus" sortable>
|
||||||
<el-table-column label="对象" min-width="160" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">{{ parseJsonArrayText(row.targetNamesJson) || '--' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="resultMessage" label="结果消息" min-width="200" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="startedAt" label="开始时间" min-width="170" />
|
|
||||||
<el-table-column prop="finishedAt" label="结束时间" min-width="170" />
|
|
||||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link :icon="Refresh" @click="emit('check-task', row)">状态</el-button>
|
<el-tag :type="getTaskStatusMeta(row.taskStatus).type" effect="light">
|
||||||
<el-button type="primary" link :icon="Delete" @click="emit('delete-task', row)">删除</el-button>
|
{{ getTaskStatusMeta(row.taskStatus).label }}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="路径" min-width="260" show-overflow-tooltip sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="resolveTaskFilePath(row)"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
class="path-link"
|
||||||
|
@click="openTaskBackupFileDialog(row)"
|
||||||
|
>
|
||||||
|
{{ resolveTaskFilePath(row) || '-' }}
|
||||||
|
</el-button>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开始时间" min-width="170" sort-by="startedAt" sortable>
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.startedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结束时间" min-width="170" sort-by="finishedAt" sortable>
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.finishedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="resultMessage" label="结果消息" min-width="200" show-overflow-tooltip sortable />
|
||||||
|
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="task-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:icon="VideoPause"
|
||||||
|
:disabled="!canStopTask(row)"
|
||||||
|
@click="emit('stop-task', row)"
|
||||||
|
>
|
||||||
|
暂停
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:icon="RefreshRight"
|
||||||
|
:disabled="!canRestartTask(row)"
|
||||||
|
@click="emit('restart-task', row)"
|
||||||
|
>
|
||||||
|
开始
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" link :icon="Delete" @click="emit('delete-task', row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
@@ -61,109 +131,64 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="备份文件" name="files">
|
|
||||||
<div class="table-header">
|
|
||||||
<div class="header-button-lf">
|
|
||||||
<el-input
|
|
||||||
v-model="localFileQuery.taskId"
|
|
||||||
class="query-input"
|
|
||||||
placeholder="任务 ID"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="emitFileQuery"
|
|
||||||
@clear="emitFileQuery"
|
|
||||||
/>
|
|
||||||
<el-button :icon="Search" @click="emitFileQuery">查询</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="header-button-ri">
|
|
||||||
<el-button circle :icon="Refresh" @click="emit('refresh-files')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-body">
|
|
||||||
<el-table :data="files" border stripe height="100%" v-loading="fileLoading">
|
|
||||||
<el-table-column prop="fileName" label="文件名" min-width="220" fixed="left" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="schemaName" label="Schema" min-width="110" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="backupMode" label="备份模式" min-width="110" align="center" />
|
|
||||||
<el-table-column label="对象" min-width="150" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">{{ parseJsonArrayText(row.targetNamesJson) || '--' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="大小" width="120" align="center">
|
|
||||||
<template #default="{ row }">{{ formatFileSize(row.fileSize) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="filePath" label="路径" min-width="260" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="logFileName" label="日志文件" min-width="180" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="createTime" label="创建时间" min-width="170" />
|
|
||||||
<el-table-column label="操作" width="100" fixed="right" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button type="primary" link :icon="Delete" @click="emit('delete-file', row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
<div class="table-footer">
|
|
||||||
<el-pagination
|
|
||||||
background
|
|
||||||
layout="total, prev, pager, next, sizes"
|
|
||||||
:total="fileTotal"
|
|
||||||
:page-size="filePageSize"
|
|
||||||
:current-page="filePageNum"
|
|
||||||
@current-change="page => emit('file-page-change', page)"
|
|
||||||
@size-change="size => emit('file-size-change', size)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
<el-dialog v-model="backupFileDialogVisible" title="备份文件" width="760px" append-to-body class="backup-file-dialog">
|
||||||
|
<div class="backup-file-content">
|
||||||
|
<section v-for="(file, fileIndex) in activeBackupFiles" :key="file.id || fileIndex" class="backup-file-section">
|
||||||
|
<div v-if="activeBackupFiles.length > 1" class="backup-file-section-title">文件 {{ fileIndex + 1 }}</div>
|
||||||
|
<div class="backup-file-form">
|
||||||
|
<div v-for="item in getBackupFileItems(file)" :key="item.label" class="backup-file-item">
|
||||||
|
<span class="backup-file-label">{{ item.label }}</span>
|
||||||
|
<span class="backup-file-value" :title="item.value">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="backupFileDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { Delete, Refresh, Search } from '@element-plus/icons-vue'
|
import { Delete, Refresh, RefreshRight, VideoPause } from '@element-plus/icons-vue'
|
||||||
import type { Dbms } from '@/api/system/dbms/interface'
|
import type { Dbms } from '@/api/system/dbms/interface'
|
||||||
import {
|
import { formatFileSize, getTaskStatusMeta, parseJsonArrayText, resolveText, restoreModeLabels } from '../utils/normalize'
|
||||||
formatFileSize,
|
import type { DbmsTaskQuery } from './types'
|
||||||
getTaskStatusMeta,
|
|
||||||
operationTypeLabels,
|
|
||||||
parseJsonArrayText
|
|
||||||
} from '../utils/normalize'
|
|
||||||
import type { DbmsFileQuery, DbmsTaskQuery } from './types'
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'DbmsTaskStatusCard'
|
name: 'DbmsTaskStatusCard'
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
operationType: Dbms.OperationType
|
||||||
tasks: Dbms.TaskRecord[]
|
tasks: Dbms.TaskRecord[]
|
||||||
files: Dbms.BackupFileRecord[]
|
files: Dbms.BackupFileRecord[]
|
||||||
taskQuery: DbmsTaskQuery
|
taskQuery: DbmsTaskQuery
|
||||||
fileQuery: DbmsFileQuery
|
|
||||||
taskLoading: boolean
|
taskLoading: boolean
|
||||||
fileLoading: boolean
|
|
||||||
taskTotal: number
|
taskTotal: number
|
||||||
taskPageNum: number
|
taskPageNum: number
|
||||||
taskPageSize: number
|
taskPageSize: number
|
||||||
fileTotal: number
|
|
||||||
filePageNum: number
|
|
||||||
filePageSize: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'refresh-tasks': []
|
'refresh-tasks': []
|
||||||
'refresh-files': []
|
|
||||||
'search-tasks': [query: DbmsTaskQuery]
|
'search-tasks': [query: DbmsTaskQuery]
|
||||||
'search-files': [query: DbmsFileQuery]
|
|
||||||
'check-task': [row: Dbms.TaskRecord]
|
'check-task': [row: Dbms.TaskRecord]
|
||||||
|
'stop-task': [row: Dbms.TaskRecord]
|
||||||
|
'restart-task': [row: Dbms.TaskRecord]
|
||||||
'delete-task': [row: Dbms.TaskRecord]
|
'delete-task': [row: Dbms.TaskRecord]
|
||||||
'delete-file': [row: Dbms.BackupFileRecord]
|
|
||||||
'task-page-change': [page: number]
|
'task-page-change': [page: number]
|
||||||
'task-size-change': [size: number]
|
'task-size-change': [size: number]
|
||||||
'file-page-change': [page: number]
|
|
||||||
'file-size-change': [size: number]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeTab = ref('tasks')
|
const activeTab = ref('tasks')
|
||||||
const localTaskQuery = reactive<DbmsTaskQuery>({ taskStatus: props.taskQuery.taskStatus })
|
const localTaskQuery = reactive<DbmsTaskQuery>({ taskStatus: props.taskQuery.taskStatus })
|
||||||
const localFileQuery = reactive<DbmsFileQuery>({ taskId: props.fileQuery.taskId })
|
const backupFileDialogVisible = ref(false)
|
||||||
|
const activeBackupFiles = ref<Dbms.BackupFileRecord[]>([])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.taskQuery,
|
() => props.taskQuery,
|
||||||
@@ -173,24 +198,53 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.fileQuery,
|
|
||||||
query => {
|
|
||||||
localFileQuery.taskId = query.taskId
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const emitTaskQuery = () => {
|
const emitTaskQuery = () => {
|
||||||
emit('search-tasks', { taskStatus: localTaskQuery.taskStatus })
|
emit('search-tasks', { taskStatus: localTaskQuery.taskStatus })
|
||||||
}
|
}
|
||||||
|
|
||||||
const emitFileQuery = () => {
|
const formatDateTime = (value?: string | null) => {
|
||||||
emit('search-files', { taskId: localFileQuery.taskId })
|
const text = resolveText(value)
|
||||||
|
if (!text) return '-'
|
||||||
|
|
||||||
|
const parsed = dayjs(text)
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : text
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOperationTypeLabel = (operationType: Dbms.OperationType) => {
|
const formatRestoreMode = (restoreMode?: Dbms.RestoreMode | null) => {
|
||||||
return operationTypeLabels[operationType] || operationType
|
const text = resolveText(restoreMode)
|
||||||
|
return restoreModeLabels[text as Dbms.RestoreMode] || text || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskBackupFiles = (row: Dbms.TaskRecord) => {
|
||||||
|
return props.files.filter(file => file.taskId === row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTaskFilePath = (row: Dbms.TaskRecord) => {
|
||||||
|
return resolveText(getTaskBackupFiles(row)[0]?.filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTaskBackupFileDialog = (row: Dbms.TaskRecord) => {
|
||||||
|
activeBackupFiles.value = getTaskBackupFiles(row)
|
||||||
|
backupFileDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBackupFileItems = (file: Dbms.BackupFileRecord) => [
|
||||||
|
{ label: '文件名', value: resolveText(file.fileName) || '-' },
|
||||||
|
{ label: '数据库名', value: resolveText(file.schemaName) || '-' },
|
||||||
|
{ label: '备份模式', value: resolveText(file.backupMode) || '-' },
|
||||||
|
{ label: '对象', value: parseJsonArrayText(file.targetNamesJson) || '-' },
|
||||||
|
{ label: '大小', value: formatFileSize(file.fileSize) },
|
||||||
|
{ label: '路径', value: resolveText(file.filePath) || '-' },
|
||||||
|
{ label: '创建时间', value: formatDateTime(file.createTime) }
|
||||||
|
]
|
||||||
|
|
||||||
|
const canStopTask = (row: Dbms.TaskRecord) => {
|
||||||
|
return row.operationType === 'BACKUP' && row.taskStatus === 'RUNNING'
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRestartTask = (row: Dbms.TaskRecord) => {
|
||||||
|
const restartableStatuses: Dbms.TaskStatus[] = ['RUNNING', 'FAIL', 'FAILED']
|
||||||
|
return row.operationType === 'BACKUP' && row.taskStatus !== 'SUCCESS' && restartableStatuses.includes(row.taskStatus)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -255,4 +309,90 @@ const getOperationTypeLabel = (operationType: Dbms.OperationType) => {
|
|||||||
flex: none;
|
flex: none;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.path-link {
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-content {
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-section + .backup-file-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-section-title {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
column-gap: 44px;
|
||||||
|
row-gap: 28px;
|
||||||
|
padding: 4px 18px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-label {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-value {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 30px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.backup-file-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
:key="item.command"
|
:key="item.command"
|
||||||
class="toolbar-item"
|
class="toolbar-item"
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ 'is-disabled': item.disabled }"
|
:class="{ 'is-disabled': isToolbarItemDisabled(item) }"
|
||||||
:disabled="item.disabled"
|
:disabled="isToolbarItemDisabled(item)"
|
||||||
@click="emit('command', item.command)"
|
@click="emit('command', item.command)"
|
||||||
>
|
>
|
||||||
<el-icon class="toolbar-icon">
|
<el-icon class="toolbar-icon">
|
||||||
@@ -39,8 +39,8 @@ defineOptions({
|
|||||||
name: 'DbmsToolbar'
|
name: 'DbmsToolbar'
|
||||||
})
|
})
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
hasSelectedConnection: boolean
|
hasConnectedConnection: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -52,19 +52,24 @@ const toolbarItems: Array<{
|
|||||||
command: DbmsToolbarCommand
|
command: DbmsToolbarCommand
|
||||||
icon: Component
|
icon: Component
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
requiresConnection?: boolean
|
||||||
}> = [
|
}> = [
|
||||||
{ label: '连接', command: 'connect', icon: Link },
|
{ label: '连接', command: 'connect', icon: Link },
|
||||||
{ label: '新建查询', command: 'newQuery', icon: Search },
|
{ label: '新建查询', requiresConnection: true, command: 'newQuery', icon: Search },
|
||||||
{ label: '表', command: 'tables', icon: Grid },
|
{ label: '表', requiresConnection: true, command: 'tables', icon: Grid },
|
||||||
{ label: '视图', command: 'views', icon: View },
|
{ label: '视图', requiresConnection: true, command: 'views', icon: View },
|
||||||
{ label: '函数', command: 'functions', icon: Operation, disabled: true },
|
{ label: '函数', command: 'functions', icon: Operation, disabled: true },
|
||||||
{ label: '用户', command: 'users', icon: User, disabled: true },
|
{ label: '用户', command: 'users', icon: User, disabled: true },
|
||||||
{ label: '备份', command: 'backup', icon: FolderOpened },
|
{ label: '备份', requiresConnection: true, command: 'backup', icon: FolderOpened },
|
||||||
{ label: '自动运行', command: 'automation', icon: RefreshRight, disabled: true },
|
{ label: '自动运行', command: 'automation', icon: RefreshRight, disabled: true },
|
||||||
{ label: '模型', command: 'model', icon: Coin, disabled: true },
|
{ label: '模型', command: 'model', icon: Coin, disabled: true },
|
||||||
{ label: 'BI', command: 'bi', icon: DataAnalysis, disabled: true },
|
{ label: 'BI', command: 'bi', icon: DataAnalysis, disabled: true },
|
||||||
{ label: '新建连接', command: 'newConnection', icon: Connection }
|
{ label: '新建连接', command: 'newConnection', icon: Connection }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const isToolbarItemDisabled = (item: (typeof toolbarItems)[number]) => {
|
||||||
|
return Boolean(item.disabled || (item.requiresConnection && !props.hasConnectedConnection))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="dbms-workspace">
|
<section class="dbms-workspace">
|
||||||
<div class="workspace-header">
|
<div v-if="activeSection !== 'backup'" class="workspace-header">
|
||||||
<div class="workspace-title">
|
<div class="workspace-title">
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
<small v-if="selectedConnection">{{ selectedConnection.host }}:{{ selectedConnection.port }}</small>
|
<span v-if="selectedConnection" class="workspace-title-suffix">
|
||||||
|
({{ selectedConnection.host }}:{{ selectedConnection.port }})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="workspace-actions">
|
<div class="workspace-actions">
|
||||||
<el-button v-if="selectedConnection" type="primary" plain :icon="Connection" @click="emit('edit-connection', selectedConnection)">
|
<el-button v-if="selectedConnection" type="primary" plain :icon="Connection" @click="emit('edit-connection', selectedConnection)">
|
||||||
@@ -34,10 +36,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeSection === 'tables'" class="table-workspace">
|
<div v-else-if="activeSection === 'tables'" class="table-workspace">
|
||||||
<el-table :data="tables" border stripe height="100%" v-loading="tableLoading">
|
<el-table
|
||||||
<el-table-column prop="tableName" label="Name" min-width="220" fixed="left" show-overflow-tooltip />
|
:data="tables"
|
||||||
<el-table-column prop="owner" label="Owner" min-width="160" show-overflow-tooltip />
|
border
|
||||||
<el-table-column prop="comments" label="Comments" min-width="260" show-overflow-tooltip />
|
stripe
|
||||||
|
height="100%"
|
||||||
|
highlight-current-row
|
||||||
|
v-loading="tableLoading"
|
||||||
|
@current-change="handleTableCurrentChange"
|
||||||
|
@selection-change="handleTableSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="46" fixed="left" />
|
||||||
|
<el-table-column prop="tableName" label="名称" min-width="210" fixed="left" show-overflow-tooltip sortable />
|
||||||
|
<el-table-column label="自动递增值" width="130" align="center" sort-by="autoIncrement" sortable>
|
||||||
|
<template #default="{ row }">{{ formatEmptyValue(row.autoIncrement) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="updateTime" label="修改日期" min-width="160" align="center" show-overflow-tooltip sortable>
|
||||||
|
<template #default="{ row }">{{ formatEmptyValue(row.updateTime) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据长度" width="120" align="center" sort-by="dataLength" sortable>
|
||||||
|
<template #default="{ row }">{{ formatDataLength(row.dataLength) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="engine" label="引擎" width="110" align="center" show-overflow-tooltip sortable>
|
||||||
|
<template #default="{ row }">{{ formatEmptyValue(row.engine) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="行" width="100" align="center" sort-by="tableRows" sortable>
|
||||||
|
<template #default="{ row }">{{ formatEmptyValue(row.tableRows) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="comments" label="注释" min-width="220" show-overflow-tooltip sortable>
|
||||||
|
<template #default="{ row }">{{ formatEmptyValue(row.comments) }}</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,37 +78,65 @@
|
|||||||
:selected-connection="selectedConnection"
|
:selected-connection="selectedConnection"
|
||||||
:tables="tables"
|
:tables="tables"
|
||||||
:files="files"
|
:files="files"
|
||||||
:table-loading="tableLoading"
|
|
||||||
@load-tables="emit('load-tables')"
|
|
||||||
@backup="payload => emit('backup', payload)"
|
|
||||||
@restore="payload => emit('restore', payload)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DbmsTaskStatusCard
|
|
||||||
v-else-if="activeSection === 'tasks'"
|
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:files="files"
|
:restore-tasks="restoreTasks"
|
||||||
:task-query="taskQuery"
|
:task-query="taskQuery"
|
||||||
|
:restore-task-query="restoreTaskQuery"
|
||||||
:file-query="fileQuery"
|
:file-query="fileQuery"
|
||||||
|
:table-loading="tableLoading"
|
||||||
:task-loading="taskLoading"
|
:task-loading="taskLoading"
|
||||||
|
:restore-task-loading="restoreTaskLoading"
|
||||||
:file-loading="fileLoading"
|
:file-loading="fileLoading"
|
||||||
:task-total="taskTotal"
|
:task-total="taskTotal"
|
||||||
|
:restore-task-total="restoreTaskTotal"
|
||||||
:task-page-num="taskPageNum"
|
:task-page-num="taskPageNum"
|
||||||
:task-page-size="taskPageSize"
|
:task-page-size="taskPageSize"
|
||||||
|
:restore-task-page-num="restoreTaskPageNum"
|
||||||
|
:restore-task-page-size="restoreTaskPageSize"
|
||||||
:file-total="fileTotal"
|
:file-total="fileTotal"
|
||||||
:file-page-num="filePageNum"
|
:file-page-num="filePageNum"
|
||||||
:file-page-size="filePageSize"
|
:file-page-size="filePageSize"
|
||||||
|
@load-tables="emit('load-tables')"
|
||||||
|
@backup="payload => emit('backup', payload)"
|
||||||
|
@restore="payload => emit('restore', payload)"
|
||||||
@refresh-tasks="emit('refresh-tasks')"
|
@refresh-tasks="emit('refresh-tasks')"
|
||||||
|
@refresh-restore-tasks="emit('refresh-restore-tasks')"
|
||||||
@refresh-files="emit('refresh-files')"
|
@refresh-files="emit('refresh-files')"
|
||||||
@search-tasks="query => emit('search-tasks', query)"
|
@search-tasks="query => emit('search-tasks', query)"
|
||||||
|
@search-restore-tasks="query => emit('search-restore-tasks', query)"
|
||||||
@search-files="query => emit('search-files', query)"
|
@search-files="query => emit('search-files', query)"
|
||||||
@check-task="row => emit('check-task', row)"
|
@check-task="row => emit('check-task', row)"
|
||||||
|
@stop-task="row => emit('stop-task', row)"
|
||||||
|
@restart-task="row => emit('restart-task', row)"
|
||||||
@delete-task="row => emit('delete-task', row)"
|
@delete-task="row => emit('delete-task', row)"
|
||||||
@delete-file="row => emit('delete-file', row)"
|
@delete-file="row => emit('delete-file', row)"
|
||||||
@task-page-change="page => emit('task-page-change', page)"
|
@task-page-change="page => emit('task-page-change', page)"
|
||||||
@task-size-change="size => emit('task-size-change', size)"
|
@task-size-change="size => emit('task-size-change', size)"
|
||||||
|
@restore-task-page-change="page => emit('restore-task-page-change', page)"
|
||||||
|
@restore-task-size-change="size => emit('restore-task-size-change', size)"
|
||||||
@file-page-change="page => emit('file-page-change', page)"
|
@file-page-change="page => emit('file-page-change', page)"
|
||||||
@file-size-change="size => emit('file-size-change', size)"
|
@file-size-change="size => emit('file-size-change', size)"
|
||||||
|
@edit-connection="row => emit('edit-connection', row)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DbmsTaskStatusCard
|
||||||
|
v-else-if="activeSection === 'tasks'"
|
||||||
|
operation-type="BACKUP"
|
||||||
|
:tasks="tasks"
|
||||||
|
:files="files"
|
||||||
|
:task-query="taskQuery"
|
||||||
|
:task-loading="taskLoading"
|
||||||
|
:task-total="taskTotal"
|
||||||
|
:task-page-num="taskPageNum"
|
||||||
|
:task-page-size="taskPageSize"
|
||||||
|
@refresh-tasks="emit('refresh-tasks')"
|
||||||
|
@search-tasks="query => emit('search-tasks', query)"
|
||||||
|
@check-task="row => emit('check-task', row)"
|
||||||
|
@stop-task="row => emit('stop-task', row)"
|
||||||
|
@restart-task="row => emit('restart-task', row)"
|
||||||
|
@delete-task="row => emit('delete-task', row)"
|
||||||
|
@task-page-change="page => emit('task-page-change', page)"
|
||||||
|
@task-size-change="size => emit('task-size-change', size)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="workspace-empty">
|
<div v-else class="workspace-empty">
|
||||||
@@ -90,7 +146,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Connection, Refresh } from '@element-plus/icons-vue'
|
import { Connection, Refresh } from '@element-plus/icons-vue'
|
||||||
import type { Dbms } from '@/api/system/dbms/interface'
|
import type { Dbms } from '@/api/system/dbms/interface'
|
||||||
import DbmsTaskPanel from './DbmsTaskPanel.vue'
|
import DbmsTaskPanel from './DbmsTaskPanel.vue'
|
||||||
@@ -113,14 +169,20 @@ const props = defineProps<{
|
|||||||
tables: Dbms.TableRecord[]
|
tables: Dbms.TableRecord[]
|
||||||
files: Dbms.BackupFileRecord[]
|
files: Dbms.BackupFileRecord[]
|
||||||
tasks: Dbms.TaskRecord[]
|
tasks: Dbms.TaskRecord[]
|
||||||
|
restoreTasks: Dbms.TaskRecord[]
|
||||||
taskQuery: DbmsTaskQuery
|
taskQuery: DbmsTaskQuery
|
||||||
|
restoreTaskQuery: DbmsTaskQuery
|
||||||
fileQuery: DbmsFileQuery
|
fileQuery: DbmsFileQuery
|
||||||
tableLoading: boolean
|
tableLoading: boolean
|
||||||
taskLoading: boolean
|
taskLoading: boolean
|
||||||
|
restoreTaskLoading: boolean
|
||||||
fileLoading: boolean
|
fileLoading: boolean
|
||||||
taskTotal: number
|
taskTotal: number
|
||||||
|
restoreTaskTotal: number
|
||||||
taskPageNum: number
|
taskPageNum: number
|
||||||
taskPageSize: number
|
taskPageSize: number
|
||||||
|
restoreTaskPageNum: number
|
||||||
|
restoreTaskPageSize: number
|
||||||
fileTotal: number
|
fileTotal: number
|
||||||
filePageNum: number
|
filePageNum: number
|
||||||
filePageSize: number
|
filePageSize: number
|
||||||
@@ -132,14 +194,20 @@ const emit = defineEmits<{
|
|||||||
backup: [payload: BackupSubmitPayload]
|
backup: [payload: BackupSubmitPayload]
|
||||||
restore: [payload: RestoreSubmitPayload]
|
restore: [payload: RestoreSubmitPayload]
|
||||||
'refresh-tasks': []
|
'refresh-tasks': []
|
||||||
|
'refresh-restore-tasks': []
|
||||||
'refresh-files': []
|
'refresh-files': []
|
||||||
'search-tasks': [query: DbmsTaskQuery]
|
'search-tasks': [query: DbmsTaskQuery]
|
||||||
|
'search-restore-tasks': [query: DbmsTaskQuery]
|
||||||
'search-files': [query: DbmsFileQuery]
|
'search-files': [query: DbmsFileQuery]
|
||||||
'check-task': [row: Dbms.TaskRecord]
|
'check-task': [row: Dbms.TaskRecord]
|
||||||
|
'stop-task': [row: Dbms.TaskRecord]
|
||||||
|
'restart-task': [row: Dbms.TaskRecord]
|
||||||
'delete-task': [row: Dbms.TaskRecord]
|
'delete-task': [row: Dbms.TaskRecord]
|
||||||
'delete-file': [row: Dbms.BackupFileRecord]
|
'delete-file': [row: Dbms.BackupFileRecord]
|
||||||
'task-page-change': [page: number]
|
'task-page-change': [page: number]
|
||||||
'task-size-change': [size: number]
|
'task-size-change': [size: number]
|
||||||
|
'restore-task-page-change': [page: number]
|
||||||
|
'restore-task-size-change': [size: number]
|
||||||
'file-page-change': [page: number]
|
'file-page-change': [page: number]
|
||||||
'file-size-change': [size: number]
|
'file-size-change': [size: number]
|
||||||
}>()
|
}>()
|
||||||
@@ -153,7 +221,36 @@ const sectionTitles: Record<DbmsWorkspaceSection, string> = {
|
|||||||
tasks: '任务与备份文件'
|
tasks: '任务与备份文件'
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = computed(() => sectionTitles[props.activeSection] || '工作区')
|
const title = computed(() => {
|
||||||
|
const selectedConnection = props.selectedConnection
|
||||||
|
if (!selectedConnection) return sectionTitles[props.activeSection] || '工作区'
|
||||||
|
|
||||||
|
return selectedConnection.connectionName
|
||||||
|
})
|
||||||
|
const selectedTable = ref<Dbms.TableRecord | null>(null)
|
||||||
|
const selectedTables = ref<Dbms.TableRecord[]>([])
|
||||||
|
|
||||||
|
const handleTableCurrentChange = (row?: Dbms.TableRecord) => {
|
||||||
|
selectedTable.value = row || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTableSelectionChange = (rows: Dbms.TableRecord[]) => {
|
||||||
|
selectedTables.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEmptyValue = (value?: string | number | null) => {
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDataLength = (value?: string | number | null) => {
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
const numericValue = Number(value)
|
||||||
|
if (!Number.isFinite(numericValue)) return value
|
||||||
|
if (numericValue < 1024) return `${numericValue} B`
|
||||||
|
if (numericValue < 1024 * 1024) return `${Math.round(numericValue / 1024)} KB`
|
||||||
|
return `${Math.round(numericValue / 1024 / 1024)} MB`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -175,30 +272,31 @@ const title = computed(() => sectionTitles[props.activeSection] || '工作区')
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: 46px;
|
min-height: 34px;
|
||||||
padding: 8px 12px;
|
padding: 4px 12px;
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-title {
|
.workspace-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 2px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-title small {
|
.workspace-title span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-title-suffix {
|
||||||
|
flex: none;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-actions {
|
.workspace-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -44,5 +44,6 @@ export interface DbmsTreeNode {
|
|||||||
label: string
|
label: string
|
||||||
type: DbmsTreeNodeType
|
type: DbmsTreeNodeType
|
||||||
connection?: Dbms.ConnectionRecord
|
connection?: Dbms.ConnectionRecord
|
||||||
|
section?: DbmsWorkspaceSection
|
||||||
children?: DbmsTreeNode[]
|
children?: DbmsTreeNode[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||||
|
|
||||||
|
const apiSource = fs.readFileSync(path.join(root, '../../../api/system/dbms/index.ts'), 'utf8')
|
||||||
|
const apiTypesSource = fs.readFileSync(path.join(root, '../../../api/system/dbms/interface/index.ts'), 'utf8')
|
||||||
|
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||||
|
const statusCardSource = read('components/DbmsTaskStatusCard.vue')
|
||||||
|
const pageSource = read('index.vue')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['api should expose stop backup task endpoint', /stopDbmsBackupTask[\s\S]*\/database\/backups\/tasks\/stop/.test(apiSource)],
|
||||||
|
['api should expose restart backup task endpoint', /restartDbmsBackupTask[\s\S]*\/database\/backups\/tasks\/restart/.test(apiSource)],
|
||||||
|
['api types should define stop backup task params', /interface StopBackupTaskParams[\s\S]*taskId:\s*string/.test(apiTypesSource)],
|
||||||
|
['api types should define restart backup task params', /interface RestartBackupTaskParams[\s\S]*taskId:\s*string/.test(apiTypesSource)],
|
||||||
|
['backup file type should include metadataFilePath', /metadataFilePath\?:\s*string \| null/.test(apiTypesSource)],
|
||||||
|
['backup targetNames should be required by form rules', /targetNames:\s*\[\{ required:\s*true/.test(taskPanelSource)],
|
||||||
|
['backup table selection form item should bind targetNames prop', /label="表选择"[\s\S]*prop="targetNames"/.test(taskPanelSource)],
|
||||||
|
['task status card should emit stop task', /'stop-task':\s*\[row:\s*Dbms\.TaskRecord\]/.test(statusCardSource)],
|
||||||
|
['task status card should emit restart task', /'restart-task':\s*\[row:\s*Dbms\.TaskRecord\]/.test(statusCardSource)],
|
||||||
|
['task status card should render disabled stop action for unavailable backup tasks', /:disabled="!canStopTask\(row\)"[\s\S]*emit\('stop-task', row\)/.test(statusCardSource)],
|
||||||
|
['task status card should render disabled restart action for unavailable backup tasks', /:disabled="!canRestartTask\(row\)"[\s\S]*emit\('restart-task', row\)/.test(statusCardSource)],
|
||||||
|
['backup file dialog should hide metadata path', !/label:\s*'metadata'[\s\S]*file\.metadataFilePath/.test(statusCardSource)],
|
||||||
|
['backup file dialog should hide checksum', !/label:\s*'checksum'[\s\S]*file\.checksum/.test(statusCardSource)],
|
||||||
|
['backup file dialog should hide log file', !/label:\s*'日志文件'[\s\S]*file\.logFileName/.test(statusCardSource)],
|
||||||
|
['page should handle stop task event', /@stop-task="handleStopTask"/.test(pageSource)],
|
||||||
|
['page should handle restart task event', /@restart-task="handleRestartTask"/.test(pageSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms backup restore api debug contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms backup restore api debug contract passed')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const indexSource = fs.readFileSync(path.join(root, 'index.vue'), 'utf8')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
const workspaceSource = fs.readFileSync(path.join(root, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||||
|
|
||||||
|
const backupHandlerMatch = indexSource.match(
|
||||||
|
/const handleCreateBackup = async \([\s\S]*?\n}\n\nconst handleCreateRestore/
|
||||||
|
)
|
||||||
|
const backupHandlerSource = backupHandlerMatch?.[0] || ''
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'backup creation should keep user on backup panel instead of opening tasks section',
|
||||||
|
backupHandlerSource && !/activeSection\.value\s*=\s*['"]tasks['"]/.test(backupHandlerSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup panel should embed task records for same-page feedback',
|
||||||
|
/<DbmsTaskStatusCard[\s\S]*class="embedded-status-card"[\s\S]*:tasks="currentTasks"/.test(taskPanelSource) &&
|
||||||
|
/currentTasks[\s\S]*activeTab\.value === 'restore' \? props\.restoreTasks : props\.tasks/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup panel should own connection info area to free workspace header space',
|
||||||
|
/v-if="activeSection !== 'backup'"[\s\S]*class="workspace-header"/.test(workspaceSource) &&
|
||||||
|
/class="task-panel-connection"/.test(taskPanelSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms backup task same-page contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms backup task same-page contract passed')
|
||||||
@@ -11,7 +11,10 @@ const checks = [
|
|||||||
['dialog uses compact Navicat-like width', /width="630px"/.test(dialogSource)],
|
['dialog uses compact Navicat-like width', /width="630px"/.test(dialogSource)],
|
||||||
['dialog uses shared runtime size class', /class="dbms-connection-size-dialog"/.test(dialogSource)],
|
['dialog uses shared runtime size class', /class="dbms-connection-size-dialog"/.test(dialogSource)],
|
||||||
['dialog renders connection flow header', /class="connection-flow"/.test(dialogSource)],
|
['dialog renders connection flow header', /class="connection-flow"/.test(dialogSource)],
|
||||||
['dialog keeps Basic connection type visible', /model-value="Basic"/.test(dialogSource)],
|
[
|
||||||
|
'dialog keeps Basic connection type visible only for Oracle',
|
||||||
|
/el-form-item\s+v-if="selectedDbType === 'ORACLE'"\s+label="连接类型"[\s\S]*model-value="Basic"/.test(dialogSource)
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'dialog exposes service name and SID radio choices',
|
'dialog exposes service name and SID radio choices',
|
||||||
/el-radio[\s\S]*SERVICE_NAME[\s\S]*el-radio[\s\S]*SID/.test(dialogSource)
|
/el-radio[\s\S]*SERVICE_NAME[\s\S]*el-radio[\s\S]*SID/.test(dialogSource)
|
||||||
@@ -33,6 +36,20 @@ const checks = [
|
|||||||
[
|
[
|
||||||
'new Oracle connection defaults service name to ORCL',
|
'new Oracle connection defaults service name to ORCL',
|
||||||
/serviceName:\s*resolveText\(record\?\.serviceName\)\s*\|\|\s*'ORCL'/.test(payloadSource)
|
/serviceName:\s*resolveText\(record\?\.serviceName\)\s*\|\|\s*'ORCL'/.test(payloadSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'new MySQL connection defaults port to 3306',
|
||||||
|
/port:\s*Number\(record\?\.port\)\s*\|\|\s*\(dbType\s*===\s*'MYSQL'\s*\?\s*3306\s*:\s*1521\)/.test(payloadSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'new MySQL connection defaults username to root',
|
||||||
|
/username:\s*resolveText\(record\?\.username\)\s*\|\|\s*\(dbType\s*===\s*'MYSQL'\s*\?\s*'root'\s*:\s*''\)/.test(
|
||||||
|
payloadSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'connection form open initializes with selected database type defaults',
|
||||||
|
/applyForm\(createConnectionForm\(record,\s*selectedDbType\.value\)\)/.test(dialogSource)
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.join(currentDir, '..')
|
||||||
|
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||||
|
|
||||||
|
const treeSource = read('components/DbmsConnectionTree.vue')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['connection tree uses real connection name as root label', /label:\s*connection\.connectionName/.test(treeSource)],
|
||||||
|
[
|
||||||
|
'connection tree uses databaseName for MySQL child label',
|
||||||
|
/connection\.dbType\s*===\s*'MYSQL'\s*\?\s*connection\.databaseName\?\.trim\(\)/.test(treeSource)
|
||||||
|
],
|
||||||
|
['connection tree does not hardcode table placeholder node', !/label:\s*'表'/.test(treeSource)],
|
||||||
|
['connection tree does not hardcode view placeholder node', !/label:\s*'视图'/.test(treeSource)],
|
||||||
|
['connection tree does not build fake table group type', !/type:\s*'tableGroup'/.test(treeSource)],
|
||||||
|
['connection tree does not build fake view group type', !/type:\s*'viewGroup'/.test(treeSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('dbms connection tree data contract failed:')
|
||||||
|
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms connection tree data contract passed')
|
||||||
@@ -51,10 +51,7 @@ const checks = [
|
|||||||
['selector content fills dialog body', /connection-type-dialog[\s\S]*flex:\s*1/.test(read(files.selector))],
|
['selector content fills dialog body', /connection-type-dialog[\s\S]*flex:\s*1/.test(read(files.selector))],
|
||||||
['selector only exposes Oracle and MySQL', /type:\s*'ORACLE'[\s\S]*type:\s*'MYSQL'/.test(read(files.selector))],
|
['selector only exposes Oracle and MySQL', /type:\s*'ORACLE'[\s\S]*type:\s*'MYSQL'/.test(read(files.selector))],
|
||||||
['selector keeps next action explicit', /emit\('next',\s*selectedType\.value\)/.test(read(files.selector))],
|
['selector keeps next action explicit', /emit\('next',\s*selectedType\.value\)/.test(read(files.selector))],
|
||||||
[
|
['page allows MySQL connection type selection', !/if\s*\(dbType\s*===\s*'MYSQL'\)/.test(read(files.page))],
|
||||||
'page blocks MySQL until backend is available',
|
|
||||||
/if\s*\(dbType\s*===\s*'MYSQL'\)[\s\S]*MySQL 连接配置暂未接入/.test(read(files.page))
|
|
||||||
],
|
|
||||||
['connection form displays selected database type', /selectedDbType/.test(read(files.connectionDialog))],
|
['connection form displays selected database type', /selectedDbType/.test(read(files.connectionDialog))],
|
||||||
[
|
[
|
||||||
'connection form open accepts selected database type',
|
'connection form open accepts selected database type',
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ const pageDir = path.join(currentDir, '..')
|
|||||||
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||||
|
|
||||||
const pageSource = read('index.vue')
|
const pageSource = read('index.vue')
|
||||||
|
const apiSource = fs.readFileSync(path.join(pageDir, '../../../api/index.ts'), 'utf8')
|
||||||
|
|
||||||
const onMountedBlock = pageSource.match(/onMounted\(\(\)\s*=>\s*\{[\s\S]*?\n\}\)/)?.[0] ?? ''
|
const onMountedBlock = pageSource.match(/onMounted\(\(\)\s*=>\s*\{[\s\S]*?\n\}\)/)?.[0] ?? ''
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
['page should not auto load dbms overview on menu open', !onMountedBlock.includes('loadOverview()')],
|
['page should not auto load dbms overview on menu open', !onMountedBlock.includes('loadOverview()')],
|
||||||
['page should not auto load dbms connections on menu open', !onMountedBlock.includes('loadConnections()')],
|
['page should refresh dbms connections when connection tree initializes', onMountedBlock.includes('initializeConnectionTree()')],
|
||||||
|
['initial connection tree refresh should use guarded initializer', /const initializeConnectionTree = async \(\) => \{[\s\S]*await loadConnections\(\)/.test(pageSource)],
|
||||||
|
['silent initial refresh should suppress business 404 message', /data\.code && data\.code !== ResultEnum\.SUCCESS[\s\S]*silentStatusError[\s\S]*return Promise\.reject\(data\)/.test(apiSource)],
|
||||||
['page should not auto load dbms tasks on menu open', !onMountedBlock.includes('loadTasks()')],
|
['page should not auto load dbms tasks on menu open', !onMountedBlock.includes('loadTasks()')],
|
||||||
['page should not auto load dbms files on menu open', !onMountedBlock.includes('loadFiles()')],
|
['page should not auto load dbms files on menu open', !onMountedBlock.includes('loadFiles()')],
|
||||||
['connection tree keeps manual refresh entry', /<DbmsConnectionTree[\s\S]*@refresh="loadConnections"/.test(pageSource)]
|
['connection tree keeps manual refresh entry', /<DbmsConnectionTree[\s\S]*@refresh="loadConnections"/.test(pageSource)]
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
page: path.join(root, 'index.vue'),
|
||||||
|
dialog: path.join(root, 'components/DbmsConnectionDialog.vue'),
|
||||||
|
taskPanel: path.join(root, 'components/DbmsTaskPanel.vue'),
|
||||||
|
payload: path.join(root, 'utils/taskPayload.ts'),
|
||||||
|
apiTypes: path.join(root, '../../../api/system/dbms/interface/index.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['connection list should not hardcode ORACLE dbType', !/dbType:\s*'ORACLE'/.test(read(files.page))],
|
||||||
|
['mysql entry should not be blocked in connection type selection', !/if\s*\(dbType\s*===\s*'MYSQL'\)/.test(read(files.page))],
|
||||||
|
['connection payload type should define databaseName', /databaseName\?:\s*string \| null/.test(read(files.apiTypes))],
|
||||||
|
['connection form model should define databaseName', /databaseName:\s*string/.test(read(files.payload))],
|
||||||
|
['payload builder should map MySQL databaseName', /databaseName:\s*dbType === 'MYSQL' \? form\.databaseName\.trim\(\) \|\| null : null/.test(read(files.payload))],
|
||||||
|
[
|
||||||
|
'backup file list should support MySQL JDBC_EXPORT strategy',
|
||||||
|
/backupStrategy:\s*backupStrategy \|\| undefined/.test(read(files.payload))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'mysql backup payload should prefer databaseName as schemaName fallback',
|
||||||
|
/connection\.dbType === 'MYSQL'[\s\S]*connection\.databaseName[\s\S]*connection\.schemaName/.test(read(files.payload))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'mysql task panel should lock backup strategy to JDBC_EXPORT',
|
||||||
|
/isMysqlConnection[\s\S]*backupForm\.backupStrategy = 'JDBC_EXPORT'/.test(read(files.taskPanel))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task panel should hide Data Pump Directory field',
|
||||||
|
!/label="Directory"[\s\S]*v-model="backupForm\.directoryName"/.test(read(files.taskPanel))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'mysql task panel should label schema input as database name',
|
||||||
|
/:label="schemaFieldLabel"/.test(read(files.taskPanel))
|
||||||
|
],
|
||||||
|
['connection dialog should render databaseName field for MySQL', /v-if="selectedDbType === 'MYSQL'"/.test(read(files.dialog))]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms mysql api debug contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms mysql api debug contract passed')
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||||
|
|
||||||
|
const pageSource = read('index.vue')
|
||||||
|
const apiDebugSource = read('API_DEBUG.md')
|
||||||
|
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||||
|
const payloadSource = read('utils/taskPayload.ts')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'backup strategy select should be locked for all connections',
|
||||||
|
/<el-select\s+v-model="backupForm\.backupStrategy"\s+disabled>/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task panel should not render Oracle Directory field for backup',
|
||||||
|
!/label="Directory"[\s\S]*v-model="backupForm\.directoryName"/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'connection watcher should always set JDBC_EXPORT',
|
||||||
|
/backupForm\.backupStrategy\s*=\s*'JDBC_EXPORT'/.test(taskPanelSource) &&
|
||||||
|
!/backupForm\.backupStrategy\s*=\s*'DATA_PUMP'/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup payload should always send JDBC_EXPORT',
|
||||||
|
/backupStrategy:\s*'JDBC_EXPORT'/.test(payloadSource) && !/backupStrategy:\s*connection\.dbType/.test(payloadSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'JDBC_EXPORT backup payload should not send Data Pump directoryName',
|
||||||
|
/directoryName:\s*null/.test(payloadSource) && !/directoryName:\s*connection\.dbType === 'ORACLE'/.test(payloadSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup file list should always query JDBC_EXPORT files',
|
||||||
|
/buildFileListParams\([\s\S]*fileQuery\.taskId,\s*'JDBC_EXPORT'[\s\S]*\)/.test(pageSource) &&
|
||||||
|
!/selectedConnection\.value\?\.dbType === 'MYSQL' \? 'JDBC_EXPORT' : 'DATA_PUMP'/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'api debug should document unified JDBC_EXPORT strategy',
|
||||||
|
/当前前端统一使用 `JDBC_EXPORT`/.test(apiDebugSource) &&
|
||||||
|
/Oracle JDBC_EXPORT 备份/.test(apiDebugSource) &&
|
||||||
|
!/Oracle Data Pump 备份|MySQL 仅支持 `JDBC_EXPORT`|MySQL 大数据量 JDBC_EXPORT/.test(apiDebugSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms oracle jdbc export contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms oracle jdbc export contract passed')
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||||
|
|
||||||
|
const apiSource = read('../../../api/system/dbms/index.ts')
|
||||||
|
const pageSource = read('index.vue')
|
||||||
|
const workspaceSource = read('components/DbmsWorkspace.vue')
|
||||||
|
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'api should expose restore task list endpoint',
|
||||||
|
/getDbmsRestoreTaskList[\s\S]*\/database\/restores\/tasks\/list/.test(apiSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page should keep restore task state separate from backup tasks',
|
||||||
|
/const restoreTasks = ref<Dbms\.TaskRecord\[\]>\(\[\]\)/.test(pageSource) &&
|
||||||
|
/const restoreTaskTotal = ref\(0\)/.test(pageSource) &&
|
||||||
|
/const restoreTaskPage = reactive\(\{ pageNum: 1, pageSize: 10 \}\)/.test(pageSource) &&
|
||||||
|
/const restoreTaskQuery = reactive<DbmsTaskQuery>\(\{ taskStatus: '' \}\)/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page should load restore task list from restore endpoint',
|
||||||
|
/const loadRestoreTasks = async \(\) => \{[\s\S]*getDbmsRestoreTaskList[\s\S]*restoreTasks\.value = result\.data\.records \|\| \[\][\s\S]*restoreTaskTotal\.value = result\.data\.total \|\| 0[\s\S]*\}/.test(
|
||||||
|
pageSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'workspace should pass restore task props into backup and restore panel',
|
||||||
|
/:restore-tasks="restoreTasks"/.test(workspaceSource) &&
|
||||||
|
/:restore-task-query="restoreTaskQuery"/.test(workspaceSource) &&
|
||||||
|
/:restore-task-total="restoreTaskTotal"/.test(workspaceSource) &&
|
||||||
|
/:restore-task-page-num="restoreTaskPageNum"/.test(workspaceSource) &&
|
||||||
|
/:restore-task-page-size="restoreTaskPageSize"/.test(workspaceSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task panel should select restore task records when restore tab is active',
|
||||||
|
/const currentTasks = computed\(\(\) => \(activeTab\.value === 'restore' \? props\.restoreTasks : props\.tasks\)\)/.test(
|
||||||
|
taskPanelSource
|
||||||
|
) &&
|
||||||
|
/:tasks="currentTasks"/.test(taskPanelSource) &&
|
||||||
|
/@refresh-tasks="handleRefreshCurrentTasks"/.test(taskPanelSource) &&
|
||||||
|
/@search-tasks="handleSearchCurrentTasks"/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore creation should refresh restore task records without opening outer task page',
|
||||||
|
/const handleCreateRestore = async[\s\S]*await loadRestoreTasks\(\)[\s\S]*pollTaskStatus\(result\.data\.taskId, 'RESTORE'\)/.test(
|
||||||
|
pageSource
|
||||||
|
) && !/const handleCreateRestore = async[\s\S]*activeSection\.value\s*=\s*'tasks'/.test(pageSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms restore task list contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms restore task list contract passed')
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||||
|
|
||||||
|
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||||
|
const statusCardSource = read('components/DbmsTaskStatusCard.vue')
|
||||||
|
const workspaceSource = read('components/DbmsWorkspace.vue')
|
||||||
|
|
||||||
|
const tableStart = statusCardSource.indexOf('<el-table :data="tasks"')
|
||||||
|
const tableEnd = statusCardSource.indexOf('</el-table>', tableStart)
|
||||||
|
const taskTableSource = statusCardSource.slice(tableStart, tableEnd)
|
||||||
|
const restoreStart = taskTableSource.indexOf('<template v-if="operationType === \'RESTORE\'">')
|
||||||
|
const backupStart = taskTableSource.indexOf('<template v-else>', restoreStart)
|
||||||
|
const restoreTableSource = taskTableSource.slice(restoreStart, backupStart)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'task panel should pass active operation type into embedded task table',
|
||||||
|
/:operation-type="currentOperationType"/.test(taskPanelSource) &&
|
||||||
|
/const currentOperationType = computed\(\(\) => \(activeTab\.value === 'restore' \? 'RESTORE' : 'BACKUP'\)\)/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
['task status card should accept operation type prop', /operationType: Dbms\.OperationType/.test(statusCardSource)],
|
||||||
|
[
|
||||||
|
'outer task status card should keep backup operation type',
|
||||||
|
/<DbmsTaskStatusCard[\s\S]*operation-type="BACKUP"[\s\S]*:tasks="tasks"/.test(workspaceSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore task table should use restore-specific columns instead of the backup task column structure',
|
||||||
|
/prop="taskNo"/.test(restoreTableSource) &&
|
||||||
|
/prop="schemaName"/.test(restoreTableSource) &&
|
||||||
|
/formatRestoreMode\(row\.restoreMode\)/.test(restoreTableSource) &&
|
||||||
|
!/parseJsonArrayText\(row\.targetNamesJson\)/.test(restoreTableSource) &&
|
||||||
|
!/resolveTaskFilePath\(row\)/.test(restoreTableSource)
|
||||||
|
],
|
||||||
|
['restore task table should keep a check status action', /emit\('check-task', row\)/.test(restoreTableSource)],
|
||||||
|
[
|
||||||
|
'restore task table should not render backup stop and restart actions',
|
||||||
|
!/emit\('stop-task', row\)/.test(restoreTableSource) && !/emit\('restart-task', row\)/.test(restoreTableSource)
|
||||||
|
],
|
||||||
|
['restore task table action column should shrink', /width="150" fixed="right"/.test(restoreTableSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms restore task table display contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms restore task table display contract passed')
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.resolve(__dirname, '..')
|
||||||
|
const workspaceSource = fs.readFileSync(path.join(pageDir, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||||
|
|
||||||
|
const hasExpectedWidth = /label="自动递增值"[^>]*width="130"/.test(workspaceSource)
|
||||||
|
|
||||||
|
if (!hasExpectedWidth) {
|
||||||
|
console.error('dbms table auto increment width contract failed:')
|
||||||
|
console.error('- auto increment column width must be 130')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms table auto increment width contract passed')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.resolve(__dirname, '..')
|
||||||
|
const workspaceSource = fs.readFileSync(path.join(pageDir, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||||
|
|
||||||
|
const centeredColumns = [
|
||||||
|
['autoIncrement', /label="自动递增值"[^>]*align="center"/],
|
||||||
|
['updateTime', /prop="updateTime"[^>]*align="center"/],
|
||||||
|
['dataLength', /label="数据长度"[^>]*align="center"/],
|
||||||
|
['engine', /prop="engine"[^>]*align="center"/],
|
||||||
|
['tableRows', /label="行"[^>]*align="center"/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const errors = centeredColumns
|
||||||
|
.filter(([, pattern]) => !pattern.test(workspaceSource))
|
||||||
|
.map(([column]) => `${column} column must align center`)
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
console.error('dbms table column align contract failed:')
|
||||||
|
for (const error of errors) console.error(`- ${error}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms table column align contract passed')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.join(currentDir, '..')
|
||||||
|
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||||
|
|
||||||
|
const workspaceSource = read('components/DbmsWorkspace.vue')
|
||||||
|
const interfaceSource = fs.readFileSync(
|
||||||
|
path.join(pageDir, '..', '..', '..', 'api', 'system', 'dbms', 'interface', 'index.ts'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['table list uses Navicat name column label', /label="名称"/.test(workspaceSource)],
|
||||||
|
['table list shows auto increment value', /label="自动递增值"/.test(workspaceSource)],
|
||||||
|
['table list shows update time', /label="修改日期"/.test(workspaceSource)],
|
||||||
|
['table list shows data length', /label="数据长度"/.test(workspaceSource)],
|
||||||
|
['table list shows engine', /label="引擎"/.test(workspaceSource)],
|
||||||
|
['table list shows row count', /label="行"/.test(workspaceSource)],
|
||||||
|
['table list shows comments', /label="注释"/.test(workspaceSource)],
|
||||||
|
['table record type includes optional Navicat-like metadata', /autoIncrement/.test(interfaceSource) && /dataLength/.test(interfaceSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('dbms table list Navicat contract failed:')
|
||||||
|
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms table list Navicat contract passed')
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.resolve(__dirname, '..')
|
||||||
|
const workspaceSource = fs.readFileSync(path.join(pageDir, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['table list has selection column', /<el-table-column\s+type="selection"/.test(workspaceSource)],
|
||||||
|
['table list highlights current row', /<el-table[\s\S]*\bhighlight-current-row\b/.test(workspaceSource)],
|
||||||
|
['table list listens current row changes', /<el-table[\s\S]*@current-change="handleTableCurrentChange"/.test(workspaceSource)],
|
||||||
|
['table list listens multi selection changes', /<el-table[\s\S]*@selection-change="handleTableSelectionChange"/.test(workspaceSource)],
|
||||||
|
['workspace stores selected table', /const selectedTable = ref<Dbms\.TableRecord \| null>\(null\)/.test(workspaceSource)],
|
||||||
|
['workspace stores selected tables', /const selectedTables = ref<Dbms\.TableRecord\[\]>\(\[\]\)/.test(workspaceSource)],
|
||||||
|
['workspace updates selected table', /const handleTableCurrentChange = \(row\?: Dbms\.TableRecord\) => \{[\s\S]*selectedTable\.value = row \|\| null[\s\S]*\}/.test(workspaceSource)],
|
||||||
|
['workspace updates selected tables', /const handleTableSelectionChange = \(rows: Dbms\.TableRecord\[\]\) => \{[\s\S]*selectedTables\.value = rows[\s\S]*\}/.test(workspaceSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const errors = checks.filter(([, passed]) => !passed).map(([message]) => message)
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
console.error('dbms table selection contract failed:')
|
||||||
|
for (const error of errors) console.error(`- ${error}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms table selection contract passed')
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const read = relativePath => fs.readFileSync(path.join(pageDir, relativePath), 'utf8')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
{
|
||||||
|
file: 'components/DbmsOperationTable.vue',
|
||||||
|
columns: [
|
||||||
|
'connectionName',
|
||||||
|
'host',
|
||||||
|
'port',
|
||||||
|
'connectType',
|
||||||
|
'schemaName',
|
||||||
|
'username',
|
||||||
|
'directoryName',
|
||||||
|
'savePassword',
|
||||||
|
'lastTestStatus',
|
||||||
|
'lastTestMessage'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'components/DbmsTaskStatusCard.vue',
|
||||||
|
columns: [
|
||||||
|
'taskNo',
|
||||||
|
'operationType',
|
||||||
|
'taskStatus',
|
||||||
|
'progressPercent',
|
||||||
|
'schemaName',
|
||||||
|
'targetNamesJson',
|
||||||
|
'resultMessage',
|
||||||
|
'startedAt',
|
||||||
|
'finishedAt',
|
||||||
|
'fileName',
|
||||||
|
'backupMode',
|
||||||
|
'fileSize',
|
||||||
|
'filePath',
|
||||||
|
'logFileName',
|
||||||
|
'createTime'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'components/DbmsWorkspace.vue',
|
||||||
|
columns: ['tableName', 'autoIncrement', 'updateTime', 'dataLength', 'engine', 'tableRows', 'comments']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (const { file, columns } of expectations) {
|
||||||
|
const source = read(file)
|
||||||
|
for (const column of columns) {
|
||||||
|
const sortableByProp = new RegExp(`prop="${column}"[^>]*\\bsortable\\b`).test(source)
|
||||||
|
const sortableBySortBy = new RegExp(`sort-by="${column}"[^>]*\\bsortable\\b|\\bsortable\\b[^>]*sort-by="${column}"`).test(source)
|
||||||
|
if (!sortableByProp && !sortableBySortBy) {
|
||||||
|
errors.push(`${file} column ${column} must be sortable`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
console.error('dbms table sortable contract failed:')
|
||||||
|
for (const error of errors) console.error(`- ${error}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms table sortable contract passed')
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'backup form grid should render three columns on desktop',
|
||||||
|
/\.form-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup mode layout should expose classes for time range and size split modes',
|
||||||
|
/backupModeLayoutClass[\s\S]*is-time-range[\s\S]*is-size-split/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup form grid should keep one column on narrow screens',
|
||||||
|
/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*\.form-grid[\s\S]*\{[\s\S]*grid-template-columns:\s*1fr/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore form fields should share the same three-column form grid',
|
||||||
|
/<el-tab-pane\s+label="创建恢复"[\s\S]*<div class="restore-form-grid form-grid">[\s\S]*prop="backupFileId"[\s\S]*v-model="restoreForm\.restoreMode"[\s\S]*v-model="restoreForm\.targetSchemaName"[\s\S]*prop="overwriteConfirmText"[\s\S]*<el-checkbox[\s\S]*v-model="overwriteConfirmChecked"[\s\S]*我确认清空或覆盖目标数据[\s\S]*class="restore-table-action"[\s\S]*handleRestoreSubmit[\s\S]*开始恢复[\s\S]*<\/div>[\s\S]*<\/div>/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore overwrite checkbox should keep backend confirm text value',
|
||||||
|
/watch\(\s*overwriteConfirmChecked[\s\S]*restoreForm\.overwriteConfirmText\s*=\s*checked\s*\?\s*OVERWRITE_CONFIRM_TEXT\s*:\s*''/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore backup select should be derived from successful backup tasks',
|
||||||
|
/const restoreBackupOptions = computed\(\(\) =>[\s\S]*props\.tasks[\s\S]*task\.operationType === 'BACKUP'[\s\S]*task\.taskStatus === 'SUCCESS'[\s\S]*props\.files\.find\(file => file\.taskId === task\.id\)/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore backup select should display task number and file path while submitting backup file id',
|
||||||
|
/v-for="option in restoreBackupOptions"[\s\S]*:key="option\.backupFileId"[\s\S]*:label="option\.taskNo"[\s\S]*:value="option\.backupFileId"[\s\S]*restore-backup-option[\s\S]*option\.taskNo[\s\S]*option\.filePath/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'restore submit action should stay in the rightmost desktop column',
|
||||||
|
/\.restore-table-action\s*\{[\s\S]*grid-column:\s*3[\s\S]*justify-content:\s*flex-end/.test(taskPanelSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms task panel backup grid contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task panel backup grid contract passed')
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
const getStyleRules = selector => {
|
||||||
|
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
return [...taskPanelSource.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, 'g'))]
|
||||||
|
.map(match => match[1])
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupTableSelectRule = getStyleRules('.backup-table-select')
|
||||||
|
const backupTableActionRule = getStyleRules('.backup-table-action')
|
||||||
|
const sizeSplitActionRule = getStyleRules('.backup-form-grid.is-size-split .backup-table-action')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'backup panel should not render selected connection header',
|
||||||
|
!/<div class="card-header">[\s\S]*当前连接/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup panel should embed task status card below the form',
|
||||||
|
/<DbmsTaskStatusCard[\s\S]*:tasks="currentTasks"[\s\S]*:files="files"[\s\S]*@refresh-tasks="handleRefreshCurrentTasks"/.test(
|
||||||
|
taskPanelSource
|
||||||
|
) && /currentTasks[\s\S]*activeTab\.value === 'restore' \? props\.restoreTasks : props\.tasks/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup mode layout should use one three-column grid for dynamic fields',
|
||||||
|
/<div class="backup-form-grid form-grid"[\s\S]*:class="backupModeLayoutClass"[\s\S]*<el-form-item[\s\S]*class="backup-table-select"[\s\S]*handleBackupSubmit[\s\S]*<\/div>/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup table select should keep one desktop form column',
|
||||||
|
/grid-column:\s*span 1/.test(backupTableSelectRule)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup submit action should stay in the rightmost desktop column by default',
|
||||||
|
/grid-column:\s*3/.test(backupTableActionRule)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'size split backup submit action should flow after file size and table select',
|
||||||
|
/grid-column:\s*span 1/.test(sizeSplitActionRule)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup submit action should align to the right of the table select row',
|
||||||
|
/justify-content:\s*flex-end/.test(backupTableActionRule)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms task panel backup submit layout contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task panel backup submit layout contract passed')
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'task panel should render connection summary in its own topbar instead of tabs extra slot',
|
||||||
|
/class="task-panel-topbar"[\s\S]*class="task-panel-connection"/.test(taskPanelSource) &&
|
||||||
|
!/#extra[\s\S]*class="task-panel-connection"/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task panel connection summary should include database or schema name before host and port',
|
||||||
|
/const connectionTitle = computed[\s\S]*databaseName[\s\S]*schemaName[\s\S]*connection\.host[\s\S]*connection\.port/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task panel should keep connection info button opening current connection',
|
||||||
|
/<el-button[\s\S]*@click="emit\('edit-connection', selectedConnection\)"[\s\S]*<\/el-button>/.test(taskPanelSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms task panel connection summary contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task panel connection summary contract passed')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
taskPanel: path.join(root, 'components/DbmsTaskPanel.vue'),
|
||||||
|
payload: path.join(root, 'utils/taskPayload.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
const taskPanelSource = read(files.taskPanel)
|
||||||
|
const payloadSource = read(files.payload)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['task panel should not render temporary password field', !/label="临时密码"/.test(taskPanelSource)],
|
||||||
|
['backup form model should not keep temporaryPassword', !/DbmsBackupFormModel[\s\S]*temporaryPassword:\s*string/.test(payloadSource)],
|
||||||
|
['restore form model should not keep temporaryPassword', !/DbmsRestoreFormModel[\s\S]*temporaryPassword:\s*string/.test(payloadSource)],
|
||||||
|
['backup payload should not send temporaryPassword', !/buildBackupPayload[\s\S]*temporaryPassword:/.test(payloadSource)],
|
||||||
|
['restore payload should not send temporaryPassword', !/buildRestorePayload[\s\S]*temporaryPassword:/.test(payloadSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms task panel passwordless task contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task panel passwordless task contract passed')
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'task panel card body should not add top-level padding',
|
||||||
|
/\.card-body\s*\{[\s\S]*padding:\s*0/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup and restore operation tabs should not add extra top padding',
|
||||||
|
!/\.operation-tabs\s*\{[\s\S]*padding-top:/.test(taskPanelSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms task panel tab spacing contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task panel tab spacing contract passed')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.join(currentDir, '..')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(pageDir, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['backup table select supports multiple values', /<el-select[\s\S]*v-model="backupForm\.targetNames"[\s\S]*\bmultiple\b/.test(taskPanelSource)],
|
||||||
|
['backup table select keeps collapsed tags tooltip', /<el-select[\s\S]*v-model="backupForm\.targetNames"[\s\S]*\bcollapse-tags-tooltip\b/.test(taskPanelSource)],
|
||||||
|
[
|
||||||
|
'backup table select displays at most two selected table tags before collapsing',
|
||||||
|
/<el-select[\s\S]*v-model="backupForm\.targetNames"[\s\S]*:max-collapse-tags="2"/.test(taskPanelSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup table select dropdown should not force a two-column option grid',
|
||||||
|
!/\.dbms-table-select-popper \.el-select-dropdown__list\s*\{[\s\S]*grid-template-columns:\s*repeat\(2/.test(taskPanelSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('dbms task panel table select contract failed:')
|
||||||
|
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task panel table select contract passed')
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const statusCardSource = fs.readFileSync(path.join(root, 'components/DbmsTaskStatusCard.vue'), 'utf8')
|
||||||
|
|
||||||
|
const taskTableStart = statusCardSource.indexOf('<el-table :data="tasks"')
|
||||||
|
const taskTableEnd = statusCardSource.indexOf('</el-table>', taskTableStart)
|
||||||
|
const taskTableSource = statusCardSource.slice(taskTableStart, taskTableEnd)
|
||||||
|
const restoreStart = taskTableSource.indexOf('<template v-if="operationType === \'RESTORE\'">')
|
||||||
|
const backupStart = taskTableSource.indexOf('<template v-else>', restoreStart)
|
||||||
|
const restoreTableSource = taskTableSource.slice(restoreStart, backupStart)
|
||||||
|
const backupTableSource = taskTableSource.slice(backupStart)
|
||||||
|
|
||||||
|
const backupOrderedMarkers = [
|
||||||
|
'prop="taskNo"',
|
||||||
|
'prop="schemaName"',
|
||||||
|
'parseJsonArrayText(row.targetNamesJson)',
|
||||||
|
'prop="progressPercent"',
|
||||||
|
'sort-by="taskStatus"',
|
||||||
|
'resolveTaskFilePath(row)',
|
||||||
|
'formatDateTime(row.startedAt)',
|
||||||
|
'formatDateTime(row.finishedAt)',
|
||||||
|
'prop="resultMessage"',
|
||||||
|
'width="230"'
|
||||||
|
]
|
||||||
|
const markerPositions = backupOrderedMarkers.map(marker => [marker, backupTableSource.indexOf(marker)])
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['task record table should not render type column', !/label="[^"]*type[^"]*"/i.test(taskTableSource)],
|
||||||
|
['task status card should not render backup file tab', !/localFileQuery|emitFileQuery|refresh-files|file-page-change/.test(statusCardSource)],
|
||||||
|
['backup task record table should include database column', /prop="schemaName"/.test(backupTableSource)],
|
||||||
|
[
|
||||||
|
'backup task record table should follow required column order',
|
||||||
|
markerPositions.every(([, index]) => index >= 0) &&
|
||||||
|
markerPositions.every(([, index], currentIndex) => currentIndex === 0 || index > markerPositions[currentIndex - 1][1])
|
||||||
|
],
|
||||||
|
['restore task record table should use restore mode formatter', /formatRestoreMode\(row\.restoreMode\)/.test(restoreTableSource)],
|
||||||
|
['task record path should default to dash', /resolveTaskFilePath\(row\) \|\| '-'/.test(statusCardSource)],
|
||||||
|
['task record path should open backup file dialog', /openTaskBackupFileDialog\(row\)/.test(statusCardSource)],
|
||||||
|
['backup file dialog should use readonly form layout', /backup-file-form/.test(statusCardSource)],
|
||||||
|
['backup file dialog should not use table layout', !/<el-dialog[\s\S]*<el-table/.test(statusCardSource)],
|
||||||
|
['backup file dialog should render readonly values', /backup-file-value/.test(statusCardSource)],
|
||||||
|
['backup file dialog should keep footer slot', /#footer/.test(statusCardSource)],
|
||||||
|
['task dates should use normalized datetime formatter', /formatDateTime\(row\.startedAt\)/.test(statusCardSource)],
|
||||||
|
['task dates should use normalized datetime formatter for finish time', /formatDateTime\(row\.finishedAt\)/.test(statusCardSource)],
|
||||||
|
['running tasks should expose start action', /canRestartTask\(row\)[\s\S]*emit\('restart-task', row\)/.test(statusCardSource)],
|
||||||
|
['successful tasks should disable start action instead of hiding it', /:disabled="!canRestartTask\(row\)"/.test(statusCardSource)],
|
||||||
|
['non-running tasks should disable stop action instead of hiding it', /:disabled="!canStopTask\(row\)"/.test(statusCardSource)],
|
||||||
|
['task actions should not hide stop action', !/v-if="canStopTask\(row\)"/.test(statusCardSource)],
|
||||||
|
['task actions should not hide start action', !/v-if="canRestartTask\(row\)"/.test(statusCardSource)],
|
||||||
|
[
|
||||||
|
'task action column should stay wide for backup and shrink for restore',
|
||||||
|
/width="150" fixed="right"/.test(restoreTableSource) && /width="230" fixed="right"/.test(backupTableSource)
|
||||||
|
],
|
||||||
|
['task action buttons should stay on one line', /class="task-actions"/.test(statusCardSource) && /white-space:\s*nowrap/.test(statusCardSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms task status card contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms task status card contract passed')
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.join(currentDir, '..')
|
||||||
|
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||||
|
|
||||||
|
const pageSource = read('index.vue')
|
||||||
|
const toolbarSource = read('components/DbmsToolbar.vue')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['page stores connected connection id separately', /const connectedConnectionId = ref\(''\)/.test(pageSource)],
|
||||||
|
[
|
||||||
|
'page passes selected connected state to toolbar',
|
||||||
|
/<DbmsToolbar[\s\S]*:has-connected-connection="isSelectedConnectionConnected"/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'stored connection test marks connection connected only after success',
|
||||||
|
/if\s*\(result\.data\.success && payload\.connectionId\)[\s\S]*connectedConnectionId\.value = payload\.connectionId/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'successful table loading also marks the selected database connected',
|
||||||
|
/tables\.value = result\.data \|\| \[\][\s\S]*connectedConnectionId\.value = selectedConnection\.value\.id/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'toolbar disables query table view and backup until connected',
|
||||||
|
/requiresConnection:\s*true[\s\S]*command:\s*'newQuery'[\s\S]*requiresConnection:\s*true[\s\S]*command:\s*'tables'[\s\S]*requiresConnection:\s*true[\s\S]*command:\s*'views'[\s\S]*requiresConnection:\s*true[\s\S]*command:\s*'backup'/.test(
|
||||||
|
toolbarSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
['toolbar derives item disabled state from connected prop', /item\.requiresConnection && !props\.hasConnectedConnection/.test(toolbarSource)]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('dbms toolbar connected state contract failed:')
|
||||||
|
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms toolbar connected state contract passed')
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageDir = path.join(currentDir, '..')
|
||||||
|
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||||
|
|
||||||
|
const pageSource = read('index.vue')
|
||||||
|
const treeSource = read('components/DbmsConnectionTree.vue')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['tree node binds double click connect event', /@dblclick\.stop="handleNodeDoubleClick\(data\)"/.test(treeSource)],
|
||||||
|
['tree exposes connect emit for stored connection', /connect:\s*\[connection:\s*Dbms\.ConnectionRecord\]/.test(treeSource)],
|
||||||
|
['tree exposes open tables emit for schema double click', /openTables:\s*\[connection:\s*Dbms\.ConnectionRecord\]/.test(treeSource)],
|
||||||
|
['schema double click opens database table list', /if\s*\(node\.type === 'schema'\)[\s\S]*emit\('openTables',\s*node\.connection\)/.test(treeSource)],
|
||||||
|
['page listens tree connect event', /<DbmsConnectionTree[\s\S]*@connect="handleTestStoredConnection"/.test(pageSource)],
|
||||||
|
['page listens tree open tables event', /<DbmsConnectionTree[\s\S]*@open-tables="handleOpenConnectionTables"/.test(pageSource)],
|
||||||
|
['page reuses stored connection test handler', /const handleTestStoredConnection = async \(row: Dbms\.ConnectionRecord\) => \{[\s\S]*connectionId:\s*row\.id/.test(pageSource)],
|
||||||
|
[
|
||||||
|
'table list loading should silence missing backend route status toast',
|
||||||
|
/getDbmsTableList\(\{[\s\S]*schemaName:[\s\S]*\},\s*\{\s*silentStatusError:\s*true\s*\}\)/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table list loading should fall back when backend route is missing',
|
||||||
|
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*tables\.value\s*=\s*\[\][\s\S]*ElMessage\.warning/.test(pageSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tree selection background task loads should silence missing backend route status toast',
|
||||||
|
/getDbmsBackupTaskList\(/.test(pageSource) &&
|
||||||
|
/getDbmsRestoreTaskList\(/.test(pageSource) &&
|
||||||
|
/getDbmsBackupFileList\(/.test(pageSource) &&
|
||||||
|
(pageSource.match(/silentStatusError:\s*true/g) || []).length >= 5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tree selection background task loads should fall back when backend routes are missing',
|
||||||
|
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*tasks\.value\s*=\s*\[\][\s\S]*taskTotal\.value\s*=\s*0/.test(
|
||||||
|
pageSource
|
||||||
|
) &&
|
||||||
|
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*restoreTasks\.value\s*=\s*\[\][\s\S]*restoreTaskTotal\.value\s*=\s*0/.test(
|
||||||
|
pageSource
|
||||||
|
) &&
|
||||||
|
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*files\.value\s*=\s*\[\][\s\S]*fileTotal\.value\s*=\s*0/.test(
|
||||||
|
pageSource
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('dbms tree double click connect contract failed:')
|
||||||
|
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms tree double click connect contract passed')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..')
|
||||||
|
const workspaceSource = fs.readFileSync(path.join(root, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||||
|
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'workspace title should render connection name with host and port suffix',
|
||||||
|
/<span>\{\{ title \}\}<\/span>[\s\S]*class="workspace-title-suffix"[\s\S]*selectedConnection\.host[\s\S]*selectedConnection\.port/.test(
|
||||||
|
workspaceSource
|
||||||
|
) && /return selectedConnection\.connectionName/.test(workspaceSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'workspace header should not render host and port as a separate small subtitle',
|
||||||
|
!/<small\s+v-if="selectedConnection">[\s\S]*selectedConnection\.host[\s\S]*selectedConnection\.port[\s\S]*<\/small>/.test(
|
||||||
|
workspaceSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'workspace title host and port suffix should use normal font weight',
|
||||||
|
/class="workspace-title-suffix"[\s\S]*selectedConnection\.host[\s\S]*selectedConnection\.port/.test(workspaceSource) &&
|
||||||
|
/\.workspace-title-suffix\s*\{[\s\S]*font-weight:\s*400/.test(workspaceSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup workspace should not render the outer workspace header',
|
||||||
|
/v-if="activeSection !== 'backup'"[\s\S]*class="workspace-header"/.test(workspaceSource)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup task panel should render connection title in topbar area',
|
||||||
|
/class="task-panel-topbar"[\s\S]*task-panel-connection[\s\S]*\{\{ connectionTitle \}\}/.test(taskPanelSource) &&
|
||||||
|
/const connectionTitle = computed[\s\S]*connection\.connectionName[\s\S]*connection\.host[\s\S]*connection\.port/.test(
|
||||||
|
taskPanelSource
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backup task panel should expose connection info action',
|
||||||
|
/@click="emit\('edit-connection', selectedConnection\)"/.test(taskPanelSource)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, passed]) => !passed)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('dbms workspace title contract failed:')
|
||||||
|
for (const [message] of failed) {
|
||||||
|
console.error(`- ${message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('dbms workspace title contract passed')
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="table-box dbms-page">
|
<div class="table-box dbms-page">
|
||||||
<div class="dbms-workbench">
|
<div class="dbms-workbench">
|
||||||
<DbmsToolbar :has-selected-connection="Boolean(selectedConnection)" @command="handleToolbarCommand" />
|
<DbmsToolbar :has-connected-connection="isSelectedConnectionConnected" @command="handleToolbarCommand" />
|
||||||
|
|
||||||
<div class="dbms-main" :class="{ 'is-tree-collapsed': treeCollapsed }">
|
<div class="dbms-main" :class="{ 'is-tree-collapsed': treeCollapsed }">
|
||||||
<aside class="dbms-tree-panel">
|
<aside class="dbms-tree-panel">
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
:collapsed="treeCollapsed"
|
:collapsed="treeCollapsed"
|
||||||
@toggle="treeCollapsed = !treeCollapsed"
|
@toggle="treeCollapsed = !treeCollapsed"
|
||||||
@refresh="loadConnections"
|
@refresh="loadConnections"
|
||||||
|
@connect="handleTestStoredConnection"
|
||||||
|
@open-tables="handleOpenConnectionTables"
|
||||||
@select-connection="handleSelectConnection"
|
@select-connection="handleSelectConnection"
|
||||||
@select-section="handleSelectSection"
|
@select-section="handleSelectSection"
|
||||||
/>
|
/>
|
||||||
@@ -23,14 +25,20 @@
|
|||||||
:tables="tables"
|
:tables="tables"
|
||||||
:files="files"
|
:files="files"
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
|
:restore-tasks="restoreTasks"
|
||||||
:task-query="taskQuery"
|
:task-query="taskQuery"
|
||||||
|
:restore-task-query="restoreTaskQuery"
|
||||||
:file-query="fileQuery"
|
:file-query="fileQuery"
|
||||||
:table-loading="tableLoading"
|
:table-loading="tableLoading"
|
||||||
:task-loading="taskLoading"
|
:task-loading="taskLoading"
|
||||||
|
:restore-task-loading="restoreTaskLoading"
|
||||||
:file-loading="fileLoading"
|
:file-loading="fileLoading"
|
||||||
:task-total="taskTotal"
|
:task-total="taskTotal"
|
||||||
|
:restore-task-total="restoreTaskTotal"
|
||||||
:task-page-num="taskPage.pageNum"
|
:task-page-num="taskPage.pageNum"
|
||||||
:task-page-size="taskPage.pageSize"
|
:task-page-size="taskPage.pageSize"
|
||||||
|
:restore-task-page-num="restoreTaskPage.pageNum"
|
||||||
|
:restore-task-page-size="restoreTaskPage.pageSize"
|
||||||
:file-total="fileTotal"
|
:file-total="fileTotal"
|
||||||
:file-page-num="filePage.pageNum"
|
:file-page-num="filePage.pageNum"
|
||||||
:file-page-size="filePage.pageSize"
|
:file-page-size="filePage.pageSize"
|
||||||
@@ -39,14 +47,20 @@
|
|||||||
@backup="handleCreateBackup"
|
@backup="handleCreateBackup"
|
||||||
@restore="handleCreateRestore"
|
@restore="handleCreateRestore"
|
||||||
@refresh-tasks="loadTasks"
|
@refresh-tasks="loadTasks"
|
||||||
|
@refresh-restore-tasks="loadRestoreTasks"
|
||||||
@refresh-files="loadFiles"
|
@refresh-files="loadFiles"
|
||||||
@search-tasks="handleSearchTasks"
|
@search-tasks="handleSearchTasks"
|
||||||
|
@search-restore-tasks="handleSearchRestoreTasks"
|
||||||
@search-files="handleSearchFiles"
|
@search-files="handleSearchFiles"
|
||||||
@check-task="handleCheckTask"
|
@check-task="handleCheckTask"
|
||||||
|
@stop-task="handleStopTask"
|
||||||
|
@restart-task="handleRestartTask"
|
||||||
@delete-task="handleDeleteTask"
|
@delete-task="handleDeleteTask"
|
||||||
@delete-file="handleDeleteFile"
|
@delete-file="handleDeleteFile"
|
||||||
@task-page-change="handleTaskPageChange"
|
@task-page-change="handleTaskPageChange"
|
||||||
@task-size-change="handleTaskSizeChange"
|
@task-size-change="handleTaskSizeChange"
|
||||||
|
@restore-task-page-change="handleRestoreTaskPageChange"
|
||||||
|
@restore-task-size-change="handleRestoreTaskSizeChange"
|
||||||
@file-page-change="handleFilePageChange"
|
@file-page-change="handleFilePageChange"
|
||||||
@file-size-change="handleFileSizeChange"
|
@file-size-change="handleFileSizeChange"
|
||||||
/>
|
/>
|
||||||
@@ -60,7 +74,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { Dbms } from '@/api/system/dbms/interface'
|
import type { Dbms } from '@/api/system/dbms/interface'
|
||||||
import {
|
import {
|
||||||
@@ -74,8 +88,11 @@ import {
|
|||||||
getDbmsBackupTaskList,
|
getDbmsBackupTaskList,
|
||||||
getDbmsBackupTaskStatus,
|
getDbmsBackupTaskStatus,
|
||||||
getDbmsConnectionList,
|
getDbmsConnectionList,
|
||||||
|
getDbmsRestoreTaskList,
|
||||||
getDbmsRestoreTaskStatus,
|
getDbmsRestoreTaskStatus,
|
||||||
getDbmsTableList,
|
getDbmsTableList,
|
||||||
|
restartDbmsBackupTask,
|
||||||
|
stopDbmsBackupTask,
|
||||||
testDbmsConnection,
|
testDbmsConnection,
|
||||||
updateDbmsConnection
|
updateDbmsConnection
|
||||||
} from '@/api/system/dbms'
|
} from '@/api/system/dbms'
|
||||||
@@ -119,8 +136,10 @@ const connectionTypeDialogRef = ref<ConnectionTypeDialogExpose>()
|
|||||||
const connectionDialogRef = ref<ConnectionDialogExpose>()
|
const connectionDialogRef = ref<ConnectionDialogExpose>()
|
||||||
const connections = ref<Dbms.ConnectionRecord[]>([])
|
const connections = ref<Dbms.ConnectionRecord[]>([])
|
||||||
const selectedConnection = ref<Dbms.ConnectionRecord | null>(null)
|
const selectedConnection = ref<Dbms.ConnectionRecord | null>(null)
|
||||||
|
const connectedConnectionId = ref('')
|
||||||
const tables = ref<Dbms.TableRecord[]>([])
|
const tables = ref<Dbms.TableRecord[]>([])
|
||||||
const tasks = ref<Dbms.TaskRecord[]>([])
|
const tasks = ref<Dbms.TaskRecord[]>([])
|
||||||
|
const restoreTasks = ref<Dbms.TaskRecord[]>([])
|
||||||
const files = ref<Dbms.BackupFileRecord[]>([])
|
const files = ref<Dbms.BackupFileRecord[]>([])
|
||||||
const activeSection = ref<DbmsWorkspaceSection>('overview')
|
const activeSection = ref<DbmsWorkspaceSection>('overview')
|
||||||
const treeCollapsed = ref(false)
|
const treeCollapsed = ref(false)
|
||||||
@@ -128,28 +147,32 @@ const treeCollapsed = ref(false)
|
|||||||
const connectionLoading = ref(false)
|
const connectionLoading = ref(false)
|
||||||
const tableLoading = ref(false)
|
const tableLoading = ref(false)
|
||||||
const taskLoading = ref(false)
|
const taskLoading = ref(false)
|
||||||
|
const restoreTaskLoading = ref(false)
|
||||||
const fileLoading = ref(false)
|
const fileLoading = ref(false)
|
||||||
const connectionTotal = ref(0)
|
const connectionTotal = ref(0)
|
||||||
const taskTotal = ref(0)
|
const taskTotal = ref(0)
|
||||||
|
const restoreTaskTotal = ref(0)
|
||||||
const fileTotal = ref(0)
|
const fileTotal = ref(0)
|
||||||
|
|
||||||
const connectionPage = reactive({ pageNum: 1, pageSize: 1000 })
|
const connectionPage = reactive({ pageNum: 1, pageSize: 1000 })
|
||||||
const taskPage = reactive({ pageNum: 1, pageSize: 10 })
|
const taskPage = reactive({ pageNum: 1, pageSize: 10 })
|
||||||
|
const restoreTaskPage = reactive({ pageNum: 1, pageSize: 10 })
|
||||||
const filePage = reactive({ pageNum: 1, pageSize: 10 })
|
const filePage = reactive({ pageNum: 1, pageSize: 10 })
|
||||||
const connectionQuery = reactive<DbmsConnectionQuery>({ connectionName: '', schemaName: '' })
|
const connectionQuery = reactive<DbmsConnectionQuery>({ connectionName: '', schemaName: '' })
|
||||||
const taskQuery = reactive<DbmsTaskQuery>({ taskStatus: '' })
|
const taskQuery = reactive<DbmsTaskQuery>({ taskStatus: '' })
|
||||||
|
const restoreTaskQuery = reactive<DbmsTaskQuery>({ taskStatus: '' })
|
||||||
const fileQuery = reactive<DbmsFileQuery>({ taskId: '' })
|
const fileQuery = reactive<DbmsFileQuery>({ taskId: '' })
|
||||||
|
const isSelectedConnectionConnected = computed(() => Boolean(selectedConnection.value?.id && selectedConnection.value.id === connectedConnectionId.value))
|
||||||
|
|
||||||
const loadConnections = async () => {
|
const loadConnections = async (options: { silentStatusError?: boolean } = {}) => {
|
||||||
connectionLoading.value = true
|
connectionLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await getDbmsConnectionList({
|
const result = await getDbmsConnectionList({
|
||||||
pageNum: connectionPage.pageNum,
|
pageNum: connectionPage.pageNum,
|
||||||
pageSize: connectionPage.pageSize,
|
pageSize: connectionPage.pageSize,
|
||||||
connectionName: connectionQuery.connectionName || undefined,
|
connectionName: connectionQuery.connectionName || undefined,
|
||||||
schemaName: connectionQuery.schemaName || undefined,
|
schemaName: connectionQuery.schemaName || undefined
|
||||||
dbType: 'ORACLE'
|
}, options)
|
||||||
})
|
|
||||||
connections.value = result.data.records || []
|
connections.value = result.data.records || []
|
||||||
connectionTotal.value = result.data.total || 0
|
connectionTotal.value = result.data.total || 0
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,9 +180,26 @@ const loadConnections = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNotFoundError = (error: unknown) => {
|
||||||
|
const status = typeof error === 'object' && error !== null && 'response' in error ? Number((error as any).response?.status) : 0
|
||||||
|
const code = typeof error === 'object' && error !== null && 'code' in error ? String((error as any).code) : ''
|
||||||
|
return status === 404 || code === '404'
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeConnectionTree = async () => {
|
||||||
|
try {
|
||||||
|
await loadConnections({ silentStatusError: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) throw error
|
||||||
|
|
||||||
|
connections.value = []
|
||||||
|
connectionTotal.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
if (!selectedConnection.value) {
|
if (!selectedConnection.value) {
|
||||||
ElMessage.warning('请先选择连接')
|
ElMessage.warning('璇峰厛閫夋嫨杩炴帴')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +207,16 @@ const loadTables = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await getDbmsTableList({
|
const result = await getDbmsTableList({
|
||||||
connectionId: selectedConnection.value.id,
|
connectionId: selectedConnection.value.id,
|
||||||
schemaName: selectedConnection.value.schemaName || undefined
|
schemaName: selectedConnection.value.schemaName || selectedConnection.value.databaseName || undefined
|
||||||
})
|
}, { silentStatusError: true })
|
||||||
tables.value = result.data || []
|
tables.value = result.data || []
|
||||||
|
connectedConnectionId.value = selectedConnection.value.id
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) throw error
|
||||||
|
|
||||||
|
// 表列表接口可能随后端版本缺失,避免双击数据库节点时被全局 404 toast 打断页面操作。
|
||||||
|
tables.value = []
|
||||||
|
ElMessage.warning('当前后端未提供表列表接口,无法加载数据表')
|
||||||
} finally {
|
} finally {
|
||||||
tableLoading.value = false
|
tableLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -179,23 +226,60 @@ const loadTasks = async () => {
|
|||||||
taskLoading.value = true
|
taskLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await getDbmsBackupTaskList(
|
const result = await getDbmsBackupTaskList(
|
||||||
buildTaskListParams(taskPage.pageNum, taskPage.pageSize, selectedConnection.value?.id, taskQuery.taskStatus)
|
buildTaskListParams(taskPage.pageNum, taskPage.pageSize, selectedConnection.value?.id, taskQuery.taskStatus),
|
||||||
|
{ silentStatusError: true }
|
||||||
)
|
)
|
||||||
tasks.value = result.data.records || []
|
tasks.value = result.data.records || []
|
||||||
taskTotal.value = result.data.total || 0
|
taskTotal.value = result.data.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) throw error
|
||||||
|
|
||||||
|
tasks.value = []
|
||||||
|
taskTotal.value = 0
|
||||||
} finally {
|
} finally {
|
||||||
taskLoading.value = false
|
taskLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadRestoreTasks = async () => {
|
||||||
|
restoreTaskLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await getDbmsRestoreTaskList(
|
||||||
|
buildTaskListParams(restoreTaskPage.pageNum, restoreTaskPage.pageSize, selectedConnection.value?.id, restoreTaskQuery.taskStatus),
|
||||||
|
{ silentStatusError: true }
|
||||||
|
)
|
||||||
|
restoreTasks.value = result.data.records || []
|
||||||
|
restoreTaskTotal.value = result.data.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) throw error
|
||||||
|
|
||||||
|
restoreTasks.value = []
|
||||||
|
restoreTaskTotal.value = 0
|
||||||
|
} finally {
|
||||||
|
restoreTaskLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadFiles = async () => {
|
const loadFiles = async () => {
|
||||||
fileLoading.value = true
|
fileLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await getDbmsBackupFileList(
|
const result = await getDbmsBackupFileList(
|
||||||
buildFileListParams(filePage.pageNum, filePage.pageSize, selectedConnection.value?.id, fileQuery.taskId)
|
buildFileListParams(
|
||||||
|
filePage.pageNum,
|
||||||
|
filePage.pageSize,
|
||||||
|
selectedConnection.value?.id,
|
||||||
|
fileQuery.taskId,
|
||||||
|
'JDBC_EXPORT'
|
||||||
|
),
|
||||||
|
{ silentStatusError: true }
|
||||||
)
|
)
|
||||||
files.value = result.data.records || []
|
files.value = result.data.records || []
|
||||||
fileTotal.value = result.data.total || 0
|
fileTotal.value = result.data.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) throw error
|
||||||
|
|
||||||
|
files.value = []
|
||||||
|
fileTotal.value = 0
|
||||||
} finally {
|
} finally {
|
||||||
fileLoading.value = false
|
fileLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -210,11 +294,6 @@ const openConnectionTypeDialog = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectConnectionType = (dbType: Dbms.DbType) => {
|
const handleSelectConnectionType = (dbType: Dbms.DbType) => {
|
||||||
if (dbType === 'MYSQL') {
|
|
||||||
ElMessage.info('MySQL 连接配置暂未接入')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionTypeDialogRef.value?.close()
|
connectionTypeDialogRef.value?.close()
|
||||||
connectionDialogRef.value?.open('add', undefined, dbType)
|
connectionDialogRef.value?.open('add', undefined, dbType)
|
||||||
}
|
}
|
||||||
@@ -231,7 +310,7 @@ const handleToolbarCommand = async (command: DbmsToolbarCommand) => {
|
|||||||
connect: () => openConnectionTypeDialog(),
|
connect: () => openConnectionTypeDialog(),
|
||||||
newConnection: () => openConnectionTypeDialog(),
|
newConnection: () => openConnectionTypeDialog(),
|
||||||
newQuery: () => {
|
newQuery: () => {
|
||||||
ElMessage.info('查询编辑器接口暂未接入')
|
ElMessage.info('查询编辑器功能暂未接入')
|
||||||
},
|
},
|
||||||
tables: () => handleSelectSection('tables'),
|
tables: () => handleSelectSection('tables'),
|
||||||
views: () => {
|
views: () => {
|
||||||
@@ -257,8 +336,10 @@ const handleSelectConnection = (row: Dbms.ConnectionRecord) => {
|
|||||||
tables.value = []
|
tables.value = []
|
||||||
activeSection.value = 'overview'
|
activeSection.value = 'overview'
|
||||||
taskPage.pageNum = 1
|
taskPage.pageNum = 1
|
||||||
|
restoreTaskPage.pageNum = 1
|
||||||
filePage.pageNum = 1
|
filePage.pageNum = 1
|
||||||
loadTasks()
|
loadTasks()
|
||||||
|
loadRestoreTasks()
|
||||||
loadFiles()
|
loadFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,16 +357,34 @@ const handleSaveConnection = async (payload: Dbms.ConnectionPayload) => {
|
|||||||
|
|
||||||
const handleTestConnectionPayload = async (payload: Dbms.TestConnectionParams) => {
|
const handleTestConnectionPayload = async (payload: Dbms.TestConnectionParams) => {
|
||||||
const result = await testDbmsConnection(payload)
|
const result = await testDbmsConnection(payload)
|
||||||
|
// 测试连接只做临时连通性校验,不刷新正式连接列表,避免未保存连接进入左侧连接树。
|
||||||
|
if (result.data.success && payload.connectionId) {
|
||||||
|
connectedConnectionId.value = payload.connectionId
|
||||||
|
}
|
||||||
ElMessage[result.data.success ? 'success' : 'error'](result.data.message || (result.data.success ? '连接成功' : '连接失败'))
|
ElMessage[result.data.success ? 'success' : 'error'](result.data.message || (result.data.success ? '连接成功' : '连接失败'))
|
||||||
await loadConnections()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTestStoredConnection = (row: Dbms.ConnectionRecord) => {
|
const handleTestStoredConnection = async (row: Dbms.ConnectionRecord) => {
|
||||||
handleTestConnectionPayload({ connectionId: row.id })
|
selectedConnection.value = row
|
||||||
|
await handleTestConnectionPayload({ connectionId: row.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenConnectionTables = async (row: Dbms.ConnectionRecord) => {
|
||||||
|
if (selectedConnection.value?.id !== row.id) {
|
||||||
|
selectedConnection.value = row
|
||||||
|
taskPage.pageNum = 1
|
||||||
|
restoreTaskPage.pageNum = 1
|
||||||
|
filePage.pageNum = 1
|
||||||
|
loadTasks()
|
||||||
|
loadRestoreTasks()
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
activeSection.value = 'tables'
|
||||||
|
await loadTables()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteConnection = async (row: Dbms.ConnectionRecord) => {
|
const handleDeleteConnection = async (row: Dbms.ConnectionRecord) => {
|
||||||
await ElMessageBox.confirm(`确认删除连接“${row.connectionName}”?`, '删除确认', {
|
await ElMessageBox.confirm(`确认删除连接“${row.connectionName}”吗?`, '删除确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: DELETE_CONFIRM_TEXT,
|
confirmButtonText: DELETE_CONFIRM_TEXT,
|
||||||
cancelButtonText: '取消'
|
cancelButtonText: '取消'
|
||||||
@@ -295,27 +394,33 @@ const handleDeleteConnection = async (row: Dbms.ConnectionRecord) => {
|
|||||||
selectedConnection.value = null
|
selectedConnection.value = null
|
||||||
tables.value = []
|
tables.value = []
|
||||||
}
|
}
|
||||||
|
if (connectedConnectionId.value === row.id) {
|
||||||
|
connectedConnectionId.value = ''
|
||||||
|
}
|
||||||
ElMessage.success('连接已删除')
|
ElMessage.success('连接已删除')
|
||||||
await loadConnections()
|
await loadConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollTaskStatus = async (taskId: string, operationType: Dbms.OperationType) => {
|
const pollTaskStatus = async (taskId: string, operationType: Dbms.OperationType) => {
|
||||||
// 异步任务创建后短轮询状态,避免页面停留在 WAITING 无反馈。
|
// 异步任务创建后短轮询状态,避免页面停留在 WAITING 时无反馈。
|
||||||
for (let index = 0; index < 8; index += 1) {
|
for (let index = 0; index < 8; index += 1) {
|
||||||
const result =
|
const result =
|
||||||
operationType === 'RESTORE' ? await getDbmsRestoreTaskStatus(taskId) : await getDbmsBackupTaskStatus(taskId)
|
operationType === 'RESTORE' ? await getDbmsRestoreTaskStatus(taskId) : await getDbmsBackupTaskStatus(taskId)
|
||||||
if (isTerminalTaskStatus(result.data.taskStatus)) break
|
if (isTerminalTaskStatus(result.data.taskStatus)) break
|
||||||
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
||||||
}
|
}
|
||||||
|
if (operationType === 'RESTORE') {
|
||||||
|
await loadRestoreTasks()
|
||||||
|
} else {
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
|
}
|
||||||
await loadFiles()
|
await loadFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateBackup = async ({ form }: { form: import('./utils/taskPayload').DbmsBackupFormModel }) => {
|
const handleCreateBackup = async ({ form }: { form: import('./utils/taskPayload').DbmsBackupFormModel }) => {
|
||||||
if (!selectedConnection.value) return
|
if (!selectedConnection.value) return
|
||||||
const result = await createDbmsBackupTask(buildBackupPayload(selectedConnection.value, form))
|
const result = await createDbmsBackupTask(buildBackupPayload(selectedConnection.value, form))
|
||||||
ElMessage.success(`备份任务已创建:${result.data.taskNo}`)
|
ElMessage.success(`备份已开始:${result.data.taskNo}`)
|
||||||
activeSection.value = 'tasks'
|
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
pollTaskStatus(result.data.taskId, 'BACKUP')
|
pollTaskStatus(result.data.taskId, 'BACKUP')
|
||||||
}
|
}
|
||||||
@@ -325,8 +430,7 @@ const handleCreateRestore = async ({ form }: { form: import('./utils/taskPayload
|
|||||||
// 覆盖类恢复必须由后端确认文案兜底,前端只负责传递明确确认值。
|
// 覆盖类恢复必须由后端确认文案兜底,前端只负责传递明确确认值。
|
||||||
const result = await createDbmsRestoreTask(buildRestorePayload(selectedConnection.value, form))
|
const result = await createDbmsRestoreTask(buildRestorePayload(selectedConnection.value, form))
|
||||||
ElMessage.success(`恢复任务已创建:${result.data.taskNo}`)
|
ElMessage.success(`恢复任务已创建:${result.data.taskNo}`)
|
||||||
activeSection.value = 'tasks'
|
await loadRestoreTasks()
|
||||||
await loadTasks()
|
|
||||||
pollTaskStatus(result.data.taskId, 'RESTORE')
|
pollTaskStatus(result.data.taskId, 'RESTORE')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,11 +438,39 @@ const handleCheckTask = async (row: Dbms.TaskRecord) => {
|
|||||||
const result =
|
const result =
|
||||||
row.operationType === 'RESTORE' ? await getDbmsRestoreTaskStatus(row.id) : await getDbmsBackupTaskStatus(row.id)
|
row.operationType === 'RESTORE' ? await getDbmsRestoreTaskStatus(row.id) : await getDbmsBackupTaskStatus(row.id)
|
||||||
ElMessage.info(result.data.resultMessage || '任务状态已刷新')
|
ElMessage.info(result.data.resultMessage || '任务状态已刷新')
|
||||||
|
if (row.operationType === 'RESTORE') {
|
||||||
|
await loadRestoreTasks()
|
||||||
|
} else {
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStopTask = async (row: Dbms.TaskRecord) => {
|
||||||
|
await ElMessageBox.confirm(`确认停止备份任务“${row.taskNo}”吗?`, '停止确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '停止',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await stopDbmsBackupTask({ taskId: row.id })
|
||||||
|
ElMessage.success('备份任务已停止')
|
||||||
|
await loadTasks()
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestartTask = async (row: Dbms.TaskRecord) => {
|
||||||
|
await ElMessageBox.confirm(`确认重新开始备份任务“${row.taskNo}”吗?`, '重启确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '重启',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
const result = await restartDbmsBackupTask({ taskId: row.id })
|
||||||
|
ElMessage.success(`备份任务已重新开始:${result.data.taskNo}`)
|
||||||
|
await loadTasks()
|
||||||
|
pollTaskStatus(result.data.taskId, 'BACKUP')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteTask = async (row: Dbms.TaskRecord) => {
|
const handleDeleteTask = async (row: Dbms.TaskRecord) => {
|
||||||
await ElMessageBox.confirm(`确认删除任务“${row.taskNo}”?`, '删除确认', {
|
await ElMessageBox.confirm(`确认删除任务“${row.taskNo}”吗?`, '删除确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: DELETE_CONFIRM_TEXT,
|
confirmButtonText: DELETE_CONFIRM_TEXT,
|
||||||
cancelButtonText: '取消'
|
cancelButtonText: '取消'
|
||||||
@@ -349,7 +481,7 @@ const handleDeleteTask = async (row: Dbms.TaskRecord) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteFile = async (row: Dbms.BackupFileRecord) => {
|
const handleDeleteFile = async (row: Dbms.BackupFileRecord) => {
|
||||||
await ElMessageBox.confirm(`确认删除备份文件“${row.fileName}”?`, '删除确认', {
|
await ElMessageBox.confirm(`确认删除备份文件“${row.fileName}”吗?`, '删除确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: DELETE_CONFIRM_TEXT,
|
confirmButtonText: DELETE_CONFIRM_TEXT,
|
||||||
cancelButtonText: '取消'
|
cancelButtonText: '取消'
|
||||||
@@ -365,6 +497,12 @@ const handleSearchTasks = (query: DbmsTaskQuery) => {
|
|||||||
loadTasks()
|
loadTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearchRestoreTasks = (query: DbmsTaskQuery) => {
|
||||||
|
Object.assign(restoreTaskQuery, query)
|
||||||
|
restoreTaskPage.pageNum = 1
|
||||||
|
loadRestoreTasks()
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchFiles = (query: DbmsFileQuery) => {
|
const handleSearchFiles = (query: DbmsFileQuery) => {
|
||||||
Object.assign(fileQuery, query)
|
Object.assign(fileQuery, query)
|
||||||
filePage.pageNum = 1
|
filePage.pageNum = 1
|
||||||
@@ -382,6 +520,17 @@ const handleTaskSizeChange = (size: number) => {
|
|||||||
loadTasks()
|
loadTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRestoreTaskPageChange = (page: number) => {
|
||||||
|
restoreTaskPage.pageNum = page
|
||||||
|
loadRestoreTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreTaskSizeChange = (size: number) => {
|
||||||
|
restoreTaskPage.pageSize = size
|
||||||
|
restoreTaskPage.pageNum = 1
|
||||||
|
loadRestoreTasks()
|
||||||
|
}
|
||||||
|
|
||||||
const handleFilePageChange = (page: number) => {
|
const handleFilePageChange = (page: number) => {
|
||||||
filePage.pageNum = page
|
filePage.pageNum = page
|
||||||
loadFiles()
|
loadFiles()
|
||||||
@@ -393,6 +542,10 @@ const handleFileSizeChange = (size: number) => {
|
|||||||
loadFiles()
|
loadFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 连接树初始化时拉取已保存连接;后端未提供 dbms 接口时保持空树,避免进入页面直接报 404。
|
||||||
|
initializeConnectionTree()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface DbmsConnectionFormModel {
|
|||||||
connectType: Dbms.ConnectType
|
connectType: Dbms.ConnectType
|
||||||
serviceName: string
|
serviceName: string
|
||||||
sid: string
|
sid: string
|
||||||
|
databaseName: string
|
||||||
schemaName: string
|
schemaName: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
@@ -28,27 +29,29 @@ export interface DbmsBackupFormModel {
|
|||||||
timeRange: string[]
|
timeRange: string[]
|
||||||
maxFileSizeMb: number | null
|
maxFileSizeMb: number | null
|
||||||
directoryName: string
|
directoryName: string
|
||||||
temporaryPassword: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbmsRestoreFormModel {
|
export interface DbmsRestoreFormModel {
|
||||||
backupFileId: string
|
backupFileId: string
|
||||||
restoreMode: Dbms.RestoreMode
|
restoreMode: Dbms.RestoreMode
|
||||||
targetSchemaName: string
|
targetSchemaName: string
|
||||||
temporaryPassword: string
|
|
||||||
overwriteConfirmText: string
|
overwriteConfirmText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createConnectionForm = (record?: Partial<Dbms.ConnectionRecord> | null): DbmsConnectionFormModel => ({
|
export const createConnectionForm = (
|
||||||
|
record?: Partial<Dbms.ConnectionRecord> | null,
|
||||||
|
dbType: Dbms.DbType = record?.dbType || 'ORACLE'
|
||||||
|
): DbmsConnectionFormModel => ({
|
||||||
id: resolveText(record?.id),
|
id: resolveText(record?.id),
|
||||||
connectionName: resolveText(record?.connectionName),
|
connectionName: resolveText(record?.connectionName),
|
||||||
host: resolveText(record?.host),
|
host: resolveText(record?.host),
|
||||||
port: Number(record?.port) || 1521,
|
port: Number(record?.port) || (dbType === 'MYSQL' ? 3306 : 1521),
|
||||||
connectType: record?.connectType || 'SERVICE_NAME',
|
connectType: record?.connectType || 'SERVICE_NAME',
|
||||||
serviceName: resolveText(record?.serviceName) || 'ORCL',
|
serviceName: resolveText(record?.serviceName) || 'ORCL',
|
||||||
sid: resolveText(record?.sid),
|
sid: resolveText(record?.sid),
|
||||||
|
databaseName: resolveText(record?.databaseName),
|
||||||
schemaName: resolveText(record?.schemaName),
|
schemaName: resolveText(record?.schemaName),
|
||||||
username: resolveText(record?.username),
|
username: resolveText(record?.username) || (dbType === 'MYSQL' ? 'root' : ''),
|
||||||
password: '',
|
password: '',
|
||||||
savePassword: record?.savePassword === 1 ? 1 : 0,
|
savePassword: record?.savePassword === 1 ? 1 : 0,
|
||||||
directoryName: resolveText(record?.directoryName) || 'DATA_PUMP_DIR',
|
directoryName: resolveText(record?.directoryName) || 'DATA_PUMP_DIR',
|
||||||
@@ -58,22 +61,20 @@ export const createConnectionForm = (record?: Partial<Dbms.ConnectionRecord> | n
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const createBackupForm = (): DbmsBackupFormModel => ({
|
export const createBackupForm = (): DbmsBackupFormModel => ({
|
||||||
backupStrategy: 'DATA_PUMP',
|
backupStrategy: 'JDBC_EXPORT',
|
||||||
schemaName: '',
|
schemaName: '',
|
||||||
targetNames: [],
|
targetNames: [],
|
||||||
backupMode: 'FULL_TABLE',
|
backupMode: 'FULL_TABLE',
|
||||||
timeColumn: '',
|
timeColumn: '',
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
maxFileSizeMb: 512,
|
maxFileSizeMb: 512,
|
||||||
directoryName: 'DATA_PUMP_DIR',
|
directoryName: 'DATA_PUMP_DIR'
|
||||||
temporaryPassword: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createRestoreForm = (): DbmsRestoreFormModel => ({
|
export const createRestoreForm = (): DbmsRestoreFormModel => ({
|
||||||
backupFileId: '',
|
backupFileId: '',
|
||||||
restoreMode: 'SKIP',
|
restoreMode: 'SKIP',
|
||||||
targetSchemaName: '',
|
targetSchemaName: '',
|
||||||
temporaryPassword: '',
|
|
||||||
overwriteConfirmText: ''
|
overwriteConfirmText: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,15 +87,16 @@ export const buildConnectionPayload = (
|
|||||||
dbType,
|
dbType,
|
||||||
host: form.host.trim(),
|
host: form.host.trim(),
|
||||||
port: Number(form.port),
|
port: Number(form.port),
|
||||||
connectType: form.connectType,
|
connectType: dbType === 'ORACLE' ? form.connectType : null,
|
||||||
serviceName: form.connectType === 'SERVICE_NAME' ? form.serviceName.trim() || null : null,
|
serviceName: dbType === 'ORACLE' && form.connectType === 'SERVICE_NAME' ? form.serviceName.trim() || null : null,
|
||||||
sid: form.connectType === 'SID' ? form.sid.trim() || null : null,
|
sid: dbType === 'ORACLE' && form.connectType === 'SID' ? form.sid.trim() || null : null,
|
||||||
schemaName: form.schemaName.trim() || null,
|
databaseName: dbType === 'MYSQL' ? form.databaseName.trim() || null : null,
|
||||||
|
schemaName: dbType === 'ORACLE' ? form.schemaName.trim() || null : null,
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password.trim() || null,
|
password: form.password.trim() || null,
|
||||||
savePassword: form.savePassword,
|
savePassword: form.savePassword,
|
||||||
directoryName: form.directoryName.trim() || null,
|
directoryName: dbType === 'ORACLE' ? form.directoryName.trim() || null : null,
|
||||||
directoryPath: form.directoryPath.trim() || null,
|
directoryPath: dbType === 'ORACLE' ? form.directoryPath.trim() || null : null,
|
||||||
extraConfigJson: form.extraConfigJson.trim() || null,
|
extraConfigJson: form.extraConfigJson.trim() || null,
|
||||||
remark: form.remark.trim() || null
|
remark: form.remark.trim() || null
|
||||||
})
|
})
|
||||||
@@ -102,19 +104,25 @@ export const buildConnectionPayload = (
|
|||||||
export const buildBackupPayload = (
|
export const buildBackupPayload = (
|
||||||
connection: Dbms.ConnectionRecord,
|
connection: Dbms.ConnectionRecord,
|
||||||
form: DbmsBackupFormModel
|
form: DbmsBackupFormModel
|
||||||
): Dbms.CreateBackupParams => ({
|
): Dbms.CreateBackupParams => {
|
||||||
|
const schemaName =
|
||||||
|
form.schemaName.trim() ||
|
||||||
|
(connection.dbType === 'MYSQL' ? connection.databaseName || connection.schemaName : connection.schemaName || connection.databaseName) ||
|
||||||
|
undefined
|
||||||
|
|
||||||
|
return {
|
||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
backupStrategy: form.backupStrategy,
|
backupStrategy: 'JDBC_EXPORT',
|
||||||
schemaName: form.schemaName.trim() || connection.schemaName || undefined,
|
schemaName,
|
||||||
targetNames: [...form.targetNames],
|
targetNames: [...form.targetNames],
|
||||||
backupMode: form.backupMode,
|
backupMode: form.backupMode,
|
||||||
timeColumn: form.backupMode === 'TIME_RANGE' ? form.timeColumn.trim() || null : null,
|
timeColumn: form.backupMode === 'TIME_RANGE' ? form.timeColumn.trim() || null : null,
|
||||||
startTime: form.backupMode === 'TIME_RANGE' ? form.timeRange[0] || null : null,
|
startTime: form.backupMode === 'TIME_RANGE' ? form.timeRange[0] || null : null,
|
||||||
endTime: form.backupMode === 'TIME_RANGE' ? form.timeRange[1] || null : null,
|
endTime: form.backupMode === 'TIME_RANGE' ? form.timeRange[1] || null : null,
|
||||||
maxFileSizeMb: form.backupMode === 'SIZE_SPLIT' ? form.maxFileSizeMb : null,
|
maxFileSizeMb: form.backupMode === 'SIZE_SPLIT' ? form.maxFileSizeMb : null,
|
||||||
directoryName: form.directoryName.trim() || connection.directoryName || null,
|
directoryName: null
|
||||||
temporaryPassword: form.temporaryPassword.trim() || undefined
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
export const buildRestorePayload = (
|
export const buildRestorePayload = (
|
||||||
connection: Dbms.ConnectionRecord,
|
connection: Dbms.ConnectionRecord,
|
||||||
@@ -128,8 +136,7 @@ export const buildRestorePayload = (
|
|||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
backupFileId: form.backupFileId,
|
backupFileId: form.backupFileId,
|
||||||
restoreMode: form.restoreMode,
|
restoreMode: form.restoreMode,
|
||||||
targetSchemaName: form.targetSchemaName.trim() || connection.schemaName || undefined,
|
targetSchemaName: form.targetSchemaName.trim() || connection.schemaName || connection.databaseName || undefined,
|
||||||
temporaryPassword: form.temporaryPassword.trim() || undefined,
|
|
||||||
overwriteConfirmText
|
overwriteConfirmText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,13 +157,14 @@ export const buildFileListParams = (
|
|||||||
pageNum: number,
|
pageNum: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
connectionId?: string,
|
connectionId?: string,
|
||||||
taskId?: string
|
taskId?: string,
|
||||||
|
backupStrategy?: Dbms.BackupStrategy | ''
|
||||||
): Dbms.FileListParams => ({
|
): Dbms.FileListParams => ({
|
||||||
pageNum,
|
pageNum,
|
||||||
pageSize,
|
pageSize,
|
||||||
connectionId: connectionId || undefined,
|
connectionId: connectionId || undefined,
|
||||||
taskId: taskId || undefined,
|
taskId: taskId || undefined,
|
||||||
backupStrategy: 'DATA_PUMP'
|
backupStrategy: backupStrategy || undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
export const buildDeleteBackupFilePayload = (backupFileId: string): Dbms.DeleteBackupFileParams => ({
|
export const buildDeleteBackupFilePayload = (backupFileId: string): Dbms.DeleteBackupFileParams => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user